Compare commits

...
246 Commits
Author SHA1 Message Date
SivanCola afeb51806f Merge pull request #5908 from SivanCola/feature/bot-channel-settings-ui
Simplify bot channel settings / 简化机器人渠道设置
2026-07-04 16:46:33 +08:00
SivanCola af1d9151ec Merge pull request #5906 from HaoyueQin/feat/image-preview-5832
feat(frontend): add image preview on click in composer and messages / 上传图片后增加图片预览功能
2026-07-04 16:45:47 +08:00
SivanCola f6f90dc279 Preserve pasted images in browser mock
Store pasted image data URLs in the browser dev mock so composer thumbnails and image previews render the same bytes that were pasted. Keep a valid fallback preview for mock attachment paths.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 16:40:31 +08:00
SivanCola 92539bbd1b fix(bot): approve pairing into connection access 2026-07-04 16:39:18 +08:00
SivanCola 311a613c0c Add image preview interaction coverage
Ensure edit-mode image thumbnails open the shared image viewer and limit pointer/hover styling to thumbnails with an image click handler. Extend the desktop image capability test to cover composer, sent-message, and edit-mode previews.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 16:32:21 +08:00
SivanCola e76d093c57 feat(bot): simplify per-channel settings
- add per-bot access and runtime overrides for QQ and IM connections
- replace the bot overview entry with a direct four-channel manager
- keep primary bot controls between connection summary and access settings

Co-authored-by: SivanCola <[email protected]>
2026-07-04 16:13:39 +08:00
Reasonix User 7a95eed7c2 feat(frontend): add image preview on click in composer and messages
Add ImageViewer component that shows a full-size image preview in a
portal overlay (following MermaidDiagram fullscreen pattern). Both the
composer thumbnail chips and sent-message image attachments are now
clickable — opens a lightbox-style overlay with the full image.

- New ImageViewer component with createPortal to .chat-pane
- Message.tsx: image attachments get onClick + keyboard accessibility
- ComposerContextCard: new onImageClick prop for thumbnails
- Composer.tsx: integrated ImageViewer with tooltip hint
- CSS: hover scale(1.08) on thumbnails, ImageViewer overlay styles
- i18n: imageViewer.clickToPreview/closePreview/title in en/zh/zh-TW

Closes #5832
2026-07-04 14:51:14 +08:00
SivanCola 96795f99d1 Merge pull request #5900 from SivanCola/feature/session-title-auto-refresh
Auto-refresh session titles without overriding manual names / 自动更新会话标题且尊重手动命名
2026-07-04 11:51:35 +08:00
SivanCola e6b77215fd Merge pull request #5895 from SivanCola/feature/deep-provider-presets
Add curated provider presets / 增加深度适配的供应商预设
2026-07-04 11:35:30 +08:00
SivanCola 403bfcab50 Fix provider preset refresh auth and modified state 2026-07-04 11:25:19 +08:00
SivanCola df5ee95898 feat(desktop): auto-refresh session titles 2026-07-04 11:16:58 +08:00
SivanCola 14396181b8 Merge pull request #5896 from SivanCola/fix/file-ref-cache-refresh
Refresh file reference caches / 刷新文件引用缓存
2026-07-04 08:49:01 +08:00
SivanCola 0076b8816a fix(frontend): refresh workspace file caches 2026-07-04 07:22:16 +08:00
SivanCola 99928cebeb Add preset conflict actions 2026-07-04 07:11:39 +08:00
SivanCola de1fb49951 Track provider preset install state 2026-07-04 06:53:37 +08:00
SivanCola 5c8e238eef Guard provider presets from overwriting existing config 2026-07-04 06:31:51 +08:00
SivanCola 68a1a47322 Merge pull request #5894 from SivanCola/feature/bot-gateway-upgrades
Upgrade bot gateway operations / 升级 Bot 网关运维能力
2026-07-04 06:14:48 +08:00
SivanCola 0f5a7f143c fix(bot): admit role-listed users
Co-authored-by: SivanCola <[email protected]>
2026-07-04 06:00:11 +08:00
SivanCola 4a6dcda402 Compact provider preset cards 2026-07-04 05:49:47 +08:00
SivanCola 6e6c32627b fix(bot): stabilize gateway CI checks
Co-authored-by: SivanCola <[email protected]>
2026-07-04 05:42:15 +08:00
SivanCola d30e6f1c37 Polish provider preset card layout 2026-07-04 05:29:47 +08:00
SivanCola 7c64489c69 docs(bot): update gateway usage guide 2026-07-04 05:18:18 +08:00
SivanCola 73f622326f Add curated provider preset catalog 2026-07-04 05:10:17 +08:00
95cf355b25 feat(bot): consolidate related gateway improvements
Adapt related bot gateway and adapter work from PRs #5105, #5093, #5046, and #5888.

Co-authored-by: cecil-su <[email protected]>

Co-authored-by: Juko-div <[email protected]>

Co-authored-by: SEPURI-SAI-KRISHNA <[email protected]>

Co-authored-by: zlwu <[email protected]>
2026-07-04 05:08:06 +08:00
SivanCola 54d5fe65b4 fix(bot): resolve gateway review findings 2026-07-04 04:55:27 +08:00
SivanCola 9237eb82b6 feat(bot): add project session navigation commands 2026-07-04 04:25:01 +08:00
SivanCola 62a95e31b0 Merge pull request #5890 from zlwu/fix/feishu-reaction-cleanup
fix(feishu): remove pending reaction emoji after turn completes
2026-07-04 03:58:08 +08:00
SivanCola a1bbcd7194 feat(bot): upgrade gateway operations 2026-07-04 03:52:27 +08:00
SivanCola b6f0a8ebbd Fix queued bot reaction cleanup
Store queued reaction cleanup callbacks on the gateway instead of controller state so reactions added before controller creation still get removed.

Carry queued cleanup callbacks into the merged follow-up turn so the pending reaction is removed when that turn finishes.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 03:48:07 +08:00
SivanCola 23ce2a7af7 Merge pull request #5893 from SivanCola/fix/subagent-bot-workspace-routing
Fix workspace routing for subagents and bot sessions / 修复子代理与 Bot 会话工作区路由
2026-07-04 03:45:41 +08:00
SivanCola c880b7ad73 Fix workspace routing for subagents and bot sessions 2026-07-04 03:36:15 +08:00
SivanCola 499f7f2e44 Merge pull request #5887 from HaoyueQin/fix/desktop-paste-block-display
fix(desktop): show pasted text content in message bubbles / 修复桌面端粘贴文本在消息气泡中仅显示标签
2026-07-04 02:40:50 +08:00
SivanCola 53086911fd Merge pull request #5892 from SivanCola/fix/active-goal-transient-strip
Strip active-goal from user previews / 从用户预览中剥离 active-goal
2026-07-04 02:39:56 +08:00
SivanCola 850c4b5db5 Merge remote-tracking branch 'origin/main-v2' into fix/active-goal-transient-strip
# Conflicts:
#	internal/agent/preview.go
#	internal/agent/preview_test.go
2026-07-04 02:34:44 +08:00
zlwu c57062b788 fix(feishu): remove pending reaction emoji after turn completes
AddPendingReaction now returns a cleanup function that the gateway
defers in runTurn. When the turn finishes (or errors), the OnIt
emoji is automatically removed via MessageReaction.Delete API.

Minimal interface change: return type becomes (func(), error) instead
of error. QQ/WeChat are unaffected — they don't implement this
interface.
2026-07-04 02:34:41 +08:00
SivanCola 26e5b6ad01 Simplify pasted block controls
Use a single expand/collapse control for pasted message blocks and remove the separate preview button to avoid overlapping interaction semantics.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 02:31:22 +08:00
SivanCola 010b761286 Merge pull request #5891 from SivanCola/feature/capability-orchestrator-routing
Capability routing hints for skills and MCP / 为 Skills 和 MCP 添加能力路由提示
2026-07-04 02:30:57 +08:00
SivanCola 519f94232f test(desktop): guard active-goal history display 2026-07-04 02:29:51 +08:00
SivanCola d0e0d4e4f3 Merge pull request #5889 from SivanCola/fix/reasoning-language-auto-anchor
Anchor Chinese reasoning language from raw prompts / 从原始提问锚定中文思考语言
2026-07-04 02:25:40 +08:00
SivanCola d451290d46 Make pasted block expansion reversible
Keep pasted message cards mounted after expansion so users can collapse the full text back to the compact card state.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 02:22:10 +08:00
SivanCola 29c859b7b0 Fix capability routing CI gates 2026-07-04 02:21:54 +08:00
SivanColaandmyipanta 91035ddd8e fix(agent): strip active-goal from user previews
Adapted from #4513 by @myipanta.

Co-authored-by: myipanta <[email protected]>
2026-07-04 02:21:49 +08:00
SivanColaandliaoyl830 4eb0b6e1c3 fix(agent): anchor Chinese reasoning language from raw prompts
Co-authored-by: liaoyl830 <[email protected]>
2026-07-04 02:19:12 +08:00
SivanCola 3cff8c9ffc Fix pasted block locale parsing
Ensure message pasted block parsing recognizes Traditional Chinese labels and add a focused regression test for Simplified Chinese, Traditional Chinese, and English labels.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 02:11:53 +08:00
SivanCola 0003bef4e7 Add capability routing hints 2026-07-04 02:10:51 +08:00
SivanCola b82a97dd1d Merge pull request #5886 from SivanCola/feature/third-party-parser-hardening
Harden parser paths with maintained libraries / 用成熟库加固解析路径
2026-07-04 01:20:01 +08:00
HaoyueQin 6968f1dca3 fix(desktop): show pasted text blocks with preview/expand in message bubbles
Pasted long text in the desktop input is folded into a collapsible
label (e.g. '[已粘贴文本 #2 · 31 行]').  When the message is sent
the bubble previously showed only that label, hiding the actual
content from the user even though the model received it.

This change adds per-block preview/expand controls inside the
user-message bubble, modelled after the composer's existing paste
block UI:
- A bordered card with the label on the left and Eye (preview) +
  Expand buttons on the right
- Preview toggles a <pre> showing the raw pasted content
- Expand irreversibly replaces the label with the actual text
  inline at its original position in the message

Closes #5863
2026-07-04 01:19:55 +08:00
SivanCola ecd14ab332 fix: remove unused parser helpers 2026-07-04 01:11:34 +08:00
SivanColaandJuly-X 174ea10844 refactor: harden parsers with maintained libraries
Replace several hand-rolled parsers and text processors with maintained libraries while preserving Reasonix-specific policy boundaries and fallbacks.

Add focused regression coverage for shell argv policy, HTML extraction, frontmatter decoding, dotenv parsing, glob matching, diff output, grapheme clipping, and optional tree-sitter code indexing.

Co-authored-by: July-X <[email protected]>
2026-07-04 01:11:34 +08:00
SivanCola c87628398f Merge pull request #5884 from SivanCola/chore/math-rendering-attribution
Record math rendering attribution / 补记数学渲染贡献归属
2026-07-04 01:05:10 +08:00
e02e62c310 chore: record math rendering attribution
Refs #5662, #5649, #4543.

This empty follow-up commit records commit-level GitHub attribution for contributors whose source PR work was kept or adapted by the #5662 math rendering consolidation.

Co-authored-by: drafish <[email protected]>
Co-authored-by: lightfront <[email protected]>
Co-authored-by: light-front-theory <[email protected]>
2026-07-04 00:59:48 +08:00
SivanCola fee799a7ed Merge pull request #5172 from liaoyl830/fix-5167-tool-schema-properties
fix(provider): include empty properties in tool schemas (#5167)
2026-07-04 00:40:33 +08:00
SivanCola 1e7379c29d Merge main-v2 into schema properties PR
Update the OpenAI empty-parameters regression expectation after #5883 so it matches the stricter canonical schema shape.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 00:32:56 +08:00
SivanCola 0b579f2110 Merge pull request #5883 from SivanCola/fix/provider-explicit-temperature
Preserve explicit zero temperature and empty tool schemas / 保留显式零温度并补齐空工具 schema
2026-07-04 00:22:42 +08:00
SivanCola 1ea049d9c2 fix(openai): default empty tool parameters 2026-07-04 00:08:11 +08:00
SivanColaandSEPURI-SAI-KRISHNA 8e5d9e815d fix(provider): preserve explicit zero temperature
Adapted from #5048 by @SEPURI-SAI-KRISHNA. Keep the explicit-zero temperature serialization fix while preserving caller-controlled DeepSeek temperature behavior.

Co-authored-by: SEPURI-SAI-KRISHNA <[email protected]>
2026-07-03 23:51:28 +08:00
SivanCola cba9390d2b Merge pull request #5882 from SivanCola/fix/bash-repeat-guard-ast
Parse Bash redirects in repeat guard / 用 Bash AST 识别 repeat guard 重定向
2026-07-03 23:29:09 +08:00
SivanCola 34ca109ec4 Merge pull request #5881 from SivanCola/fix/acp-host-owned-permissions
Fix ACP permission ownership / 修复 ACP 权限归属
2026-07-03 23:20:42 +08:00
SivanCola 4c7386f921 fix(agent): parse bash redirects in repeat guard 2026-07-03 23:16:19 +08:00
SivanCola 224c6e61d3 Merge pull request #5840 from SauronSkywalker/fix/window-controls-ui-and-context-panel-realtime
fix(windows): window desktop controls white box & ContextPanel real-time data桌面客户端窗口控制按钮的视觉 bug与概览面板数据更新修复
2026-07-03 23:11:45 +08:00
SivanCola b44e9af7e5 fix(acp): leave persistent permissions to hosts 2026-07-03 23:09:55 +08:00
SivanCola e7a8a1320d Merge pull request #5876 from SivanCola/fix/bash-ast-shell-parsing
Parse Bash syntax with AST / 用 AST 解析 Bash 语法
2026-07-03 23:02:31 +08:00
SivanCola 7f51ad178b Refresh context panel on usage token changes
Co-authored-by: SivanCola <[email protected]>
2026-07-03 22:57:13 +08:00
SivanColaandJuly-X 8664212c45 fix(bash): parse shell syntax with Bash AST
Replace hand-rolled Bash scanners with mvdan.cc/sh AST parsing for background keepalive detection, permission matching, plan-mode read-only checks, safe redirect normalization, and verification command matching.

Add heredoc regressions for issue #5624 so command execution and cancellation both release turns promptly.

Co-authored-by: July-X <[email protected]>
2026-07-03 22:41:16 +08:00
SivanCola 4ec73b2441 Merge pull request #5875 from SivanCola/fix/sandbox-settings-diagnostics
Clarify sandbox write roots settings / 明确沙箱可写根设置
2026-07-03 22:30:23 +08:00
SivanCola acd567721c Merge pull request #5874 from SivanCola/fix/sandbox-parallel-safety-integration
Fail closed bash sandbox and make parallel tasks read-only / Bash 沙箱失败即拒绝并限制并行子任务为只读
2026-07-03 22:21:11 +08:00
SivanCola a72c392b68 fix: align bash sandbox unavailable messaging 2026-07-03 22:13:12 +08:00
SivanCola 2e15cc9d59 test: make sandbox write root expectations portable 2026-07-03 22:12:46 +08:00
SivanCola eaba743bc5 fix: clarify sandbox write roots settings 2026-07-03 21:54:59 +08:00
SivanCola 157809c4d7 fix(agent): harden bash sandbox and parallel tasks 2026-07-03 21:49:49 +08:00
SivanCola 1447b62908 Merge pull request #5871 from SivanCola/fix/planmode-safe-null-redirects
Allow plan mode safe null redirects / 放宽计划模式安全空重定向
2026-07-03 21:17:45 +08:00
SivanCola d3b5111ca8 fix(planmode): allow safe null redirects 2026-07-03 21:02:26 +08:00
SivanCola a306a07f82 Merge pull request #5867 from SivanCola/fix/plan-mode-read-only-commands
Allow plan-mode read-only command prefixes / 支持计划模式只读命令前缀
2026-07-03 20:53:08 +08:00
SivanCola 15101b87f4 Merge pull request #5868 from SivanCola/fix/bash-devnull-redirect-permission
Match safe bash redirects in permission rules / 支持安全 Bash 重定向权限匹配
2026-07-03 20:49:28 +08:00
SivanCola 6f989b09c5 fix(permission): normalize cross-shell null redirects 2026-07-03 20:39:36 +08:00
SivanCola 110c5ece63 Merge pull request #5845 from myipanta/fix/workspace-prompt-5792
fix: add workspace root to system prompt for project context awareness
2026-07-03 19:17:56 +08:00
SivanCola 7837083eba Merge remote-tracking branch 'origin/main-v2' into fix/plan-mode-read-only-commands
# Conflicts:
#	internal/boot/boot.go
#	internal/control/controller.go
2026-07-03 19:13:23 +08:00
SivanCola f14ce69798 fix(permission): match bash safe redirects 2026-07-03 19:11:49 +08:00
SivanColaandSivanCola bfda54539b test: cover workspace prompt fork and rewind
Co-authored-by: SivanCola <[email protected]>
2026-07-03 19:10:41 +08:00
SivanCola ee20852d1b Add plan-mode bash read-only trust approval 2026-07-03 19:09:45 +08:00
SivanCola a7a256adc1 Merge pull request #5703 from morningtzh/feat/permission-decompose-compound-bash
feat(permission): match compound bash commands segment-by-segment
2026-07-03 18:56:55 +08:00
SivanColaandSivanCola 0466fef9d6 Make workspace prompt escaping test portable
Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:52:48 +08:00
SivanCola dc68694cdb Merge pull request #5865 from esengine/fix/desktop-release-manifest-gateway
fix: route desktop updater via release gateway
2026-07-03 18:51:11 +08:00
SivanColaandSivanCola a1029786ad Escape workspace root in prompt line
Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:46:00 +08:00
SivanCola 50a658e434 Merge pull request #5857 from SivanCola/fix/windows-shell-cancel-hang
Fix Windows shell cancellation for Git Bash descendants / 修复 Windows Shell 取消残留进程
2026-07-03 18:45:38 +08:00
SivanColaandSivanCola 9a4f56e4cf fix(permission): preserve compound bash exact rules
Keep whole-command bash allow/ask/deny rules authoritative before segment-level decomposition, so existing exact compound approvals and denials keep their previous precedence.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:44:39 +08:00
SivanColaandmyipanta 06634777c1 fix: route desktop updater via release gateway
Co-authored-by: myipanta <[email protected]>
2026-07-03 18:41:09 +08:00
SivanCola 7bd9c13f01 Merge pull request #5856 from esengine/fix/session-conflict-recovery
fix(session): recover stale controller transcripts
2026-07-03 18:37:38 +08:00
SivanCola 107456d605 Cover shell routing during rapid tab switches 2026-07-03 18:36:58 +08:00
SivanCola f19c00f141 fix(desktop): surface recovery lease failures 2026-07-03 18:29:12 +08:00
SivanCola 2ba719ebab fix(planmode): allow declared read-only bash prefixes 2026-07-03 18:26:40 +08:00
SivanCola 7cb64f55e0 Preserve direct shell session isolation 2026-07-03 18:19:10 +08:00
SivanCola 201423138e Merge pull request #5864 from SivanCola/codex/fix-5753-fresh-human-memory-approval
Restrict remember/forget and plan approvals to fresh human-only flow
2026-07-03 18:17:07 +08:00
SivanCola 787fe8a0bf fix(session): require lease before recovery switch 2026-07-03 18:16:21 +08:00
SivanCola 9d0890f92a Merge pull request #5844 from myipanta/fix/memory-yolo-test-5753
test: add YOLO regression test for remember/forget forced approval (#…
2026-07-03 18:12:32 +08:00
SivanCola 79efac68a3 Unify tracked shell cancellation paths 2026-07-03 18:10:43 +08:00
SivanCola 608527502a Restrict remember/forget and plan approvals to fresh human-only flow 2026-07-03 17:45:09 +08:00
SivanCola 61baf002e9 test: cover project blank session workspace binding 2026-07-03 17:37:18 +08:00
SivanCola 0f89cec5ea Merge pull request #5861 from SivanCola/fix/serve-http-auth-cookie
Fix serve HTTP auth cookies / 修复 serve HTTP 登录 Cookie
2026-07-03 17:33:40 +08:00
SivanCola 6482375b72 fix(session): preserve malformed metadata errors 2026-07-03 17:26:19 +08:00
SivanCola 5f2eac7053 fix(serve): keep HTTP auth cookies usable 2026-07-03 17:25:48 +08:00
SivanCola f61118b8fe Stabilize Windows Git Bash cancel regression 2026-07-03 17:23:56 +08:00
SivanCola 8ecdc894a8 fix(session): add transcript CAS and runtime leases 2026-07-03 17:22:14 +08:00
jiangrenhang faab493b02 fix: add workspace root to system prompt for project context awareness
When switching projects in the desktop app, the AI previously had no explicit provider-visible cue for the active workspace when project memory was absent or similar across roots. Add a Current workspace line to the cache-stable boot prompt after the language policy so each controller carries its resolved workspace root.

The existing no-memory prompt invariant now strips the boot-time workspace line, and a new regression test verifies two different WorkspaceRoot values produce different system prompt workspace lines.

Cache-impact: low; adds one stable per-session workspace line to the provider-visible system prompt. Different project roots get distinct prompt prefixes, which is expected for project-scoped sessions.

Cache-guard: go test ./internal/boot

System-prompt-review: required; adds "Current workspace: <root>" after LanguagePolicy and before token-economy/environment sections.

Refs #5792
2026-07-03 17:20:05 +08:00
SivanCola d7bd736a85 Use real Python for Windows Git Bash cancel test 2026-07-03 17:19:26 +08:00
SivanCola 4e3a1aea57 Fix Windows Git Bash regression test path 2026-07-03 17:13:42 +08:00
SivanCola afa8b710f4 Merge pull request #5773 from GTC2080/GTC/fix-5769-stop-button
fix(desktop): keep stop state when switching to running tabs
2026-07-03 17:08:36 +08:00
SivanCola 02702c8378 Fix Windows shell cancellation for tracked process trees 2026-07-03 17:07:43 +08:00
SivanColaandSivanCola 863d07be20 Merge main-v2 into PR 5773
Preserve both project-switch send fallback and running-tab rapid switch behavior.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 17:02:25 +08:00
SivanCola 2b105dd575 Merge pull request #5854 from GTC2080/GTC/fix-yolo-project-switch
fix: reconcile opened topic runtime
2026-07-03 16:57:11 +08:00
SivanCola 7902c9321d fix(session): recover stale controller transcripts 2026-07-03 16:50:52 +08:00
SivanColaandSivanCola 7e2a14ef91 fix: preserve last-click tab activation
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:47:17 +08:00
SivanCola 7135b32e14 Merge pull request #5720 from GTC2080/GTC/fix-5716-creation-tool-collapse
fix(desktop): hide collapsed creation tool bodies
2026-07-03 16:46:37 +08:00
SivanColaandSivanCola acc954a506 fix: reconcile runtime on active tab fallback
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:29:51 +08:00
SivanCola 4e440421af Merge pull request #5852 from GTC2080/GTC/fix-block-comment-prompt
fix: allow block comment prompts
2026-07-03 16:17:23 +08:00
SivanCola a9928d3ef5 Merge pull request #5847 from SivanCola/feature/desktop-system-notifications
Add desktop system notifications for turns / 为桌面端任务完成增加系统通知
2026-07-03 16:11:13 +08:00
SivanCola 74b9dd77b6 Merge pull request #5851 from SivanCola/fix/session-snapshot-conflict
Guard session snapshots against stale overwrites / 防止旧快照覆盖会话
2026-07-03 16:05:59 +08:00
SivanColaandSivanCola 1e8fc36577 fix: share slash comment prompt dispatch
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:05:15 +08:00
SivanColaandBarsit ec814165dd chore: credit related notification contribution
Credits the Windows Toast direction from PR #5365 in the repository history.

Co-authored-by: Barsit <[email protected]>
2026-07-03 16:04:06 +08:00
SivanCola 4b4b976fcd fix(desktop): avoid duplicate Windows notification fallback 2026-07-03 15:52:33 +08:00
SivanCola aad432d693 fix(session): protect explicit rewrites from stale controllers 2026-07-03 15:49:46 +08:00
SivanCola a6807da7f8 Merge pull request #5059 from maoxin1234/feat/zhipu-thinking
feat(provider): add Zhipu GLM thinking-mode support
2026-07-03 15:49:31 +08:00
GTC2080 bf24e28377 fix: reconcile opened topic runtime 2026-07-03 17:47:18 +10:00
SivanCola 31d3b7c0e7 docs(desktop): clarify provider compatibility settings 2026-07-03 15:40:01 +08:00
GTC2080 63fbad01d3 fix: allow block comment prompts 2026-07-03 17:39:39 +10:00
SivanCola d2e91a8a11 fix(session): guard snapshots against stale overwrites 2026-07-03 15:39:25 +08:00
SivanCola aeb6431f73 chore: avoid touching cache-sensitive config 2026-07-03 14:59:53 +08:00
SivanCola 98ee87101b feat(desktop): add system notifications for turns 2026-07-03 14:58:09 +08:00
SivanCola 27b86732c2 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-07-03 14:42:31 +08:00
SivanCola 912bbd86ae Merge pull request #5833 from SivanCola/codex/claude-plugin-compat
Support Claude-style plugin hooks and CLAUDE.md compatibility
2026-07-03 14:37:51 +08:00
jiangrenhang 939975881e test: add YOLO regression test for remember/forget forced approval (#5753)
The protection for remember/forget tools already exists on main-v2
(requiresFreshApprovalTool + policy.Ask rules in newInteractiveGate),
but only Auto mode had a dedicated test. This adds an equivalent test
for YOLO/full-access mode to prevent regression.

Cache-impact: none — test-only change.

Cache-guard: go test ./internal/control/ -run "TestToolApprovalModeYoloForcesMemoryAskRules"

Refs: #5753
2026-07-03 13:46:11 +08:00
Reasonix Dev 746d06800a fix: window controls white box & ContextPanel real-time data
- Window controls white box hidden when workspace panel closed
- Window controls dim (opacity 0.35) when command palette open
- Creation mode window controls height matches dock tools (40px)
- Topicbar buttons no longer pushed off-screen when workspace opens
- Creation dock tabs use flex layout to prevent overlap
- ContextPanel prompt/completion/reasoning tokens now real-time
- Cache hit/miss rate now real-time (usage precedence over info)
- Throttled background refresh (1s) during streaming for telemetry data
2026-07-03 13:11:33 +08:00
MorningTZH bd31c9e10e test(permission): cover heredoc, leading operator, process substitution
Documents the tokenizer's scope explicitly and hardens against three shapes
that would otherwise decompose incorrectly:

- heredocs (`cat <<EOF … EOF`): body isn't shell syntax but the tokenizer
  can't tell where it ends without tracking the delimiter. Bail on any
  unquoted `<<` and fall back to exact match.
- leading control operators (`&& ls`, `; ls`, `| grep`, `|| echo`):
  malformed shell; refuse to decompose.
- process substitution (`<(cmd)` / `>(cmd)`): tracked as opaque via the
  same paren-depth counter used for `$(cmd)`, so operators inside don't
  split the outer command.

Also expands the package doc to enumerate known out-of-scope shapes and to
note that the hand-rolled tokenizer exists to avoid a full `mvdan.cc/sh`
dependency; the contract is `[]string` or `nil` for exact-match fallback,
so swapping in a real parser later is a local change.
2026-07-03 13:00:25 +08:00
MorningTZH f55083fa62 fix(permission): use tagged switch for parenDepth branch (QF1003)
golangci-lint (staticcheck QF1003) flagged the if/else if on the same
variable as a tagged-switch candidate. Same behavior, just the idiomatic
form the project's lint config expects.
2026-07-03 12:53:22 +08:00
MorningTZH 23b429d3d5 feat(permission): match compound bash commands segment-by-segment
Compound bash commands (`&&` / `||` / `|` / `;`) currently fall back to
whole-string matching in the rule table, so permanent approvals for atomic
commands never carry over to a compound of those same commands. #3882 reported
this and was closed as by-design because synthesizing a prefix from a compound
(`cd /path && …` → `Bash(cd /path:*)`) would silently authorize whatever
follows the operator.

This change takes a different route with the same safety posture: instead of
synthesizing new rules, decompose a compound at match time and check each
simple-command segment against the existing rule table independently. The
storage path is unchanged — `BashCommandPrefix` still returns nil for compound
subjects, so "always allow" still stores the exact literal on the persist
side. What changes is only the matching algorithm.

- Add `DecomposeBashCommand` (`internal/permission/bash_decompose.go`): a
  hand-rolled tokenizer that splits on `;`, `|`, `||`, `&`, `&&`, and
  newlines while treating single/double quotes, `$(…)`, backticks, and
  backslash-escapes as opaque. `2>&1` / `>&2` are recognized as fd
  duplication (not splitters). Unbalanced quotes/parens return nil so the
  caller falls back to exact-string matching.
- Route `Policy.DecideSubject` bash path through `decideBashSegments` when
  decomposition yields ≥2 segments. Precedence stays deny > ask > allow >
  fallback: any segment hitting deny denies the whole call, any missing rule
  turns the whole call into Ask, whole-call Allow requires every segment to
  be covered by an allow rule or classified read-only by `shellsafe`.
- Effect: a user who has approved `Bash(git add:*)` + `Bash(git commit:*)` +
  `Bash(git push:*)` (via the existing atomic prefix save path or manual
  config) now passes `git add . && git commit -m … && git push` silently.

Out of scope for this pass:

- Redirect stripping (`2>/dev/null`, `> file`) inside individual segments —
  `bashPrefixMatches` still rejects subjects with any shell syntax, so a
  segment carrying a redirect misses even a matching prefix rule. Follow-up.
- Per-segment approval UI in the TUI. This change is match-side only.

Cache-impact: none — permission is called before any LLM request; nothing
about system prompt, memory prefix, tool schema, or provider serialization
changes.

Cache-guard: `go test ./internal/permission/... -count=1` (15 tokenizer
cases + 6 policy cases, all pass). `go build ./cmd/reasonix` and `go vet ./...`
clean.

Refs: #5702, #3882, #3815
2026-07-03 12:53:22 +08:00
YHH a87adced3c Merge pull request #5838 from esengine/site-registry-origin
Point skills page at the live registry API (crash worker)
2026-07-03 12:29:26 +08:00
SivanCola 573f58a1c2 Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/src/components/SettingsPanel.tsx
#	desktop/frontend/src/locales/en.ts
#	desktop/frontend/src/locales/zh-TW.ts
#	desktop/frontend/src/locales/zh.ts
#	desktop/settings_app_test.go
2026-07-03 12:26:39 +08:00
yhh b7d1e95c50 fix(site): point skills registry API at the live worker
registry.reasonix.io was never provisioned; the folded registry API now
serves from the crash worker. Default the skills page at crash.reasonix.io
so discovery/publish work; PUBLIC_REGISTRY_ORIGIN still overrides.
2026-07-02 21:24:18 -07:00
SivanCola a8fcd8c1c8 Merge pull request #5808 from ying-hua/fix/provider-extra-body
fix(provider): support provider extra_body
2026-07-03 12:22:10 +08:00
SivanColaandSivanCola 3307be74d9 feat(desktop): expose provider thinking override
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:21:25 +08:00
SivanCola 639736f5fa fix: return failure for plugin install JSON errors 2026-07-03 12:19:57 +08:00
YHH 2c7ad4973e Merge pull request #5834 from esengine/admin-unify
Unify community registry into the admin backend + speed up the dashboard
2026-07-03 12:11:28 +08:00
SivanColaandSivanCola eefcb09038 Add provider extra body settings UI
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:10:16 +08:00
SivanColaandSivanCola 02a1741a3d fix(provider): honor DeepSeek thinking disable config
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:07:11 +08:00
yhh 0f2ce6529f chore(crash-dashboard): set reasonix-registry database_id 2026-07-02 21:05:31 -07:00
SivanCola 9bc6344fa1 feat: add session plugin usage command 2026-07-03 11:58:21 +08:00
SivanColaandSivanCola dbced6638a Merge main-v2 into provider extra_body
Resolve docs/GUIDE.md by keeping both the extra_body configuration docs and the latest Desktop hooks guide section.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 11:53:58 +08:00
SivanCola 253faa132f feat: surface plugin package usage details 2026-07-03 11:39:17 +08:00
yhh 606d782379 feat(crash-dashboard): fold skill/MCP registry into the admin backend
Give the owner one admin console instead of a standalone, undeployed
registry worker. The registry source moves under the crash worker as a
second data plane (REGISTRY_DB); its public + publish API is served by
delegating to the folded Hono app, and a server-rendered Community tab
moderates user-published skills/MCP (approve/reject/hide/verify) gated on
the unified dashboard admin role, writing the shared audit log.

Requires a reasonix-registry D1 (schema in registry-schema.sql) before
the auto-deploy runs; the id in wrangler.toml is a placeholder.
2026-07-02 20:13:14 -07:00
SivanCola ad3d06343d feat: expand Claude-style plugin compatibility 2026-07-03 11:01:47 +08:00
yhh ac5f37f40b perf(crash-dashboard): cache identity + per-module queries + indexes
Dashboard loads blocked on a fresh id.reasonix.io /me round-trip and ran
all nine aggregation queries regardless of the module shown. Cache the
resolved identity per token hash (60s), fetch only the active module's
data (metric_users COUNT(DISTINCT) now hits only /stats/preferences), and
index the groups/pings/metrics columns the filters and facets scan.
2026-07-02 19:59:07 -07:00
SivanCola 9b5c870bd3 Merge pull request #5791 from SivanCola/feature/session-hook-subagent-depth
Support hook bootstrap, nested subagents, and plugin packages / 支持 Hook 启动上下文、二级子代理与插件包
2026-07-03 10:43:38 +08:00
SivanCola 2b29e2d92f docs: expand plugin CLI usage 2026-07-02 19:08:30 +08:00
SivanCola bafd9db24d docs: describe desktop plugin settings 2026-07-02 19:04:33 +08:00
SivanCola 8ff0b64884 fix(desktop): refine local plugin install layout 2026-07-02 19:00:48 +08:00
SivanCola 8de3dba0ff fix(desktop): swap plugin install actions and options 2026-07-02 18:56:52 +08:00
SivanCola d2ec3a1d68 fix(desktop): compact plugin install name field 2026-07-02 18:54:15 +08:00
SivanCola 9b70f121c0 fix(desktop): left align plugin replace option 2026-07-02 18:52:16 +08:00
SivanCola a71a850213 fix(desktop): extend plugin local path field 2026-07-02 18:50:14 +08:00
SivanCola 95d06df682 fix(desktop): align plugin install mode layouts 2026-07-02 18:48:25 +08:00
SivanCola addd1125d9 fix(desktop): unify plugin install form layout 2026-07-02 18:43:56 +08:00
SivanCola 08cf2b4585 fix(desktop): unify plugin install action label 2026-07-02 18:34:12 +08:00
ying-hua 052708f6f4 Support provider extra_body 2026-07-02 18:33:27 +08:00
SivanCola 554e881a69 fix(desktop): clarify plugin link mode 2026-07-02 18:29:22 +08:00
SivanCola 4e6db19690 fix(desktop): clarify plugin replacement option 2026-07-02 18:25:55 +08:00
SivanCola 044936cd5b refactor(desktop): simplify plugin install settings 2026-07-02 18:19:23 +08:00
SivanCola 48f9b9418a feat(desktop): add plugin folder picker 2026-07-02 18:10:09 +08:00
SivanCola 8ac66a4b18 fix(history): satisfy around clamp lint 2026-07-02 17:51:19 +08:00
SivanCola b99c08a52c feat(desktop): add plugin settings page 2026-07-02 17:48:39 +08:00
SivanCola 61e943beb3 fix(history): clamp around window without overflow 2026-07-02 17:48:34 +08:00
SivanCola 9e9d9e6e44 Merge pull request #5801 from esengine/docs/website-docs-refresh
Refresh website docs page / 刷新官网文档页
2026-07-02 17:38:20 +08:00
SivanCola f7c6048fa4 Refresh website documentation page 2026-07-02 17:32:57 +08:00
SivanCola 42ee48f214 feat(plugin): add unified plugin packages 2026-07-02 17:23:56 +08:00
SivanCola 6acdf3edb3 test(control): wait for edited prompt autosave 2026-07-02 16:45:42 +08:00
SivanCola 5d63d8e29a fix(agent): satisfy hook context lint 2026-07-02 16:41:51 +08:00
SivanCola 26ef771c7a feat(agent): support hook context and nested subagents 2026-07-02 15:58:51 +08:00
SivanCola bf53799e8a Merge pull request #5786 from SivanCola/feature/mouse-capture-toggle
Auto-copy mouse selection + mouse-capture toggle / 拖拽选中自动复制 + 鼠标接管开关
2026-07-02 15:37:35 +08:00
SivanCola ee9d976622 feat(cli): auto-copy mouse selection and add mouse-capture toggle
Releasing a left-drag selection in the TUI transcript now copies it to
the clipboard automatically (native terminal convention), with a
transient status-line "copied to clipboard" notice shared by the
mouse, right-click, and Ctrl+C copy paths.

Add "/mouse" to release mouse capture back to the terminal on demand,
restoring its native click-drag selection and right-click context
menu at the cost of in-app drag-select, the scrollbar, and
wheel-scroll while it's off. REASONIX_DISABLE_MOUSE=1 starts every
session with it off.
2026-07-02 15:02:17 +08:00
SivanCola d5ba463964 Merge pull request #5774 from SivanCola/fix/windows-classic-drag-rail
Fix Windows classic titlebar dragging / 修复 Windows 经典标题栏拖动
2026-07-02 12:00:44 +08:00
SivanCola 4762cd33d6 fix(desktop): add Windows classic drag rail 2026-07-02 11:53:20 +08:00
GTC2080 9838a27280 fix: keep stop state when switching to running tabs 2026-07-02 13:49:44 +10:00
SivanCola 484bca854f Merge pull request #5757 from SivanCola/feature/custom-provider-setup
Simplify custom provider setup / 简化自定义供应商接入配置
2026-07-02 10:05:33 +08:00
SivanCola 9ff0b20589 feat(desktop): simplify custom provider setup 2026-07-02 09:45:56 +08:00
SivanCola a2eaf80601 Merge pull request #5755 from ttmouse/feat/heartbeat-bot-push
feat: Heartbeat 定时任务输出实时推送到 Bot 渠道(飞书/QQ)
2026-07-02 09:44:31 +08:00
SivanCola cf2a8b061b Localize heartbeat bot push labels
Co-authored-by: SivanCola <[email protected]>
2026-07-02 03:06:32 +08:00
SivanCola fcd6180976 Fix heartbeat bot forwarding isolation
Co-authored-by: SivanCola <[email protected]>
2026-07-02 02:18:24 +08:00
SivanCola 6cbffaa78b Merge pull request #5754 from SivanCola/fix/anchor-edit-safety
Guard anchor edits after same-turn writes / 防止同回合锚点编辑失效
2026-07-02 01:58:50 +08:00
SivanCola 9ee254b2ec Fix anchor edit guard follow-ups 2026-07-02 01:44:07 +08:00
ttmouse cae565c158 feat: Heartbeat → Bot 渠道事件推送(双 Sink 分流) 2026-07-02 01:27:02 +08:00
SivanCola 3255803636 Guard anchor edits after same-turn writes 2026-07-02 01:05:53 +08:00
SivanCola c8ac697010 Merge pull request #5751 from SivanCola/fix/desktop-trash-ancillary-lag
Reduce desktop trash fallback hydration lag / 降低桌面端删除话题后的恢复卡顿
2026-07-02 00:39:24 +08:00
SivanCola 8d8bccd334 Merge pull request #5752 from SivanCola/fix/windows-classic-titlebar-tools
Fix Windows classic titlebar tool positioning / 修复 Windows 经典标题栏工具定位
2026-07-02 00:38:20 +08:00
SivanCola 3b0b9c0546 fix(desktop): keep checkpoint file arrays non-null 2026-07-02 00:32:52 +08:00
SivanCola 3608146829 Fix Windows classic titlebar tool positioning 2026-07-02 00:13:27 +08:00
SivanCola 3b3c9dba57 fix(desktop): reduce trash fallback hydration lag 2026-07-02 00:03:20 +08:00
SivanCola e1c2f46e8a Merge pull request #5743 from SivanCola/fix/desktop-code-preview-scroll
Fix workspace code preview scrolling / 修复工作区代码预览滚动
2026-07-01 23:27:44 +08:00
SivanCola 798c7ce85a fix(desktop): restore code preview scrolling 2026-07-01 23:06:04 +08:00
SivanCola 2ade3cd50d Merge pull request #5737 from esengine/fix/windows-frameless-titlebar
Fix Windows frameless titlebar controls / 修复 Windows 无边框标题栏控制
2026-07-01 23:01:58 +08:00
SivanCola da348d552e Merge pull request #5738 from SivanCola/fix/desktop-session-save-failures
Fix silent desktop session save failures / 修复桌面会话保存失败静默丢失
2026-07-01 22:46:02 +08:00
SivanCola 96431bc31f fix(desktop): surface session snapshot failures 2026-07-01 22:31:45 +08:00
SivanCola 2b56585f8f fix(desktop): add Windows frameless titlebar controls 2026-07-01 22:25:25 +08:00
SivanCola 4ea38a7a22 Merge pull request #5733 from SivanCola/fix/bash-cancel-wait-stall
Fix stalled bash cancel waits / 修复 bash 取消后等待卡死
2026-07-01 22:06:01 +08:00
SivanCola 5b8a9c0cd5 fix(bash): preserve wait error after cancel 2026-07-01 21:49:53 +08:00
SivanCola 1f4d811ac3 Merge pull request #5729 from SivanCola/feature/desktop-unread-topic-dot
Show unread topic attention dots / 显示未读会话提醒点
2026-07-01 21:39:05 +08:00
YHH 7cab46ac3a Merge pull request #5731 from esengine/feat/account-aware-skills
feat(skills): account-aware registry page with admin review
2026-07-01 21:38:13 +08:00
SivanCola 0a38be6852 fix(bash): release cancelled tracked waits 2026-07-01 21:38:00 +08:00
SivanCola 0ae11ebdef fix(desktop): omit hidden unread topic actions 2026-07-01 21:33:18 +08:00
yhh fdb99115d8 feat(skills): make the registry page account-aware with admin review
Reuse the site account client (scripts/auth.js) to show sign-in state in the nav, gate publishing behind a signed-in session, and reveal a moderator review queue (approve/reject) to admins. Adds currentAccount()/accountLogout() helpers to auth.js for account-aware pages.
2026-07-01 06:31:40 -07:00
SivanCola 2bcac618d7 feat(desktop): show unread topic attention dots 2026-07-01 21:13:10 +08:00
YHH 25184026bc Merge pull request #5728 from esengine/feat/registry-moderation
feat(registry): moderate submissions before they go live
2026-07-01 20:54:20 +08:00
yhh 3a1d04ae23 feat(registry): moderate submissions before they go live
Publishing now requires a verified email; non-admin submissions land as 'pending' and stay out of the listing, detail, and activity feed until an admin approves them. Adds /v1/admin review endpoints (approve / reject / hide / verify) gated on the admin role. Versions of an already-approved package stay live; admins publish straight to active.
2026-07-01 05:47:41 -07:00
SivanCola e73221c8b7 Merge pull request #5726 from esengine/feature/composer-guidance-queue
Add composer guidance queue controls / 新增 Composer 引导队列控制
2026-07-01 20:44:54 +08:00
YHH bd9923d644 Merge pull request #5725 from esengine/feat/skills-mcp-registry
feat: skill/MCP registry — worker + web discovery page
2026-07-01 20:28:37 +08:00
yhh 6892b5a035 feat(site): add skills registry page
Browse, search, sort, and publish community skills and MCP servers at /skills, with a live activity feed. Client-fetches the registry (PUBLIC_REGISTRY_ORIGIN, defaults to registry.reasonix.io) and links it from the home nav.
2026-07-01 05:21:13 -07:00
SivanCola 885ad5627f feat(desktop): add composer guidance queue controls 2026-07-01 20:21:08 +08:00
yhh e702eb082e feat(registry): add skill/MCP registry worker
Discovery layer over the existing install_source rail. Hono + D1: list/search published skills and MCP servers, record installs, and serve a live activity feed. Identity is delegated to id.reasonix.io; the worker stores only metadata and a source pointer, and never fetches or executes package content.
2026-07-01 05:21:07 -07:00
GTC2080 ef1d287fc0 fix(desktop): hide collapsed creation tool bodies 2026-07-01 20:51:18 +10:00
SivanCola ebd73457da Merge pull request #5711 from SivanCola/fix/compact-prompt-approvals
Compact approval prompts
2026-07-01 17:55:11 +08:00
YHH 3f037fa745 feat(site): bilingual community forum (EN / 中文) (#5715)
The /community pages shipped English-only; the rest of the site is
bilingual. Add the same EN/中文 toggle: a lang switch in the community
nav, .l-en/.l-zh spans for all static chrome, and the shared reasonix-lang
choice so the language carries over from the main site. The client
renders dynamic labels (categories, times, counts, badges) as toggled
spans too, and localizes the anti-spam/gate errors by code — so the whole
forum reads in either language.
2026-07-01 17:53:13 +08:00
SivanCola f83b84b896 fix(desktop): show approval details by default 2026-07-01 17:47:51 +08:00
SivanCola be59e66976 Merge pull request #5714 from SivanCola/feature/context-panel-usage-visuals
Refine context panel usage overview / 优化上下文面板用量概览
2026-07-01 17:42:36 +08:00
SivanCola d924d998a0 feat: refine context panel usage overview 2026-07-01 17:32:31 +08:00
YHH 8b04b164dc fix(forum): let staff bypass the trust-based post gates (#5713)
Categories with a high min_trust_to_post (e.g. Announcements at 4) were
unreachable: admins/moderators seed at trust 2, so the min_trust and
new-member link checks blocked them. Staff now bypass the trust gates
(and the rate limiter) while still needing a verified email.
2026-07-01 17:32:09 +08:00
SivanCola 118fe34378 Merge pull request #5704 from SivanCola/fix/session-profile-restore
fix(desktop): Restore session profile on switch / 修复切会话恢复权限与思考模式
2026-07-01 17:25:18 +08:00
SivanCola 170771fc3a fix(desktop): compact approval prompts 2026-07-01 17:19:20 +08:00
YHH 06fc964bc9 feat(site): community forum frontend at /community (#5708)
The human-facing forum, built on the approved design and unified into the
main site: Astro pages at reasonix.io/community (discussions home, thread,
new topic, guidelines). They're client-rendered against the
forum.reasonix.io API and gated on the shared id.reasonix.io session (the
rxid cookie is sent cross-subdomain), so there's no second login and no
separate app to deploy — it ships with the site via pages.yml.

Anti-spam surfaces here too: the composer only appears when signed in, and
the API's gate errors (verify email, links-restricted, rate limits) are
shown inline. Reuses the site's design tokens with self-hosted fonts.
API base is build-configurable via PUBLIC_FORUM_API (defaults to
https://forum.reasonix.io).
2026-07-01 17:16:45 +08:00
YHH 6643299111 feat(forum): community forum API with shared identity + anti-spam (#5706)
* feat(forum): community forum API with shared identity + anti-spam

First slice of the Reasonix Community forum: a new reasonix-forum worker
(forum.reasonix.io) backed by its own D1. Identity is the shared
id.reasonix.io session — it reads the .reasonix.io rxid cookie and
resolves it via /me (Bearer), the same pattern the crash dashboard uses —
so there's no second login.

Anti-abuse is built in at the identity/trust gate rather than per-post
content scanning, keeping it cheap: posting requires a verified email;
new (trust 0) members can't post links; per-trust daily caps + a per-IP
limiter throttle flooding; and N distinct flags auto-hide a post pending
moderation. Endpoints: categories/topics/posts read, create topic, reply,
and flag.

Bootstrap (out of band): wrangler d1 create reasonix-forum, apply
schema.sql + seed.sql, fill database_id. Frontend, deploy CI, and the
moderation queue land in follow-ups.

* ci(forum): provision D1 + auto-deploy forum.reasonix.io on merge

Fill the reasonix-forum database_id (D1 created) and add the deploy
workflow so merging to main-v2 ships forum.reasonix.io, matching the
accounts/crash worker pattern.
2026-07-01 17:15:03 +08:00
SivanCola 72ce3d8d93 fix(desktop): persist closed session profile 2026-07-01 17:07:07 +08:00
SivanCola 0aad3bd186 fix(desktop): restore session profile on switch 2026-07-01 16:44:08 +08:00
YHH 1858974dc7 feat(crash): unify dashboard auth onto id.reasonix.io identity (#5701)
The crash dashboard ran its own account system — a separate D1 users/
sessions table with its own password login — which fragmented identity
from id.reasonix.io. Make it consume the shared identity instead: it
resolves the .reasonix.io rxid cookie via id.reasonix.io/me (Bearer) and
keeps only a per-dashboard role in a new `access` table. Existing roles
migrate by email (migrate-access.sql); ADMIN_EMAILS force-promotes the
owner so a missed migration can't lock them out. Sign-in redirects to the
shared login; sign-out revokes the shared session.

Also: accounts /auth/logout accepts a Bearer token (so crash can revoke
server-side), and the site login honours a next to any *.reasonix.io.
2026-07-01 16:26:24 +08:00
SivanCola ac2f6695c5 Merge pull request #5700 from SivanCola/docs/attribute-todo-panel-contributors
Attribute TodoPanel contributor credit / 补充 TodoPanel 贡献署名
2026-07-01 16:19:41 +08:00
69d0185ff6 docs: leave attribution in PR history
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:13:34 +08:00
4d8caaa944 docs: attribute todo panel contributors
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:10:02 +08:00
SivanCola 5e40ed682b Merge pull request #5698 from SivanCola/fix/todo-panel-batch-collapse
fix(desktop): scope todo panel completion state / 修复待办面板跨会话完成态
2026-07-01 15:56:29 +08:00
SivanCola 1284d1c6c1 fix(desktop): scope todo panel state by session 2026-07-01 15:46:04 +08:00
YHH 3093f03585 feat(site): account UI — sign-in, register, profile, device approval (#5682)
The accounts API (id.reasonix.io) shipped without a web frontend, so no
one could register or sign in. Add Astro pages that consume the JSON API:
login (handles the post-verify ?verified banner), register, forgot/reset,
a gated account page (profile edit, password change, sign out, delete),
and a device-approval page — the human half of the CLI/desktop device
flow (enter the terminal's code, review, approve/reject). Reuses the
site's existing design tokens; all copy is bilingual like the rest of the
site. The API base is build-configurable via PUBLIC_ACCOUNTS_API and
defaults to https://id.reasonix.io.
2026-07-01 15:42:22 +08:00
YHH f39f06896f feat(accounts): device-authorization sign-in for CLI/desktop (#5681)
The worker only did cookie-based web login, so the CLI/desktop had no way
to authenticate without a browser redirect. Add an RFC 8628-style device
flow: /device/start issues a device_code (polled) plus a short user_code
the human approves at APP_ORIGIN/device while signed in; /device/poll
returns the minted cli session on the winning atomic claim. Only the
device code's peppered hash is stored, and the session token is created at
claim time, so it never lands in the DB. Session middleware now resolves a
Bearer token in addition to the cookie, so the one sessions table serves
both web and cli kinds.
2026-07-01 15:42:11 +08:00
YHH cb1a0bfd18 ci(accounts): sync RESEND_API_KEY from repo secret on deploy (#5695)
The worker reads env.RESEND_API_KEY at runtime from Cloudflare, but the
key was only stored as a GitHub Actions secret — where wrangler deploy
never pushes it — so mail silently fell back to the console stub. Add a
deploy step that pipes the RESEND_API_KEY repo secret into
wrangler secret put, giving the mail key a single source of truth and
removing the need for local wrangler auth. Guarded so a missing secret
just skips (stays in stub mode) instead of setting an empty key.
2026-07-01 15:26:57 +08:00
YHH c757d24483 ci(accounts): auto-deploy id.reasonix.io on merge to main-v2 (#5679)
* ci(accounts): auto-deploy id.reasonix.io on merge to main-v2

The accounts worker shipped without a deploy pipeline — only the crash
worker had one, so id.reasonix.io could never go live from CI. Mirror
deploy-crash-worker with the repo's standard pnpm setup (action-setup@v6
+ setup-node cache), path-filtered to workers/accounts/**. Migrations
and secrets stay manual and out of band, same as the crash worker.

* fix(accounts): send from the verified send.reasonix.io domain

MAIL_FROM was [email protected], but only send.reasonix.io is a
verified Resend domain (the apex isn't). Resend rejects sends from an
unverified from-domain, so every verification/reset email would 403.
Send from the verified subdomain instead.
2026-07-01 15:13:54 +08:00
SivanCola c38b0801cc Merge pull request #5691 from SivanCola/fix/transient-blank-session-delete
Fix delete fallback blank sessions / 修复删除后的空白会话残留
2026-07-01 15:05:16 +08:00
SivanCola 8d42210d7c fix(desktop): discard unused transient blank sessions 2026-07-01 14:55:19 +08:00
SivanCola 191772931f fix(desktop): keep delete fallback blanks out of project tree 2026-07-01 14:27:47 +08:00
liaoyl830 5b81febdab fix(provider): include empty properties in tool schemas (#5167) 2026-06-23 21:58:36 +08:00
maoxin1234 c58805afea docs: add reasoning-controls-by-provider reference
Document which OpenAI-compatible backend uses which reasoning protocol
(thinking.type vs reasoning_effort), which params each ignores, and how to
drive thinking on a non-auto-detected backend via the `thinking` field.
Includes a troubleshooting section for telling provider behaviour apart
from a Reasonix bug. Suggested by @eghrhegpe (#5063).
2026-06-23 09:46:05 +08:00
maoxin1234 44876d2607 feat(provider): generic thinking toggle + DeepSeek disable
Broaden the thinking-mode support beyond Zhipu auto-detection (credit
@eghrhegpe, #5063):

- Add a vendor-agnostic `thinking` config field (enabled|disabled) read
  from cfg.Extra, so OpenAI-compatible providers we don't auto-detect
  (e.g. opencode.ai) can drive thinking.type explicitly. An explicit
  field also overrides the Zhipu effort default.
- Let DeepSeek turn thinking off via /effort disabled (off now maps to
  disabled instead of erroring), routed through thinking.type=disabled
  with no reasoning_effort.

Covered by buildRequest/parse tests plus updated DeepSeek effort tests.
2026-06-22 22:28:46 +08:00
maoxin1234 8ecdf392ad feat(provider): add Zhipu GLM thinking-mode support
Zhipu's OpenAI-compatible endpoint (open.bigmodel.cn / api.z.ai) gates
chain-of-thought through `thinking.type` (enabled|disabled) and silently
ignores `reasoning_effort`, so GLM users could not turn thinking off and
paid for reasoning tokens they didn't want.

Detect Zhipu hosts via IsZhipu() and route reasoning control to the
thinking knob, mirroring the existing MiniMax handling: emit thinking.type
and omit reasoning_effort. /effort exposes enabled|disabled and maps stale
depth levels onto them.
2026-06-22 21:27:00 +08:00
399 changed files with 44044 additions and 5103 deletions

No files matched your search

@@ -0,0 +1,49 @@
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
+38
View File
@@ -0,0 +1,38 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
+53 -5
View File
@@ -8,7 +8,8 @@ name: Release desktop
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
#
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
@@ -260,9 +261,9 @@ jobs:
working-directory: desktop
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
# Canary is R2-only and never appears on the GitHub releases page; the mirror
# job picks up the signed dist via the canary-dist artifact below. Stable
# publishes a GitHub release as usual.
# Canary never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the canary-dist artifact below. Stable publishes a
# GitHub release as usual.
- name: Publish GitHub release
if: steps.ver.outputs.channel != 'canary'
env:
@@ -285,7 +286,7 @@ jobs:
needs: publish
runs-on: ubuntu-latest
permissions:
contents: read # gh release download
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
if: ${{ github.repository_owner == 'esengine' }}
@@ -366,6 +367,53 @@ jobs:
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
TAG: ${{ steps.ver.outputs.tag }}
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
.download_page == "https://reasonix.io/#start" and
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
pointer=""
else
pointer="latest"
fi
if [ -n "$pointer" ]; then
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
fi
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
done
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release upload "$VERSION" assets/latest.json --clobber
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
+20
View File
@@ -48,3 +48,23 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
exit 0
;;
esac
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
+10 -40
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"unicode/utf8"
"reasonix/internal/shellparse"
)
type diffOpts struct {
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
fields := splitShellFields(testCmd)
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return false, "invalid test command: " + err.Error()
}
fields := test.Argv
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
if len(test.Env) > 0 {
cmd.Env = append(os.Environ(), test.Env...)
}
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
func splitShellFields(s string) []string {
var out []string
var cur strings.Builder
inSingle, inDouble := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inSingle:
if c == '\'' {
inSingle = false
} else {
cur.WriteByte(c)
}
case inDouble:
if c == '"' {
inDouble = false
} else {
cur.WriteByte(c)
}
case c == '\'':
inSingle = true
case c == '"':
inDouble = true
case c == ' ' || c == '\t' || c == '\n':
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
repo := t.TempDir()
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
import (
"os"
"testing"
)
func TestEnv(t *testing.T) {
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
t.Fatalf("REASONIX_E2E_ENV = %q", got)
}
}
`)
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
if !ok {
t.Fatalf("runTests failed:\n%s", out)
}
}
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
if ok {
t.Fatal("runTests accepted dynamic env assignment")
}
if !strings.Contains(out, "invalid test command: shell expansion") {
t.Fatalf("output = %q, want shell expansion rejection", out)
}
}
func writeE2EFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+9 -4
View File
@@ -101,7 +101,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
and attaches the current desktop manifest to the matching CLI release for old
clients that still ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
@@ -109,9 +111,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
```
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
an update banner when a newer version is published; **Settings → Software update**
has a manual check. Self-update behavior by platform:
The app checks `latest.json` on startup (R2 first, then the
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
newer version is published; **Settings → Software update** has a manual check.
The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
the CLI release line. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
+312 -46
View File
@@ -43,7 +43,9 @@ import (
"reasonix/internal/i18n"
"reasonix/internal/mcpdiag"
"reasonix/internal/memory"
"reasonix/internal/notify"
"reasonix/internal/plugin"
"reasonix/internal/pluginpkg"
"reasonix/internal/provider"
"reasonix/internal/skill"
)
@@ -141,6 +143,9 @@ type App struct {
metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
notificationSenderOnce sync.Once
notificationSender notify.Sender
runtimeEvents asyncRuntimeEmitter
// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
@@ -635,7 +640,9 @@ func (a *App) snapshotAllTabs() {
tabs := a.runtimeTabsLocked()
a.mu.RUnlock()
for _, t := range tabs {
_ = a.snapshotTab(t)
if err := a.snapshotTab(t); err != nil {
slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
}
}
}
@@ -657,8 +664,11 @@ func (a *App) shutdown(context.Context) {
a.mu.RUnlock()
for _, t := range tabs {
if t.Ctrl != nil {
_ = a.snapshotTab(t)
if err := a.snapshotTab(t); err != nil {
slog.Warn("desktop: shutdown snapshot failed", "tab", t.ID, "err", err)
}
t.Ctrl.Close()
t.releaseSessionLease()
}
}
}
@@ -739,6 +749,7 @@ func (a *App) SubmitToTab(tabID, input string) error {
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitDisplay(input, input)
return nil
}
@@ -755,6 +766,7 @@ func (a *App) submitUserTurnToTab(tabID, input string) bool {
if ctrl == nil {
return false
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitUserTurn(input, input)
return true
}
@@ -780,6 +792,7 @@ func (a *App) RunShellForTab(tabID, command string) error {
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.RunShell(command)
return nil
}
@@ -805,6 +818,7 @@ func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitDisplay(display, input)
return nil
}
@@ -824,6 +838,7 @@ func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) e
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitEditedDisplay(display, input, original)
return nil
}
@@ -910,6 +925,43 @@ func (a *App) snapshotTab(tab *WorkspaceTab) error {
return ctrl.Snapshot()
}
func snapshotTabDirect(tab *WorkspaceTab) error {
if tab == nil || tab.ReadOnly || tab.Ctrl == nil {
return nil
}
return tab.Ctrl.Snapshot()
}
func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
if err := a.snapshotTab(tab); err != nil {
a.reportTabSnapshotError(tab, action, err)
if strings.TrimSpace(action) == "" {
return fmt.Errorf("save current session: %w", err)
}
return fmt.Errorf("save current session before %s: %w", action, err)
}
return nil
}
func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
if err == nil {
return
}
tabID := ""
if tab != nil {
tabID = tab.ID
}
slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
if tab == nil || tab.sink == nil {
return
}
prefix := "Session autosave failed"
if strings.TrimSpace(action) != "" && action != "autosave" {
prefix = "Session save failed before " + action
}
tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
}
func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
if tab == nil {
return ""
@@ -1311,6 +1363,45 @@ func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
}
func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
if tab == nil || strings.TrimSpace(tab.TopicID) != "" {
return
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
if strings.TrimSpace(scope) == "global" {
scope = "global"
workspaceRoot = ""
} else {
scope = "project"
workspaceRoot = normalizeProjectRoot(workspaceRoot)
}
topicID := newTopicID()
a.mu.Lock()
if current := a.tabs[tab.ID]; current == tab {
if strings.TrimSpace(tab.TopicID) != "" {
a.mu.Unlock()
return
}
tab.TopicID = topicID
tab.TopicTitle = defaultTopicTitle
a.saveTabsLocked()
} else {
if strings.TrimSpace(tab.TopicID) != "" {
a.mu.Unlock()
return
}
tab.TopicID = topicID
tab.TopicTitle = defaultTopicTitle
}
a.mu.Unlock()
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
a.persistTabSessionPath(tab, tab.currentSessionPath())
a.emitProjectTreeChanged()
}
func messagesHaveConversationContent(messages []provider.Message) bool {
for _, msg := range messages {
if msg.Role != provider.RoleSystem {
@@ -1345,6 +1436,9 @@ func (a *App) ClearSession() error {
if err := ctrl.ClearSession(); err != nil {
return err
}
if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil {
return err
}
tab.resetTelemetry()
a.persistTabSessionPath(tab, ctrl.SessionPath())
a.invalidatePromptHistoryCache()
@@ -1389,6 +1483,8 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
TokenMode: currentTabTokenMode(tab),
SharedHost: sharedHost,
CleanupPendingReconciler: reconcileDesktopCleanupPending,
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
OnSessionRecovered: a.handleTabSessionRecovered(tab),
})
if err != nil {
if teardownTimedOut {
@@ -1420,6 +1516,10 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
if err := tab.ensureSessionLease(path); err != nil {
newCtrl.Close()
return err
}
newCtrl.SetSessionPath(path)
a.mu.Lock()
@@ -1461,13 +1561,17 @@ func removeDesktopSessionArtifacts(path string) error {
type CheckpointMeta struct {
Turn int `json:"turn"`
Prompt string `json:"prompt"`
Files []string `json:"files"` // cumulative files RestoreCode would affect from this turn
Files []string `json:"files"` // stable preview of cumulative files RestoreCode would affect from this turn
FileCount int `json:"fileCount"` // full cumulative file count, including entries omitted from Files
FilesTruncated bool `json:"filesTruncated,omitempty"`
TurnFileCount int `json:"turnFileCount"` // files changed during this turn only
Time int64 `json:"time"` // unix milliseconds
CanCode bool `json:"canCode"`
CanConversation bool `json:"canConversation"`
}
const checkpointFilePreviewLimit = 60
// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
func (a *App) Checkpoints() []CheckpointMeta {
return a.CheckpointsForTab("")
@@ -1503,23 +1607,48 @@ func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
// files RestoreCode would actually affect from this turn.
hasCodeAfter := false
codeFileSet := make(map[string]bool, len(metas)*2)
codeFilePreview := []string{}
for i := len(out) - 1; i >= 0; i-- {
if len(out[i].Files) > 0 {
hasCodeAfter = true
}
for _, f := range out[i].Files {
if codeFileSet[f] {
continue
}
codeFileSet[f] = true
codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
}
out[i].CanCode = hasCodeAfter
out[i].Files = make([]string, 0, len(codeFileSet))
for f := range codeFileSet {
out[i].Files = append(out[i].Files, f)
}
sort.Strings(out[i].Files) // map iteration is unordered; keep the list stable
out[i].FileCount = len(codeFileSet)
out[i].Files = append([]string{}, codeFilePreview...)
out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
}
return out
}
func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
if limit <= 0 || path == "" {
return preview
}
idx := sort.SearchStrings(preview, path)
if idx < len(preview) && preview[idx] == path {
return preview
}
if len(preview) < limit {
preview = append(preview, "")
copy(preview[idx+1:], preview[idx:])
preview[idx] = path
return preview
}
if idx >= limit {
return preview
}
copy(preview[idx+1:], preview[idx:limit-1])
preview[idx] = path
return preview
}
// ToolResultForTab returns the full arguments and output for one tool call that
// were elided from the frontend's in-memory items[] for memory efficiency. The
// caller (frontend ToolCard) loads this on demand when the user expands a
@@ -1787,7 +1916,11 @@ func (a *App) ListSessions() []SessionMeta {
out := make([]SessionMeta, 0, len(infos))
for _, s := range infos {
_, isOpen := open[s.Path]
meta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)
title := strings.TrimSpace(s.CustomTitle)
if title == "" {
title = titles[filepath.Base(s.Path)]
}
meta := sessionMetaFromInfo(s, title, s.Path == active, isOpen, 0)
if route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {
applyChannelSessionRoute(&meta, route)
}
@@ -1812,7 +1945,11 @@ func (a *App) ListTrashedSessions() []SessionMeta {
continue
}
deletedAt := trashedSessionDeletedAt(path)
out = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))
title := strings.TrimSpace(infos[0].CustomTitle)
if title == "" {
title = titles[filepath.Base(path)]
}
out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt))
}
}
sort.Slice(out, func(i, j int) bool {
@@ -2179,7 +2316,11 @@ func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) err
continue
}
if err := item.ctrl.Snapshot(); err != nil {
return err
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
return err
}
slog.Warn("desktop: skipping stale runtime snapshot before removing session",
"session", item.sessionPath, "err", err)
}
item.ctrl.SetSessionPath("")
a.quiesceTabAutosave(item.tab)
@@ -2288,6 +2429,7 @@ func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[
releasedTabs[item.tab] = true
}
a.releaseTabSharedHost(item.tab)
item.tab.releaseSessionLease()
}
}
if item.ctrl == nil {
@@ -2315,11 +2457,7 @@ func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
root = ""
}
if topicID == "" {
topic, err := a.CreateTopic(scope, root, "")
if err != nil {
return err
}
topicID = topic.ID
return a.openTransientBlankRuntime(scope, root)
}
var err error
if a.singleSurfaceLayoutEnabled() {
@@ -2332,6 +2470,56 @@ func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
return err
}
func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
scope = strings.TrimSpace(scope)
if scope != "project" {
scope = "global"
}
actualRoot := ""
if scope == "project" {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
if workspaceRoot == "" {
return fmt.Errorf("workspaceRoot is required")
}
saveWorkspace(workspaceRoot)
_ = addProject(workspaceRoot, "")
actualRoot = workspaceRoot
} else {
actualRoot = globalWorkspaceRoot()
if err := os.MkdirAll(actualRoot, 0o755); err != nil {
return fmt.Errorf("create global workspace: %w", err)
}
}
model, toolApprovalMode := desktopNewSessionDefaults()
sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model)
if err != nil {
return err
}
tab := &WorkspaceTab{
Scope: scope,
WorkspaceRoot: actualRoot,
TopicTitle: defaultTopicTitle,
SessionPath: sessionPath,
model: model,
tokenMode: boot.TokenModeFull,
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
toolApprovalMode: toolApprovalMode,
disabledMCP: map[string]ServerView{},
}
a.mu.Lock()
tab.ID = a.newUniqueTabIDLocked()
tab.sink = &tabEventSink{tabID: tab.ID, app: a}
a.tabs[tab.ID] = tab
a.tabOrder = append(a.tabOrder, tab.ID)
a.activeTabID = tab.ID
a.saveTabsLocked()
a.mu.Unlock()
a.startTabControllerBuild(tab)
return nil
}
func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
a.mu.RLock()
defer a.mu.RUnlock()
@@ -2447,9 +2635,19 @@ func (a *App) PurgeTrashedSession(path string) error {
}
// RenameSession sets a custom display name for a session (empty clears it back to
// the preview). It only affects the history panel; the file on disk is unchanged.
// the preview). The transcript file is unchanged; the canonical name lives in
// the branch meta sidecar, with the legacy .titles.json map kept as a
// compatibility write-through for older desktop data paths.
func (a *App) RenameSession(path, title string) error {
if err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {
dir := a.activeSessionDir()
sessionPath, _, err := validateSessionPath(dir, path)
if err != nil {
return err
}
if err := agent.RenameSession(sessionPath, title); err != nil {
return err
}
if err := setSessionTitle(dir, sessionPath, title); err != nil {
return err
}
a.invalidatePromptHistoryCache()
@@ -2609,11 +2807,13 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
return nil
}
profile := loadTabSessionProfile(sessionPath)
ctrl := tab.Ctrl
if ctrl == nil {
a.mu.Lock()
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
tab.ActivityStatus = ""
@@ -2630,18 +2830,27 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
return nil
}
_ = a.snapshotTab(tab) // persist writable sessions before switching the view.
if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
return err
}
if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
if err := saveTabSessionMeta(tab, oldPath); err != nil {
return fmt.Errorf("save current session metadata before switching sessions: %w", err)
}
}
if tab.hasActiveRuntimeWork() {
if !a.detachRuntimeForReplacement(tab) {
return fmt.Errorf("current session runtime cannot be detached")
}
} else {
ctrl.Close()
tab.releaseSessionLease()
}
a.mu.Lock()
tab.Ctrl = nil
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
tab.ActivityStatus = ""
@@ -3249,8 +3458,10 @@ func (a *App) RemoveWorkspace(dir string) error {
if !tabInWorkspace(tab, dir) {
continue
}
if tab.Ctrl != nil && !tab.ReadOnly {
_ = tab.Ctrl.Snapshot()
if err := snapshotTabDirect(tab); err != nil {
a.mu.Unlock()
slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err)
return fmt.Errorf("save current session before removing workspace: %w", err)
}
a.markTabRemovedLocked(tab)
closeTabs = append(closeTabs, tab)
@@ -4824,6 +5035,7 @@ func (a *App) Commands() []CommandInfo {
{Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin"},
{Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin"},
{Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin"},
{Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin"},
{Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin"},
{Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin"},
{Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin"},
@@ -4890,6 +5102,9 @@ func (a *App) SlashArgs(input string) SlashArgsResult {
DisconnectedMCP: ctrl.DisconnectedMCPNames(),
CurrentModel: model,
}
if names, err := pluginpkg.InstalledNames(config.ReasonixHomeDir()); err == nil {
data.PluginNames = names
}
seen := map[string]bool{}
for _, m := range a.Models() {
data.ModelRefs = append(data.ModelRefs, m.Ref)
@@ -4920,6 +5135,7 @@ type CapabilitiesView struct {
Servers []ServerView `json:"servers"`
Skills []SkillView `json:"skills"`
SkillRoots []SkillRootView `json:"skillRoots"`
Plugins []PluginView `json:"plugins"`
}
// SkillsSettingsView is the skills management page's data, split from MCP
@@ -5002,6 +5218,7 @@ func (a *App) Capabilities() CapabilitiesView {
Servers: a.MCPServers(),
Skills: skills.Skills,
SkillRoots: skills.SkillRoots,
Plugins: a.Plugins(),
}
}
@@ -5433,6 +5650,27 @@ func (a *App) PickSkillFolder() (string, error) {
return normalizeSkillPath(dir), nil
}
// PickPluginFolder opens a directory picker for choosing a local plugin package
// source. It returns the selected directory path; plugin install/plan performs
// manifest validation and decides whether to copy or link the package.
func (a *App) PickPluginFolder() (string, error) {
if a.ctx == nil {
return "", nil
}
cur := a.activeWorkspaceRoot()
if strings.TrimSpace(cur) == "" {
cur, _ = os.Getwd()
}
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Choose plugin folder",
DefaultDirectory: dialogDefaultDirectory(cur),
})
if err != nil || dir == "" {
return "", err
}
return filepath.Clean(dir), nil
}
// AddSkillPath adds a custom skill root to the user config and rebuilds the
// controller so the skills index and slash menu reflect it immediately.
func (a *App) AddSkillPath(path string) error {
@@ -6370,7 +6608,9 @@ func (a *App) SetModelForTab(tabID, name string) error {
if prevPath == "" {
prevPath = tab.Ctrl.SessionPath()
}
_ = a.snapshotTab(tab)
if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
return err
}
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
@@ -6389,11 +6629,27 @@ func (a *App) SetModelForTab(tabID, name string) error {
TokenMode: currentTabTokenMode(tab),
SharedHost: sharedHost,
CleanupPendingReconciler: reconcileDesktopCleanupPending,
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
OnSessionRecovered: a.handleTabSessionRecovered(tab),
})
if err != nil {
tab.releaseSessionLease()
return err
}
a.bindControllerDisplayRecorder(newCtrl)
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, tab.mode)
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
if err := tab.ensureSessionLease(path); err != nil {
newCtrl.Close()
tab.releaseSessionLease()
return err
}
resumeWithFreshSystemPrompt(newCtrl, carried, path)
a.persistTabSessionPath(tab, path)
a.mu.Lock()
tab.Ctrl = newCtrl
tab.model = name
@@ -6401,14 +6657,6 @@ func (a *App) SetModelForTab(tabID, name string) error {
tab.Label = newCtrl.Label()
a.saveTabsLocked()
a.mu.Unlock()
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, tab.mode)
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
resumeWithFreshSystemPrompt(newCtrl, carried, path)
a.persistTabSessionPath(tab, path)
return nil
}
@@ -6474,7 +6722,9 @@ func (a *App) SetEffortForTab(tabID, level string) error {
if prevPath == "" {
prevPath = tab.Ctrl.SessionPath()
}
_ = a.snapshotTab(tab)
if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
return err
}
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
@@ -6489,11 +6739,26 @@ func (a *App) SetEffortForTab(tabID, level string) error {
TokenMode: currentTabTokenMode(tab),
SharedHost: sharedHost,
CleanupPendingReconciler: reconcileDesktopCleanupPending,
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
OnSessionRecovered: a.handleTabSessionRecovered(tab),
})
if err != nil {
tab.releaseSessionLease()
return err
}
a.bindControllerDisplayRecorder(newCtrl)
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, tab.mode)
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
if err := tab.ensureSessionLease(path); err != nil {
newCtrl.Close()
tab.releaseSessionLease()
return err
}
resumeWithFreshSystemPrompt(newCtrl, carried, path)
a.persistTabSessionPath(tab, path)
a.mu.Lock()
tab.Ctrl = newCtrl
tab.model = modelRef
@@ -6503,13 +6768,6 @@ func (a *App) SetEffortForTab(tabID, level string) error {
tab.Ready = true
a.saveTabsLocked()
a.mu.Unlock()
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, tab.mode)
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
resumeWithFreshSystemPrompt(newCtrl, carried, path)
a.persistTabSessionPath(tab, path)
return nil
}
@@ -6551,7 +6809,9 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
if prevPath == "" {
prevPath = oldCtrl.SessionPath()
}
_ = a.snapshotTab(tab)
if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
return err
}
carried = oldCtrl.History()
}
sharedHost := a.lookupSharedHost(tab.SharedHostKey)
@@ -6565,14 +6825,27 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
TokenMode: mode,
SharedHost: sharedHost,
CleanupPendingReconciler: reconcileDesktopCleanupPending,
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
OnSessionRecovered: a.handleTabSessionRecovered(tab),
})
if err != nil {
return err
}
a.bindControllerDisplayRecorder(newCtrl)
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, tab.mode)
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
if err := tab.ensureSessionLease(path); err != nil {
newCtrl.Close()
return err
}
resumeWithFreshSystemPrompt(newCtrl, carried, path)
if oldCtrl != nil {
oldCtrl.Close()
}
a.persistTabSessionPath(tab, path)
a.mu.Lock()
tab.Ctrl = newCtrl
tab.model = modelRef
@@ -6582,13 +6855,6 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
tab.Ready = true
a.saveTabsLocked()
a.mu.Unlock()
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, tab.mode)
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
resumeWithFreshSystemPrompt(newCtrl, carried, path)
a.persistTabSessionPath(tab, path)
return nil
}
+343 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -49,7 +50,12 @@ func waitForFile(t *testing.T, path, want string) {
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
@@ -132,6 +138,314 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
waitForAutosaveIdle(t, tab)
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures = tab.saveFailures
tab.saveMu.Unlock()
if failures != 0 {
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
}
}
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
originalTopic := "topic_original"
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
t.Fatalf("set original topic title: %v", err)
}
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
Scope: "global",
TopicID: originalTopic,
TopicTitle: "Original",
Preview: "first",
Turns: 2,
SchemaVersion: agent.BranchMetaCountsVersion,
}); err != nil {
t.Fatalf("SaveBranchMeta original: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: originalTopic,
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
if tab.TopicID == "" || tab.TopicID == originalTopic {
t.Fatalf("tab topic ID = %q, want fresh recovery topic", tab.TopicID)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
}
tabMeta := app.tabMeta(tab, true)
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
found := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.TopicID == tab.TopicID {
found = true
if !node.Recovered || node.RecoveryDigest != meta.RecoveryDigest || node.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("project tree recovery node = %+v, want digest %q parent %q", node, meta.RecoveryDigest, meta.ParentID)
}
}
walk(node.Children)
}
}
walk(nodes)
if !found {
t.Fatalf("project tree did not include recovery topic %q: %#v", tab.TopicID, nodes)
}
}
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
OriginalPath: originalPath,
BranchMeta: agent.BranchMeta{
Name: agent.RecoveryBranchDefaultName,
Scope: "global",
TopicID: "topic_recovery",
TopicTitle: "Recovery",
},
})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
lease, err := agent.TryAcquireSessionLease(recovery.Path)
if err != nil {
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
}
defer lease.Release()
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
runtimeEvents := make(chan runtimeEventEnvelope, 4)
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(nil), payload...),
}
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: "topic_original",
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
err = tab.Ctrl.Snapshot()
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
}
if got := tab.Ctrl.SessionPath(); got != originalPath {
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
}
if tab.SessionPath != originalPath {
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
}
if tab.TopicID != "topic_original" {
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
}
deadline := time.After(time.Second)
for {
select {
case emitted := <-runtimeEvents:
if emitted.name != "session:recovery-failed" {
continue
}
if len(emitted.payload) != 1 {
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
}
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
if !ok {
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
}
if failed.Reason != "lease_held" {
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
}
return
case <-deadline:
t.Fatal("session:recovery-failed event was not emitted")
}
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, _ := appWithTab(t, path)
a.tabs["target_tab"] = &WorkspaceTab{
ID: "target_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabOrder = []string{"test_tab", "target_tab"}
err := a.SetActiveTab("target_tab")
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
}
if a.activeTabID != "test_tab" {
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
}
}
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
target := filepath.Join(t.TempDir(), "target.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
if err := sess.Save(target); err != nil {
t.Fatalf("save target: %v", err)
}
loaded, err := agent.LoadSession(target)
if err != nil {
t.Fatalf("load target: %v", err)
}
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
t.Fatalf("rebind error = %v, want persistence failure", err)
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
@@ -187,6 +501,34 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
}
}
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs[survivor.ID] = survivor
a.tabOrder = []string{tab.ID, survivor.ID}
err := a.CloseTab(tab.ID)
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
t.Fatalf("CloseTab error = %v, want persistence failure", err)
}
if _, ok := a.tabs[tab.ID]; !ok {
t.Fatal("tab was removed even though its session could not be saved")
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
+217
View File
@@ -3,6 +3,8 @@ package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -189,6 +191,221 @@ func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
}
}
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
tabA := waitForTabReady(t, app, first.ID)
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
}
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
if second.ID == first.ID {
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
}
tabB := waitForTabReady(t, app, second.ID)
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
}
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
if err != nil {
t.Fatalf("create project A empty session: %v", err)
}
tab := &WorkspaceTab{
ID: "blank-project-b",
Scope: "project",
WorkspaceRoot: projectB,
SessionPath: pathA,
}
if blankTabSessionPathHasNoContent(tab) {
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
}
}
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
forked, err := app.Fork(turn)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.ID == "" || forked.ID == second.ID {
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
}
forkTab := waitForTabReady(t, app, forked.ID)
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(forkTab.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
if err := app.Rewind(turn, "conversation"); err != nil {
t.Fatalf("Rewind: %v", err)
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
t.Helper()
if tab == nil || tab.Ctrl == nil {
t.Fatal("tab controller is required")
}
sys := systemPromptFrom(tab.Ctrl.History())
if strings.TrimSpace(sys) == "" {
t.Fatal("tab controller did not expose a system prompt")
}
sessionDir := tab.Ctrl.SessionDir()
sessionPath := tab.Ctrl.SessionPath()
workspaceRoot := tab.Ctrl.WorkspaceRoot()
label := tab.Ctrl.Label()
tab.Ctrl.Close()
sess := agent.NewSession(sys)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Runner: ag,
Executor: ag,
SessionDir: sessionDir,
SessionPath: sessionPath,
WorkspaceRoot: workspaceRoot,
Label: label,
SystemPrompt: sys,
Sink: event.Discard,
})
tab.Ctrl = ctrl
app.bindControllerDisplayRecorder(ctrl)
return ctrl
}
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
t.Helper()
ctrl.SubmitUserTurn(input, input)
waitNotRunning(t, ctrl)
deadline := time.Now().Add(time.Second)
for {
checkpoints := ctrl.Checkpoints()
if len(checkpoints) > 0 {
return checkpoints[len(checkpoints)-1].Turn
}
if time.Now().After(deadline) {
t.Fatal("controller did not record a checkpoint")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
+614 -5
View File
@@ -27,7 +27,9 @@ import (
"reasonix/internal/jobs"
"reasonix/internal/memory"
"reasonix/internal/plugin"
"reasonix/internal/pluginpkg"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/tool"
)
@@ -955,21 +957,36 @@ api_key_env = "DEEPSEEK_API_KEY"
}
app := NewApp()
if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth {
t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth)
}
if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
t.Fatalf("SetSubagentModel: %v", err)
}
if err := app.SetSubagentEffort("max"); err != nil {
t.Fatalf("SetSubagentEffort: %v", err)
}
if err := app.SetMaxSubagentDepth(1); err != nil {
t.Fatalf("SetMaxSubagentDepth(1): %v", err)
}
if err := app.SetMaxSubagentDepth(2); err != nil {
t.Fatalf("SetMaxSubagentDepth(2): %v", err)
}
got := app.Settings()
if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
}
if got.Agent.MaxSubagentDepth != 2 {
t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
}
if cfg.Agent.MaxSubagentDepth != 2 {
t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth)
}
}
func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {
@@ -1214,6 +1231,385 @@ api_key_env = "DEEPSEEK_API_KEY"
}
}
func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
isolateDesktopUserDirs(t)
view := NewApp().Settings()
if len(view.ProviderPresets) < 18 {
t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets))
}
got := map[string]ProviderPresetView{}
for _, preset := range view.ProviderPresets {
got[preset.ID] = preset
}
for _, curated := range config.CuratedProviderPresets() {
id := curated.ID
preset, ok := got[id]
if !ok {
t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
}
if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
t.Fatalf("preset %q view has missing fields: %+v", id, preset)
}
}
}
func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView {
t.Helper()
for _, preset := range view.ProviderPresets {
if preset.ID == id {
return preset
}
}
t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
return ProviderPresetView{}
}
func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
[desktop]
provider_access = []
[[providers]]
name = "mimo-api"
kind = "openai"
base_url = "https://custom.example/v1"
models = ["custom-model"]
default = "custom-model"
api_key_env = "MIMO_API_KEY"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
view := NewApp().Settings()
presetView := providerPresetViewByID(t, view, "mimo-api")
if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView)
}
var providerView *ProviderView
for i := range view.Providers {
if view.Providers[i].Name == "mimo-api" {
providerView = &view.Providers[i]
break
}
}
if providerView == nil {
t.Fatal("mimo-api provider view missing")
}
if providerView.Added {
t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it")
}
}
func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) {
isolateDesktopUserDirs(t)
preset, ok := config.CuratedProviderPreset("mimo-api")
if !ok || len(preset.Entries) == 0 {
t.Fatal("missing mimo-api preset")
}
legacy := preset.Entries[0]
legacy.PresetID = ""
legacy.PresetVersion = 0
cfg := config.Default()
if err := cfg.UpsertProvider(legacy); err != nil {
t.Fatalf("upsert legacy provider: %v", err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
view := NewApp().Settings()
presetView := providerPresetViewByID(t, view, "mimo-api")
if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView)
}
}
func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
isolateDesktopUserDirs(t)
preset, ok := config.CuratedProviderPreset("mimo-api")
if !ok || len(preset.Entries) == 0 {
t.Fatal("missing mimo-api preset")
}
modified := preset.Entries[0]
modified.BaseURL = "https://custom.example/v1"
cfg := config.Default()
if err := cfg.UpsertProvider(modified); err != nil {
t.Fatalf("upsert modified provider: %v", err)
}
cfg.Desktop.ProviderAccess = []string{"mimo-api"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
view := NewApp().Settings()
presetView := providerPresetViewByID(t, view, "mimo-api")
if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView)
}
}
func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
isolateDesktopUserDirs(t)
preset, ok := config.CuratedProviderPreset("mimo-api")
if !ok || len(preset.Entries) == 0 {
t.Fatal("missing mimo-api preset")
}
similar := preset.Entries[0]
similar.Name = "my-mimo"
similar.PresetID = ""
similar.PresetVersion = 0
cfg := config.Default()
if err := cfg.UpsertProvider(similar); err != nil {
t.Fatalf("upsert similar provider: %v", err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
view := NewApp().Settings()
presetView := providerPresetViewByID(t, view, "mimo-api")
if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) {
t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView)
}
}
func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("MIMO_API_KEY", "")
os.Unsetenv("MIMO_API_KEY")
if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil {
t.Fatalf("AddProviderPresetAccess: %v", err)
} else if warning != "" {
t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
}
cfg := config.LoadForEdit(config.UserConfigPath())
p, ok := cfg.Provider("mimo-api")
if !ok {
t.Fatal("mimo-api provider not saved")
}
if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
t.Fatalf("mimo-api provider after preset add = %+v", p)
}
if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion)
}
if !p.NoProxy {
t.Fatal("mimo-api preset should save no_proxy = true")
}
if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") {
t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels)
}
if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
}
if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess)
}
data, err := os.ReadFile(config.UserCredentialsPath())
if err != nil {
t.Fatalf("read saved credentials: %v", err)
}
if !strings.Contains(string(data), "MIMO_API_KEY=sk-mimo") {
t.Fatalf("saved credentials missing MiMo key:\n%s", data)
}
view := NewApp().Settings()
var presetView *ProviderPresetView
var providerView *ProviderView
for i := range view.ProviderPresets {
if view.ProviderPresets[i].ID == "mimo-api" {
presetView = &view.ProviderPresets[i]
}
}
for i := range view.Providers {
if view.Providers[i].Name == "mimo-api" {
providerView = &view.Providers[i]
}
}
if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet {
t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView)
}
if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet {
t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView)
}
}
func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("MIMO_API_KEY", "")
os.Unsetenv("MIMO_API_KEY")
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
cfg := config.Default()
custom := config.ProviderEntry{
Name: "mimo-api",
Kind: "openai",
BaseURL: "https://custom.example/v1",
Models: []string{"custom-model"},
Default: "custom-model",
APIKeyEnv: "MIMO_API_KEY",
Headers: map[string]string{"X-Custom": "keep-me"},
}
if err := cfg.UpsertProvider(custom); err != nil {
t.Fatalf("upsert custom provider: %v", err)
}
cfg.Desktop.ProviderAccess = []string{"mimo-api"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil {
t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider")
} else if !strings.Contains(err.Error(), "provider name(s) already exist") {
t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err)
} else if warning != "" {
t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning)
}
cfg = config.LoadForEdit(config.UserConfigPath())
got, ok := cfg.Provider("mimo-api")
if !ok {
t.Fatal("mimo-api provider missing after rejected add")
}
if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) {
t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom)
}
data, err := os.ReadFile(config.UserCredentialsPath())
if err != nil {
t.Fatalf("read saved credentials: %v", err)
}
if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
t.Fatalf("credentials changed after rejected add:\n%s", data)
}
}
func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("MIMO_API_KEY", "")
os.Unsetenv("MIMO_API_KEY")
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
cfg := config.Default()
custom := config.ProviderEntry{
Name: "mimo-api",
Kind: "openai",
BaseURL: "https://custom.example/v1",
Models: []string{"custom-model"},
Default: "custom-model",
APIKeyEnv: "MIMO_API_KEY",
Headers: map[string]string{"X-Custom": "remove-me"},
}
if err := cfg.UpsertProvider(custom); err != nil {
t.Fatalf("upsert custom provider: %v", err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil {
t.Fatalf("ResetProviderPresetAccess: %v", err)
}
cfg = config.LoadForEdit(config.UserConfigPath())
got, ok := cfg.Provider("mimo-api")
if !ok {
t.Fatal("mimo-api provider missing after reset")
}
if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
}
if len(got.Headers) != 0 {
t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers)
}
if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess)
}
data, err := os.ReadFile(config.UserCredentialsPath())
if err != nil {
t.Fatalf("read saved credentials: %v", err)
}
if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
t.Fatalf("credentials changed after reset:\n%s", data)
}
presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api")
if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView)
}
}
func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
isolateDesktopUserDirs(t)
if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil {
t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider")
} else if !strings.Contains(err.Error(), "no same-name provider exists") {
t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err)
}
}
func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
for _, preset := range config.CuratedProviderPresets() {
preset := preset
t.Run(preset.ID, func(t *testing.T) {
isolateDesktopUserDirs(t)
if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil {
t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err)
} else if warning != "" {
t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning)
}
cfg := config.LoadForEdit(config.UserConfigPath())
access := providerAccessSet(cfg.Desktop.ProviderAccess)
for _, entry := range preset.Entries {
got, ok := cfg.Provider(entry.Name)
if !ok {
t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID)
}
if !access[entry.Name] {
t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess)
}
if got.Kind != entry.Kind || got.BaseURL != entry.BaseURL || got.DefaultModel() != entry.DefaultModel() || got.APIKeyEnv != entry.APIKeyEnv || got.AuthHeader != entry.AuthHeader || got.NoProxy != entry.NoProxy {
t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry)
}
if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion {
t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion)
}
if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol {
t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry)
}
if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) {
t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry)
}
if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) {
t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry)
}
}
view := NewApp().Settings()
var presetView *ProviderPresetView
for i := range view.ProviderPresets {
if view.ProviderPresets[i].ID == preset.ID {
presetView = &view.ProviderPresets[i]
break
}
}
if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured {
t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView)
}
})
}
}
func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "")
@@ -1523,6 +1919,13 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
cfg := config.Default()
preset, ok := config.CuratedProviderPreset("mimo-api")
if !ok || len(preset.Entries) == 0 {
t.Fatal("mimo-api preset missing")
}
if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
t.Fatalf("upsert mimo-api preset: %v", err)
}
cfg.DefaultModel = "mimo-api/mimo-v2.5-pro"
cfg.Desktop.ProviderAccess = []string{"mimo-api"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
@@ -1534,14 +1937,13 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
for _, want := range []string{
"mimo-api/mimo-v2.5-pro",
"mimo-api/mimo-v2.5",
"mimo-api/mimo-v2-omni",
} {
if !refs[want] {
t.Fatalf("Models() refs = %+v, missing %s", models, want)
}
}
if len(models) != 3 {
t.Fatalf("Models() len = %d, want 3: %+v", len(models), models)
if len(models) != 2 {
t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
}
}
@@ -1871,6 +2273,34 @@ func TestEnsureTabControllerWorkspaceRebuildsStaleWorkspace(t *testing.T) {
assertTabRebuiltToPinnedWorkspace(t, f)
}
func TestEnsureTabControllerWorkspaceWarnsWhenPinnedSessionSwitchesWorkspace(t *testing.T) {
f := newStaleWorkspaceBindingFixture(t, "warn_workspace_switch")
events := make(chan event.Event, 8)
f.tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
events <- e
}))
if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
t.Fatalf("ensureTabControllerWorkspace: %v", err)
}
assertTabRebuiltToPinnedWorkspace(t, f)
deadline := time.After(2 * time.Second)
for {
select {
case e := <-events:
if e.Kind == event.Notice &&
e.Level == event.LevelWarn &&
strings.Contains(e.Text, f.projectA) &&
strings.Contains(e.Text, "switched tab") {
return
}
case <-deadline:
t.Fatal("did not receive workspace switch warning notice")
}
}
}
func TestSteerForTabReconcilesStaleWorkspaceBeforeIdleFallback(t *testing.T) {
f := newStaleWorkspaceBindingFixture(t, "steer_idle_fallback")
@@ -3201,8 +3631,8 @@ func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
if tab.TopicID == topicID {
t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID)
}
if strings.TrimSpace(tab.TopicID) == "" {
t.Fatalf("fallback tab %q has empty topic ID", id)
if strings.TrimSpace(tab.TopicID) != "" {
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
}
}
trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
@@ -3676,6 +4106,17 @@ func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
if err := app.RenameSession(pathA, "A title"); err != nil {
t.Fatalf("RenameSession in active session dir: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(pathA)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after RenameSession ok=%v err=%v", ok, err)
}
if meta.CustomTitle != "A title" {
t.Fatalf("custom title should be written to branch meta, got %q", meta.CustomTitle)
}
sessions = app.ListSessions()
if len(sessions) != 1 || sessions[0].Title != "A title" {
t.Fatalf("ListSessions should return custom title from branch meta, got %+v", sessions)
}
if titles := loadSessionTitles(dirA); titles["a.jsonl"] != "A title" {
t.Fatalf("title should be written beside the active session, got %+v", titles)
}
@@ -4209,6 +4650,51 @@ args = ["-y", "@playwright/mcp"]
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
home := isolateDesktopUserDirs(t)
reasonixHome := filepath.Join(home, ".reasonix")
root := filepath.Join(reasonixHome, "plugins", "superpowers")
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{
"name": "superpowers",
"version": "6.1.0",
"description": "Planning workflows",
"skills": "./skills/"
}`), 0o644); err != nil {
t.Fatal(err)
}
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
Name: "superpowers",
Root: "plugins/superpowers",
Version: "6.1.0",
Description: "Planning workflows",
ManifestKind: "codex",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
app := NewApp()
plugins := app.Capabilities().Plugins
if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 {
t.Fatalf("Capabilities().Plugins = %+v", plugins)
}
if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/plan" {
t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails)
}
}
func TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -5304,6 +5790,129 @@ func TestRunShellForTabRoutesToRequestedTab(t *testing.T) {
}
}
func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := t.TempDir()
projectB := t.TempDir()
globalRoot := t.TempDir()
shellEvents := make(chan event.Event, 64)
projectEvents := make(chan event.Event, 64)
globalEvents := make(chan event.Event, 64)
shellCtrl := control.New(control.Options{
Sink: event.FuncSink(func(e event.Event) { shellEvents <- e }),
WorkspaceRoot: projectA,
})
projectCtrl := control.New(control.Options{
Sink: event.FuncSink(func(e event.Event) { projectEvents <- e }),
WorkspaceRoot: projectB,
})
globalCtrl := control.New(control.Options{
Sink: event.FuncSink(func(e event.Event) { globalEvents <- e }),
WorkspaceRoot: globalRoot,
})
defer shellCtrl.Close()
defer projectCtrl.Close()
defer globalCtrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{
"shell": {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true},
"project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true},
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true},
},
tabOrder: []string{"shell", "project-b", "global"},
activeTabID: "shell",
}
marker := "shell-route-marker.txt"
if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil {
t.Fatalf("RunShellForTab: %v", err)
}
waitForShellDispatch(t, shellEvents, marker)
waitForFile(t, filepath.Join(projectA, marker), "shell")
for i := 0; i < 8; i++ {
if err := app.SetActiveTab("project-b"); err != nil {
t.Fatalf("SetActiveTab(project-b): %v", err)
}
if err := app.SetActiveTab("global"); err != nil {
t.Fatalf("SetActiveTab(global): %v", err)
}
if err := app.SetActiveTab("shell"); err != nil {
t.Fatalf("SetActiveTab(shell): %v", err)
}
}
if err := app.SetActiveTab("project-b"); err != nil {
t.Fatalf("SetActiveTab(project-b final): %v", err)
}
app.CancelTab("shell")
cancelled := false
deadline := time.After(15 * time.Second)
for {
select {
case e := <-shellEvents:
if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
cancelled = e.Tool.Err != ""
}
if e.Kind == event.TurnDone {
if !cancelled {
t.Fatal("shell tab finished without a cancelled shell result")
}
if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("shell marker appeared in project-b workspace: %v", err)
}
if got := activeTabIDForTest(app); got != "project-b" {
t.Fatalf("active tab = %q, want project-b after background shell cancel", got)
}
assertNoEvents(t, projectEvents, "project-b")
assertNoEvents(t, globalEvents, "global")
return
}
case <-deadline:
t.Fatal("timed out waiting for shell tab cancellation")
}
}
}
func longRunningMarkerCommand(marker string) string {
if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker)
}
return fmt.Sprintf("printf shell > %s; sleep 30", marker)
}
func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) {
t.Helper()
deadline := time.After(5 * time.Second)
for {
select {
case e := <-ch:
if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) {
return
}
case <-deadline:
t.Fatal("timed out waiting for shell dispatch")
}
}
}
func activeTabIDForTest(app *App) string {
app.mu.RLock()
defer app.mu.RUnlock()
return app.activeTabID
}
func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) {
t.Helper()
select {
case e := <-ch:
t.Fatalf("%s received event while shell ran in another tab: %+v", name, e)
default:
}
}
type blockingRunner struct {
started chan struct{}
release chan struct{}
+22 -1
View File
@@ -15,6 +15,7 @@ import (
"reasonix/internal/bot"
"reasonix/internal/bot/feishu"
"reasonix/internal/bot/weixin"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
@@ -48,6 +49,7 @@ type BotConnectionView struct {
Model string `json:"model"`
ToolApprovalMode string `json:"toolApprovalMode"`
WorkspaceRoot string `json:"workspaceRoot"`
Access BotAccessView `json:"access"`
Credential BotConnectionCredentialView `json:"credential"`
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
LastError string `json:"lastError"`
@@ -164,6 +166,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
Label: "微信",
Enabled: true,
Status: "connected",
Access: botInstallAccess(result.UserID),
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
}, func(c *config.Config) {
c.Bot.Enabled = true
@@ -518,6 +521,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
Label: label,
Enabled: true,
Status: "connected",
Access: botInstallAccess(userID),
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
}, func(c *config.Config) {
c.Bot.Enabled = true
@@ -545,6 +549,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
if conn.Status == "" {
conn.Status = "connected"
}
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
conn.ToolApprovalMode = "ask"
}
if conn.ID == "" {
conn.ID = connectionID(conn.Provider, conn.Domain)
}
@@ -556,6 +563,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
for i, existing := range c.Bot.Connections {
if existing.ID == conn.ID {
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
conn.Access = existing.Access
}
c.Bot.Connections[i] = conn
replaced = true
break
@@ -697,6 +707,7 @@ func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
return BotConnectionView{
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
Access: botAccessViewFromConfig(conn.Access),
Credential: BotConnectionCredentialView{
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
SecretSet: botCredentialSecretSet(conn),
@@ -763,8 +774,9 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
Enabled: view.Enabled,
Status: strings.TrimSpace(view.Status),
Model: strings.TrimSpace(view.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
Access: botAccessConfigFromView(view.Access),
Credential: config.BotConnectionCredential{
AppID: strings.TrimSpace(view.Credential.AppID),
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
@@ -873,6 +885,15 @@ func connectionID(provider, domain string) string {
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
}
func botInstallAccess(userID string) config.BotAccessConfig {
userID = strings.TrimSpace(userID)
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
if userID != "" {
access.Users = []string{userID}
}
return access
}
func randomInstallID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"log"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
const botForwardSendTimeout = 30 * time.Second
const botForwardQueueSize = 64
// ── Forward target ──────────────────────────────────────────────────────────
// botForwardTarget identifies one remote chat to send forwarded events to.
type botForwardTarget struct {
ConnID string
Domain string
ChatID string
ChatType bot.ChatType
}
// ── Event forwarder ─────────────────────────────────────────────────────────
// botEventForwarder implements event.Sink and forwards relevant events to
// connected bot channels through the desktopBotRuntime. It is attached to a
// tabEventSink when a heartbeat task should push AI output to IM channels.
//
// It accumulates Text events and sends them as complete messages on TurnDone
// (and occasionally during generation when the buffer grows large enough), so
// the remote side sees progressive streaming output rather than one big blob.
type botEventForwarder struct {
runtime *desktopBotRuntime
targets []botForwardTarget
mu sync.Mutex
buf strings.Builder
queueMu sync.Mutex
queue chan string
closed bool
closeOnce sync.Once
}
// newBotEventForwarder creates a forwarder that sends to all given targets.
// runtime may be nil — Emit calls are then no-ops.
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
f := &botEventForwarder{
runtime: runtime,
targets: targets,
queue: make(chan string, botForwardQueueSize),
}
go f.run()
return f
}
// Emit implements event.Sink. It forwards text and lifecycle events to the
// connected bot channels; reasoning, tool dispatch, and other internal events
// are dropped to avoid noisy IM output.
func (f *botEventForwarder) Emit(e event.Event) {
if f.runtime == nil || len(f.targets) == 0 {
return
}
switch e.Kind {
case event.TurnStarted:
f.mu.Lock()
f.buf.Reset()
f.mu.Unlock()
case event.Text:
f.mu.Lock()
f.buf.WriteString(e.Text)
size := f.buf.Len()
f.mu.Unlock()
// Flush opportunistically when the buffer crosses a threshold, so long
// streams (e.g. "tell me three jokes") produce multiple messages.
if size >= 400 {
f.flush()
}
case event.TurnDone:
f.flush()
f.Close()
case event.ApprovalRequest:
// The heartbeat turn belongs to the desktop tab controller, not the bot
// gateway session, so remote /approve replies cannot satisfy this ID.
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
text += "\n请回到桌面窗口处理。"
f.sendToAll(text)
case event.AskRequest:
var qb strings.Builder
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
for i, q := range e.Ask.Questions {
if i > 0 {
qb.WriteString("\n")
}
qb.WriteString(q.Prompt)
}
qb.WriteString("\n请回到桌面窗口处理。")
f.sendToAll(qb.String())
case event.Notice:
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
case event.CompactionStarted:
f.sendToAll("🔄 正在压缩上下文...")
}
}
// flush sends the accumulated buffer as one message per target channel.
func (f *botEventForwarder) flush() {
f.mu.Lock()
text := strings.TrimSpace(f.buf.String())
if text == "" {
f.mu.Unlock()
return
}
f.buf.Reset()
f.mu.Unlock()
f.sendToAll(text)
}
// sendToAll dispatches text to every target channel. Errors are logged and
// non-fatal; a failed target does not block other targets.
func (f *botEventForwarder) sendToAll(text string) {
text = strings.TrimSpace(text)
if f.runtime == nil || len(f.targets) == 0 || text == "" {
return
}
f.queueMu.Lock()
defer f.queueMu.Unlock()
if f.closed {
return
}
select {
case f.queue <- text:
default:
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
}
}
func (f *botEventForwarder) run() {
for text := range f.queue {
f.sendToAllNow(text)
}
}
func (f *botEventForwarder) sendToAllNow(text string) {
for _, tgt := range f.targets {
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
ChatID: tgt.ChatID,
ChatType: tgt.ChatType,
Text: text,
})
cancel()
if err != nil {
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
}
}
}
func (f *botEventForwarder) Close() {
f.closeOnce.Do(func() {
f.flush()
f.queueMu.Lock()
f.closed = true
close(f.queue)
f.queueMu.Unlock()
})
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
type desktopForwardTestAdapter struct {
platform bot.Platform
name string
messages chan bot.InboundMessage
entered chan struct{}
release chan struct{}
sent chan bot.OutboundMessage
once sync.Once
}
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
return &desktopForwardTestAdapter{
platform: bot.PlatformFeishu,
name: "forward-test",
messages: make(chan bot.InboundMessage),
entered: make(chan struct{}),
release: make(chan struct{}),
sent: make(chan bot.OutboundMessage, 8),
}
}
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
func (a *desktopForwardTestAdapter) Name() string { return a.name }
func (a *desktopForwardTestAdapter) Start(context.Context) error {
return nil
}
func (a *desktopForwardTestAdapter) Stop() error { return nil }
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
return nil
}
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
return a.messages
}
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
a.once.Do(func() { close(a.entered) })
select {
case <-a.release:
case <-ctx.Done():
return bot.SendResult{}, ctx.Err()
}
a.sent <- msg
return bot.SendResult{MessageID: "sent"}, nil
}
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
ID: "feishu-lark",
Domain: "lark",
Platform: bot.PlatformFeishu,
Adapter: adapter,
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
return &desktopBotRuntime{gw: gw}
}
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
done := make(chan struct{})
go func() {
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("Emit blocked behind slow bot send")
}
select {
case <-adapter.entered:
case <-time.After(500 * time.Millisecond):
t.Fatal("adapter send did not start")
}
forwarder.Close()
close(adapter.release)
select {
case <-adapter.sent:
case <-time.After(500 * time.Millisecond):
t.Fatal("queued bot message was not sent after adapter release")
}
}
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
close(adapter.release)
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "approval-1",
Tool: "shell",
Subject: "run command",
}})
forwarder.Close()
select {
case msg := <-adapter.sent:
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
}
if !strings.Contains(msg.Text, "桌面") {
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("approval notice was not sent")
}
}
+118 -4
View File
@@ -33,6 +33,30 @@ func newDesktopBotRuntime() *desktopBotRuntime {
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
}
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
channel := bot.ChannelConfig{
Model: strings.TrimSpace(qq.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
}
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
return channels, connectionChannels
}
if channels == nil {
channels = make(map[bot.Platform]bot.ChannelConfig)
}
if _, ok := channels[bot.PlatformQQ]; !ok {
channels[bot.PlatformQQ] = channel
}
if connectionChannels == nil {
connectionChannels = make(map[string]bot.ChannelConfig)
}
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
connectionChannels[string(bot.PlatformQQ)] = channel
}
return channels, connectionChannels
}
func (a *App) refreshBotRuntimeAsync() {
if a.ctx == nil {
return
@@ -92,13 +116,33 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
ctx, cancel := context.WithCancel(parent)
modelName := botruntime.ModelName(cfg, "")
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
gwCfg := bot.GatewayConfig{
Model: modelName,
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
MaxSteps: cfg.Bot.MaxSteps,
QueueMode: cfg.Bot.QueueMode,
QueueCap: cfg.Bot.QueueCap,
QueueDrop: cfg.Bot.QueueDrop,
PairingEnabled: cfg.Bot.Pairing.Enabled,
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
SelfUserIDs: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
},
ControlEnabled: cfg.Bot.Control.Enabled,
ControlAddr: cfg.Bot.Control.Addr,
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
WorkspaceRoot: workspaceRoot,
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true),
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true),
Channels: channels,
ConnectionChannels: connectionChannels,
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
Enabled: plan.Enabled,
Allowlist: bot.AllowlistConfig{
Enabled: cfg.Bot.Allowlist.Enabled,
@@ -108,6 +152,16 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
},
Approvers: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
},
Admins: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
},
Groups: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
@@ -206,8 +260,8 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
if !cfg.Bot.Enabled {
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
}
if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"}
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
}
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
if len(unknown) > 0 {
@@ -258,3 +312,63 @@ func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
return true
}
// SendToAdapter sends a message through the running gateway's adapter
// identified by connID. Returns an error if the gateway is not running
// or no matching adapter is found.
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
r.mu.Lock()
gw := r.gw
r.mu.Unlock()
if gw == nil {
return bot.SendResult{}, nil // gateway not running — silent no-op
}
return gw.SendToAdapter(ctx, connID, domain, msg)
}
// Running returns true if the bot gateway is currently active.
func (r *desktopBotRuntime) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.gw != nil
}
// ForwardTargets returns the list of bot forward targets derived from the
// current config's bot connections and their session mappings. Each mapping
// produces one target (connID + chatID + chatType) for event forwarding.
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
if cfg == nil {
return nil
}
var targets []botForwardTarget
seen := make(map[botForwardTarget]bool)
for _, conn := range cfg.Bot.Connections {
if !conn.Enabled {
continue
}
connID := botruntime.ConnectionRuntimeID(conn)
domain := strings.TrimSpace(conn.Domain)
for _, sm := range conn.SessionMappings {
remoteID := strings.TrimSpace(sm.RemoteID)
if remoteID == "" {
continue
}
chatType := bot.ChatDM
if sm.ChatType != "" {
chatType = bot.ChatType(sm.ChatType)
}
target := botForwardTarget{
ConnID: connID,
Domain: domain,
ChatID: remoteID,
ChatType: chatType,
}
if seen[target] {
continue
}
seen[target] = true
targets = append(targets, target)
}
}
return targets
}
+70
View File
@@ -39,6 +39,7 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = false
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
@@ -49,6 +50,44 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
}
}
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = true
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if !plan.Start {
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
}
}
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
Model: "qq-model",
ToolApprovalMode: "auto",
WorkspaceRoot: "/tmp/qq-project",
}, nil, nil)
channel, ok := channels[bot.PlatformQQ]
if !ok {
t.Fatalf("platform QQ channel missing: %+v", channels)
}
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
}
connectionChannel, ok := connectionChannels["qq"]
if !ok {
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
}
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
}
}
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = false
@@ -63,6 +102,37 @@ func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
}
}
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
SessionMappings: []config.BotConnectionSessionMapping{
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
},
}}
targets := newDesktopBotRuntime().ForwardTargets(cfg)
if len(targets) != 2 {
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
}
seen := map[string]bool{}
for _, target := range targets {
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
if seen[key] {
t.Fatalf("duplicate target %q in %+v", key, targets)
}
seen[key] = true
}
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
t.Fatalf("targets = %+v, want group and dm targets", targets)
}
}
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
+2 -2
View File
@@ -42,9 +42,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
}
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil ||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil ||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("Settings() contains nil array fields: %+v", got)
+74
View File
@@ -25,6 +25,28 @@ func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
}
}
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
t.Helper()
raw, err := json.Marshal(metas)
if err != nil {
t.Fatal(err)
}
var payload []struct {
Files json.RawMessage `json:"files"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
for i, item := range payload {
if string(item.Files) == "null" {
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
}
if len(item.Files) == 0 || item.Files[0] != '[' {
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
}
}
}
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
// reverts files touched in that turn or any later one, so a turn with no file
// changes of its own can still rewind code when a later turn changed files. The
@@ -77,4 +99,56 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
}
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
}
if len(metas[2].Files) != 0 {
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
}
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
}
if len(metas[0].Files) != checkpointFilePreviewLimit {
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
}
if !metas[0].FilesTruncated {
t.Fatal("turn 0 should mark file preview as truncated")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
+3 -3
View File
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
// release tag used in download URLs (e.g. "desktop-v1.1.0").
func genManifest(dir, version, tag string) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" {
repo = "esengine/reasonix"
if repo == "" || repo == "esengine/reasonix" {
repo = "esengine/DeepSeek-Reasonix"
}
m := update.Manifest{
Version: version,
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
DownloadPage: "https://reasonix.io/#start",
Platforms: map[string]update.Asset{},
}
entries, err := os.ReadDir(dir)
+4 -1
View File
@@ -85,6 +85,9 @@ func TestGenManifest(t *testing.T) {
if m.Version != "v1.2.0" {
t.Fatalf("version = %q, want v1.2.0", m.Version)
}
if m.DownloadPage != "https://reasonix.io/#start" {
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
}
if len(m.Platforms) != 5 {
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
}
@@ -92,7 +95,7 @@ func TestGenManifest(t *testing.T) {
if !ok {
t.Fatal("windows-amd64 missing")
}
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
if win.URL != wantURL {
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
}
+2 -2
View File
@@ -10,9 +10,9 @@
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
},
"dependencies": {
"@gsap/react": "^2.1.2",
@@ -16,7 +16,16 @@ const transpiled = ts.transpileModule(source, {
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl);
const {
dismissedTodoKeyForScope,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldOpenTodoPanelByDefault,
shouldShowTodoPanel,
todoBatchKey,
todoDismissalKey,
todoPanelScope,
} = await import(moduleUrl);
const completedTodos = [
{ content: "Inspect the report", status: "completed" },
@@ -42,6 +51,66 @@ assert.equal(
false,
"a user dismissal still hides that exact todo list",
);
const completedKey = todoDismissalKey(completedTodos);
const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"a completed todo dismissal hides the list in the session where it was closed",
);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos),
true,
"a completed todo dismissal in one session does not hide another session's list",
);
dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey));
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"closing another session's todo does not forget the first session dismissal",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-a",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
}),
"session:/sessions/a.jsonl",
"a restored history session uses its session path as the todo panel scope",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-b",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
eventChannel: "desktop-events",
}),
"tab:tab-b",
"a stale active-tab fallback must not scope dismissal to the previous session",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-c",
activeTab: {
id: "tab-c",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
},
}),
"tab:tab-c",
"an unsaved topic session uses its tab id so sibling sessions do not share todo state",
);
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
@@ -61,6 +130,36 @@ assert.notEqual(
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"real progress produces a fresh dismissal key",
);
assert.equal(
todoBatchKey(activeTodos),
todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"status progress stays in the same todo batch",
);
assert.equal(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])),
"status progress keeps the same scoped open-state key",
);
assert.notEqual(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)),
"the same task list in a different session gets isolated open state",
);
assert.notEqual(
todoBatchKey(activeTodos),
todoBatchKey([{ content: "Run a different task", status: "in_progress" }]),
"new task content creates a new todo batch",
);
assert.equal(
shouldOpenTodoPanelByDefault(activeTodos),
true,
"new incomplete todo batches open by default",
);
assert.equal(
shouldOpenTodoPanelByDefault(completedTodos),
false,
"completed todo batches collapse by default",
);
const iterations = 200_000;
const started = performance.now();
+230 -10
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
@@ -10,8 +10,11 @@ import {
Activity,
CircleHelp,
Command,
Copy as RestoreIcon,
Download,
Minus,
Search,
Square,
SquarePen,
PanelLeft,
PanelRight,
@@ -29,12 +32,13 @@ import {
Brain,
Cpu,
Palette,
X,
} from "lucide-react";
import { useToast } from "./lib/toast";
import { asArray } from "./lib/array";
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
import { useController, type Item, type LiveStream } from "./lib/useController";
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
import { playSuccessChime } from "./lib/sound";
import { Transcript } from "./components/Transcript";
@@ -59,7 +63,15 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
import "./custom/features/heartbeat/heartbeat.css";
import { CopyButton } from "./components/CopyButton";
import { parseTodos } from "./lib/tools";
import { shouldShowTodoPanel, todoDismissalKey } from "./lib/todoVisibility";
import {
dismissedTodoKeyForScope,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldShowTodoPanel,
todoBatchKey,
todoDismissalKey,
todoPanelScope,
} from "./lib/todoVisibility";
import {
type BotConnectionView,
type BotRuntimeStatusView,
@@ -182,9 +194,70 @@ function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutSt
return "classic";
}
const SHOW_CONTEXT_DOCK = true;
const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys";
const MAX_DISMISSED_TODO_KEYS = 160;
type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string };
type WorkspaceInsertTarget = "composer" | "planRevision";
type DesktopPlatform = "darwin" | "windows" | "linux";
function WindowsWindowControls() {
const [maximised, setMaximised] = useState(false);
const syncMaximised = useCallback(() => {
void app.IsMainWindowMaximised()
.then(setMaximised)
.catch(() => setMaximised(false));
}, []);
useEffect(() => {
syncMaximised();
window.addEventListener("resize", syncMaximised);
window.addEventListener("focus", syncMaximised);
return () => {
window.removeEventListener("resize", syncMaximised);
window.removeEventListener("focus", syncMaximised);
};
}, [syncMaximised]);
const toggleMaximise = useCallback(() => {
void app.ToggleMaximiseMainWindow()
.then(() => window.setTimeout(syncMaximised, 80))
.catch(() => undefined);
}, [syncMaximised]);
return (
<div className="windows-window-controls" aria-label="Window controls">
<button
className="windows-window-control windows-window-control--minimize"
type="button"
aria-label="Minimize window"
title="Minimize"
onClick={() => void app.MinimiseMainWindow()}
>
<Minus size={13} strokeWidth={1.9} />
</button>
<button
className="windows-window-control windows-window-control--maximize"
type="button"
aria-label="Maximize or restore window"
aria-pressed={maximised}
title={maximised ? "Restore" : "Maximize"}
onClick={toggleMaximise}
>
{maximised ? <RestoreIcon size={12} strokeWidth={1.75} /> : <Square size={11} strokeWidth={1.8} />}
</button>
<button
className="windows-window-control windows-window-control--close"
type="button"
aria-label="Close window"
title="Close"
onClick={() => void app.CloseMainWindow()}
>
<X size={13} strokeWidth={1.9} />
</button>
</div>
);
}
type HistoryViewState =
| { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] }
| { kind: "history"; source: "all"; sessions: SessionMeta[] }
@@ -231,6 +304,29 @@ type SidebarImConnectionDetailProps = {
onManageAllowlist: () => void;
};
function loadDismissedTodoKeys(): Set<string> {
try {
const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY);
if (!saved) return new Set();
const parsed = JSON.parse(saved) as unknown;
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
} catch {
return new Set();
}
}
function saveDismissedTodoKeys(keys: ReadonlySet<string>): void {
try {
window.localStorage.setItem(
DISMISSED_TODO_STORAGE_KEY,
JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)),
);
} catch {
/* ignore quota errors */
}
}
function isSidebarImConnection(connection: BotConnectionView): boolean {
return connection.provider === "feishu" || connection.provider === "weixin";
}
@@ -652,6 +748,21 @@ function browserPlatformOverride(): DesktopPlatform | null {
return null;
}
const GUIDANCE_QUEUE_MOCK_ITEMS = [
"先确认发送后输入框为什么残留刚发的消息,再决定修哪里。",
"保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。",
"最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。",
] as const;
function browserMockScenarioParam(): string {
if (typeof window === "undefined" || window.runtime) return "";
return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? "";
}
function isGuidanceMockScenario(value: string): boolean {
return value === "guidance" || value === "guide" || value === "steer";
}
function detectBrowserPlatform(): DesktopPlatform {
const override = browserPlatformOverride();
if (override) return override;
@@ -919,6 +1030,9 @@ export default function App() {
const rightDockMode = useLayoutStore((s) => s.rightDockMode);
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
const [dockRefreshKey, setDockRefreshKey] = useState(0);
const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0);
const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []);
const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`;
const [projectRevision, setProjectRevision] = useState(0);
const [activeTopicTurns, setActiveTopicTurns] = useState<number | undefined>(undefined);
const [composerInsertRequest, setComposerInsertRequest] = useState<ComposerInsertRequest | null>(null);
@@ -1395,7 +1509,11 @@ export default function App() {
// a stale local dismissal cannot hide work that still blocks final readiness;
// completed lists collapse automatically and can then be dismissed. The
// dismissal key is still based on stable todo content/state so history reloads
// do not resurrect the same finished list under a different event id.
// do not resurrect the same finished list under a different event id. The
// batch key ignores status changes so progress within the same task list does
// not look like a brand-new task batch. Dismissal and open state are scoped to
// the active session/topic/tab so different projects and sessions do not hide
// or reopen each other's todo panels.
const todoEntry = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const it = state.items[i];
@@ -1407,9 +1525,30 @@ export default function App() {
}, [state.items]);
const todoItem = todoEntry?.item ?? null;
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
const [dismissedTodo, setDismissedTodo] = useState<string | null>(null);
const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(loadDismissedTodoKeys);
const todoKey = useMemo(() => todoDismissalKey(todos), [todos]);
const todoBatch = useMemo(() => todoBatchKey(todos), [todos]);
const todoScope = useMemo(
() => todoPanelScope({ activeTab, activeTabId, eventChannel: state.meta?.eventChannel }),
[activeTab, activeTabId, state.meta?.eventChannel],
);
const dismissedTodo = useMemo(
() => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey),
[dismissedTodoKeys, todoKey, todoScope],
);
const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
const dismissTodos = useCallback(() => {
if (!scopedTodoKey) return;
setDismissedTodoKeys((current) => {
if (current.has(scopedTodoKey)) return current;
const next = new Set(current);
next.add(scopedTodoKey);
saveDismissedTodoKeys(next);
return next;
});
}, [scopedTodoKey]);
const sessionTitle = topicTitle(activeTab);
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
@@ -2126,6 +2265,16 @@ export default function App() {
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
}, [transcriptItems, rewindState]);
const latestGuidanceConsumed = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const item = state.items[i];
if (item.kind === "notice" && item.text.startsWith("↪ ")) {
return { key: item.id, text: item.text.slice(2) };
}
}
return null;
}, [state.items]);
// send wrapper: commits any pending optimistic rewind before sending.
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
if (activeTab?.readOnly) throw new Error("channel session is read-only");
@@ -2442,6 +2591,37 @@ export default function App() {
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
[enqueueNavigation]);
useEffect(() => {
return onSessionRecovered((event) => {
const scope = event.scope === "project" ? "project" : "global";
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
setProjectRevision((value) => value + 1);
void refreshTabMetas();
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
? {
actionLabel: t("recovery.open"),
durationMs: 9000,
onAction: () => {
void enqueueNavigation({
kind: "topic",
scope,
workspaceRoot,
topicId: event.topicId || "",
sessionPath: event.recoveryPath || "",
});
},
}
: { durationMs: 9000 });
});
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
useEffect(() => {
return onSessionRecoveryFailed((event) => {
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
showToast(t(key), "error", { durationMs: 9000 });
});
}, [showToast, t]);
const handleNewTab = useCallback(async () => {
closeTransientOverlays();
setSidebarImDetailConnectionId("");
@@ -2623,7 +2803,7 @@ export default function App() {
setProjectRevision((value) => value + 1);
const tabs = await refreshTabMetas();
if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
await syncActiveTab(true);
await syncActiveTab(false);
}
}, [activeTabId, refreshTabMetas, syncActiveTab]);
@@ -2680,6 +2860,8 @@ export default function App() {
: t("sidebar.collapse");
const sidebarNavTooltipDisabled = !sidebarCollapsed;
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
const workspacePanelResetWidth = rightDockDetailActive
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
: defaultRightDockTreeWidth();
@@ -2700,7 +2882,23 @@ export default function App() {
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
const recoveryBannerTitle = activeTab?.recovered
? [
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
].filter(Boolean).join(" · ")
: "";
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const windowsFramelessChrome = desktopPlatform === "windows";
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (!windowsFramelessChrome) return;
const target = event.target as HTMLElement | null;
if (!target?.closest(".app-chrome, .topicbar, .workbench-dock__tools")) return;
if (target.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return;
event.preventDefault();
void app.ToggleMaximiseMainWindow();
}, [windowsFramelessChrome]);
// Creation keeps the classic sidebar/chat structure while gating chrome tweaks
// behind its own style flag so classic/workbench remain unchanged.
const appChromeHidden = sidebarWorkbench || sidebarCreation;
@@ -2715,11 +2913,13 @@ export default function App() {
<ShellExpandProvider>
<ShellHotkeys />
<TextSizeHotkeys />
<div
ref={appRef}
className={[
<div
ref={appRef}
onDoubleClickCapture={handleWindowsTitlebarDoubleClick}
className={[
"app",
`app--${desktopPlatform}`,
windowsFramelessChrome ? "app--windows-frameless" : "",
browserPreviewChrome ? "app--browser-preview" : "",
sidebarWorkbench ? "app--workbench" : "",
sidebarCreation ? "app--creation" : "",
@@ -3240,6 +3440,13 @@ export default function App() {
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
)}
{activeTab?.recovered && !sidebarImDetailConnection && (
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
<span className="banner__badge">{t("recovery.branch")}</span>
<span>{t("recovery.banner")}</span>
</div>
)}
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
<main className="main">
@@ -3280,7 +3487,14 @@ export default function App() {
{!sidebarImDetailConnection && (
<footer className="footer" ref={footerRef}>
{showTodos && <TodoPanel todos={todos} onDismiss={() => setDismissedTodo(todoKey)} />}
{showTodos && (
<TodoPanel
key={scopedTodoBatch}
stateKey={scopedTodoBatch}
todos={todos}
onDismiss={dismissTodos}
/>
)}
{rewindState && (
<UndoRewindBanner
meta={{
@@ -3372,6 +3586,10 @@ export default function App() {
retry={state.retry}
transientDismissSignal={transientOverlayDismissSignal}
sessionKey={composerSessionKey}
fileRefRefreshKey={composerFileRefRefreshKey}
guidanceConsumedKey={latestGuidanceConsumed?.key}
guidanceConsumedText={latestGuidanceConsumed?.text}
guidanceQueuePreviewItems={guidanceQueueMockItems}
/>
<StatusBar
context={state.context}
@@ -3487,6 +3705,7 @@ export default function App() {
onPreviewModeChange={handleWorkspacePreviewModeChange}
onAddToChat={addWorkspaceTextToComposer}
onRequestPanelWidth={ensureWorkspacePanelWidth}
onFileTreeRefresh={refreshComposerFileRefs}
refreshKey={dockRefreshKey}
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
showViewTabs={false}
@@ -3566,6 +3785,7 @@ export default function App() {
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
void handleOpenTopic(scope, workspaceRoot, topicId);
}} />
{windowsFramelessChrome && <WindowsWindowControls />}
</div>
</ShellExpandProvider>
);
@@ -69,6 +69,12 @@ ok(
"AppChrome markup includes classic tab strip containers",
);
ok(
/const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
/\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
"AppChrome exposes the classic drag rail on macOS and Windows",
);
ok(
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
@@ -252,6 +258,37 @@ for (const selector of [
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
]) {
const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
ok(
finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
paddingRight.includes("--chrome-panel-control-size") &&
!paddingRight.includes("--chrome-right-toggle-offset"),
`${selector} keeps titlebar tools fixed beside the Windows controls`,
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
]) {
ok(
finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
`${selector} stays fixed outside the Windows window controls`,
);
}
ok(
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
"Windows classic chrome keeps a draggable rail while tabs remain clickable",
);
for (const selector of [
".layout--workbench-chrome-hidden",
":root[data-theme-style] .layout--workbench-chrome-hidden",
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
@@ -12,6 +13,17 @@ import type { WireApproval } from "../lib/types";
let passed = 0;
let failed = 0;
type GsapToOptions = { onComplete?: () => void };
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
to?: (target: unknown, vars: GsapToOptions) => unknown;
};
if (typeof gsapForTests.to === "function") {
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
}
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
@@ -196,5 +208,77 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "tool-approval",
tool: "bash",
subject: "npm run build\n\nRun the build command to verify frontend artifacts.",
},
});
const subject = document.querySelector(".approval-subject");
ok(subject != null, "tool approval shows its full subject by default");
eq(
subject?.textContent,
"npm run build\n\nRun the build command to verify frontend artifacts.",
"default-open tool approval keeps the complete subject visible",
);
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<[boolean, boolean, boolean]> = [];
const { root } = await renderApproval({
approval: {
id: "memory-approval",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
},
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
});
const text = document.body.textContent ?? "";
ok(text.includes("Allow once"), "fresh-human approval shows allow once");
ok(text.includes("Deny"), "fresh-human approval shows deny");
ok(!text.includes("Allow for session"), "fresh-human approval hides session grant");
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|4Deny",
"fresh-human approval keeps allow/deny shortcut keys",
);
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
if (!allowButton) throw new Error("allow once button did not render");
await act(async () => {
allowButton.click();
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([[true, false, false]]), "fresh-human approval allows only once");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -43,7 +43,7 @@ ok(
"App loads secondary drawers on demand",
);
ok(
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage|PluginsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
!/import\s+\{[^}]*\bMemorySettingsPage\b[^}]*\}\s+from\s+["']\.\/MemoryPanel["']/.test(settingsSource),
"SettingsPanel keeps secondary settings pages out of the first settings chunk",
);
@@ -2,11 +2,11 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MCPServersSettingsPage } from "../components/CapabilitiesPanel";
import { MCPServersSettingsPage, PluginsSettingsPage } from "../components/CapabilitiesPanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { Meta, ServerView, TabMeta } from "../lib/types";
import type { Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
@@ -80,6 +80,7 @@ function installDom() {
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
@@ -106,6 +107,16 @@ function findButton(label: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
}
function setInputValue(input: HTMLInputElement, value: string) {
const win = input.ownerDocument.defaultView;
const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
setter?.call(input, value);
const eventCtor = win?.Event ?? Event;
const inputEventCtor = win?.InputEvent ?? eventCtor;
input.dispatchEvent(new inputEventCtor("input", { bubbles: true, data: value, inputType: "insertText" } as InputEventInit));
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
}
console.log("capabilities panel MCP actions");
{
@@ -233,3 +244,221 @@ console.log("capabilities panel MCP actions");
});
dom.window.close();
}
console.log("capabilities panel plugin actions");
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
const tabs: TabMeta[] = [{
id: "tab-plugin",
scope: "project",
workspaceRoot: "/tmp/reasonix-test",
workspaceName: "reasonix-test",
topicId: "topic-plugin",
topicTitle: "Plugins",
label: "Plugins",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "auto",
active: true,
cwd: "/tmp/reasonix-test",
}];
let planCalls = 0;
let installCalls = 0;
let toggleCalls = 0;
let updateCalls = 0;
let doctorCalls = 0;
let removeCalls = 0;
let pickFolderCalls = 0;
const plannedSources: string[] = [];
const installedSources: string[] = [];
let plugins: PluginView[] = [{
name: "superpowers",
version: "0.1.0",
description: "Shared agent skills and hooks.",
source: "git:github.com/obra/superpowers",
root: "~/.reasonix/plugins/superpowers",
manifestKind: "reasonix",
enabled: true,
skills: 2,
hooks: 1,
mcpServers: 0,
}];
window.go = {
main: {
App: {
Meta: async () => meta,
ListTabs: async () => tabs,
Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
planCalls += 1;
plannedSources.push(source);
ok(options.dryRun === true, "plugin preview asks for dry-run planning");
return JSON.stringify({
ok: true,
status: "planned",
name: "superpowers",
actions: [{ kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned" }],
});
},
InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
installCalls += 1;
installedSources.push(source);
const next: PluginView = {
name: "superpowers",
version: "0.1.1",
description: "Shared agent skills and hooks.",
source,
root: "~/.reasonix/plugins/superpowers",
manifestKind: "reasonix",
enabled: true,
skills: 3,
hooks: 1,
mcpServers: 1,
skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/plan", runAs: "inline" }],
hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
mcpServerDetails: [{ name: "context", transport: "stdio", command: "node server.js" }],
};
plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
},
SetPluginEnabled: async (name: string, enabled: boolean) => {
toggleCalls += 1;
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
},
UpdatePlugin: async (name: string) => {
updateCalls += 1;
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
return JSON.stringify({ ok: true, status: "done", name });
},
PluginDoctor: async (name: string) => {
doctorCalls += 1;
return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
},
RemovePlugin: async (name: string) => {
removeCalls += 1;
plugins = plugins.filter((plugin) => plugin.name !== name);
},
PickPluginFolder: async () => {
pickFolderCalls += 1;
return "/tmp/superpowers-plugin";
},
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
await flush();
});
await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
.map((option) => option.textContent ?? "");
ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
const chooseFolder = findButton("Choose plugin folder");
if (!chooseFolder) throw new Error("missing plugin folder picker button");
await act(async () => {
chooseFolder.click();
await flush();
});
await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
const gitMode = findButton("Git repository");
if (!gitMode) throw new Error("missing Git repository install mode");
await act(async () => {
gitMode.click();
await flush();
});
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
if (!sourceInput) throw new Error("missing plugin git source input");
await act(async () => {
setInputValue(sourceInput, "git:github.com/obra/superpowers");
await flush();
});
await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
const preview = findButton("Preview");
if (!preview) throw new Error("missing plugin preview button");
await act(async () => {
preview.click();
await flush();
});
await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
const install = findButton("Install plugin");
if (!install) throw new Error("missing plugin install button");
await act(async () => {
install.click();
await flush();
});
await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
if (!disclosure) throw new Error("missing plugin disclosure");
await act(async () => {
disclosure.click();
await flush();
});
await waitFor("plugin update action", () => Boolean(findButton("Update")));
ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
ok(document.body.textContent?.includes("/plan") ?? false, "expanded plugin details list exported skill invocations");
ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
const update = findButton("Update");
if (!update) throw new Error("missing plugin update button");
await act(async () => {
update.click();
await flush();
});
await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
const doctor = findButton("Doctor");
if (!doctor) throw new Error("missing plugin doctor button");
await act(async () => {
doctor.click();
await flush();
});
await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
if (!toggle) throw new Error("missing plugin enable toggle");
await act(async () => {
toggle.click();
await flush();
});
await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
const remove = findButton("Remove plugin");
if (!remove) throw new Error("missing plugin remove button");
await act(async () => {
remove.click();
await flush();
});
const confirmRemove = findButton("Confirm remove");
if (!confirmRemove) throw new Error("missing plugin confirm remove button");
await act(async () => {
confirmRemove.click();
await flush();
});
await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
await act(async () => {
root.unmount();
});
dom.window.close();
}
@@ -8,7 +8,7 @@ import { Composer, composerPickFileEntry } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { AppBindings } from "../lib/bridge";
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
import type { CollaborationMode, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -200,6 +200,16 @@ async function waitFor(label: string, predicate: () => boolean) {
throw new Error(`timed out waiting for ${label}`);
}
type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
function fileEntry(name: string): DirEntry {
return { name, isDir: false };
}
async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
await rerender({ insertRequest: { id, text, mode: "replace" } });
}
console.log("\ncomposer goal toggle");
{
@@ -568,5 +578,327 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer({
running: true,
guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
});
let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
if (!guidanceMore) throw new Error("guidance overflow button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
if (!guidanceMore) throw new Error("guidance collapse button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
},
});
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
ok(sendButton.disabled === true, "running steer button stays disabled without input");
await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
ok(sendButton.disabled === false, "running steer button enables after text input");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
eq(textarea.value, "", "queued running guidance clears the composer draft");
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!guidanceItem) throw new Error("running guidance chip did not render");
ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
await act(async () => {
dismissButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
submitDisabled: true,
onSend: (displayText) => {
calls.send.push(displayText);
},
});
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer();
await replaceComposerDraft(rerender, 101, "@");
await waitFor("initial @ directory load", () => listDirCalls === 1);
await replaceComposerDraft(rerender, 102, "");
await replaceComposerDraft(rerender, 103, "@");
await waitFor("@ directory revalidation call", () => listDirCalls === 2);
eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
await replaceComposerDraft(rerender, 201, "@");
await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
await rerender({ fileRefRefreshKey: "1" });
await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const realDateNow = Date.now;
let now = 1000;
let searchCalls = 0;
Date.now = () => now;
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => {
searchCalls += 1;
return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
},
});
const { root, rerender } = await renderComposer();
try {
await replaceComposerDraft(rerender, 301, "@alpha");
await waitFor("initial @ search request", () => searchCalls === 1);
eq(searchCalls, 1, "@ search fetches the first query");
await replaceComposerDraft(rerender, 302, "");
now = 2000;
await replaceComposerDraft(rerender, 303, "@alpha");
await act(async () => {
await flushTimers();
});
eq(searchCalls, 1, "@ search cache is reused inside the TTL");
await replaceComposerDraft(rerender, 304, "");
now = 7001;
await replaceComposerDraft(rerender, 305, "@alpha");
await waitFor("expired @ search cache refresh", () => searchCalls === 2);
eq(searchCalls, 2, "@ search cache revalidates after the TTL");
} finally {
Date.now = realDateNow;
}
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
if (listDirCalls === 1) {
return new Promise<DirEntry[]>((resolve) => {
staleListDirResolve = resolve;
});
}
if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
return new Promise<DirEntry[]>((resolve) => {
thirdListDirResolve = resolve;
});
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
await replaceComposerDraft(rerender, 401, "@cache");
await waitFor("initial stale @ directory request", () => listDirCalls === 1);
await rerender({ fileRefRefreshKey: "1" });
await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
await act(async () => {
await flushTimers();
});
staleListDirResolve?.([fileEntry("cache-stale.txt")]);
await act(async () => {
await flushTimers();
});
await replaceComposerDraft(rerender, 402, "");
await replaceComposerDraft(rerender, 403, "@cache");
await waitFor("second fresh @ directory request", () => listDirCalls === 3);
await act(async () => {
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
thirdListDirResolve?.([fileEntry("cache-later.txt")]);
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -5,6 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { UserMessage } from "../components/Message";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
@@ -137,7 +138,9 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
root.render(
<LocaleProvider>
<ToastProvider>
<Composer {...currentProps} />
<div className="chat-pane">
<Composer {...currentProps} />
</div>
</ToastProvider>
</LocaleProvider>,
);
@@ -183,6 +186,29 @@ function imagePasteEvent(file: File): Event {
return event;
}
function imageViewerOpen(): boolean {
return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
}
function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const paint = async () => {
await act(async () => {
root.render(
<LocaleProvider>
<div className="chat-pane">
<UserMessage text={text} {...props} />
</div>
</LocaleProvider>,
);
await flushTimers();
});
};
return { root, paint };
}
console.log("\ncomposer image capability");
{
@@ -255,5 +281,107 @@ console.log("\ncomposer image capability");
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
SavePastedImage: async () => ".reasonix/attachments/mock.png",
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root } = await renderComposer({ imageInputEnabled: true });
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
await act(async () => {
textarea().dispatchEvent(imagePasteEvent(file));
await flushTimers();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
if (!thumb) throw new Error("missing composer image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(imageViewerOpen);
ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
await paint();
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
if (!thumb) throw new Error("missing message image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
if (!close) throw new Error("missing image viewer close button");
await act(async () => {
close.click();
await flushTimers();
});
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
turn: 1,
onEdit: () => true,
});
await paint();
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
if (!edit) throw new Error("missing message edit button");
await act(async () => {
edit.click();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
if (!thumb) throw new Error("missing edit image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(imageViewerOpen);
ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { contextBreakdown, contextCostDisplay, contextSourceRows, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
let passed = 0;
@@ -49,7 +49,7 @@ eq(
42_124,
"legend values sum to used context tokens",
);
eq(Math.round(mock.otherPct), 33, "donut endpoint follows used/window percent");
eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
eq(
@@ -90,9 +90,16 @@ eq(
reasoningPct: 0,
otherPct: 0,
},
"unknown context window keeps donut segments empty",
"unknown context window keeps usage segments empty",
);
console.log("\ncontext window status");
eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
console.log("\ncontext panel cost");
const infoCost = contextCostDisplay({
@@ -118,6 +125,32 @@ console.log("\ncontext panel cache rate");
eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
console.log("\ncontext panel usage refresh key");
eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
ok(
contextUsageRefreshKey({
totalTokens: 10,
promptTokens: 10,
completionTokens: 0,
reasoningTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
}) !== contextUsageRefreshKey({
totalTokens: 11,
promptTokens: 10,
completionTokens: 1,
reasoningTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
}),
"general token changes refresh even when cache counters stay unchanged",
);
console.log("\ncontext panel source rows");
@@ -0,0 +1,39 @@
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
import { parsePastedBlocks } from "../components/Message";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (JSON.stringify(a) === JSON.stringify(b)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
function wrapped(label: string, content: string): string {
return `${label}\n\n--- Begin ${label} ---\n${content}\n--- End ${label} ---`;
}
console.log("\nmessage pasted blocks");
for (const [label, name] of [
["[已粘贴文本 #2 · 31 行]", "Simplified Chinese"],
["[已貼上文字 #2 · 31 行]", "Traditional Chinese"],
["[Pasted text #2 · 31 lines]", "English"],
] as const) {
eq(
parsePastedBlocks(`before\n${label}\nafter`, wrapped(label, "line 1\nline 2")),
[{ label, content: "line 1\nline 2\n" }],
`${name} pasted text labels are parsed from submit text`,
);
}
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -74,7 +74,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
};
}
function meta(): Meta {
function meta(overrides: Partial<Meta> = {}): Meta {
return {
label: "model",
ready: true,
@@ -91,6 +91,7 @@ function meta(): Meta {
tokenMode: "full",
goal: "",
goalStatus: "stopped",
...overrides,
};
}
@@ -211,10 +212,14 @@ await act(async () => {
});
const guardedStartupTabs = deferred<TabMeta[]>();
let ensureBlankSurfaceCalls = 0;
const staleProjectA = "/repo/project-a";
const targetProjectB = "/repo/project-b";
const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
window.go.main.App = {
ListTabs: async () => guardedStartupTabs.promise,
MetaForTab: async () => meta(),
MetaForTab: async (tabID: string) => tabID === "tab-new"
? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
: meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
@@ -224,9 +229,17 @@ window.go.main.App = {
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
EnsureBlankSurface: async () => {
ensureBlankSurfaceCalls += 1;
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
return tabMeta({
id: "tab-new",
topicId: "topic-new",
topicTitle: "New session",
workspaceRoot: targetProjectB,
workspaceName: "project-b",
workspacePath: targetProjectB,
cwd: targetProjectB,
});
},
} as Partial<AppBindings> as AppBindings;
@@ -239,20 +252,31 @@ await act(async () => {
});
await act(async () => {
await controller?.ensureBlankSurface("project", "/repo");
await controller?.ensureBlankSurface("project", targetProjectB);
await flushPromises();
});
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
await act(async () => {
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
guardedStartupTabs.resolve([tabMeta({
id: "tab-old",
topicId: "topic-old",
topicTitle: "Old session",
workspaceRoot: staleProjectA,
workspaceName: "project-a",
workspacePath: staleProjectA,
cwd: staleProjectA,
})]);
await guardedStartupTabs.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
await act(async () => {
guardRoot.unmount();
@@ -6,6 +6,9 @@ import {
activeSessionAncestorKeys,
projectTreeTopicOpenRequest,
projectTreeShouldSuppressOpenForRename,
projectTreeReadActivityKey,
projectTreeTopicHasUnreadActivity,
projectTreeShouldRenderTopicActions,
} from "../components/ProjectTree";
import type { ProjectNode } from "../lib/types";
@@ -104,6 +107,58 @@ eq(
"regular project topic still opens by topic",
);
const completedTopic: ProjectNode = {
key: "topic_complete",
kind: "topic",
label: "Completed",
root: "/repo",
topicId: "topic-complete",
lastActivityAt: 2000,
};
const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
true,
"completed inactive topic with newer activity shows unread attention",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
false,
"completed topic stops showing unread attention once opened at its latest activity",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
false,
"active topic does not show unread attention",
);
eq(
projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
false,
"running topic keeps runtime status instead of completed-unread attention",
);
eq(
projectTreeShouldRenderTopicActions(false, true, false),
true,
"read workbench topic renders hover actions",
);
eq(
projectTreeShouldRenderTopicActions(false, true, true),
false,
"unread workbench topic omits hover actions from the keyboard tab order",
);
eq(
projectTreeShouldRenderTopicActions(true, true, false),
false,
"runtime session rows do not render topic hover actions",
);
eq(
projectTreeShouldSuppressOpenForRename(
{ rowKey: "topic-a", canRename: true },
@@ -47,14 +47,14 @@ eq(
);
eq(
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }),
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"], { preserveCurated: true }),
["mimo-v2.5-pro"],
"manual access refresh preserves selected MiMo model instead of importing provider catalog",
);
eq(
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]),
["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"],
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"]),
["mimo-v2.5-pro", "mimo-v2.5"],
"manual access refresh can show provider catalog as unsaved candidates",
);
@@ -6,6 +6,8 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import {
SettingsPanel,
formatProviderExtraBody,
parseProviderExtraBody,
providerBaseURLFromChatURL,
providerChatURLPreview,
providerEditorEffectiveKind,
@@ -58,18 +60,52 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
autoPlan: "off",
providers: [],
officialProviders: [],
providerPresets: [],
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
bot: {
enabled: false,
model: "",
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
allowlist: { enabled: false, allowAll: false, qqUsers: [], feishuUsers: [], weixinUsers: [], qqGroups: [], feishuGroups: [], weixinGroups: [] },
qq: { enabled: false, appId: "", appSecretEnv: "", secretSet: false, sandbox: false },
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: { qq: [], feishu: [], weixin: [] },
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
routes: [],
allowlist: {
enabled: false,
allowAll: false,
qqUsers: [],
feishuUsers: [],
weixinUsers: [],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: {
enabled: false,
appId: "",
appSecretEnv: "",
secretSet: false,
sandbox: false,
model: "",
toolApprovalMode: "ask",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
},
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
connections: [],
@@ -96,11 +132,20 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
console.log("\nsettings refresh snapshot");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
let extraBodyRejected = false;
try {
parseProviderExtraBody("[true]");
} catch {
extraBodyRejected = true;
}
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
@@ -220,6 +265,88 @@ ok(document.body.textContent?.includes("Settings could not be loaded.") === fals
await act(async () => {
retryRoot.unmount();
});
// Bots tab: direct four-channel bot manager.
const botsRootEl = document.createElement("div");
document.body.appendChild(botsRootEl);
const botsRoot = createRoot(botsRootEl);
const botsSettings = baseSettings("standard");
botsSettings.bot.connections = [
{
id: "conn-feishu-1",
provider: "feishu",
domain: "feishu",
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
sessionMappings: [],
lastError: "",
createdAt: "",
updatedAt: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
},
];
window.go = {
main: {
App: {
Settings: async () => botsSettings,
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
botsRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="bots" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
if (!feishuTab) throw new Error("Feishu channel tab did not render");
await act(async () => {
feishuTab.click();
await flushPromises();
});
await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
const modelIndex = selectedBotDetailText.indexOf("Model");
const accessControlIndex = selectedBotDetailText.indexOf("Access control");
ok(
connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
"selected bot detail places enable, approval, and model controls between summary and access control",
);
ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
await act(async () => {
botsRoot.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -130,23 +130,34 @@ const tabA = tabMeta("tab-a", { active: true });
const tabB = tabMeta("tab-b");
const tabC = tabMeta("tab-c");
const tabD = tabMeta("tab-d");
const tabE = tabMeta("tab-e");
const tabF = tabMeta("tab-f");
const tabG = tabMeta("tab-g");
let backendActiveId = "tab-a";
const historyB = deferred<HistoryMessage[]>();
const historyD = deferred<HistoryMessage[]>();
const contextDGate = deferred<ContextInfo>();
const setActiveBGate = deferred<void>();
const setActiveEGate = deferred<void>();
const setActiveFGate = deferred<void>();
const submitTabCGate = deferred<void>();
const historyCalls: string[] = [];
const cancelCalls: string[] = [];
let contextDCalls = 0;
let holdNextContextForD = false;
let setActiveCalls = 0;
let newSessionCalls = 0;
let failSetActiveFor = "";
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
function currentTabs(): TabMeta[] {
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
return Array.from(tabsById.values()).map((tab) => {
const running = runningTabs.has(tab.id);
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
});
}
window.runtime = {
@@ -179,6 +190,7 @@ window.go = {
historyCalls.push(tabID);
if (tabID === "tab-b") return historyB.promise;
if (tabID === "tab-d") return historyD.promise;
if (tabID === "tab-e") return [userMessage("fork E")];
return [userMessage("cached A")];
},
HistoryPageForTab: async (tabID: string) => {
@@ -193,14 +205,28 @@ window.go = {
NewSession: async () => {
newSessionCalls += 1;
},
Fork: async () => {
tabsById.set("tab-e", tabE);
backendActiveId = "tab-e";
runningTabs.add("tab-e");
return { ...tabE, active: true, running: true };
},
ReplayPendingPrompts: async () => {},
SetActiveTab: async (tabID: string) => {
setActiveCalls += 1;
if (tabID === "tab-b") await setActiveBGate.promise;
if (tabID === "tab-e") await setActiveEGate.promise;
if (tabID === "tab-f") await setActiveFGate.promise;
if (tabID === failSetActiveFor) throw new Error("persist failed");
backendActiveId = tabID;
},
CancelTab: async (tabID: string) => {
cancelCalls.push(tabID);
runningTabs.delete(tabID);
},
SubmitToTab: async (tabID: string) => {
runningTabs.add(tabID);
if (tabID === "tab-c") await submitTabCGate.promise;
},
SubmitDisplayToTab: async (tabID: string) => {
runningTabs.add(tabID);
@@ -275,6 +301,93 @@ eq(controller?.activeTabId, "tab-a", "late history for another tab does not chan
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
const historyCallsBeforeFallbackSync = historyCalls.length;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
await flushPromises();
});
backendActiveId = "tab-b";
await act(async () => {
await controller?.syncActiveTab(false);
await flushPromises();
});
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
runningTabs.add("tab-e");
let switchToE: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
await flushPromises();
});
eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
await act(async () => {
controller?.cancel();
await flushPromises();
});
eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
await act(async () => {
setActiveEGate.resolve();
await switchToE;
await flushPromises();
});
eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
let switchToF: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToF = controller?.switchTab("tab-f", tabF);
await flushPromises();
});
eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
let switchToG: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToG = controller?.switchTab("tab-g", tabG);
await switchToG;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
await act(async () => {
setActiveFGate.resolve();
await switchToF;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
failSetActiveFor = "tab-b";
const historyCallsBeforeFailedSwitch = historyCalls.length;
await act(async () => {
await controller?.switchTab("tab-b", tabB);
await flushPromises();
});
eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
failSetActiveFor = "";
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
@@ -293,10 +406,15 @@ eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transc
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
let tabCSendResolved = false;
await act(async () => {
controller?.sendToTab("tab-c", "streaming C");
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
sendPromise?.then(() => {
tabCSendResolved = true;
});
await flushPromises();
});
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
await act(async () => {
await controller?.switchTab("tab-c", tabC);
await flushPromises();
@@ -304,6 +422,11 @@ await act(async () => {
eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
await act(async () => {
submitTabCGate.resolve();
await submitTabCGate.promise;
await flushPromises();
});
holdNextContextForD = true;
await act(async () => {
@@ -342,6 +465,11 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
const historyCallsBeforeReopenD = historyCalls.length;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await act(async () => {
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
await flushPromises();
@@ -349,6 +477,17 @@ await act(async () => {
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
await act(async () => {
await controller?.rewind(0, "fork");
await flushPromises();
});
eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
runningTabs.delete("tab-e");
const contextCallsBeforeInactiveD = contextDCalls;
await act(async () => {
@@ -113,6 +113,10 @@ eq(
"var(--statusbar-dock-height)",
"status bar min-height follows the scaled dock token",
);
eq(finalDeclaration(".provider-template-grid", "grid-auto-rows"), "92px", "provider preset cards use compact equal-height grid rows");
eq(finalDeclaration(".provider-template-card", "height"), "100%", "provider preset cards stretch to the grid row height");
eq(finalDeclaration(".provider-template-card strong", "-webkit-line-clamp"), "1", "provider preset card titles clamp to one line");
eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2", "provider preset card descriptions clamp to two lines");
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
@@ -160,6 +164,16 @@ ok(
);
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
eq(
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
"0 !important",
"collapsed creation tool bodies keep mounted content clipped",
);
eq(
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
"hidden",
"collapsed creation tool bodies do not paint hidden tool text",
);
ok(
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
"composer compact controls activate at the capped theme width",
@@ -0,0 +1,163 @@
// Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
return {
id: "tab-send",
scope: "project",
workspaceRoot: "/repo/send",
workspaceName: "send",
workspacePath: "/repo/send",
gitBranch: "main",
topicId: "topic-send",
topicTitle: "Send",
label: "model-send",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo/send",
...overrides,
};
}
function metaFor(tab: TabMeta): Meta {
return {
label: tab.label,
ready: tab.ready,
startupErr: tab.startupErr,
eventChannel: "agent:event",
cwd: tab.cwd || tab.workspaceRoot,
workspaceRoot: tab.workspaceRoot,
workspaceName: tab.workspaceName,
workspacePath: tab.workspacePath,
gitBranch: tab.gitBranch,
autoApproveTools: false,
bypass: false,
collaborationMode: tab.collaborationMode ?? "normal",
toolApprovalMode: tab.toolApprovalMode ?? "ask",
tokenMode: tab.tokenMode ?? "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nuse controller send fallback");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const backendTab = tabMeta({ backgroundJobs: 2 });
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
let tabsAvailable = false;
let submitCalls = 0;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => (tabsAvailable ? [backendTab] : []),
MetaForTab: async () => metaFor(backendTab),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SubmitToTab: async (tabId: string) => {
submitCalls += tabId === "tab-send" ? 1 : 0;
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
tabsAvailable = true;
await act(async () => {
await controller?.send("hello from fallback");
await flushPromises();
});
eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello from fallback") ?? false, "send fallback keeps the optimistic user turn");
eq(submitCalls, 1, "send fallback submits to the activated tab");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WorkspaceChangesView } from "../lib/types";
import type { DirEntry, WorkspaceChangesView } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -101,6 +101,47 @@ async function renderWorkspace(changes: WorkspaceChangesView) {
return { dom, root };
}
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
const dom = installDom();
window.go = {
main: {
App: {
ListDir: async () => [],
SearchFileRefs: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
initialViewMode: "files",
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel {...currentProps} />
</LocaleProvider>,
);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
console.log("\nworkspace changes git errors");
{
@@ -151,5 +192,28 @@ console.log("\nworkspace changes git errors");
dom.window.close();
}
{
const calls: string[] = [];
const listDir = async (dir: string): Promise<DirEntry[]> => {
calls.push(dir);
return [];
};
const { dom, root, rerender } = await renderFilesWorkspace(
{ ListDir: listDir },
{ fileListRequest: { id: 1, paths: ["src/app.ts"] } },
);
await waitFor("initial referenced file dirs", () => calls.filter((dir) => dir === "src/").length === 1);
await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
await waitFor("referenced file dirs revalidated", () => calls.filter((dir) => dir === "src/").length === 2);
ok(calls.filter((dir) => dir === "src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -70,6 +70,9 @@ eq(
);
eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row");
eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport");
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
+3 -17
View File
@@ -1,4 +1,4 @@
import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react";
import { PanelLeft, PanelRight, Search } from "lucide-react";
import { TabBar } from "./TabBar";
import type { TabMeta } from "../lib/types";
import { useT } from "../lib/i18n";
@@ -58,14 +58,13 @@ export function AppChrome({
}: AppChromeProps) {
const t = useT();
const darwinChrome = platform === "darwin";
const showWindowsPreviewControls = browserPreviewChrome && platform === "windows";
const titlebarDragRail = darwinChrome || platform === "windows";
const chromeClassName = [
"app-chrome",
"app-chrome--tabs",
darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs",
workbenchChrome ? "app-chrome--workbench" : "",
!darwinChrome ? "app-chrome--identityless" : "",
showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "",
`app-chrome--platform-${platform}`,
].filter(Boolean).join(" ");
const tabBar = (
@@ -92,7 +91,7 @@ export function AppChrome({
<span />
</div>
)}
{darwinChrome && <span className="app-chrome__drag-rail" aria-hidden="true" />}
{titlebarDragRail && <span className="app-chrome__drag-rail" aria-hidden="true" />}
<button
className={[
"app-chrome__panel-toggle",
@@ -194,19 +193,6 @@ export function AppChrome({
<PanelRight size={16} />
</button>
)}
{showWindowsPreviewControls && (
<div className="app-chrome__window-controls app-chrome__window-controls--windows" aria-hidden="true">
<span className="app-chrome__window-control app-chrome__window-control--minimize">
<Minus size={12} strokeWidth={1.9} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--maximize">
<Square size={10} strokeWidth={1.8} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--close">
<X size={12} strokeWidth={1.9} />
</span>
</div>
)}
</header>
);
}
@@ -27,6 +27,10 @@ function animateShelfExit(
options.onComplete();
}
function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
}
export function ApprovalModal({
approval,
onAnswer,
@@ -48,8 +52,17 @@ export function ApprovalModal({
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
const subject = approval.subject.trim();
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
const hasToolDetails = Boolean(approval.reason || subject);
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
const [revisionOpen, setRevisionOpen] = useState(false);
const [revisionText, setRevisionText] = useState("");
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
const cardRef = useRef<HTMLDivElement | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
@@ -59,7 +72,6 @@ export function ApprovalModal({
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
// jarring pop when the API cycles through 4+ pending approvals.
const closingRef = useRef(false);
const subject = approval.subject.trim();
const fileMenu = useFileReferenceMenu(revisionText, cwd);
const answerWithExit = (fn: () => void) => {
@@ -88,9 +100,10 @@ export function ApprovalModal({
const chooseToolAction = (key: string) => {
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
else if (key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (key === "3") answerWithExit(() => onAnswer(true, true, true));
else if (key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
else if (!isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (key === "Escape") answerWithExit(onStop);
};
@@ -98,11 +111,12 @@ export function ApprovalModal({
cardRef.current?.focus();
setRevisionOpen(false);
setRevisionText("");
setDetailsOpen(showToolDetailsByDefault);
setSelectedIndex(isPlanApproval ? 1 : 0);
playAttentionChime();
}, [approval.id, isPlanApproval]);
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : 4;
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
@@ -123,6 +137,7 @@ export function ApprovalModal({
event.preventDefault();
const key = String(selectedIndexRef.current + 1);
if (isPlanApproval) choosePlanAction(key);
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
else chooseToolAction(key);
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
event.preventDefault();
@@ -132,7 +147,7 @@ export function ApprovalModal({
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
useEffect(() => {
if (revisionOpen) {
@@ -219,6 +234,7 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--plan-approval"
barRef={cardRef}
titleId="plan-approval-title"
title={t("approval.planReady")}
@@ -280,27 +296,46 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--tool-approval"
barRef={cardRef}
titleId="tool-approval-title"
title={t("approval.toolPending")}
badges={<PromptBadge>{approval.tool}</PromptBadge>}
badges={<PromptBadge>{toolLabel}</PromptBadge>}
meta={toolMeta}
headerActions={
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
<>
{hasToolDetails && (
<PromptHeaderAction onClick={() => setDetailsOpen((open) => !open)}>
{t(detailsOpen ? "approval.hideDetails" : "approval.details")}
</PromptHeaderAction>
)}
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
</>
}
actions={
<>
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
{isFreshHumanApproval ? (
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
) : (
<>
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
</>
)}
</>
}
>
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
{detailsOpen && (
<div className="approval-details">
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
</div>
)}
</PromptShelf>
</div>
+20 -1
View File
@@ -22,8 +22,10 @@ export function AskCard({
// Per-question state: selected option labels, and an optional typed answer.
const [sel, setSel] = useState<Record<string, string[]>>({});
const [custom, setCustom] = useState<Record<string, string>>({});
const [customOpen, setCustomOpen] = useState(false);
const [active, setActive] = useState(0);
const shelfRef = useRef<HTMLDivElement | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
const advanceTimer = useRef<number | null>(null);
const questions = ask.questions;
@@ -36,6 +38,7 @@ export function AskCard({
shelfRef.current?.focus();
setSel({});
setCustom({});
setCustomOpen(false);
setActive(0);
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
playAttentionChime();
@@ -66,7 +69,15 @@ export function AskCard({
(sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
const currentAnswered = q ? answered(q) : false;
const showSubmitAction = q ? q.multi || Boolean(custom[q.id]?.trim()) : false;
const showSubmitAction = q ? q.multi || customOpen || Boolean(custom[q.id]?.trim()) : false;
useEffect(() => {
setCustomOpen(false);
}, [active]);
useEffect(() => {
if (customOpen) customInputRef.current?.focus();
}, [customOpen]);
const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
if (advanceTimer.current != null) {
@@ -89,6 +100,7 @@ export function AskCard({
setCustom(nextCustom);
setSel(nextSel);
setCustomOpen(false);
if (!question.multi) {
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
@@ -148,6 +160,7 @@ export function AskCard({
return (
<PromptShelf
className="prompt-shelf--compact prompt-shelf--ask"
barRef={shelfRef}
titleId="ask-shelf-title"
title={t("ask.title")}
@@ -165,6 +178,9 @@ export function AskCard({
headerActions={
<>
<PromptHeaderAction onClick={onDismiss}>{t("ask.justChat")}</PromptHeaderAction>
{!customOpen && (
<PromptHeaderAction onClick={() => setCustomOpen(true)}>{t("ask.customAnswer")}</PromptHeaderAction>
)}
<PromptHeaderAction onClick={onStop} ariaLabel={t("composer.stopShort")}>Esc</PromptHeaderAction>
</>
}
@@ -213,8 +229,10 @@ export function AskCard({
)
}
>
{customOpen && (
<div className="ask-shelf__custom-row">
<input
ref={customInputRef}
className="ask-shelf__custom"
placeholder={t("ask.customPlaceholder")}
value={custom[q.id] ?? ""}
@@ -225,6 +243,7 @@ export function AskCard({
}}
/>
</div>
)}
</PromptShelf>
);
}
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
import type { CapabilitiesView, MCPServerInput, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
@@ -20,6 +20,7 @@ type SettingsSnapshot<T> = { key: string; value: T };
let mcpSettingsSnapshot: SettingsSnapshot<ServerView[]> | null = null;
let skillsSettingsSnapshot: SettingsSnapshot<SkillsSettingsView> | null = null;
let pluginsSettingsSnapshot: SettingsSnapshot<PluginView[]> | null = null;
function settingsSnapshotKey(meta: Awaited<ReturnType<typeof app.Meta>> | null | undefined, tabs: TabMeta[] | null | undefined): string {
const active = tabs?.find((tab) => tab.active);
@@ -50,7 +51,7 @@ export function CapabilitiesPanel({
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
const reload = useCallback(async () => {
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [], plugins: [] }))));
}, []);
useEffect(() => {
void reload();
@@ -314,6 +315,7 @@ export function CapabilitiesPanel({
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
return {
servers: normalizeServerViews(view?.servers),
plugins: asArray(view?.plugins),
...normalizeSkillsSettingsView(view),
};
}
@@ -1590,6 +1592,619 @@ function AddServerForm({
);
}
type PluginInstallPlanAction = {
action?: string;
kind?: string;
name?: string;
source?: string;
status?: string;
message?: string;
error?: string;
};
type PluginInstallPlanView = {
raw: string;
ok?: boolean;
status?: string;
name?: string;
actions: PluginInstallPlanAction[];
warnings: string[];
error?: string;
};
type PluginInstallMode = "local" | "git";
// PluginsSettingsPage is the desktop plugin package manager embedded inside
// Settings. It mirrors the MCP/Skills density: install planning on top, package
// rows below, and diagnostics/details only when a row is expanded.
export function PluginsSettingsPage() {
const t = useT();
const [snapshotKey, setSnapshotKey] = useState("");
const [plugins, setPlugins] = useState<PluginView[] | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [installMode, setInstallMode] = useState<PluginInstallMode>("local");
const [localSource, setLocalSource] = useState("");
const [gitSource, setGitSource] = useState("");
const [name, setName] = useState("");
const [link, setLink] = useState(false);
const [replace, setReplace] = useState(false);
const [plan, setPlan] = useState<PluginInstallPlanView | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [diagnostics, setDiagnostics] = useState<Record<string, PluginView>>({});
const reload = useCallback(async () => {
const [meta, tabs] = await Promise.all([
app.Meta().catch(() => null),
app.ListTabs().catch(() => []),
]);
const key = settingsSnapshotKey(meta, tabs);
setSnapshotKey(key);
const cached = key ? pluginsSettingsSnapshot : null;
if (cached?.key === key) {
setPlugins(cached.value);
} else {
setPlugins(null);
}
const next = normalizePluginViews(await app.Plugins().catch(() => []));
pluginsSettingsSnapshot = { key, value: next };
setPlugins(next);
}, []);
useEffect(() => { void reload(); }, [reload]);
const run = async (fn: () => Promise<unknown>, reloadAfter = true) => {
setBusy(true);
setErr(null);
setNotice(null);
try {
const result = await fn();
if (typeof result === "string" && result.trim()) {
const parsed = parsePluginInstallPlan(result);
setNotice(pluginPlanNotice(parsed, t));
}
if (reloadAfter) await reload();
return true;
} catch (e) {
setErr(String((e as Error)?.message ?? e));
if (reloadAfter) await reload();
return false;
} finally {
setBusy(false);
}
};
const sourceValue = (installMode === "local" ? localSource : gitSource).trim();
const installOptions = (): PluginInstallOptions => ({
dryRun: false,
link: installMode === "local" ? link : false,
replace,
name: installMode === "git" ? name.trim() || undefined : undefined,
});
const actionBusy = busy || !snapshotKey || !plugins;
const canPlan = sourceValue.length > 0 && !actionBusy;
const summary = plugins ? pluginListSummary(plugins, t) : "";
const togglePlugin = useCallback((pluginName: string) => {
setExpanded((prev) => { const next = new Set(prev); if (next.has(pluginName)) next.delete(pluginName); else next.add(pluginName); return next; });
}, []);
const setMode = (mode: PluginInstallMode) => {
setInstallMode(mode);
setPlan(null);
};
const previewInstall = () => {
if (!sourceValue) return;
void run(async () => {
const raw = await app.PlanPluginInstall(sourceValue, { ...installOptions(), dryRun: true });
setPlan(parsePluginInstallPlan(raw));
}, false);
};
const install = () => {
if (!sourceValue) return;
void run(async () => {
const raw = await app.InstallPlugin(sourceValue, installOptions());
setPlan(parsePluginInstallPlan(raw));
return raw;
});
};
const runDoctor = (pluginName: string) => {
void run(async () => {
const view = normalizePluginView(await app.PluginDoctor(pluginName));
setDiagnostics((prev) => ({ ...prev, [pluginName]: view }));
setExpanded((prev) => {
const next = new Set(prev);
next.add(pluginName);
return next;
});
}, false);
};
const updateLocalSource = (value: string) => {
setLocalSource(value);
setPlan(null);
};
const updateGitSource = (value: string) => {
setGitSource(value);
setPlan(null);
};
const pickPluginFolder = () => {
void run(async () => {
const path = await app.PickPluginFolder();
if (path) {
setInstallMode("local");
updateLocalSource(path);
}
}, false);
};
return (
<section className="mem-section">
{err && <div className="banner banner--error">{err}</div>}
{notice && !err && <div className="banner banner--success">{notice}</div>}
<div className="cap-plugin-installer">
<div className="cap-plugin-installer__head">
<div className="cap-plugin-installer__copy">
<div className="cap-plugin-installer__title">{t("caps.pluginInstallTitle")}</div>
<div className="cap-plugin-installer__hint">{t("caps.pluginInstallHint")}</div>
</div>
<div className="cap-tabs cap-plugin-installer__mode" role="group" aria-label={t("caps.pluginInstallMethod")}>
<button
className={`cap-tab${installMode === "local" ? " cap-tab--active" : ""}`}
type="button"
aria-pressed={installMode === "local"}
onClick={() => setMode("local")}
>
{t("caps.pluginInstallLocal")}
</button>
<button
className={`cap-tab${installMode === "git" ? " cap-tab--active" : ""}`}
type="button"
aria-pressed={installMode === "git"}
onClick={() => setMode("git")}
>
{t("caps.pluginInstallGit")}
</button>
</div>
</div>
<div className="cap-plugin-form-grid">
{installMode === "local" ? (
<div className="cap-plugin-fields cap-plugin-fields--local">
<div className="cap-plugin-folder-field">
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={pickPluginFolder}>
{t("caps.pluginChooseLocalFolder")}
</button>
<div
className={`cap-plugin-path${localSource ? "" : " cap-plugin-path--empty"}`}
aria-label={t("caps.pluginLocalFolder")}
>
{localSource || t("caps.pluginNoLocalFolder")}
</div>
</div>
</div>
) : (
<div className="cap-plugin-fields cap-plugin-fields--git">
<input
className="mem-input"
aria-label={t("caps.pluginGitSource")}
placeholder={t("caps.pluginSourcePlaceholder")}
value={gitSource}
onInput={(e) => updateGitSource(e.currentTarget.value)}
onChange={(e) => updateGitSource(e.target.value)}
/>
<div className="cap-plugin-field">
<input
className="mem-input"
aria-label={t("caps.pluginInstallName")}
placeholder={t("caps.pluginInstallNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
)}
<div className="cap-plugin-installer__options">
<div className="cap-plugin-option-block">
<label className="cap-plugin-option">
<input type="checkbox" checked={replace} disabled={actionBusy} onChange={(e) => setReplace(e.target.checked)} />
<span>{t("caps.pluginReplace")}</span>
</label>
<div className="cap-plugin-option-hint">{t("caps.pluginReplaceHint")}</div>
</div>
{installMode === "local" && (
<div className="cap-plugin-option-block">
<label className="cap-plugin-option">
<input type="checkbox" checked={link} disabled={actionBusy} onChange={(e) => setLink(e.target.checked)} />
<span>{t("caps.pluginLink")}</span>
</label>
<div className="cap-plugin-option-hint">{t("caps.pluginLinkHint")}</div>
</div>
)}
</div>
<div className="cap-plugin-installer__actions">
<button className="btn btn--small" type="button" disabled={!canPlan} onClick={previewInstall}>
{t("caps.pluginPreview")}
</button>
<button className="btn btn--primary btn--small" type="button" disabled={!canPlan} onClick={install}>
{t("caps.pluginInstall")}
</button>
</div>
</div>
</div>
{plan && <PluginPlanPreview plan={plan} />}
<div className="cap-server-section cap-plugin-section">
<div className="cap-server-section__head">
<div className="cap-server-section__copy">
<div className="cap-server-section__title">{t("caps.installedPlugins")}</div>
{plugins && plugins.length > 0 && <div className="drawer__summary">{summary}</div>}
</div>
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={() => void reload()}>
{t("caps.pluginRefresh")}
</button>
</div>
{!plugins ? (
<div className="mem-empty">{t("caps.loading")}</div>
) : plugins.length === 0 ? (
<div className="mem-empty mem-empty--cta">
<strong>{t("caps.noPluginsTitle")}</strong>
<span>{t("caps.noPluginsHint")}</span>
</div>
) : (
<div className="cap-server-group">
{plugins.map((plugin) => (
<PluginRow
key={plugin.name}
plugin={plugin}
diagnostic={diagnostics[plugin.name]}
busy={actionBusy}
expanded={expanded.has(plugin.name)}
onToggleDetails={() => togglePlugin(plugin.name)}
onToggleEnabled={(enabled) => void run(() => app.SetPluginEnabled(plugin.name, enabled))}
onUpdate={() => void run(() => app.UpdatePlugin(plugin.name))}
onDoctor={() => runDoctor(plugin.name)}
onRemove={() => void run(() => app.RemovePlugin(plugin.name))}
/>
))}
</div>
)}
</div>
</section>
);
}
function PluginPlanPreview({ plan }: { plan: PluginInstallPlanView }) {
const t = useT();
return (
<div className={`cap-plugin-plan${plan.error ? " cap-plugin-plan--error" : ""}`}>
<div className="cap-plugin-plan__head">
<div className="cap-plugin-plan__title">{plan.error ? t("caps.pluginPlanError") : t("caps.pluginPlanReady")}</div>
{plan.status && <span className="cap-source-badge">{plan.status}</span>}
</div>
{plan.name && <div className="cap-plugin-plan__meta">{plan.name}</div>}
{plan.error && <div className="cap-plugin-plan__warning">{plan.error}</div>}
{plan.warnings.map((warning, idx) => (
<div className="cap-plugin-plan__warning" key={`${warning}-${idx}`}>{warning}</div>
))}
{plan.actions.length > 0 ? (
<div className="cap-plugin-actions">
{plan.actions.map((action, idx) => (
<div className="cap-plugin-action" key={`${action.action || action.kind || "action"}-${idx}`}>
<span className="cap-plugin-action__name">{pluginPlanActionLabel(action, t)}</span>
{action.status && <span className="cap-source-badge">{action.status}</span>}
{action.source && <span className="cap-plugin-action__source">{action.source}</span>}
{action.message && <span className="cap-plugin-action__source">{action.message}</span>}
{action.error && <span className="cap-plugin-plan__warning">{action.error}</span>}
</div>
))}
</div>
) : (
<pre className="cap-plugin-plan__raw">{plan.raw}</pre>
)}
</div>
);
}
function PluginRow({
plugin,
diagnostic,
busy,
expanded,
onToggleDetails,
onToggleEnabled,
onUpdate,
onDoctor,
onRemove,
}: {
plugin: PluginView;
diagnostic?: PluginView;
busy: boolean;
expanded: boolean;
onToggleDetails: () => void;
onToggleEnabled: (enabled: boolean) => void;
onUpdate: () => void;
onDoctor: () => void;
onRemove: () => void;
}) {
const t = useT();
const status = plugin.error ? "failed" : plugin.enabled ? "connected" : "disabled";
const warnings = pluginWarnings(plugin, diagnostic);
const sub = plugin.error || pluginCapabilitiesSummary(plugin, t);
return (
<div className={`cap-server-entry cap-plugin-entry${plugin.enabled ? "" : " cap-server-entry--disabled"}`}>
<Tooltip label={plugin.error} disabled={!plugin.error} fill block>
<div className={`cap-row${plugin.enabled ? "" : " cap-row--disabled"}`}>
<Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
<button
className="cap-disclosure"
aria-expanded={expanded}
type="button"
onClick={onToggleDetails}
>
{expanded ? "⌄" : "›"}
</button>
</Tooltip>
<span className={`cap-dot cap-dot--${status}`} />
<div className="cap-row__text">
<div className="cap-row__head">
<span className="cap-row__name">{plugin.name}</span>
{plugin.manifestKind && <span className="cap-row__transport">{plugin.manifestKind}</span>}
{plugin.version && <span className="cap-source-badge">{plugin.version}</span>}
{warnings.length > 0 && <span className="cap-row__update cap-row__update--error">{t("caps.pluginWarnings", { count: warnings.length })}</span>}
</div>
<div className="cap-row__sub">{sub}</div>
</div>
<div className="cap-row__actions">
<Tooltip label={plugin.enabled ? t("caps.pluginDisable") : t("caps.pluginEnable")}>
<label className="cap-switch">
<input
type="checkbox"
checked={plugin.enabled}
disabled={busy}
onChange={(e) => onToggleEnabled(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
</div>
</div>
</Tooltip>
{expanded && (
<div className="cap-server-details">
<div className="cap-detail-grid">
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.status")}</span>
<span className="cap-detail__value">{plugin.enabled ? t("caps.pluginEnabled") : t("caps.pluginDisabled")}</span>
</div>
{plugin.version && (
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.pluginVersion")}</span>
<span className="cap-detail__value">{plugin.version}</span>
</div>
)}
{plugin.source && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.pluginSource")}</span>
<span className="cap-detail__code">{plugin.source}</span>
</div>
)}
{plugin.root && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.pluginRoot")}</span>
<span className="cap-detail__code">{plugin.root}</span>
</div>
)}
</div>
{plugin.description && <div className="cap-plugin-description">{plugin.description}</div>}
<PluginUsageDetails plugin={plugin} />
{diagnostic?.error && <div className="cap-source__warning">{diagnostic.error}</div>}
{warnings.map((warning, idx) => (
<div className="cap-source__warning" key={`${plugin.name}-warning-${idx}`}>{warning}</div>
))}
<div className="cap-detail-actions">
<button className="btn btn--small" disabled={busy} type="button" onClick={onUpdate}>
{t("caps.pluginUpdate")}
</button>
<button className="btn btn--small" disabled={busy} type="button" onClick={onDoctor}>
{t("caps.pluginDoctor")}
</button>
<InlineConfirmButton
label={t("caps.pluginRemove")}
confirmLabel={t("caps.pluginConfirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={onRemove}
/>
</div>
</div>
)}
</div>
);
}
function PluginUsageDetails({ plugin }: { plugin: PluginView }) {
const t = useT();
const skills = asArray(plugin.skillDetails);
const hooks = asArray(plugin.hookDetails);
const mcps = asArray(plugin.mcpServerDetails);
const hasDetails = skills.length > 0 || hooks.length > 0 || mcps.length > 0;
return (
<div className="cap-plugin-usage">
<div className="cap-plugin-usage__title">{t("caps.pluginUsageTitle")}</div>
<div className="cap-plugin-usage__hint">
{plugin.enabled ? t("caps.pluginUsageEnabledHint") : t("caps.pluginUsageDisabledHint")}
</div>
{hasDetails ? (
<div className="cap-plugin-capabilities">
{skills.length > 0 && <PluginSkillList skills={skills} />}
{hooks.length > 0 && <PluginHookList hooks={hooks} />}
{mcps.length > 0 && <PluginMCPList servers={mcps} />}
</div>
) : (
<div className="cap-plugin-usage__empty">{t("caps.pluginNoCapabilityDetails")}</div>
)}
</div>
);
}
function PluginSkillList({ skills }: { skills: PluginSkillView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginSkillsTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginSkillsHint")}</div>
<div className="cap-plugin-capability__list">
{skills.map((skill) => (
<div className="cap-plugin-capability__item" key={`${skill.name}-${skill.path || skill.invocation || ""}`}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{skill.invocation || `/${skill.name}`}</span>
{skill.runAs && <span className="cap-source-badge">{skill.runAs}</span>}
</div>
<div className="cap-plugin-capability__desc">{skill.description || t("caps.pluginNoDescription")}</div>
</div>
))}
</div>
</div>
);
}
function PluginHookList({ hooks }: { hooks: PluginHookView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginHooksTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginHooksHint")}</div>
<div className="cap-plugin-capability__list">
{hooks.map((hook, idx) => {
const target = hook.command || hook.contextFile || t("caps.pluginHookNoTarget");
return (
<div className="cap-plugin-capability__item" key={`${hook.event}-${hook.match || "*"}-${target}-${idx}`}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{hook.event}</span>
<span className="cap-source-badge">{hook.match || "*"}</span>
</div>
<div className="cap-plugin-capability__desc">{hook.description || target}</div>
</div>
);
})}
</div>
</div>
);
}
function PluginMCPList({ servers }: { servers: PluginMCPServerView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginMCPTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginMCPHint")}</div>
<div className="cap-plugin-capability__list">
{servers.map((server) => (
<div className="cap-plugin-capability__item" key={server.name}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{server.name}</span>
{server.transport && <span className="cap-source-badge">{server.transport}</span>}
</div>
<div className="cap-plugin-capability__desc">{server.command || server.url || t("caps.pluginMCPNoTarget")}</div>
</div>
))}
</div>
</div>
);
}
function normalizePluginViews(plugins: PluginView[] | null | undefined): PluginView[] {
return sortPluginsForDisplay(asArray(plugins).map(normalizePluginView));
}
function normalizePluginView(plugin: PluginView): PluginView {
return {
...plugin,
name: plugin.name || "plugin",
root: plugin.root || "",
enabled: Boolean(plugin.enabled),
skills: Number.isFinite(plugin.skills) ? plugin.skills : 0,
hooks: Number.isFinite(plugin.hooks) ? plugin.hooks : 0,
mcpServers: Number.isFinite(plugin.mcpServers) ? plugin.mcpServers : 0,
skillDetails: asArray(plugin.skillDetails),
hookDetails: asArray(plugin.hookDetails),
mcpServerDetails: asArray(plugin.mcpServerDetails),
warnings: asArray(plugin.warnings),
};
}
function sortPluginsForDisplay(plugins: PluginView[]): PluginView[] {
return [...plugins].sort((a, b) => {
const priority = pluginDisplayPriority(a) - pluginDisplayPriority(b);
if (priority !== 0) return priority;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}
function pluginDisplayPriority(plugin: PluginView): number {
if (plugin.error) return 0;
if (plugin.enabled) return 1;
return 2;
}
function pluginListSummary(plugins: PluginView[], t: ReturnType<typeof useT>): string {
const enabled = plugins.filter((plugin) => plugin.enabled && !plugin.error).length;
const issues = plugins.filter((plugin) => Boolean(plugin.error) || asArray(plugin.warnings).length > 0).length;
return t("caps.pluginsSummary", { enabled, total: plugins.length, issues });
}
function pluginCapabilitiesSummary(plugin: PluginView, t: ReturnType<typeof useT>): string {
if (plugin.skills === 0 && plugin.hooks === 0 && plugin.mcpServers === 0) return t("caps.pluginNoCapabilities");
return t("caps.pluginCounts", { skills: plugin.skills, hooks: plugin.hooks, mcps: plugin.mcpServers });
}
function pluginWarnings(plugin: PluginView, diagnostic?: PluginView): string[] {
const warnings = [...asArray(plugin.warnings), ...asArray(diagnostic?.warnings)];
return Array.from(new Set(warnings.filter((warning) => warning.trim().length > 0)));
}
function parsePluginInstallPlan(raw: string): PluginInstallPlanView {
try {
const value = JSON.parse(raw) as Record<string, unknown>;
const actions = (Array.isArray(value.actions) ? value.actions : []).flatMap((action) => {
if (!action || typeof action !== "object") return [];
const item = action as Record<string, unknown>;
return [{
action: stringValue(item.action),
kind: stringValue(item.kind),
name: stringValue(item.name),
source: stringValue(item.source),
status: stringValue(item.status),
message: stringValue(item.message),
error: stringValue(item.error),
}];
});
return {
raw,
ok: typeof value.ok === "boolean" ? value.ok : undefined,
status: stringValue(value.status),
name: stringValue(value.name),
actions,
warnings: (Array.isArray(value.warnings) ? value.warnings : []).flatMap((warning) => typeof warning === "string" ? [warning] : []),
error: stringValue(value.error),
};
} catch {
return { raw, actions: [], warnings: [] };
}
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function pluginPlanActionLabel(action: PluginInstallPlanAction, t: ReturnType<typeof useT>): string {
const verb = action.action || action.kind || t("caps.pluginAction");
return [verb, action.name].filter(Boolean).join(" · ");
}
function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT>): string {
if (plan.error) return plan.error;
if (plan.status === "done" || plan.status === "applied" || plan.status === "complete") return t("caps.pluginPlanInstalled");
return plan.status ? t("caps.pluginPlanStatus", { status: plan.status }) : t("caps.pluginPlanComplete");
}
// MCPServersSettingsPage is a self-contained MCP servers management page
// embedded inside the settings centre.
export function MCPServersSettingsPage() {
+249 -40
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ArrowUp, Check, ChevronsUpDown, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
import { ArrowUp, Check, ChevronDown, ChevronUp, ChevronsUpDown, CornerDownRight, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
import { asArray } from "../lib/array";
import { filterAtMatches } from "../lib/atMatches";
import { DedupIndex, sha256 } from "../lib/attachDedup";
@@ -26,6 +26,7 @@ import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { Tooltip } from "./Tooltip";
import { ComposerContextCard } from "./ComposerContextCard";
import { ImageViewer } from "./ImageViewer";
import { VirtualMenu } from "./VirtualMenu";
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
@@ -57,12 +58,24 @@ const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
// it; the real gap is a few ms, so keep it short or a deliberate quick second
// Enter (submit) gets eaten too.
const IME_CONFIRM_GRACE_MS = 100;
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
type PastedBlock = {
label: string;
text: string;
};
type PendingGuidance = {
id: number;
text: string;
submitText: string;
};
type FileRefSearchCacheEntry = {
entries: DirEntry[];
cachedAt: number;
};
type ComposerDraft = {
text: string;
attachments: Attachment[];
@@ -163,6 +176,13 @@ function emptyComposerDraft(): ComposerDraft {
};
}
function guidanceTextMatches(queued: string, consumed: string): boolean {
const left = queued.trim();
const right = consumed.trim();
if (!left || !right) return false;
return left === right || left.includes(right) || right.includes(left);
}
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
return {
text: draft.text,
@@ -431,6 +451,10 @@ export function Composer({
retry,
transientDismissSignal,
sessionKey,
fileRefRefreshKey,
guidanceConsumedKey,
guidanceConsumedText,
guidanceQueuePreviewItems,
}: {
running: boolean;
collaborationMode: CollaborationMode;
@@ -470,6 +494,10 @@ export function Composer({
retry?: { attempt: number; max: number };
transientDismissSignal?: number;
sessionKey?: string;
fileRefRefreshKey?: number | string;
guidanceConsumedKey?: string;
guidanceConsumedText?: string;
guidanceQueuePreviewItems?: readonly string[];
}) {
const { t, locale } = useI18n();
const { showToast } = useToast();
@@ -477,6 +505,15 @@ export function Composer({
const now = useTick(running);
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
const openComposerImageViewer = useCallback((url: string, name: string) => {
setImageViewer({ open: true, url, name });
}, []);
const closeComposerImageViewer = useCallback(() => {
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
}, []);
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
@@ -498,6 +535,10 @@ export function Composer({
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
const [pastChatQuery, setPastChatQuery] = useState("");
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
const [pendingGuidance, setPendingGuidance] = useState<PendingGuidance[]>([]);
const [guidanceExpanded, setGuidanceExpanded] = useState(false);
const [guidanceSendingId, setGuidanceSendingId] = useState<number | null>(null);
const nextGuidanceId = useRef(1);
const [loadingPastChats, setLoadingPastChats] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [composerPrompt, setComposerPrompt] = useState<string | null>(null);
@@ -524,6 +565,8 @@ export function Composer({
const lastSelectionRef = useRef({ start: 0, end: 0 });
const consumedInsertIdRef = useRef(0);
const lastTransientDismissSignal = useRef(transientDismissSignal);
const lastGuidanceConsumedKey = useRef(guidanceConsumedKey);
const selfDispatchedGuidanceRef = useRef<string[]>([]);
const submittingRef = useRef(false);
const nativeClipboardPasteTimerRef = useRef<number | null>(null);
// Snapshot of the current cwd so async callbacks (openPastChats) can detect
@@ -533,6 +576,7 @@ export function Composer({
const attachmentDedupRef = useRef(new DedupIndex());
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
const guidanceQueuePreviewKey = (guidanceQueuePreviewItems ?? []).map((item) => item.trim()).filter(Boolean).join("\n");
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
const activeDraftKeyRef = useRef(draftKey);
const textRef = useRef(text);
@@ -609,14 +653,37 @@ export function Composer({
useEffect(() => () => clearNativeClipboardPasteTimer(), []);
useEffect(() => {
if (wasRunning.current && !running && text.trim() === "") {
pastedBlocksRef.current = [];
setPastedBlocks([]);
setOpenPastedLabels([]);
if (wasRunning.current && !running) {
setPendingGuidance([]);
setGuidanceExpanded(false);
if (text.trim() === "") {
pastedBlocksRef.current = [];
setPastedBlocks([]);
setOpenPastedLabels([]);
}
}
wasRunning.current = running;
}, [running, text]);
useEffect(() => {
setPendingGuidance([]);
setGuidanceExpanded(false);
}, [draftKey]);
useEffect(() => {
if (!running || !guidanceQueuePreviewKey) return;
setGuidanceExpanded(false);
setPendingGuidance(
guidanceQueuePreviewKey
.split("\n")
.map((text) => ({ id: nextGuidanceId.current++, text, submitText: text })),
);
}, [guidanceQueuePreviewKey, running]);
useEffect(() => {
if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
}, [guidanceExpanded, pendingGuidance.length]);
// --- slash commands (whole-input "/token") ---
const [commands, setCommands] = useState<CommandInfo[]>([]);
useEffect(() => {
@@ -694,16 +761,9 @@ export function Composer({
const [entries, setEntries] = useState<DirEntry[]>([]);
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
// When the workspace/project changes (cwd prop), invalidate all @ mention
// state so the picker reloads candidates for the new project. Without this,
// dirCache/searchCache retain entries from the old project and the picker
// shows stale results (issue #3601).
const prevCwdRef = useRef(cwd);
useEffect(() => {
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
prevCwdRef.current = cwd;
const clearFileRefState = useCallback(() => {
dirCache.current = {};
searchCache.current = {};
setEntries([]);
@@ -714,29 +774,50 @@ export function Composer({
setLoadingPastChats(false);
setActive(0);
setDismissed(false);
}, [cwd]);
}, []);
// When the workspace/project changes (cwd prop), invalidate all @ mention
// state so the picker reloads candidates for the new project. Without this,
// dirCache/searchCache retain entries from the old project and the picker
// shows stale results (issue #3601).
const prevCwdRef = useRef(cwd);
useEffect(() => {
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
prevCwdRef.current = cwd;
clearFileRefState();
}, [clearFileRefState, cwd]);
const prevFileRefRefreshKeyRef = useRef(fileRefRefreshKey);
useEffect(() => {
if (prevFileRefRefreshKeyRef.current === fileRefRefreshKey) return;
prevFileRefRefreshKeyRef.current = fileRefRefreshKey;
clearFileRefState();
}, [clearFileRefState, fileRefRefreshKey]);
useEffect(() => {
if (atRaw === null) return;
const cached = dirCache.current[atDir];
if (cached) {
setEntries(cached);
return;
} else {
setEntries([]);
}
let live = true;
app
.ListDir(atDir)
.then((es) => {
const list = asArray(es);
if (!live) return;
dirCache.current[atDir] = list;
if (live) setEntries(list);
setEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
// re-fetch when the menu opens or the directory level changes
}, [atRaw === null, atDir, cwd]);
// Re-fetch when the menu opens, the directory level changes, or the
// workspace tree refreshes; cached data is only a fast first paint.
}, [atRaw === null, atDir, cwd, fileRefRefreshKey]);
useEffect(() => {
if (atRaw === null || atDir !== "" || atFrag === "") {
setSearchEntries([]);
@@ -744,23 +825,25 @@ export function Composer({
}
const cached = searchCache.current[atFrag];
if (cached) {
setSearchEntries(cached);
return;
setSearchEntries(cached.entries);
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
} else {
setSearchEntries([]);
}
setSearchEntries([]);
let live = true;
app
.SearchFileRefs(atFrag)
.then((es) => {
const list = es ?? [];
searchCache.current[atFrag] = list;
if (live) setSearchEntries(list);
const list = asArray(es);
if (!live) return;
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
setSearchEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
}, [atRaw === null, atDir, atFrag, cwd]);
}, [atRaw === null, atDir, atFrag, cwd, fileRefRefreshKey]);
const atMatches = useMemo(
() => {
if (atRaw === null) return [];
@@ -817,6 +900,28 @@ export function Composer({
setDismissed(true);
}, [transientDismissSignal]);
const takeSelfDispatchedGuidance = useCallback((text: string): boolean => {
const idx = selfDispatchedGuidanceRef.current.findIndex((queued) => guidanceTextMatches(queued, text));
if (idx < 0) return false;
selfDispatchedGuidanceRef.current.splice(idx, 1);
return true;
}, []);
useEffect(() => {
if (!guidanceConsumedKey || guidanceConsumedKey === lastGuidanceConsumedKey.current) return;
lastGuidanceConsumedKey.current = guidanceConsumedKey;
const consumed = (guidanceConsumedText ?? "").trim();
if (consumed && takeSelfDispatchedGuidance(consumed)) return;
setPendingGuidance((items) => {
if (items.length === 0) return items;
const idx = consumed
? items.findIndex((item) => guidanceTextMatches(item.submitText, consumed) || guidanceTextMatches(item.text, consumed))
: -1;
const removeAt = idx >= 0 ? idx : 0;
return items.filter((_, index) => index !== removeAt);
});
}, [guidanceConsumedKey, guidanceConsumedText, takeSelfDispatchedGuidance]);
// When the @ trigger disappears (user deleted the @), close the past:chats
// sub-menu and reset related state. Without this, showPastChats can outlive
// the @ token and leave the session list visible with no way to dismiss it.
@@ -1111,7 +1216,7 @@ export function Composer({
}, [showToast, t]);
const submit = async () => {
if (disabled || submitDisabled || readOnly || submittingRef.current) return;
if (disabled || (!running && submitDisabled) || readOnly || submittingRef.current) return;
const submitDraftKey = activeDraftKeyRef.current;
const currentText = textRef.current;
const trimmedText = currentText.trim();
@@ -1150,6 +1255,16 @@ export function Composer({
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
if (running) {
const guidanceText = displayText.trim();
const guidanceSubmitText = submitText.trim();
if (guidanceText) {
const id = nextGuidanceId.current++;
setPendingGuidance((items) => [...items, { id, text: guidanceText, submitText: guidanceSubmitText || guidanceText }]);
}
clearSubmittedDraft(submitDraftKey);
return;
}
await onSend(displayText, submitText);
clearSubmittedDraft(submitDraftKey);
} catch (error) {
@@ -1160,6 +1275,27 @@ export function Composer({
}
};
const sendQueuedGuidance = async (item: PendingGuidance) => {
if (disabled || readOnly || guidanceSendingId !== null) return;
const displayText = item.text.trim();
const submitText = item.submitText.trim() || displayText;
if (!displayText || !submitText) return;
selfDispatchedGuidanceRef.current.push(submitText);
setGuidanceSendingId(item.id);
try {
await onSend(displayText, submitText);
setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id));
window.setTimeout(() => {
takeSelfDispatchedGuidance(submitText);
}, 5000);
} catch (error) {
takeSelfDispatchedGuidance(submitText);
showToast(error instanceof Error ? error.message : String(error), "warn");
} finally {
setGuidanceSendingId((current) => (current === item.id ? null : current));
}
};
const readFileAsDataURL = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -1929,6 +2065,21 @@ export function Composer({
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
const composerPlaceholder = readOnly
? t("composer.readOnlyChannel")
: disabled
? t("common.loading")
: running
? t("composer.steerPlaceholder")
: goalModeOn && !activeGoal
? t("composer.goalInputPlaceholder")
: t("composer.placeholder");
const hiddenGuidanceCount = Math.max(0, pendingGuidance.length - 2);
const visibleGuidance = guidanceExpanded ? pendingGuidance : pendingGuidance.slice(0, 2);
const showGuidanceExpander = pendingGuidance.length > 2;
const composerMetaClass = [
"composer-meta",
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
@@ -2185,6 +2336,58 @@ export function Composer({
</div>
</div>
)}
{pendingGuidance.length > 0 && (
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
<div className="composer-guidance-head">
<span className="composer-guidance-head__label">
<CornerDownRight size={14} />
<span>{t("composer.guidanceCount", { n: pendingGuidance.length })}</span>
</span>
</div>
<div className="composer-guidance-list">
{visibleGuidance.map((item) => (
<div className="composer-guidance-item" key={item.id}>
<CornerDownRight size={14} className="composer-guidance-item__icon" />
<span className="composer-guidance-item__text">{item.text}</span>
<Tooltip label={t("composer.guidanceSend")}>
<button
className="composer-guidance-item__guide"
type="button"
aria-label={t("composer.guidanceSend")}
disabled={!running || disabled || readOnly || guidanceSendingId !== null}
onClick={() => void sendQueuedGuidance(item)}
>
<CornerDownRight size={13} />
<span>{t("composer.guidanceMode")}</span>
</button>
</Tooltip>
<Tooltip label={t("composer.guidanceDismiss")}>
<button
className="composer-guidance-item__action"
type="button"
aria-label={t("composer.guidanceDismiss")}
disabled={guidanceSendingId === item.id}
onClick={() => setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id))}
>
<Trash2 size={14} />
</button>
</Tooltip>
</div>
))}
{showGuidanceExpander && (
<button
className="composer-guidance-more"
type="button"
aria-expanded={guidanceExpanded}
onClick={() => setGuidanceExpanded((value) => !value)}
>
{guidanceExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
<span>{guidanceExpanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenGuidanceCount })}</span>
</button>
)}
</div>
</div>
)}
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0) && (
<div className="composer-context" aria-label={t("composer.contextItems")}>
{sortComposerAttachments(attachments).map((a) => {
@@ -2193,10 +2396,11 @@ export function Composer({
<ComposerContextCard
key={a.path}
variant="attachment"
tooltipLabel={a.path}
tooltipLabel={a.previewUrl ? `${t("imageViewer.clickToPreview")} — ${a.path}` : a.path}
removeLabel={t("composer.removeImage")}
onRemove={() => removeAttachment(a.path)}
previewUrl={a.previewUrl}
onImageClick={a.previewUrl ? () => openComposerImageViewer(a.previewUrl!, attachmentName(a)) : undefined}
imageOnly={imageOnly}
name={attachmentName(a)}
meta={attachmentExt(attachmentName(a)) || t("msg.fileAttachment")}
@@ -2240,6 +2444,12 @@ export function Composer({
))}
</div>
)}
<ImageViewer
open={imageViewer.open}
imageUrl={imageViewer.url}
imageName={imageViewer.name}
onClose={closeComposerImageViewer}
/>
{activePastedBlocks.length > 0 && (
<div className="composer__pasted">
{activePastedBlocks.map((block) => {
@@ -2324,7 +2534,7 @@ export function Composer({
lastCompositionEndAt.current = Date.now();
}}
style={textareaStyle}
placeholder={readOnly ? t("composer.readOnlyChannel") : disabled ? t("common.loading") : goalModeOn && !activeGoal ? t("composer.goalInputPlaceholder") : t("composer.placeholder")}
placeholder={composerPlaceholder}
rows={1}
disabled={disabled || readOnly}
/>
@@ -2333,17 +2543,16 @@ export function Composer({
{composerPrompt}
</span>
)}
{!running && (
<Tooltip label={t("composer.send")}>
<button
className="composer__btn composer__btn--send"
onClick={submit}
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled || submitDisabled || readOnly}
>
<ArrowUp size={16} />
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
onClick={submit}
disabled={submitBlocked}
aria-label={submitTooltip}
>
<ArrowUp size={16} />
</button>
</Tooltip>
</div>
<div className={composerMetaClass}>
<div className="composer-meta__params">
@@ -12,6 +12,7 @@ export function ComposerContextCard({
removeDisabled = false,
removeIconSize,
previewUrl,
onImageClick,
imageOnly = false,
folder = false,
name,
@@ -26,6 +27,8 @@ export function ComposerContextCard({
removeDisabled?: boolean;
removeIconSize?: number;
previewUrl?: string;
/** called when the image thumbnail is clicked (only for image attachments with previewUrl) */
onImageClick?: () => void;
imageOnly?: boolean;
folder?: boolean;
name?: string;
@@ -46,7 +49,13 @@ export function ComposerContextCard({
<Tooltip label={tooltipLabel}>
<span className="composer-context__label">
{previewUrl ? (
<span className="composer-context__thumb">
<span
className={`composer-context__thumb${onImageClick ? " composer-context__thumb--interactive" : ""}`}
onClick={onImageClick}
role={onImageClick ? "button" : undefined}
tabIndex={onImageClick ? 0 : undefined}
onKeyDown={onImageClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onImageClick(); } } : undefined}
>
<img src={previewUrl} alt="" draggable={false} />
</span>
) : variant === "attachment" ? (
+257 -210
View File
@@ -23,9 +23,9 @@ interface ContextPanelProps {
refreshKey?: number;
}
function fmtTokens(n: number): string {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
function fmtFullTokens(n: number): string {
if (n <= 0) return "0";
return String(Math.round(n));
}
function fmtDuration(ms: number, t: Translator): string {
@@ -62,11 +62,6 @@ export function formatMetricTokens(tokens: number | undefined, locale: Locale |
return { display: exact, exact };
}
function fmtTurns(turns: number | undefined, t: Translator): string {
if (typeof turns !== "number" || turns < 0) return "-";
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}
function fmtUsageCacheRate(usage?: WireUsage): string {
if (!usage) return "-";
const denom = usage.cacheHitTokens + usage.cacheMissTokens;
@@ -80,10 +75,44 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
return `${((hitTokens / denom) * 100).toFixed(2)}%`;
}
interface HealthResult {
type MetricTone = "accent" | "good" | "notice" | "warn";
type UsageAnalysisView = "source" | "type";
type ContextUsageRefreshFields = Pick<
WireUsage,
"totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
>;
export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
if (!usage) return "";
return [
usage.totalTokens ?? 0,
usage.promptTokens ?? 0,
usage.completionTokens ?? 0,
usage.reasoningTokens ?? 0,
usage.sessionCacheHitTokens ?? 0,
usage.sessionCacheMissTokens ?? 0,
].join(":");
}
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
const denom = hitTokens + missTokens;
if (denom <= 0) return undefined;
const pct = (hitTokens / denom) * 100;
if (pct >= 80) return "good";
if (pct >= 60) return "notice";
return "warn";
}
function formatSharePercent(value: number, total: number): string {
if (total <= 0 || value <= 0) return "-";
const pct = (value / total) * 100;
if (pct > 0 && pct < 1) return "<1%";
return `${Math.round(pct)}%`;
}
interface ContextWindowStatus {
tone: "good" | "notice" | "warn";
shortKey: DictKey;
vars: Record<string, string | number>;
key: DictKey;
}
export function contextCostDisplay({
@@ -171,33 +200,11 @@ export function contextBreakdown(
};
}
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
if (usagePct >= 85) {
return {
tone: "warn",
shortKey: "context.healthNearLimitShort",
vars: { pct: usagePct },
};
}
if (readCount >= 8) {
return {
tone: "notice",
shortKey: "context.healthManyFilesShort",
vars: { count: readCount },
};
}
if (cachePct > 0 && cachePct < 50) {
return {
tone: "notice",
shortKey: "context.healthLowCacheShort",
vars: { pct: cachePct },
};
}
return {
tone: "good",
shortKey: "context.healthGoodShort",
vars: {},
};
export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
return { tone: "good", key: "context.windowStatusHealthy" };
}
const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
@@ -286,7 +293,6 @@ export function ContextPanel({
sessionTokens,
sessionCost,
sessionCurrency,
sessionTurns,
turnTokens,
turnCost,
balance,
@@ -295,7 +301,10 @@ export function ContextPanel({
}: ContextPanelProps) {
const { locale, t } = useI18n();
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
const refreshSeq = useRef(0);
const lastRefreshTime = useRef(0);
const usageRefreshKey = contextUsageRefreshKey(usage);
const refresh = useCallback(async () => {
if (!tabId) return;
@@ -320,19 +329,23 @@ export function ContextPanel({
void refresh();
}, [refresh, refreshKey]);
const hasPanelUsage = Boolean(
(info?.requestCount ?? 0) > 0 ||
(info?.promptTokens ?? 0) > 0 ||
(info?.completionTokens ?? 0) > 0 ||
(info?.totalTokens ?? 0) > 0 ||
(info?.reasoningTokens ?? 0) > 0 ||
(info?.cacheHitTokens ?? 0) > 0 ||
(info?.cacheMissTokens ?? 0) > 0
);
// Refresh the panel snapshot while usage events stream. The key includes
// general token fields so providers without cache telemetry still tick.
useEffect(() => {
if (!usageRefreshKey) return;
const now = Date.now();
if (now - lastRefreshTime.current >= 1000) {
lastRefreshTime.current = now;
void refresh();
}
}, [usageRefreshKey, refresh]);
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
const promptTokens = hasPanelUsage ? info?.promptTokens ?? 0 : usage?.promptTokens ?? 0;
const completionTokens = hasPanelUsage ? info?.completionTokens ?? 0 : usage?.completionTokens ?? 0;
// Prefer live usage props (updated in real-time by the reducer during streaming)
// over the async-fetched info snapshot (only refreshed on turn_done).
const promptTokens = usage?.promptTokens ?? info?.promptTokens ?? 0;
const completionTokens = usage?.completionTokens ?? info?.completionTokens ?? 0;
const totalTokens = info?.totalTokens && info.totalTokens > 0
? info.totalTokens
: sessionTokens && sessionTokens > 0
@@ -340,33 +353,28 @@ export function ContextPanel({
: usage?.totalTokens && usage.totalTokens > 0
? usage.totalTokens
: promptTokens + completionTokens;
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
const cacheHitTokens = hasPanelUsage ? info?.cacheHitTokens ?? 0 : usage?.cacheHitTokens ?? 0;
const cacheMissTokens = hasPanelUsage ? info?.cacheMissTokens ?? 0 : usage?.cacheMissTokens ?? 0;
// Session-cumulative values for the metrics cards (方案A: 纯前端改数据源)
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
const sessionCompletion = info?.sessionCompletionTokens ?? 0;
const sessionCacheHitMetric = formatMetricTokens(sessionCacheHit, locale);
const sessionCacheMissMetric = formatMetricTokens(sessionCacheMiss, locale);
const sessionCompletionMetric = formatMetricTokens(sessionCompletion, locale);
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
// Session-cumulative values for the top summary.
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
// the Go agent (includes background tasks) and arrives with every usage event.
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
const showSourceUsageRows = sourceUsageRows.length > 0;
const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
const visibleSourceRows = sourceUsageRows.slice(0, 3);
const hiddenSourceRows = sourceUsageRows.slice(3);
const readFiles = asArray(info?.readFiles);
const changedFiles = asArray(info?.changedFiles);
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
const compactPct = context?.compactRatio ? Math.round(context.compactRatio * 100) : 0;
const cacheDenom = cacheHitTokens + cacheMissTokens;
const cachePct = cacheDenom > 0 ? (cacheHitTokens / cacheDenom) * 100 : 0;
const cachePctDisplay = formatCacheHitRate(cacheHitTokens, cacheMissTokens);
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
const compactPct = Math.round(compactRatio * 100);
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
const donutStyle = {
background: `conic-gradient(#13a7a5 0 ${breakdown.promptPct}%, #2f6df6 ${breakdown.promptPct}% ${breakdown.completionPct}%, #f97316 ${breakdown.completionPct}% ${breakdown.reasoningPct}%, var(--border) ${breakdown.reasoningPct}% ${breakdown.otherPct}%, var(--border-soft) ${breakdown.otherPct}% 100%)`,
};
const eventTimes = [
...readFiles.map((file) => file.time),
...changedFiles.map((file) => file.latestTime ?? 0),
@@ -375,100 +383,165 @@ export function ContextPanel({
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
const health = contextHealth(usagePct, Math.round(cachePct), readFiles.length);
const windowStatus = contextWindowStatus(usagePct, compactPct);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(sessionCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
const usedLabel = fmtFullTokens(usedTokens);
const windowLabel = fmtFullTokens(windowTokens);
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
const tokenTypeRows = [
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
{ key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
{ key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
{ key: "other", label: t("context.other"), value: breakdown.otherTokens },
];
const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
const renderSourceRow = (row: ContextSourceRow) => {
const inputMetric = formatMetricTokens(row.promptTokens, locale);
const outputMetric = formatMetricTokens(row.completionTokens, locale);
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
return (
<div className="context-panel__source-row" key={row.source}>
<div className="context-panel__source-head">
<span>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)}
</span>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
</div>
<div className="context-panel__source-summary">
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
</div>
<details className="context-panel__source-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__source-details-body">
<SourceSplitBar
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
segments={[
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
]}
/>
{cacheReported ? (
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
]}
compact
/>
) : (
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
)}
<div className="context-panel__source-metrics">
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
</div>
</div>
</details>
</div>
);
};
return (
<div className="context-panel">
<div className="context-panel__body">
<section className="context-panel__overview">
<section className="context-panel__usage">
<SectionHeading title={t("context.windowTitle")} meta={t("context.windowSubtitle")} />
<div className="context-panel__usage-visual">
<div className="context-panel__donut" style={donutStyle}>
<div className="context-panel__donut-core">
<strong>{fmtTokens(usedTokens)}</strong>
<span>/ {fmtTokens(windowTokens)} tokens</span>
<SectionHeading title={t("context.windowTitle")} />
<div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
<div className="context-panel__capacity-top">
<span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
<strong>{usedLabel}/{windowLabel}</strong>
</div>
<div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
<div className="context-panel__capacity-scale" aria-hidden="true">
<span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
<span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
<span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
</div>
</div>
<div className="context-panel__percent">{usagePct}%</div>
</div>
<div className="context-panel__usage-progress" aria-label={t("context.windowSubtitle")}>
<div className="context-panel__progress-head">
<strong>{fmtTokens(usedTokens)} / {fmtTokens(windowTokens)}</strong>
<span>{usagePct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
<div className="context-panel__capacity-foot">
<span>{t("context.windowUsedLabel")}</span>
<span className="context-panel__capacity-remaining">
<span>{t("context.windowCompactDistance")}</span>
<strong>{compactRemainingLabel}</strong>
</span>
</div>
</div>
<div className="context-panel__summary-rows">
<MiniStat label={t("status.compactLabel")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("status.sessionTurnsLabel")} value={fmtTurns(sessionTurns, t)} />
</div>
<div className="context-panel__breakdown">
<TokenLegend label={t("context.prompt")} value={breakdown.promptTokens} color="prompt" />
<TokenLegend label={t("context.completion")} value={breakdown.completionTokens} color="completion" />
<TokenLegend label={t("context.reasoning")} value={breakdown.reasoningTokens} color="reasoning" />
<TokenLegend label={t("context.other")} value={breakdown.otherTokens} color="other" />
<div className="context-panel__total">
<span>{t("context.total")}</span>
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
</section>
<section className="context-panel__section context-panel__session-section">
<SectionHeading title={t("context.sessionMetrics")} />
<div className="context-panel__session-metrics">
<div className="context-panel__summary-rows">
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
</div>
</div>
</section>
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.runtimeMetrics")} />
<div className="context-panel__runtime-card">
<div className="context-panel__runtime-summary">
<RuntimeMetric label={t("context.time")} value={fmtDuration(elapsed, t)} />
<RuntimeMetric label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<RuntimeMetric label={t("context.outputTokens")} value={sessionCompletionMetric.display} title={sessionCompletionMetric.exact} />
<RuntimeMetric label={t("context.sessionTokens")} value={totalTokensMetric.display} title={totalTokensMetric.exact} />
</div>
<div className="context-panel__runtime-cache">
<div className="context-panel__runtime-cache-head">
<span>{t("context.inputCache")}</span>
<strong>{formatCacheHitRate(sessionCacheHit, sessionCacheMiss)}</strong>
<section className="context-panel__section context-panel__analysis">
<SectionHeading title={t("context.usageAnalysis")}>
{showSourceUsageRows && (
<div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "source"}
onClick={() => setAnalysisView("source")}
>
{t("context.usageAnalysisSource")}
</button>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "type"}
onClick={() => setAnalysisView("type")}
>
{t("context.usageAnalysisType")}
</button>
</div>
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: sessionCacheHit, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: sessionCacheMiss, tone: "miss" },
]}
/>
<div className="context-panel__runtime-cache-values">
<SourceMetric label={t("context.sourceCacheHit")} value={sessionCacheHitMetric.display} title={sessionCacheHitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={sessionCacheMissMetric.display} title={sessionCacheMissMetric.exact} />
</div>
</div>
</div>
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.costMetrics")} />
<div className="context-panel__stats">
<MetricCard label={t("context.cacheHit")} value={cachePctDisplay} tone="accent" />
<MetricCard label={t("context.sessionCost")} value={formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" })} />
</div>
{showSourceUsageRows && (
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")}>
)}
</SectionHeading>
{activeAnalysisView === "source" ? (
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
<div className="context-panel__source-overview">
<div className="context-panel__source-overview-head">
<strong>{t("context.sourceBreakdown")}</strong>
<span>{t("context.sourceShareTokens")}</span>
<strong>{t("context.sourceShareTitle")}</strong>
</div>
<div className="context-panel__source-sharebar" aria-hidden="true">
{sourceUsageRows.map((row) => {
@@ -495,70 +568,51 @@ export function ContextPanel({
})}
</div>
</div>
{sourceUsageRows.map((row) => {
const inputMetric = formatMetricTokens(row.promptTokens, locale);
const outputMetric = formatMetricTokens(row.completionTokens, locale);
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
return (
<div className="context-panel__source-row" key={row.source}>
<div className="context-panel__source-head">
<span>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)}
</span>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
</div>
<div className="context-panel__source-summary">
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
</div>
<SourceSplitBar
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
segments={[
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
]}
/>
{cacheReported ? (
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
]}
compact
/>
) : (
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
)}
<details className="context-panel__source-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__source-metrics">
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
</div>
</details>
{visibleSourceRows.map(renderSourceRow)}
{hiddenSourceRows.length > 0 && (
<details className="context-panel__source-more">
<summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
<div className="context-panel__source-more-list">
{hiddenSourceRows.map(renderSourceRow)}
</div>
);
})}
</details>
)}
</div>
) : (
<div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
<div className="context-panel__type-overview">
<div className="context-panel__type-overview-head">
<strong>{t("context.tokenBreakdown")}</strong>
</div>
<div className="context-panel__type-sharebar" aria-hidden="true">
{tokenTypeRows.map((row) => row.value > 0 ? (
<span
className={`context-panel__type-share context-panel__type-share--${row.key}`}
key={row.key}
style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
/>
) : null)}
</div>
<div className="context-panel__type-legend">
{tokenTypeRows.map((row) => (
<span key={row.key}>
<i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
{row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
</span>
))}
</div>
</div>
<details className="context-panel__breakdown-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__breakdown">
{tokenTypeRows.map((row) => (
<TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
))}
</div>
</details>
</div>
)}
</section>
<section className="context-panel__section context-panel__section--status">
<SectionHeading title={t("context.sessionStatus")} />
<div className="context-panel__stats">
<MetricCard label={t("context.health")} value={t(health.shortKey, health.vars)} tone={health.tone} />
<MetricCard label={t("context.compaction")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
</div>
</section>
</section>
</div>
@@ -586,11 +640,14 @@ function TokenLegend({ label, value, color }: { label: string; value: number; co
);
}
function MiniStat({ label, value }: { label: string; value: string }) {
function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
const wideClass = wide ? " context-panel__mini-stat--wide" : "";
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__mini-stat">
<div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong>{value}</strong>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
@@ -607,16 +664,6 @@ function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; v
);
}
function RuntimeMetric({ label, value, title }: { label: string; value: string; title?: string }) {
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__runtime-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
const exactTitle = title && title !== value ? title : undefined;
return (
@@ -6,6 +6,13 @@ import { app } from "../lib/bridge";
import type { DirEntry } from "../lib/types";
import { VirtualMenu } from "./VirtualMenu";
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
type FileRefSearchCacheEntry = {
entries: DirEntry[];
cachedAt: number;
};
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
return entry.path || atDir + entry.name;
}
@@ -58,7 +65,7 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState(false);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
const prevCwdRef = useRef(cwd);
useEffect(() => {
@@ -82,15 +89,17 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
const cached = dirCache.current[atDir];
if (cached) {
setEntries(cached);
return;
} else {
setEntries([]);
}
let live = true;
app
.ListDir(atDir)
.then((next) => {
const list = asArray(next);
if (!live) return;
dirCache.current[atDir] = list;
if (live) setEntries(list);
setEntries(list);
})
.catch(() => {});
return () => {
@@ -105,17 +114,19 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
}
const cached = searchCache.current[atFrag];
if (cached) {
setSearchEntries(cached);
return;
setSearchEntries(cached.entries);
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
} else {
setSearchEntries([]);
}
setSearchEntries([]);
let live = true;
app
.SearchFileRefs(atFrag)
.then((next) => {
const list = next ?? [];
searchCache.current[atFrag] = list;
if (live) setSearchEntries(list);
const list = asArray(next);
if (!live) return;
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
setSearchEntries(list);
})
.catch(() => {});
return () => {
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useT } from "../lib/i18n";
export interface ImageViewerProps {
open: boolean;
/** data-URL of the image to display */
imageUrl: string;
/** optional filename shown at the bottom */
imageName?: string;
onClose: () => void;
}
/**
* ImageViewer renders a full-size image preview in a portal overlay.
*
* It follows the same portal pattern as MermaidDiagram fullscreen:
* the overlay is portaled into .chat-pane (fallback document.body) so it
* covers the chat area without being clipped by overflow containers.
*/
export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
const t = useT();
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
const [visible, setVisible] = useState(false);
// Resolve portal target when opening.
useEffect(() => {
if (!open) {
setVisible(false);
setPortalTarget(null);
return;
}
const target = document.querySelector(".chat-pane") ?? document.body;
setPortalTarget(target);
// Trigger enter animation on the next frame.
const raf = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(raf);
}, [open]);
// Close on Escape.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
// Prevent body scroll while open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!open || !portalTarget || !imageUrl) return null;
const overlay = (
<div
className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label={imageName || t("imageViewer.title")}
>
<button
className="image-viewer__close"
type="button"
onClick={onClose}
aria-label={t("imageViewer.closePreview")}
>
<X size={22} />
</button>
<div className="image-viewer__content">
<img
className="image-viewer__image"
src={imageUrl}
alt={imageName || ""}
draggable={false}
/>
{imageName && <div className="image-viewer__name">{imageName}</div>}
</div>
</div>
);
return createPortal(overlay, portalTarget);
}
+164 -22
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
import { Markdown } from "./Markdown";
@@ -10,6 +10,7 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
import { app } from "../lib/bridge";
import { replaySubmitText } from "../lib/editReplay";
import { useT } from "../lib/i18n";
import { ImageViewer } from "./ImageViewer";
import { Tooltip } from "./Tooltip";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { displayReasoningText } from "../lib/reasoningDisplay";
@@ -81,6 +82,31 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
return merged;
}
type PastedBlockInfo = {
label: string;
content: string;
};
const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
const labels = text.match(PASTE_LABEL_RE);
if (!labels || labels.length === 0 || !submitText) return [];
const unique = [...new Set(labels)];
const blocks: PastedBlockInfo[] = [];
for (const label of unique) {
const beginMarker = `--- Begin ${label} ---`;
const endMarker = `--- End ${label} ---`;
const beginIdx = submitText.indexOf(beginMarker);
const endIdx = submitText.indexOf(endMarker);
if (beginIdx < 0 || endIdx <= beginIdx) continue;
const contentStart = beginIdx + beginMarker.length;
const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
blocks.push({ label, content });
}
return blocks;
}
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
const t = useT();
const bodyRef = useRef<HTMLDivElement>(null);
@@ -181,6 +207,65 @@ export function UserMessage({
const [editSubmitting, setEditSubmitting] = useState(false);
const editRef = useRef<HTMLTextAreaElement>(null);
const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
const openImageViewer = useCallback(async (path: string, name: string) => {
let url = imagePreviews[path];
if (!url) {
try {
url = await app.AttachmentDataURL(path);
setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
} catch {
return;
}
}
setImageViewer({ open: true, url, name });
}, [imagePreviews]);
const closeImageViewer = useCallback(() => {
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
}, []);
const pasteBlocks = useMemo(() => parsePastedBlocks(actionText, submitText), [actionText, submitText]);
const [expandedPasteLabels, setExpandedPasteLabels] = useState<Record<string, boolean>>({});
type DisplaySegment =
| { type: "text"; content: string }
| { type: "paste"; block: PastedBlockInfo };
const displaySegments = useMemo((): DisplaySegment[] => {
if (pasteBlocks.length === 0) return [{ type: "text", content: displayText }];
const segments: DisplaySegment[] = [];
// Order blocks by their position in the text so cards appear inline.
const ordered = pasteBlocks
.map((b) => ({ block: b, pos: displayText.indexOf(b.label) }))
.filter((x) => x.pos >= 0)
.sort((a, b) => a.pos - b.pos);
let remaining = displayText;
for (const { block } of ordered) {
const idx = remaining.indexOf(block.label);
if (idx < 0) continue;
// Text before the label: strip the trailing newline that separated the
// label from the preceding line so the card sits tight against the text.
if (idx > 0) {
let before = remaining.slice(0, idx);
before = before.replace(/\n$/, "");
if (before) segments.push({ type: "text", content: before });
}
segments.push({ type: "paste", block });
remaining = remaining.slice(idx + block.label.length);
}
// Strip the leading newline that followed the label.
remaining = remaining.replace(/^\n/, "");
if (remaining.trim()) segments.push({ type: "text", content: remaining });
return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
}, [displayText, pasteBlocks]);
const togglePasteExpand = (label: string) => {
setExpandedPasteLabels((prev) => ({
...prev,
[label]: !prev[label],
}));
};
const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
const imagePreviewKey = orderedAttachments
.concat(orderedDraftAttachments)
@@ -305,11 +390,12 @@ export function UserMessage({
<ComposerContextCard
key={attachment.path}
variant={attachment.source === "workspace" ? "workspace" : "attachment"}
tooltipLabel={attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
removeDisabled={editSubmitting}
onRemove={() => removeDraftAttachment(attachment.path)}
previewUrl={imagePreview}
onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
imageOnly={imageOnly}
folder={attachment.kind === "folder"}
label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
@@ -355,26 +441,78 @@ export function UserMessage({
)}
</div>
) : (
displayText && <div className="msg__text">{displayText}</div>
<>
{displaySegments.map((seg, i) => {
if (seg.type === "text") {
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
}
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
return (
<div className="msg-pasted" key={seg.block.label}>
<div className="msg-pasted-block">
<div className="msg-pasted-head">
<FileText size={15} />
<span className="msg-pasted-label">{seg.block.label}</span>
<div className="msg-pasted-actions">
<Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
<button type="button" onClick={() => togglePasteExpand(seg.block.label)}>
{expanded ? t("common.collapse") : t("composer.pastedExpand")}
</button>
</Tooltip>
</div>
</div>
{expanded && (
<div className="msg-pasted-expanded">{seg.block.content}</div>
)}
</div>
</div>
);
})}
</>
)}
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
{orderedAttachments.length > 0 && (
<div className="msg-attachments" aria-label={t("msg.attachments")}>
{orderedAttachments.map((attachment, index) => (
<div className={`msg-attachment msg-attachment--${attachment.kind}`} key={`${attachment.path}:${index}`} title={attachment.path}>
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
{attachment.kind === "image" && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
</span>
<span className="msg-attachment__main">
<span className="msg-attachment__name">{attachment.name}</span>
<span className="msg-attachment__meta">
{attachment.kind === "folder"
? t("msg.folderReference")
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
{orderedAttachments.map((attachment, index) => {
const isImage = attachment.kind === "image";
const el = (
<div
className={`msg-attachment msg-attachment--${attachment.kind}`}
key={isImage ? undefined : `${attachment.path}:${index}`}
title={isImage ? undefined : attachment.path}
onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
role={isImage ? "button" : undefined}
tabIndex={isImage ? 0 : undefined}
onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
>
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
{isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
</span>
</span>
</div>
))}
<span className="msg-attachment__main">
<span className="msg-attachment__name">{attachment.name}</span>
<span className="msg-attachment__meta">
{attachment.kind === "folder"
? t("msg.folderReference")
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
</span>
</span>
</div>
);
if (isImage) {
return (
<Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
{el}
</Tooltip>
);
}
return el;
})}
<ImageViewer
open={imageViewer.open}
imageUrl={imageViewer.url}
imageName={imageViewer.name}
onClose={closeImageViewer}
/>
</div>
)}
</div>
@@ -483,9 +621,9 @@ export function TurnActions({
}
};
const actionMeta = (scope: MessageActionScope): string => {
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
const total = checkpoint.files.length;
const turnCount = checkpoint.turnFileCount ?? 0;
const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
if ((scope === "code" || scope === "both") && total > 0) {
const turnCount = checkpoint?.turnFileCount ?? 0;
if (turnCount > 0 && turnCount < total) {
return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
}
@@ -496,12 +634,16 @@ export function TurnActions({
const actionTooltipLabel = (scope: MessageActionScope) => {
const reason = actionDisabledReason(scope);
if (reason) return <span>{reason}</span>;
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
const files = checkpoint?.files ?? [];
const total = checkpoint?.fileCount ?? files.length;
if ((scope === "code" || scope === "both") && total > 0) {
const hidden = Math.max(0, total - files.length);
return (
<div className="rewind__files-tooltip">
{checkpoint.files.map((file) => (
{files.map((file) => (
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
))}
{hidden > 0 && <div>+{hidden}</div>}
</div>
);
}
+148 -3
View File
@@ -159,6 +159,43 @@ function topicStatusLabel(node: ProjectNode, t: Translator): string {
return status ? t(topicStatusLabels[status]) : "";
}
function topicActivityAt(node: ProjectNode): number {
return node.lastActivityAt || node.createdAt || 0;
}
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
const request = projectTreeTopicOpenRequest(node);
if (!request?.topicId) return null;
return [
request.scope,
request.workspaceRoot,
request.topicId,
request.sessionPath ?? "",
].join("\u001f");
}
type ProjectTreeReadActivity = Record<string, number>;
export function projectTreeTopicHasUnreadActivity(
node: ProjectNode,
readActivity: ProjectTreeReadActivity,
activeScope?: string,
activeWorkspaceRoot?: string,
activeTopicId?: string,
activeSessionPath?: string,
): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
if (topicStatus(node) !== "") return false;
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
}
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, compactTopics: boolean, unread: boolean): boolean {
return !isSessionNode && compactTopics && !unread;
}
function topicActivityLabel(ms: number, t: Translator, compact = false): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
@@ -226,6 +263,31 @@ type WorkbenchTreeSections = {
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
const READ_ACTIVITY_KEY = "projectTree:readActivity";
const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
function loadReadActivity(): ProjectTreeReadActivity {
try {
const raw = localStorage.getItem(READ_ACTIVITY_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, unknown>;
const out: ProjectTreeReadActivity = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
}
return out;
} catch {
return {};
}
}
function saveReadActivity(readActivity: ProjectTreeReadActivity) {
try {
localStorage.setItem(READ_ACTIVITY_KEY, JSON.stringify(readActivity));
} catch {
/* localStorage unavailable */
}
}
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
try {
@@ -497,6 +559,7 @@ export function ProjectTree({
const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
const filterRef = useRef<HTMLDivElement>(null);
const filterTriggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -562,6 +625,67 @@ export function ProjectTree({
void refresh();
}, [refresh, refreshSignal]);
const markNodeRead = useCallback((node: ProjectNode) => {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (!key || activityAt <= 0) return;
setReadActivity((prev) => {
if ((prev[key] ?? 0) >= activityAt) return prev;
const next = { ...prev, [key]: activityAt };
saveReadActivity(next);
return next;
});
}, []);
useEffect(() => {
if (tree.length === 0) return;
try {
if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
} catch {
return;
}
const baseline: ProjectTreeReadActivity = {};
const collectBaseline = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
}
collectBaseline(asArray(node.children));
}
};
collectBaseline(tree);
try {
localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
} catch {
/* localStorage unavailable */
}
if (Object.keys(baseline).length === 0) return;
setReadActivity((prev) => {
const next = { ...prev };
let changed = false;
for (const [key, value] of Object.entries(baseline)) {
if ((next[key] ?? 0) >= value) continue;
next[key] = value;
changed = true;
}
if (!changed) return prev;
saveReadActivity(next);
return next;
});
}, [tree]);
useEffect(() => {
const markActive = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
markActive(asArray(node.children));
}
};
markActive(tree);
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, tree]);
useEffect(() => {
try {
localStorage.setItem(WORKBENCH_ORGANIZE_KEY, workbenchOrganizeMode);
@@ -1010,12 +1134,21 @@ export function ProjectTree({
const status = topicStatus(node);
const statusLabel = topicStatusLabel(node, t);
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const topicId = node.topicId ?? "";
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
const imSourceLabel = imSource?.label || "";
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
const title = [label, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const recovered = Boolean(node.recovered);
const recoveryTitle = recovered
? [
t("recovery.branch"),
node.recoveryReason ? t("recovery.reason", { reason: node.recoveryReason }) : "",
node.recoveryDigest ? t("recovery.digest", { digest: node.recoveryDigest.slice(0, 12) }) : "",
].filter(Boolean).join(" · ")
: "";
const title = [label, recoveryTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
const pinned = Boolean(node.pinned);
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
@@ -1092,7 +1225,7 @@ export function ProjectTree({
}
const row = (
<div
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${recovered ? " project-tree__topic--recovered" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
style={accentStyle}
onContextMenu={isSessionNode ? undefined : openTopicMenu}
>
@@ -1112,6 +1245,7 @@ export function ProjectTree({
}
const timer = setTimeout(() => {
if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
markNodeRead(node);
onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
}, 200);
clickTimerRef.current = { ...nextClick, timer };
@@ -1145,6 +1279,11 @@ export function ProjectTree({
</span>
)}
{!compactTopics && statusLabel && <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>}
{recovered && (
<span className="project-tree__topic-recovery" title={recoveryTitle}>
{t("recovery.badge")}
</span>
)}
</span>
{!compactTopics && !creationTopics && meta && (
<span className="project-tree__topic-meta">
@@ -1168,8 +1307,14 @@ export function ProjectTree({
{meta}
</span>
)}
{compactTopics && recovered && (
<span className="sr-only">
{t("recovery.branch")}
</span>
)}
</button>
{!isSessionNode && compactTopics && (
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
<span className="project-tree__topic-actions" aria-label={t("projectTree.topicActions")}>
<Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
<button
@@ -1,6 +1,8 @@
import type { ReactNode, RefObject } from "react";
export function PromptShelf({
className,
cardClassName,
titleId,
title,
badges,
@@ -13,6 +15,8 @@ export function PromptShelf({
barRef,
role = "dialog",
}: {
className?: string;
cardClassName?: string;
titleId: string;
title: ReactNode;
badges?: ReactNode;
@@ -26,10 +30,10 @@ export function PromptShelf({
role?: "dialog" | "region";
}) {
return (
<div className="prompt-shelf" aria-live="polite">
<div className={["prompt-shelf", className ?? ""].filter(Boolean).join(" ")} aria-live="polite">
<div
ref={barRef}
className="prompt-shelf__card"
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
role={role}
aria-modal={role === "dialog" ? "false" : undefined}
aria-labelledby={titleId}
File diff suppressed because it is too large. Load diff
+45 -10
View File
@@ -1,22 +1,39 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../lib/i18n";
import type { Todo } from "../lib/tools";
import { shouldOpenTodoPanelByDefault } from "../lib/todoVisibility";
import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
const STORAGE_KEY = "todoPanel:open";
const STORAGE_KEY = "todoPanel:openStates";
const MAX_STORED_OPEN_STATES = 80;
function loadOpenState(): boolean {
function loadOpenStates(): Record<string, boolean> {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved === null ? true : saved === "1";
if (!saved) return {};
const parsed = JSON.parse(saved) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const states: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "boolean") states[key] = value;
}
return states;
} catch {
return true;
return {};
}
}
function saveOpenState(open: boolean): void {
function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
const states = loadOpenStates();
return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
}
function saveOpenState(stateKey: string, open: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
entries.push([stateKey, open]);
const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
} catch {
/* ignore quota errors */
}
@@ -24,23 +41,35 @@ function saveOpenState(open: boolean): void {
// TodoPanel is the live task list pinned just above the composer — the kernel's
// latest todo_write call drives it, and it updates in place as the agent flips
// items to in_progress / completed. The collapsed/expanded state is persisted
// globally in localStorage so the user's choice sticks across sessions.
// items to in_progress / completed. Open state follows the current todo batch:
// new incomplete work opens by default, finished work collapses to a reviewable
// summary, and manual expand/collapse is restored only for the same batch.
export function TodoPanel({
stateKey,
todos,
onDismiss,
}: {
stateKey: string;
todos: Todo[];
onDismiss: () => void;
}) {
const t = useT();
const [open, setOpen] = useState(loadOpenState);
const currentRef = useRef<HTMLLIElement | null>(null);
const done = todos.filter((t) => t.status === "completed").length;
const current = todos.find((t) => t.status === "in_progress");
const allDone = todos.length > 0 && done === todos.length;
const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault(todos)));
const wasAllDoneRef = useRef(allDone);
useEffect(() => {
if (allDone && !wasAllDoneRef.current) {
saveOpenState(stateKey, false);
setOpen(false);
}
wasAllDoneRef.current = allDone;
}, [allDone, stateKey]);
useEffect(() => {
if (!open) return;
@@ -58,7 +87,13 @@ export function TodoPanel({
role="region"
headerActions={
<>
<PromptHeaderAction onClick={() => setOpen((value) => { const next = !value; saveOpenState(next); return next; })}>
<PromptHeaderAction
onClick={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
>
{open ? t("common.collapse") : t("common.expand")}
</PromptHeaderAction>
{allDone && (
@@ -24,6 +24,7 @@ import {
Search,
X,
} from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import {
@@ -206,6 +207,7 @@ export function WorkspacePanel({
onPreviewModeChange,
onAddToChat,
onRequestPanelWidth,
onFileTreeRefresh,
refreshKey,
initialViewMode = "files",
revealPathRequest,
@@ -224,6 +226,7 @@ export function WorkspacePanel({
onPreviewModeChange?: (active: boolean) => void;
onAddToChat?: (text: string) => void;
onRequestPanelWidth?: (width: number) => void;
onFileTreeRefresh?: () => void;
refreshKey?: number;
initialViewMode?: "files" | "changed";
revealPathRequest?: WorkspaceRevealRequest | null;
@@ -275,6 +278,8 @@ export function WorkspacePanel({
const workspaceChangesRequestIdRef = useRef(0);
const gitHistoryRequestIdRef = useRef(0);
const commitDetailRequestIdRef = useRef(0);
const dirLoadGenerationRef = useRef(0);
const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
const recentAnchorRef = useRef<HTMLButtonElement>(null);
const openDirsRef = useRef(openDirs);
const pendingTreeRevealPathRef = useRef<string | null>(null);
@@ -284,8 +289,12 @@ export function WorkspacePanel({
}, [openDirs]);
const loadDir = useCallback(async (dir: string) => {
const entries = await app.ListDir(dir).catch(() => []);
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
const generation = dirLoadGenerationRef.current;
const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
dirLoadRequestIdsRef.current[dir] = requestId;
const entries = await app.ListDir(dir).catch((): DirEntry[] => []);
if (dirLoadGenerationRef.current !== generation || dirLoadRequestIdsRef.current[dir] !== requestId) return;
setEntriesByDir((prev) => ({ ...prev, [dir]: asArray(entries) }));
}, []);
const loadGitHistory = useCallback(async () => {
@@ -399,15 +408,15 @@ export function WorkspacePanel({
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
const dirs = parentDirs(path);
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
dirs.forEach((dir) => void loadDir(dir));
},
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
);
useEffect(() => {
if (!open) return;
dirLoadGenerationRef.current += 1;
dirLoadRequestIdsRef.current = {};
setEntriesByDir({});
setOpenDirs(new Set([""]));
setSelectedPath(null);
@@ -503,10 +512,8 @@ export function WorkspacePanel({
setTreeMenu(null);
const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
}, [entriesByDir, fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
dirs.forEach((dir) => void loadDir(dir));
}, [fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
useEffect(() => {
if (!open || changeListRequest) return;
@@ -649,10 +656,10 @@ export function WorkspacePanel({
void loadWorkspaceChanges();
return;
}
onFileTreeRefresh?.();
const dirs = Array.from(openDirsRef.current);
setEntriesByDir({});
dirs.forEach((dir) => void loadDir(dir));
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
}, [loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, viewMode]);
const refreshSelected = useCallback(() => {
if (!selectedPath) return;
@@ -698,12 +705,12 @@ export function WorkspacePanel({
next.delete(dir);
} else {
next.add(dir);
if (!entriesByDir[dir]) void loadDir(dir);
void loadDir(dir);
}
return next;
});
},
[entriesByDir, loadDir],
[loadDir],
);
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
@@ -1262,6 +1269,7 @@ export function WorkspacePanel({
setFilter("");
showTreeEvenSplit();
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
void loadDir("");
}}
>
{shortCwd(cwd) || t("workspace.title")}
@@ -190,6 +190,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
});
@@ -218,6 +219,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
}, []);
@@ -952,37 +954,63 @@ function TaskEditor({
/>
</div>
{/* Approval Mode */}
<div className="heartbeat-editor__field">
<label>{t("heartbeat.fieldApprovalMode")}</label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
title={t("heartbeat.approvalModeAskTooltip")}
>
{t("heartbeat.approvalModeAsk")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
title={t("heartbeat.approvalModeAutoTooltip")}
>
{t("heartbeat.approvalModeAuto")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
title={t("heartbeat.approvalModeYoloTooltip")}
>
{t("heartbeat.approvalModeYolo")}
</button>
{/* Approval Mode + Push to bot (side by side) */}
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px" }}>
<label>{t("heartbeat.fieldApprovalMode")}</label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
title={t("heartbeat.approvalModeAskTooltip")}
>
{t("heartbeat.approvalModeAsk")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
title={t("heartbeat.approvalModeAutoTooltip")}
>
{t("heartbeat.approvalModeAuto")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
title={t("heartbeat.approvalModeYoloTooltip")}
>
{t("heartbeat.approvalModeYolo")}
</button>
</div>
<span className="heartbeat-editor__mode-hint">
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
t("heartbeat.approvalModeAskHint")}
</span>
</div>
{/* Push to bot channels */}
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px", textAlign: "left" }}>
<label>{t("heartbeat.notifyChannels")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${draft.notifyChannels === true ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: true }))}
>
{t("heartbeat.notifyChannelsOn")}
</button>
<button
className={`set-seg__btn${draft.notifyChannels !== true ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: false }))}
>
{t("heartbeat.notifyChannelsOff")}
</button>
</div>
<span className="heartbeat-editor__mode-hint">
{draft.notifyChannels === true
? t("heartbeat.notifyChannelsOnHint")
: t("heartbeat.notifyChannelsOffHint")}
</span>
</div>
<span className="heartbeat-editor__mode-hint">
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
t("heartbeat.approvalModeAskHint")}
</span>
</div>
{/* New conversation per run */}
@@ -15,4 +15,5 @@ export interface HeartbeatTask {
approvalMode?: "ask" | "auto" | "yolo"; // empty defaults to "yolo"
timeWindowStart?: string; // "HH:MM" — interval tasks only run after this time
timeWindowEnd?: string; // "HH:MM" — interval tasks only run before this time
notifyChannels?: boolean; // true = push to bot channels; false/nil = skip
}
+353 -37
View File
@@ -48,13 +48,18 @@ import type {
Mode,
ModelInfo,
NetworkView,
PluginInstallOptions,
PluginView,
ProjectNode,
PromptHistoryEntry,
PromptHistoryResult,
ProviderPresetView,
ProviderView,
QuestionAnswer,
ServerView,
SessionMeta,
SessionRecoveryFailedEvent,
SessionRecoveryEvent,
SettingsView,
SkillsSettingsView,
SkillRootView,
@@ -118,6 +123,10 @@ interface DesktopWindowState {
// to AppBindings, then run `pnpm typecheck` to verify.
export interface AppBindings {
Platform(): Promise<string>;
MinimiseMainWindow(): Promise<void>;
ToggleMaximiseMainWindow(): Promise<void>;
IsMainWindowMaximised(): Promise<boolean>;
CloseMainWindow(): Promise<void>;
// ── Heartbeat ──
HeartbeatListTasks(): Promise<unknown>;
HeartbeatReloadTasks(): Promise<unknown>;
@@ -203,6 +212,13 @@ export interface AppBindings {
Capabilities(): Promise<CapabilitiesView>;
MCPServers(): Promise<ServerView[]>;
SkillsSettings(): Promise<SkillsSettingsView>;
Plugins(): Promise<PluginView[]>;
PlanPluginInstall(source: string, options: PluginInstallOptions): Promise<string>;
InstallPlugin(source: string, options: PluginInstallOptions): Promise<string>;
RemovePlugin(name: string): Promise<void>;
SetPluginEnabled(name: string, enabled: boolean): Promise<void>;
UpdatePlugin(name: string): Promise<string>;
PluginDoctor(name: string): Promise<PluginView>;
AddMCPServer(input: MCPServerInput): Promise<number>;
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
RemoveMCPServer(name: string): Promise<void>;
@@ -212,6 +228,7 @@ export interface AppBindings {
TrustMCPServerTools(name: string, toolNames: string[]): Promise<void>;
UntrustMCPServerTool(name: string, toolName: string): Promise<void>;
PickSkillFolder(): Promise<string>;
PickPluginFolder(): Promise<string>;
AddSkillPath(path: string): Promise<void>;
RemoveSkillPath(path: string): Promise<void>;
RefreshSkills(): Promise<void>;
@@ -273,10 +290,13 @@ export interface AppBindings {
SetPlannerModel(ref: string): Promise<void>;
SetSubagentModel(ref: string): Promise<void>;
SetSubagentEffort(level: string): Promise<void>;
SetMaxSubagentDepth(depth: number): Promise<void>;
SetAutoPlan(mode: string): Promise<void>;
SetDefaultToolApprovalMode(mode: string): Promise<void>;
SaveProvider(p: ProviderView): Promise<void>;
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
AddProviderPresetAccess(id: string, key: string): Promise<string>;
ResetProviderPresetAccess(id: string): Promise<void>;
FetchProviderModels(p: ProviderView): Promise<string[]>;
DeleteProvider(name: string): Promise<void>;
RemoveProviderAccess(name: string): Promise<void>;
@@ -285,6 +305,7 @@ export interface AppBindings {
SetPermissionMode(mode: string): Promise<void>;
AddPermissionRule(list: string, rule: string): Promise<void>;
RemovePermissionRule(list: string, rule: string): Promise<void>;
ReloadSettings(): Promise<void>;
SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string): Promise<void>;
SetNetwork(n: NetworkView): Promise<void>;
SetBotSettings(b: BotSettingsView): Promise<void>;
@@ -567,23 +588,38 @@ export function onProjectTreeChanged(cb: () => void): () => void {
return () => {};
}
export function onSessionRecovered(cb: (payload: SessionRecoveryEvent) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("session:recovered", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryEvent));
}
return () => {};
}
export function onSessionRecoveryFailed(cb: (payload: SessionRecoveryFailedEvent) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("session:recovery-failed", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryFailedEvent));
}
return () => {};
}
// app proxies each call to the live binding (or the dev mock only when truly
// outside the shell), so a late-injected window.go is picked up transparently.
function bridgeBreadcrumb(method: string): string {
if (method === "ReportCrash") return "";
if (/^(Submit|SubmitDisplay|RunShell|Steer|Cancel|Approve|AnswerQuestion|ReplayPendingPrompts)/.test(method))
return `turn ${method}`;
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort)/.test(method))
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth)/.test(method))
return `model ${method}`;
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
return `settings ${method}`;
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
return `provider ${method}`;
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
return `mcp ${method}`;
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
return `skill ${method}`;
if (/^(MinimiseMainWindow|ToggleMaximiseMainWindow|IsMainWindowMaximised|CloseMainWindow)$/.test(method)) return `window ${method}`;
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace)/.test(method))
return `nav ${method}`;
return "";
@@ -681,18 +717,149 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
}
function mockScenario(): "demo" | "fresh" | "running" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (value === "running" || value === "busy" || value === "streaming") return "running";
return "demo";
}
type MockProviderPresetTemplate = {
id: string;
label: string;
description: string;
keyEnv: string;
provider: ProviderView;
};
function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl" | "models" | "default" | "apiKeyEnv"> & Partial<ProviderView>): ProviderView {
return {
name: p.name,
builtIn: false,
added: true,
kind: p.kind,
baseUrl: p.baseUrl,
modelsUrl: p.modelsUrl ?? "",
models: p.models,
visionModels: p.visionModels ?? [],
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? ((p.visionModels ?? []).length > 0)),
default: p.default,
apiKeyEnv: p.apiKeyEnv,
headers: p.headers,
extraBody: p.extraBody,
authHeader: p.authHeader,
keySet: Boolean(p.keySet),
balanceUrl: p.balanceUrl ?? "",
contextWindow: p.contextWindow ?? 0,
reasoningProtocol: p.reasoningProtocol ?? "",
thinking: p.thinking ?? "",
supportedEfforts: p.supportedEfforts ?? [],
defaultEffort: p.defaultEffort ?? "",
modelOverrides: p.modelOverrides,
};
}
function mockPreset(id: string, label: string, description: string, keyEnv: string, provider: ProviderView): MockProviderPresetTemplate {
return { id, label, description, keyEnv, provider };
}
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
const mockGLMCodingModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7"];
const mockGLMAnthropicModels = ["glm-5.2[1m]", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"];
const mockQwenAPIModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "MiniMax-M2.5", "glm-5", "glm-4.7", "kimi-k2.5"];
const mockQwenPlanModels = ["qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"];
const mockQwenPlanVisionModels = ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"];
const mockStepFunModels = ["step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"];
const mockOpenCodeGoModels = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"];
const mockOpenCodeGoAnthropicModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"];
const mockOpenCodeZenAnthropicModels = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"];
const mockNovitaModels = ["zai-org/glm-5.2", "moonshotai/kimi-k2.7-code", "minimax/minimax-m3", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "qwen/qwen3.7-max", "qwen/qwen3.6-plus", "zai-org/glm-5v-turbo"];
const mockGMIModels = ["zai-org/GLM-5.2-FP8", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "moonshotai/Kimi-K2.7-Code", "anthropic/claude-sonnet-4.6", "openai/gpt-5.5"];
const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code", "zai/glm-5.2", "deepseek/deepseek-v4-pro"];
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("minimax-cn-api", "MiniMax CN API", "MiniMax China OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-cn-api", kind: "openai", baseUrl: "https://api.minimaxi.com/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-global-api", "MiniMax Global API", "MiniMax international OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-api", kind: "openai", baseUrl: "https://api.minimax.io/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-cn-anthropic", "MiniMax CN Anthropic", "MiniMax China Anthropic-compatible M-series endpoint.", "MINIMAX_PLAN_API_KEY", mockProviderTemplate({ name: "minimax-cn-anthropic", kind: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_PLAN_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-global-anthropic", "MiniMax Global Anthropic", "MiniMax international Anthropic-compatible endpoint with Bearer auth.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-anthropic", kind: "anthropic", baseUrl: "https://api.minimax.io/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("glm-cn", "GLM CN API", "Zhipu GLM China OpenAI-compatible API with thinking controls.", "GLM_API_KEY", mockProviderTemplate({ name: "glm-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "GLM_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("zai-global", "Z.AI Global API", "Z.AI international OpenAI-compatible GLM API.", "ZAI_API_KEY", mockProviderTemplate({ name: "zai-global", kind: "openai", baseUrl: "https://api.z.ai/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "ZAI_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("glm-coding-plan-cn", "GLM Coding Plan CN", "Zhipu GLM China coding-plan endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "GLM_PLAN_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("glm-coding-plan-cn-anthropic", "GLM Coding Plan CN Anthropic", "Zhipu GLM China coding-plan Anthropic-compatible endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://open.bigmodel.cn/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "GLM_PLAN_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
mockPreset("zai-coding-plan-global", "Z.AI Coding Plan Global", "Z.AI international coding-plan endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global", kind: "openai", baseUrl: "https://api.z.ai/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "ZAI_CODING_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("zai-coding-plan-global-anthropic", "Z.AI Coding Plan Global Anthropic", "Z.AI international coding-plan Anthropic-compatible endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "ZAI_CODING_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
mockPreset("opencode-go", "OpenCode Go", "OpenCode Go relay with per-model capability overrides.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", models: mockOpenCodeGoModels, default: "glm-5.2", apiKeyEnv: "OPENCODE_GO_API_KEY", contextWindow: 128000 })),
mockPreset("opencode-go-anthropic", "OpenCode Go Anthropic", "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen/go", models: mockOpenCodeGoAnthropicModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus"], default: "qwen3.7-plus", apiKeyEnv: "OPENCODE_GO_API_KEY", thinking: "adaptive", contextWindow: 262144 })),
mockPreset("opencode-zen-anthropic", "OpenCode Zen Anthropic", "OpenCode Zen Anthropic-compatible route for Claude and Qwen models.", "OPENCODE_API_KEY", mockProviderTemplate({ name: "opencode-zen-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen", models: mockOpenCodeZenAnthropicModels, visionModels: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"], default: "claude-sonnet-4-6", apiKeyEnv: "OPENCODE_API_KEY", contextWindow: 262144 })),
mockPreset("qwen-cn", "Qwen CN API", "Alibaba DashScope China standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-cn", kind: "openai", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
mockPreset("qwen-global", "Qwen Global API", "Alibaba DashScope international standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-global", kind: "openai", baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
mockPreset("qwen-coding-plan-cn", "Qwen Coding Plan CN", "Alibaba Cloud Qwen Coding Plan China endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn", kind: "openai", baseUrl: "https://coding.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
mockPreset("huggingface", "HuggingFace Router", "HuggingFace Inference Router OpenAI-compatible endpoint.", "HF_TOKEN", mockProviderTemplate({ name: "huggingface", kind: "openai", baseUrl: "https://router.huggingface.co/v1", models: ["zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3.5-72B-Instruct"], default: "zai-org/GLM-5.2", apiKeyEnv: "HF_TOKEN" })),
mockPreset("nvidia", "NVIDIA NIM", "NVIDIA NIM OpenAI-compatible accelerated inference endpoint.", "NVIDIA_API_KEY", mockProviderTemplate({ name: "nvidia", kind: "openai", baseUrl: "https://integrate.api.nvidia.com/v1", models: ["nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-ultra-550b-a55b", "deepseek-ai/deepseek-v4-pro", "qwen/qwen3.5-397b-a17b"], default: "nvidia/nemotron-3-nano-30b-a3b", apiKeyEnv: "NVIDIA_API_KEY" })),
mockPreset("kilocode", "KiloCode", "Kilo Code gateway OpenAI-compatible endpoint.", "KILOCODE_API_KEY", mockProviderTemplate({ name: "kilocode", kind: "openai", baseUrl: "https://api.kilo.ai/api/gateway", models: ["kilo/auto"], default: "kilo/auto", apiKeyEnv: "KILOCODE_API_KEY" })),
mockPreset("ollama-cloud", "Ollama Cloud", "Hosted Ollama Cloud OpenAI-compatible endpoint with max reasoning effort.", "OLLAMA_API_KEY", mockProviderTemplate({ name: "ollama-cloud", kind: "openai", baseUrl: "https://ollama.com/v1", models: mockOllamaCloudModels, default: "glm-5.2", apiKeyEnv: "OLLAMA_API_KEY" })),
];
function mockProviderPresetViews(): ProviderPresetView[] {
return mockProviderPresetTemplates.map((template) => ({
id: template.id,
label: template.label,
description: template.description,
keyEnv: template.keyEnv,
providerNames: [template.provider.name],
models: [...template.provider.models],
added: false,
status: "available",
statusProviderNames: [],
keySet: false,
requiresKey: true,
configured: false,
}));
}
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
if (!template) return undefined;
return {
...JSON.parse(JSON.stringify(template.provider)) as ProviderView,
keySet: Boolean(key.trim()),
};
}
const mockPreviewImageDataURL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
function makeMockApp(): AppBindings {
const scenario = mockScenario();
const freshMock = scenario === "fresh";
const runningMock = scenario === "running";
const guidanceMock = scenario === "guidance";
const runningMock = scenario === "running" || guidanceMock;
const mockAttachmentDataURLs = new Map<string, string>();
let cancelled = false;
let pendingAskPreview = false;
let pendingApprovalPreview = false;
@@ -781,6 +948,7 @@ function makeMockApp(): AppBindings {
],
},
];
let capPlugins: PluginView[] = [];
const mockSwitchWorkspace = async (path: string) => {
cwd = path || "~";
workspaces = [cwd, ...workspaces.filter((p) => p !== cwd)].slice(0, 12);
@@ -863,37 +1031,64 @@ function makeMockApp(): AppBindings {
subagentEffort: "",
autoPlan: "off",
providers: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
],
officialProviders: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
],
providerPresets: mockProviderPresetViews(),
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], shell: "auto" },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: cwd, effectiveWriteRoots: [cwd], shell: "auto" },
network: {
proxyMode: "auto",
proxyUrl: "",
noProxy: "",
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
},
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
bot: {
enabled: !freshMock,
model: "",
toolApprovalMode: "ask",
maxSteps: 25,
debounceMs: 1500,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: {
qq: [],
feishu: [],
weixin: [],
},
control: {
enabled: false,
addr: "127.0.0.1:37913",
tokenEnv: "REASONIX_BOT_CONTROL_TOKEN",
},
pairing: {
enabled: true,
requestTtlMinutes: 60,
maxPendingPerPlatform: 3,
},
routes: [],
allowlist: {
enabled: true,
allowAll: false,
qqUsers: [],
feishuUsers: freshMock ? [] : ["ou_mock_user_001"],
weixinUsers: freshMock ? [] : ["wxid_mock_user_001"],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false },
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] } },
feishu: {
enabled: false,
domain: "feishu",
@@ -920,10 +1115,11 @@ function makeMockApp(): AppBindings {
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: "cli_mock_lark",
appSecretEnv: "FEISHU_BOT_APP_SECRET",
accountId: "",
@@ -954,10 +1150,11 @@ function makeMockApp(): AppBindings {
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["wxid_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: "",
appSecretEnv: "",
accountId: "default",
@@ -997,7 +1194,7 @@ function makeMockApp(): AppBindings {
metrics: true,
memoryCompilerEnabled: true,
configPath: "~/projects/reasonix/reasonix.toml",
providerKinds: ["openai"],
providerKinds: ["openai", "anthropic"],
autoApproveTools: false,
bypass: false,
};
@@ -1381,7 +1578,7 @@ function makeMockApp(): AppBindings {
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
active: !guidanceMock,
cwd: "~/projects/joyquant-db",
},
{
@@ -1401,7 +1598,7 @@ function makeMockApp(): AppBindings {
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: false,
active: guidanceMock,
cwd: "~/projects/joyquant-sys",
},
{
@@ -1453,6 +1650,18 @@ function makeMockApp(): AppBindings {
}
};
return {
async MinimiseMainWindow() {
console.info("mock MinimiseMainWindow");
},
async ToggleMaximiseMainWindow() {
console.info("mock ToggleMaximiseMainWindow");
},
async IsMainWindowMaximised() {
return false;
},
async CloseMainWindow() {
console.info("mock CloseMainWindow");
},
async Platform() {
const override = browserPlatformOverride();
if (override) return override;
@@ -1824,7 +2033,7 @@ function makeMockApp(): AppBindings {
async ClearSession() {},
async Checkpoints() {
return [
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], fileCount: 1, turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
];
},
async CheckpointsForTab() {
@@ -2136,6 +2345,7 @@ function makeMockApp(): AppBindings {
{ name: "model", description: "Switch model", kind: "builtin" as const },
{ name: "effort", description: "Set reasoning effort", kind: "builtin" as const },
{ name: "skill", description: "List skills", kind: "builtin" as const },
{ name: "plugins", description: "Manage plugin packages", kind: "builtin" as const },
{ name: "explore", description: "Investigate the codebase in an isolated subagent", kind: "skill" as const },
{ name: "review", description: "Review the staged diff", hint: "[focus]", kind: "custom" as const },
];
@@ -2145,6 +2355,7 @@ function makeMockApp(): AppBindings {
servers: capServers.map((s) => ({ ...s })),
skills: capSkills.map((s) => ({ ...s })),
skillRoots: capSkillRoots.map((s) => ({ ...s })),
plugins: capPlugins.map((p) => ({ ...p })),
};
},
async MCPServers() {
@@ -2156,6 +2367,59 @@ function makeMockApp(): AppBindings {
skillRoots: capSkillRoots.map((s) => ({ ...s })),
};
},
async Plugins() {
return capPlugins.map((p) => ({ ...p }));
},
async PlanPluginInstall(source: string, options: PluginInstallOptions) {
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
return JSON.stringify({
ok: true,
status: "planned",
kind: "plugin",
actions: [{ kind: "plugin", action: "install_plugin_package", name, source, status: "planned" }],
});
},
async InstallPlugin(source: string, options: PluginInstallOptions) {
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
const existing = capPlugins.findIndex((p) => p.name === name);
const view: PluginView = {
name,
version: "dev",
description: "Mock plugin",
source,
root: `~/.reasonix/plugins/${name}`,
manifestKind: "reasonix",
enabled: true,
skills: 1,
hooks: 0,
mcpServers: 0,
skillDetails: [{ name: "plan", description: "Plan work before implementation", invocation: "/plan", runAs: "inline" }],
};
if (existing >= 0) capPlugins[existing] = view;
else capPlugins.push(view);
return JSON.stringify({ ok: true, status: "done", kind: "plugin", actions: [{ kind: "plugin", name }] });
},
async RemovePlugin(name: string) {
capPlugins = capPlugins.filter((p) => p.name !== name);
},
async SetPluginEnabled(name: string, enabled: boolean) {
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, enabled } : p);
},
async UpdatePlugin(name: string) {
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, version: p.version || "dev" } : p);
return JSON.stringify({ ok: true, status: "done", kind: "plugin", name });
},
async PluginDoctor(name: string) {
return capPlugins.find((p) => p.name === name) || {
name,
root: "",
enabled: false,
skills: 0,
hooks: 0,
mcpServers: 0,
error: "plugin is not installed",
};
},
async AddMCPServer(input: MCPServerInput) {
const tools = input.transport === "stdio" ? 3 : 5;
capServers.push({
@@ -2262,6 +2526,9 @@ function makeMockApp(): AppBindings {
async PickSkillFolder() {
return "~/my-skills";
},
async PickPluginFolder() {
return "~/plugins/superpowers";
},
async AddSkillPath(path: string) {
const dir = path.trim() || "~/my-skills";
if (!capSkillRoots.some((r) => r.scope === "custom" && r.dir === dir)) {
@@ -2432,14 +2699,20 @@ function makeMockApp(): AppBindings {
async RevealPath(path: string) {
console.info("mock RevealPath", path);
},
async SavePastedImage(_dataUrl: string) {
return ".reasonix/attachments/mock.png";
async SavePastedImage(dataUrl: string) {
const path = `.reasonix/attachments/mock-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async SaveClipboardImage() {
return ".reasonix/attachments/mock-clipboard.png";
const path = `.reasonix/attachments/mock-clipboard-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, mockPreviewImageDataURL);
return path;
},
async SavePastedFile(name: string, _dataUrl: string) {
return `.reasonix/attachments/mock-${name}`;
async SavePastedFile(name: string, dataUrl: string) {
const path = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async PickExportFile(defaultFilename: string, _mimeType: string) {
return defaultFilename;
@@ -2466,10 +2739,12 @@ function makeMockApp(): AppBindings {
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
}
return { kind: "attachment" as const, path: `.reasonix/attachments/mock-${name}` };
const attachmentPath = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(attachmentPath, mockPreviewImageDataURL);
return { kind: "attachment" as const, path: attachmentPath };
},
async AttachmentDataURL(_path: string) {
return "data:image/png;base64,iVBORw0KGgo=";
async AttachmentDataURL(path: string) {
return mockAttachmentDataURLs.get(path) ?? mockPreviewImageDataURL;
},
async Models() {
const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0];
@@ -2668,6 +2943,9 @@ function makeMockApp(): AppBindings {
async SetSubagentEffort(level: string) {
settings.subagentEffort = level;
},
async SetMaxSubagentDepth(depth: number) {
settings.agent = { ...settings.agent, maxSubagentDepth: depth <= 1 ? 1 : 2 };
},
async SetAutoPlan(mode: string) {
settings.autoPlan = mode;
},
@@ -2682,7 +2960,7 @@ function makeMockApp(): AppBindings {
},
async AddOfficialProviderAccess(kind: string, key: string) {
const templates: Record<string, ProviderView> = {
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
};
const next = templates[kind];
if (!next) throw new Error(`unknown official provider template ${kind}`);
@@ -2691,12 +2969,47 @@ function makeMockApp(): AppBindings {
else settings.providers.push(next);
return "";
},
async AddProviderPresetAccess(id: string, key: string) {
const preset = settings.providerPresets.find((p) => p.id === id);
if (!preset) throw new Error(`unknown provider preset ${id}`);
const next = cloneMockProviderTemplate(id, key);
if (!next) throw new Error(`unknown provider preset ${id}`);
const i = settings.providers.findIndex((x) => x.name === next.name);
if (i >= 0) settings.providers[i] = { ...settings.providers[i], ...next, keySet: next.keySet || settings.providers[i].keySet };
else settings.providers.push(next);
preset.added = true;
preset.status = "installed";
preset.statusProviderNames = [...preset.providerNames];
preset.keySet = preset.keySet || !!key.trim();
preset.configured = !preset.requiresKey || preset.keySet;
return "";
},
async ResetProviderPresetAccess(id: string) {
const preset = settings.providerPresets.find((p) => p.id === id);
if (!preset) throw new Error(`unknown provider preset ${id}`);
const next = cloneMockProviderTemplate(id, "");
if (!next) throw new Error(`unknown provider preset ${id}`);
const i = settings.providers.findIndex((x) => x.name === next.name);
if (i < 0) throw new Error(`provider preset ${id} cannot be reset because no same-name provider exists`);
const existing = settings.providers[i];
settings.providers[i] = {
...next,
added: true,
keySet: existing.apiKeyEnv === next.apiKeyEnv ? existing.keySet : next.keySet,
};
preset.added = true;
preset.status = "installed";
preset.statusProviderNames = [...preset.providerNames];
preset.keySet = preset.keySet || settings.providers[i].keySet;
preset.configured = !preset.requiresKey || preset.keySet;
},
async FetchProviderModels(p: ProviderView) {
if (!p.baseUrl.trim()) throw new Error(t("settings.fetchModelsMissingBaseUrl"));
if (providerRequiresKey(p) && !p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
await delay(350);
if (p.baseUrl.includes("deepseek")) return ["deepseek-v4-flash", "deepseek-v4-pro"];
if (p.baseUrl.includes("mimo") || p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5", "mimo-v2.5-pro"];
if (p.baseUrl.includes("token-plan")) return ["mimo-v2.5", "mimo-v2.5-pro"];
if (p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5-pro", "mimo-v2.5"];
return ["gpt-5", "gpt-5-mini", "qwen3-coder"];
},
async DeleteProvider(name: string) {
@@ -2729,8 +3042,10 @@ function makeMockApp(): AppBindings {
const k = list as "allow" | "ask" | "deny";
settings.permissions[k] = settings.permissions[k].filter((r) => r !== rule);
},
async ReloadSettings() {},
async SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string) {
settings.sandbox = { bash, network, workspaceRoot, allowWrite, shell };
const effectiveWorkspaceRoot = workspaceRoot.trim() || cwd;
settings.sandbox = { bash, network, workspaceRoot, allowWrite, effectiveWorkspaceRoot, effectiveWriteRoots: [effectiveWorkspaceRoot, ...allowWrite], shell };
},
async SetNetwork(n: NetworkView) {
settings.network = n;
@@ -2804,10 +3119,11 @@ function makeMockApp(): AppBindings {
label: domain === "lark" ? "Lark" : domain === "weixin" ? "微信" : "飞书",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [provider === "weixin" ? "wxid_mock_user_001" : "ou_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: provider === "feishu" ? "cli_mock" : "",
appSecretEnv: provider === "feishu" ? (domain === "lark" ? "LARK_BOT_APP_SECRET" : "FEISHU_BOT_APP_SECRET") : "",
accountId: provider === "weixin" ? "mock-account" : "",
@@ -2952,7 +3268,7 @@ function makeMockApp(): AppBindings {
},
async OpenDownloadPage() {
if (typeof window !== "undefined") {
window.open("https://github.com/esengine/reasonix/releases/latest", "_blank", "noopener");
window.open("https://reasonix.io/#start", "_blank", "noopener");
}
},
// Dev seam: drives the overlay flow in the browser until ConnectKey sets the
+25 -4
View File
@@ -4,11 +4,19 @@ export interface Toast {
id: number;
text: string;
level: "info" | "warn" | "error";
actionLabel?: string;
onAction?: () => void;
}
export interface ToastOptions {
actionLabel?: string;
onAction?: () => void;
durationMs?: number;
}
export interface ToastContextValue {
toasts: Toast[];
showToast: (text: string, level?: Toast["level"]) => void;
showToast: (text: string, level?: Toast["level"], options?: ToastOptions) => void;
}
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
@@ -23,13 +31,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const showToast = useCallback((text: string, level: Toast["level"] = "info") => {
const showToast = useCallback((text: string, level: Toast["level"] = "info", options: ToastOptions = {}) => {
const id = nextId++;
setToasts((prev) => [...prev, { id, text, level }]);
setToasts((prev) => [...prev, { id, text, level, actionLabel: options.actionLabel, onAction: options.onAction }]);
const timer = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timers.current.delete(id);
}, 2500);
}, options.durationMs ?? (options.actionLabel ? 8000 : 2500));
timers.current.set(id, timer);
}, []);
@@ -49,6 +57,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
{t.level === "error" && <span className="toast__icon">❌</span>}
<span className="toast__text">{t.text}</span>
{t.actionLabel && t.onAction && (
<button
type="button"
className="toast__action"
onClick={(event) => {
event.stopPropagation();
dismissToast(t.id);
t.onAction?.();
}}
>
{t.actionLabel}
</button>
)}
</div>
))}
</div>
@@ -1,5 +1,17 @@
import type { Todo } from "./tools";
export interface TodoPanelScopeInput {
activeTabId?: string | null;
activeTab?: {
id?: string | null;
scope?: string | null;
workspaceRoot?: string | null;
topicId?: string | null;
sessionPath?: string | null;
} | null;
eventChannel?: string | null;
}
export function todoDismissalKey(todos: Todo[]): string {
if (todos.length === 0) return "";
return JSON.stringify(todos.map((todo) => ({
@@ -10,6 +22,50 @@ export function todoDismissalKey(todos: Todo[]): string {
})));
}
export function todoPanelScope({ activeTab, activeTabId, eventChannel }: TodoPanelScopeInput): string {
const tabId = String(activeTabId ?? "").trim();
const tab = !tabId || activeTab?.id === tabId ? activeTab : null;
const sessionPath = tab?.sessionPath?.trim();
if (sessionPath) return `session:${sessionPath}`;
if (tabId) return `tab:${tabId}`;
const topicId = tab?.topicId?.trim();
if (tab && topicId) return `topic:${tab.scope ?? ""}:${tab.workspaceRoot ?? ""}:${topicId}`;
const channel = String(eventChannel ?? "").trim();
return channel ? `event:${channel}` : "";
}
export function scopedTodoDismissalKey(scope: string | null | undefined, todoKey: string | null | undefined): string {
const key = String(todoKey ?? "").trim();
if (!key) return "";
const prefix = String(scope ?? "").trim();
return prefix ? `${prefix}\0${key}` : key;
}
export function dismissedTodoKeyForScope(
scope: string | null | undefined,
dismissedKeys: ReadonlySet<string> | null | undefined,
todoKey: string | null | undefined,
): string | null {
const scopedKey = scopedTodoDismissalKey(scope, todoKey);
if (!scopedKey || !dismissedKeys?.has(scopedKey)) return null;
return todoKey ?? null;
}
export function todoBatchKey(todos: Todo[]): string {
if (todos.length === 0) return "";
return JSON.stringify(todos.map((todo) => ({
content: String(todo.content ?? ""),
level: typeof todo.level === "number" ? todo.level : 0,
})));
}
export function scopedTodoBatchKey(scope: string | null | undefined, batchKey: string | null | undefined): string {
const key = String(batchKey ?? "").trim();
if (!key) return "";
const prefix = String(scope ?? "").trim();
return prefix ? `${prefix}\0${key}` : key;
}
export function shouldShowTodoPanel(
todoKey: string | null | undefined,
dismissedTodoKey: string | null,
@@ -20,6 +76,10 @@ export function shouldShowTodoPanel(
return todoKey !== dismissedTodoKey;
}
export function shouldOpenTodoPanelByDefault(todos: Todo[]): boolean {
return hasIncompleteTodos(todos);
}
function todoStatus(status: unknown): string {
const normalized = String(status ?? "").trim();
return normalized || "pending";
+165 -1
View File
@@ -208,6 +208,10 @@ export interface TabMeta {
goal?: string;
goalStatus?: GoalStatus;
autoResearch?: AutoResearchCompactView;
recovered?: boolean;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
startupErr?: string;
active: boolean;
cwd: string;
@@ -228,6 +232,10 @@ export interface ProjectNode {
running?: boolean;
status?: ProjectTopicStatus;
pinned?: boolean;
recovered?: boolean;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
children?: ProjectNode[];
}
@@ -239,6 +247,23 @@ export interface TopicMeta {
createdAt: number;
}
export interface SessionRecoveryEvent {
originalPath?: string;
recoveryPath: string;
scope?: string;
workspaceRoot?: string;
topicId?: string;
topicTitle?: string;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
existing?: boolean;
}
export interface SessionRecoveryFailedEvent {
reason?: "lease_held" | "lease_unavailable" | string;
}
export interface ContextPanelInfo {
usedTokens: number;
windowTokens: number;
@@ -356,6 +381,8 @@ export interface CheckpointMeta {
turn: number;
prompt: string;
files: string[];
fileCount?: number;
filesTruncated?: boolean;
turnFileCount?: number;
time: number; // unix ms
canCode?: boolean;
@@ -681,11 +708,55 @@ export interface CapabilitiesView {
servers: ServerView[];
skills: SkillView[];
skillRoots: SkillRootView[];
plugins: PluginView[];
}
export interface SkillsSettingsView {
skills: SkillView[];
skillRoots: SkillRootView[];
}
export interface PluginView {
name: string;
version?: string;
description?: string;
source?: string;
root: string;
manifestKind?: string;
enabled: boolean;
skills: number;
hooks: number;
mcpServers: number;
skillDetails?: PluginSkillView[];
hookDetails?: PluginHookView[];
mcpServerDetails?: PluginMCPServerView[];
warnings?: string[];
error?: string;
}
export interface PluginSkillView {
name: string;
description?: string;
path?: string;
invocation?: string;
runAs?: string;
}
export interface PluginHookView {
event: string;
match?: string;
command?: string;
contextFile?: string;
description?: string;
}
export interface PluginMCPServerView {
name: string;
transport?: string;
command?: string;
url?: string;
}
export interface PluginInstallOptions {
dryRun?: boolean;
link?: boolean;
replace?: boolean;
name?: string;
}
export interface MCPServerInput {
name: string;
transport: string; // stdio | http | sse
@@ -789,7 +860,7 @@ export interface MemoryView {
}
// SettingsTab is the top-level navigation item in the Settings Centre modal.
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "plugins" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
// Settings panel payloads (desktop/settings_app.go).
export interface ProviderView {
@@ -805,6 +876,9 @@ export interface ProviderView {
modelsUrl: string; // optional override for model discovery; empty derives from baseUrl
default: string;
apiKeyEnv: string;
headers?: Record<string, string> | null; // optional extra request headers for compatible gateways
extraBody?: Record<string, unknown> | null; // optional extra top-level request body fields for compatible gateways
authHeader?: boolean; // Anthropic-compatible: send Authorization: Bearer instead of x-api-key
keySet: boolean; // the env var currently resolves to a value
requiresKey?: boolean; // false for explicit no-auth providers
configured?: boolean; // selectable: key is set or no key is required
@@ -813,8 +887,35 @@ export interface ProviderView {
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
contextWindow: number;
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
modelOverrides?: ProviderModelOverrideView[] | null;
}
export interface ProviderPresetView {
id: string;
label: string;
description: string;
keyEnv: string;
providerNames: string[];
models: string[];
added: boolean;
status?: "available" | "installed" | "installed_modified" | "name_conflict" | "similar_existing";
statusProviderNames?: string[];
keySet: boolean;
requiresKey?: boolean;
configured?: boolean;
keySource?: string;
keySourcePath?: string;
}
export interface ProviderModelOverrideView {
model: string;
reasoningProtocol: string;
supportedEfforts: string[];
defaultEffort: string;
vision?: boolean | null;
}
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
@@ -847,6 +948,8 @@ export interface SandboxView {
network: boolean;
workspaceRoot: string;
allowWrite: string[];
effectiveWorkspaceRoot: string;
effectiveWriteRoots: string[];
shell: string; // "auto" | "bash" | "powershell" | "pwsh"
}
@@ -869,6 +972,7 @@ export interface AgentView {
temperature: number;
maxSteps: number;
plannerMaxSteps: number;
maxSubagentDepth: number;
systemPrompt: string;
coldResumePrune: boolean;
reasoningLanguage: string; // "auto" | "zh" | "en"
@@ -880,17 +984,67 @@ export interface BotAllowlistView {
qqUsers: string[];
feishuUsers: string[];
weixinUsers: string[];
qqApprovers: string[];
feishuApprovers: string[];
weixinApprovers: string[];
qqAdmins: string[];
feishuAdmins: string[];
weixinAdmins: string[];
qqGroups: string[];
feishuGroups: string[];
weixinGroups: string[];
}
export interface BotAccessView {
enabled: boolean;
allowAll: boolean;
pairingEnabled: boolean;
users: string[];
groups: string[];
approvers: string[];
admins: string[];
}
export interface BotSelfUserIDsView {
qq: string[];
feishu: string[];
weixin: string[];
}
export interface BotPairingView {
enabled: boolean;
requestTtlMinutes: number;
maxPendingPerPlatform: number;
}
export interface BotControlView {
enabled: boolean;
addr: string;
tokenEnv: string;
}
export interface BotRouteView {
connectionId: string;
platform: string;
chatType: string;
chatId: string;
userId: string;
threadId: string;
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
}
export interface QQBotView {
enabled: boolean;
appId: string;
appSecretEnv: string;
secretSet: boolean;
sandbox: boolean;
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
access: BotAccessView;
}
export interface FeishuBotView {
@@ -943,6 +1097,7 @@ export interface BotConnectionView {
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
access: BotAccessView;
credential: BotConnectionCredentialView;
sessionMappings: BotConnectionSessionMappingView[];
lastError: string;
@@ -956,6 +1111,14 @@ export interface BotSettingsView {
toolApprovalMode: ToolApprovalMode | "" | string;
maxSteps: number;
debounceMs: number;
queueMode: string;
queueCap: number;
queueDrop: string;
ignoreSelfMessages: boolean;
selfUserIds: BotSelfUserIDsView;
control: BotControlView;
pairing: BotPairingView;
routes: BotRouteView[];
allowlist: BotAllowlistView;
qq: QQBotView;
feishu: FeishuBotView;
@@ -1031,6 +1194,7 @@ export interface SettingsView {
autoPlan: string;
providers: ProviderView[];
officialProviders: ProviderView[];
providerPresets: ProviderPresetView[];
permissions: PermissionsView;
sandbox: SandboxView;
network: NetworkView;
+130 -40
View File
@@ -43,6 +43,9 @@ export type LiveStream = { id: string; text: string; reasoning: string; reasonin
export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both";
export type MessageActionState = { turn: number; scope: MessageActionScope };
export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup";
type SyncActiveTabOptions = {
preserveCachedHistory?: boolean;
};
const HISTORY_PAGE_TURNS = 60;
@@ -165,6 +168,7 @@ type RuntimeMetaSnapshot = {
running: boolean;
pendingPrompt?: boolean;
backgroundJobs?: number;
cancelRequested?: boolean;
cancellable?: boolean;
};
@@ -351,6 +355,18 @@ type Action =
| { type: "clearAsk" }
| { type: "reset" };
function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
return {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(meta.pendingPrompt),
backgroundJobs: meta.backgroundJobs ?? 0,
cancelRequested: Boolean(meta.cancelRequested),
cancellable: foregroundRunning,
};
}
// ---- reducer helpers (unchanged logic) ----
export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
@@ -1109,17 +1125,35 @@ export function useController() {
return;
}
const ancillaryStartedAt = Date.now();
const [effort, jobs, checkpoints, context] = await Promise.all([
app.EffortForTab(tabId).catch((err) => { noteFailure("effort", err); return undefined; }),
app.JobsForTab(tabId).catch((err) => { noteFailure("jobs", err); return undefined; }),
app.CheckpointsForTab(tabId).catch((err) => { noteFailure("checkpoints", err); return undefined; }),
app.ContextUsageForTab(tabId).catch((err) => { noteFailure("context", err); return undefined; }),
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
const startedAt = Date.now();
try {
const value = await load();
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
return value;
} catch (err) {
noteFailure(label, err);
return undefined;
}
};
const [effort, jobs, context] = await Promise.all([
loadAncillary("effort", () => app.EffortForTab(tabId)),
loadAncillary("jobs", () => app.JobsForTab(tabId)),
loadAncillary("context", () => app.ContextUsageForTab(tabId)),
]);
if (!stillCurrent()) return;
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
if (!stillCurrent()) return;
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
return;
}
const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
if (!stillCurrent()) return;
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
app.BalanceForTab(tabId)
.then((balance) => {
@@ -1171,6 +1205,22 @@ export function useController() {
return tabs.find((tab) => tab.active) ?? tabs[0];
}, []);
const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot) => {
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
dispatchTo(tabId, {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(tab.pendingPrompt),
backgroundJobs: tab.backgroundJobs ?? 0,
cancelRequested: Boolean(tab.cancelRequested),
cancellable: foregroundRunning,
});
// backend_status reconciliation can clear a live prompt from frontend state.
// If the backend is still blocked, ask it to replay the approval/ask event.
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
return foregroundRunning;
}, [dispatchTo]);
const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
for (let attempt = 0; attempt < 60; attempt += 1) {
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
@@ -1180,7 +1230,7 @@ export function useController() {
}
}, []);
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false): Promise<string | undefined> => {
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false, options: SyncActiveTabOptions = {}): Promise<string | undefined> => {
const active = await activeTabFromBackend();
if (!active) return undefined;
// When guard is true, skip if the frontend already settled on a
@@ -1194,9 +1244,15 @@ export function useController() {
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
await loadSessionDataForTab(active.id, reset, "startup");
const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
if (!reset) dispatchRuntimeStatusForTab(active.id, active);
await loadSessionDataForTab(active.id, reset, "startup", {
preserveCachedHistory,
sessionPath: active.sessionPath,
});
if (reset) dispatchRuntimeStatusForTab(active.id, active);
return active.id;
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab]);
const reconcileTabRuntime = useCallback(async (
tabId: string,
@@ -1208,19 +1264,8 @@ export function useController() {
if (!tab) return undefined;
const local = statesRef.current.get(tabId);
const needsInitialLoad = !local?.meta;
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab);
const missedTurnDone = Boolean(local?.running && !foregroundRunning);
dispatchTo(tabId, {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(tab.pendingPrompt),
backgroundJobs: tab.backgroundJobs ?? 0,
cancelRequested: Boolean(tab.cancelRequested),
cancellable: foregroundRunning,
});
// backend_status reconciliation can clear a live prompt from frontend state.
// If the backend is still blocked, ask it to replay the approval/ask event.
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
await loadSessionDataForTab(tabId, missedTurnDone, "startup");
return tabs;
@@ -1234,7 +1279,7 @@ export function useController() {
if (effort) dispatchTo(tabId, { type: "effort", effort });
if (balance) dispatchTo(tabId, { type: "balance", balance });
return tabs;
}, [dispatchTo, loadSessionDataForTab]);
}, [dispatchRuntimeStatusForTab, loadSessionDataForTab]);
const clearCancelReconcileTimer = useCallback((tabId: string) => {
const timer = cancelReconcileTimers.current.get(tabId);
@@ -1375,9 +1420,12 @@ export function useController() {
dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq });
invalidateCache();
try {
await (original
const submitPromise = original
? app.SubmitEditedDisplayToTab(tabId, display, submit, original)
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit));
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit);
void submitPromise.catch((error) => {
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
});
} catch (error) {
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
throw error;
@@ -1394,9 +1442,10 @@ export function useController() {
setActiveTabId(active.id);
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
dispatchRuntimeStatusForTab(active.id, active);
return sendToTab(active.id, displayText, submitText);
});
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, sendToTab]);
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
const runShell = useCallback(async (command: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
@@ -1703,11 +1752,13 @@ export function useController() {
setActiveTabId(tab.id);
activeTabIdRef.current = tab.id;
confirmBackendActiveTab(tab.id);
dispatchRuntimeStatusForTab(tab.id, tab);
// The fork's controller builds in a background goroutine: an immediate
// load reads empty history, and the ready-event fallback can still
// target the source tab, leaving the fork blank (#3742).
await waitForTabReady(tab.id);
await loadSessionDataForTab(tab.id, true);
await reconcileTabRuntime(tab.id, { hydrateSessionData: false });
} else {
await syncActiveTabFromBackend(true);
}
@@ -1730,11 +1781,12 @@ export function useController() {
} finally {
dispatchTo(sourceTabId, { type: "message_action_done" });
}
}, [activeTabId, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
}, [activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
// Tab management: switch preserves per-tab state; open creates it.
const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise<TabMeta[] | undefined> => {
const startedAt = Date.now();
const previousTabId = activeTabIdRef.current;
const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath;
const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath);
addBreadcrumb("tab.switch", `click ${tabId}`);
@@ -1743,11 +1795,25 @@ export function useController() {
dispatchTo(tabId, { type: "backend_activation_start" });
if (optimisticTab) {
dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
const optimisticStatus = backendStatusFromRuntimeMeta(optimisticTab);
if (optimisticStatus.running) dispatchTo(tabId, optimisticStatus);
}
dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab" });
addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
const backendActivation = app.SetActiveTab(tabId)
.then(() => {
.then(async () => {
if (activeTabIdRef.current !== tabId) {
const currentTabId = activeTabIdRef.current;
if (currentTabId) {
await app.SetActiveTab(currentTabId).then(() => {
if (activeTabIdRef.current === currentTabId) confirmBackendActiveTab(currentTabId);
}).catch((err) => {
addBreadcrumb("tab.switch", `set-active-stale-reassert-failed ${currentTabId}: ${errorMessage(err)}`);
});
}
addBreadcrumb("tab.switch", `set-active-stale ${tabId} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
return false;
}
confirmBackendActiveTab(tabId);
addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
return true;
@@ -1755,6 +1821,11 @@ export function useController() {
.catch((err) => {
dispatchTo(tabId, { type: "backend_activation_done" });
dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
if (previousTabId && activeTabIdRef.current === tabId) {
setActiveTabId(previousTabId);
activeTabIdRef.current = previousTabId;
addBreadcrumb("tab.switch", `set-active-failed-reverted ${tabId} -> ${previousTabId} ms=${Date.now() - startedAt}`);
}
return false;
});
trackBackendActivation(tabId, backendActivation);
@@ -1786,13 +1857,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
const meta = await app.OpenGlobalTab(topicId);
@@ -1804,13 +1878,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string): Promise<TabMeta> => {
const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
@@ -1822,13 +1899,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = ""): Promise<TabMeta> => {
const meta = await app.ActivateTopic(scope, workspaceRoot, topicId, sessionPath);
@@ -1842,21 +1922,28 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems });
dispatchRuntimeStatusForTab(meta.id, meta);
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems })
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
.catch(() => {});
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
// Ensure a blank tab exists for the given scope — reuses an existing one
// or creates a new tab, then loads its session data.
const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const meta = await app.EnsureBlankTab(scope, workspaceRoot);
const isNewTab = !statesRef.current.has(meta.id);
setActiveTabId(meta.id);
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id), "open-topic");
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic");
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
@@ -1867,16 +1954,19 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, true, "open-topic");
dispatchRuntimeStatusForTab(meta.id, meta);
void loadSessionDataForTab(meta.id, true, "open-topic")
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
.catch(() => {});
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const closeTab = useCallback(async (tabId: string) => {
try {
await app.CloseTab(tabId);
statesRef.current.delete(tabId);
bump();
if (tabId === activeTabId) await syncActiveTabFromBackend(true);
if (tabId === activeTabId) await syncActiveTabFromBackend(false);
} catch { /* ignore */ }
}, [activeTabId, bump, syncActiveTabFromBackend]);
+347 -59
View File
@@ -205,6 +205,7 @@ export const en = {
"caps.mcpSummary": "{connected} connected · {failed} issues · {tools} tools",
"caps.connectorsTab": "MCP servers",
"caps.skillsTab": "Agent skills",
"caps.pluginsTab": "Plugins",
"caps.servers": "MCP servers",
"caps.noServers": "No MCP servers configured. Add one with /mcp add.",
"caps.availableServers": "Available MCPs",
@@ -311,6 +312,62 @@ export const en = {
"caps.trustedReadOnlyTitle": "Remembered as read-only for future planning and research",
"caps.searchSkills": "Search skills…",
"caps.noSkillMatches": "No matching skills.",
"caps.pluginRefresh": "Refresh plugins",
"caps.pluginInstallTitle": "Install plugin",
"caps.pluginInstallHint": "Add a local plugin folder or install from a Git repository.",
"caps.pluginInstallMethod": "Plugin install method",
"caps.pluginInstallLocal": "Local folder",
"caps.pluginInstallGit": "Git repository",
"caps.pluginSource": "Plugin source",
"caps.pluginGitSource": "Git repository URL",
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
"caps.pluginChooseLocalFolder": "Choose plugin folder",
"caps.pluginLocalFolder": "Local plugin folder",
"caps.pluginNoLocalFolder": "No folder selected",
"caps.pluginInstallName": "Install name (optional)",
"caps.pluginInstallNamePlaceholder": "install name (optional)",
"caps.pluginLink": "Developer mode: link source folder",
"caps.pluginLinkHint": "Do not copy the plugin; use the selected folder directly. Useful for plugin debugging. Moving or deleting the folder breaks the plugin.",
"caps.pluginReplace": "Overwrite same-name plugin",
"caps.pluginReplaceHint": "When a plugin with the same name already exists, allow this source to replace it.",
"caps.pluginPreview": "Preview",
"caps.pluginInstall": "Install plugin",
"caps.noPluginsTitle": "No plugins installed",
"caps.noPluginsHint": "Install a plugin package from Git or a local path to add skills, hooks, and MCP servers through one managed unit.",
"caps.installedPlugins": "Installed plugins",
"caps.pluginsSummary": "{enabled} enabled · {total} installed · {issues} issues",
"caps.pluginCounts": "{skills} skills · {hooks} hooks · {mcps} MCPs",
"caps.pluginNoCapabilities": "No exported capabilities",
"caps.pluginWarnings": "{count} warnings",
"caps.pluginEnable": "Enable plugin",
"caps.pluginDisable": "Disable plugin",
"caps.pluginEnabled": "Enabled",
"caps.pluginDisabled": "Disabled",
"caps.pluginVersion": "Version",
"caps.pluginRoot": "Install path",
"caps.pluginUpdate": "Update",
"caps.pluginDoctor": "Doctor",
"caps.pluginRemove": "Remove plugin",
"caps.pluginConfirmRemove": "Confirm remove",
"caps.pluginPlanError": "Install check failed",
"caps.pluginPlanReady": "Install plan",
"caps.pluginAction": "Action",
"caps.pluginPlanStatus": "Plugin operation: {status}",
"caps.pluginPlanComplete": "Plugin operation complete",
"caps.pluginPlanInstalled": "Plugin installed. Expand it below to see how to use the exported skills, hooks, and MCP servers.",
"caps.pluginUsageTitle": "How to use",
"caps.pluginUsageEnabledHint": "Enabled plugins load into new sessions. Use /skills to browse skills, invoke /<skill>, or ask naturally; hooks and MCP servers run through the normal session lifecycle.",
"caps.pluginUsageDisabledHint": "Enable this plugin before its skills, hooks, or MCP servers can participate in sessions.",
"caps.pluginNoCapabilityDetails": "No detailed capability inventory is available for this plugin.",
"caps.pluginSkillsTitle": "Skills",
"caps.pluginSkillsHint": "Browse with /skills, invoke directly, or ask for the workflow by name.",
"caps.pluginHooksTitle": "Hooks",
"caps.pluginHooksHint": "Hooks trigger automatically on matching lifecycle events.",
"caps.pluginMCPTitle": "MCP servers",
"caps.pluginMCPHint": "Tools from these servers are available through the normal MCP/tool flow.",
"caps.pluginNoDescription": "No description",
"caps.pluginHookNoTarget": "No hook target",
"caps.pluginMCPNoTarget": "No launch target",
"caps.sources": "Sources",
"caps.sourcesSummaryActive": "{active} active",
"caps.sourcesSummaryMissing": "{missing} missing",
@@ -411,6 +468,17 @@ export const en = {
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
"composer.resize": "Drag to resize composer, double-click to reset",
"composer.send": "Send (Enter)",
"composer.queueGuidance": "Add to guidance queue (Enter)",
"composer.steer": "Add guidance to current turn (Enter)",
"composer.steerPlaceholder": "Add guidance to the queue...",
"composer.guidanceQueue": "Queued guidance",
"composer.guidanceCount": "Queued guidance {n}",
"composer.guidanceRemaining": "{n} more queued",
"composer.guidanceCollapse": "Collapse",
"composer.guidanceMode": "Guide",
"composer.guidanceSend": "Send this guidance to the transcript",
"composer.guidanceDismiss": "Dismiss queued guidance",
"composer.guidanceMore": "More guidance options",
"composer.shellMode": "Shell mode: prefix input with !",
"composer.shellModeOn": "Shell mode on: click to remove !",
"composer.stop": "Stop (Esc)",
@@ -421,6 +489,9 @@ export const en = {
"composer.pastedExpand": "Expand",
"composer.pastedRemove": "Remove pasted text",
"composer.removeImage": "Remove image",
"imageViewer.clickToPreview": "Click to preview",
"imageViewer.closePreview": "Close preview",
"imageViewer.title": "Image preview",
"composer.attachImageFailed": "Image paste failed",
"composer.attachFileFailed": "File attach failed",
"composer.attachDropFailed": "Dropped file attach failed",
@@ -545,6 +616,11 @@ export const en = {
"heartbeat.approvalModeAskHint": "Each tool call needs manual confirmation",
"heartbeat.approvalModeAutoHint": "Auto-approve safe tools, keep deny rules",
"heartbeat.approvalModeYoloHint": "Skip all permission prompts, tasks run automatically",
"heartbeat.notifyChannels": "Push to bot channels",
"heartbeat.notifyChannelsOn": "On",
"heartbeat.notifyChannelsOff": "Off",
"heartbeat.notifyChannelsOnHint": "AI output is streamed to connected Feishu/QQ bots",
"heartbeat.notifyChannelsOffHint": "Only create a desktop conversation; do not push to bots",
"heartbeat.fieldNewConversation": "Conversation mode",
"heartbeat.newConversationEachRunOn": "New conversation each run",
"heartbeat.newConversationEachRunOff": "Reuse same conversation",
@@ -589,6 +665,7 @@ export const en = {
"ask.hideDetails": "Hide",
"ask.back": "Back",
"ask.next": "Next",
"ask.customAnswer": "Other answer",
"ask.customPlaceholder": "Type your own answer…",
"ask.justChat": "Just chat",
@@ -654,6 +731,18 @@ export const en = {
"history.historySessionActions": "History session actions",
"history.trashActions": "Trash actions",
// session recovery
"recovery.branch": "Recovery branch",
"recovery.badge": "Recovery",
"recovery.open": "Open recovery branch",
"recovery.toast": "Unsaved local changes were saved as {title}.",
"recovery.failedLease": "The recovery branch is in use by another runtime, so this tab did not switch to it.",
"recovery.failedUnavailable": "The recovery branch could not be opened, so this tab stayed on the original session.",
"recovery.banner": "This session was recovered from a save conflict. Compare it with the original before continuing.",
"recovery.reason": "Reason: {reason}",
"recovery.digest": "Digest: {digest}",
"recovery.parent": "Parent: {parent}",
// project tree
"projectTree.workspaceTitle": "Projects",
"projectTree.pinnedTitle": "Pinned",
@@ -827,6 +916,7 @@ export const en = {
"settings.tab.capabilities": "MCP & Skills",
"settings.tab.mcp": "MCP & Tools",
"settings.tab.skills": "Skills",
"settings.tab.plugins": "Plugins",
"settings.tab.memory": "Memory",
"settings.tab.hooks": "Hooks",
"settings.tab.shortcuts": "Shortcuts",
@@ -842,6 +932,7 @@ export const en = {
"settings.tabSub.capabilities": "Servers, tools & skills",
"settings.tabSub.mcp": "Servers & tools",
"settings.tabSub.skills": "Sources & enablement",
"settings.tabSub.plugins": "Packages & diagnostics",
"settings.tabSub.memory": "Project & global",
"settings.tabSub.hooks": "Shell automation",
"settings.tabSub.shortcuts": "Keys & help",
@@ -923,6 +1014,10 @@ export const en = {
"settings.subagentEffort": "Subagent effort",
"settings.subagentEffortDefault": "auto (provider default)",
"settings.subagentHint": "Applies to task and runAs=subagent skills unless a tool call or skill-specific config overrides it.",
"settings.subagentDepth": "Subagent nesting depth",
"settings.subagentDepthHint": "Controls whether a first-layer subagent may dispatch one more reviewer or worker subagent.",
"settings.subagentDepthOne": "1 layer",
"settings.subagentDepthTwo": "2 layers",
"settings.defaultToolApprovalMode": "New session approval",
"settings.defaultToolApprovalModeHint": "Saved to your global settings file and used only for new sessions; switching or restoring an existing session will not overwrite its Ask / Auto / Yolo state.",
"settings.defaultToolApprovalMode.ask": "Ask",
@@ -945,7 +1040,7 @@ export const en = {
"settings.coldResumePrune.on": "On",
"settings.coldResumePrune.off": "Off",
"settings.reasoningLanguage": "Thinking language",
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto follows the conversation language without injecting a language instruction.",
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.",
"settings.reasoningLanguage.auto": "Auto",
"settings.reasoningLanguage.zh": "中文",
"settings.reasoningLanguage.en": "English",
@@ -1006,7 +1101,7 @@ export const en = {
"settings.providerModelsUrlPlaceholder": "e.g. https://api.openai.com/v1/models",
"settings.providerModelsUrlHint": "Optional. Use when model discovery needs a different URL than the chat endpoint.",
"settings.providerModels": "models (comma-separated)",
"settings.providerApiKeyEnv": "api_key_env (e.g. DEEPSEEK_API_KEY)",
"settings.providerApiKeyEnv": "api_key_env (optional)",
"settings.providerBalanceUrl": "Balance URL (optional)",
"settings.balanceUrlPlaceholder": "balance endpoint (e.g. https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "Optional. If set, the wallet balance is queried and shown in the status bar.",
@@ -1016,12 +1111,22 @@ export const en = {
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto (model and endpoint detection)",
"settings.providerReasoningProtocolHint": "For OpenAI-compatible reasoning models. deepseek uses DeepSeek reasoning fields, openai uses ordinary reasoning_effort, and none disables it.",
"settings.effortBehavior": "Effort behavior",
"settings.effortUseProtocolDefaults": "Protocol defaults",
"settings.effortUseCustom": "Customize levels",
"settings.effortProtocolDefault.auto": "Protocol defaults stay stored as empty supported_efforts. Reasonix will infer known DeepSeek models/endpoints as high|max, OpenAI reasoning as low|medium|high, and unsupported providers as no /effort.",
"settings.effortProtocolDefault.deepseek": "DeepSeek defaults expose auto|high|max. Reasonix keeps thinking enabled and uses reasoning_effort for depth.",
"settings.effortProtocolDefault.openai": "OpenAI reasoning defaults expose auto|low|medium|high and send ordinary reasoning_effort.",
"settings.effortProtocolDefault.minimax": "MiniMax-style endpoints use provider-specific thinking levels such as adaptive|disabled. Add those as custom levels only when the endpoint supports them.",
"settings.effortProtocolDefault.none": "Effort is disabled for this provider; Reasonix will not send reasoning_effort or thinking controls.",
"settings.supportedEfforts": "Supported efforts",
"settings.supportedEffortsCustomPlaceholder": "Custom level (press Enter)",
"settings.supportedEffortsHint": "Toggle the /effort levels this provider supports, or add a custom name. Empty uses the built-in default (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
"settings.supportedEffortsNoPresets": "No standard presets",
"settings.supportedEffortsNoPresetsHint": "Use custom levels only for endpoints that document their own effort names.",
"settings.supportedEffortsHint": "Custom levels override protocol defaults. Pick only levels the endpoint documents; unsupported combinations can make the model request fail.",
"settings.defaultEffort": "Default effort",
"settings.defaultEffortAuto": "auto (use built-in default)",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the levels above. Empty falls back to the first level (or the built-in default when none are selected).",
"settings.defaultEffortAuto": "unset (use protocol/custom default)",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Leave unset to use the protocol default, or the first custom level when levels are customized.",
"settings.proxyMode": "Proxy mode",
"settings.proxyMode.auto": "auto",
"settings.proxyMode.env": "env",
@@ -1042,11 +1147,76 @@ export const en = {
"settings.toggleOff": "Off",
"settings.botSetup": "Bot entry",
"settings.botSetupHint": "Enable the entry, then connect the chat channels you need.",
"settings.botConnectPhoneTitle": "Add IM bot",
"settings.botConnectPhoneSubtitle": "Manual setup for QQ, QR connection for Feishu, Lark, and WeChat.",
"settings.botGateway": "Bot gateway",
"settings.botGatewayHint": "Manage the IM bot gateway, model, and runtime parameters from the desktop app.",
"settings.botGatewayHint": "Manage the IM bot gateway master switch and connection status from the desktop app.",
"settings.botBrowserPreviewWarning": "This is a browser preview. It saves mock settings only and does not write local config or start the QQ/IM bot gateway. Configure it in the desktop app or CLI before testing from QQ.",
"settings.botSimpleEyebrow": "Guided setup",
"settings.botSimpleTitle": "Mobile access and chat bots",
"settings.botSimpleHint": "Scan from a phone to control the current workspace. For longer-lived access from chat apps, pick a Bot Channel and configure it.",
"settings.botMobileRemoteEyebrow": "Separate entry",
"settings.botMobileRemoteTitle": "Mobile remote control",
"settings.botMobileRemoteHint": "Scan or copy a link to open the current workspace control entry from a mobile device. This is configured separately from IM bot channels.",
"settings.botMobileRemoteControlApi": "Control link",
"settings.botMobileRemoteDisabled": "Enable it to generate the QR code and link",
"settings.botMobileRemoteEnableFirst": "Enable to generate QR",
"settings.botMobileRemoteReady": "Waiting for phone connection",
"settings.botMobileRemoteWaiting": "Connect by phone scan",
"settings.botMobileRemoteLoopbackNotice": "The control API is still bound to local loopback, so this is for local preview for now. Cross-device relay can reuse this entry later.",
"settings.botMobileRemoteCopyLink": "Copy link",
"settings.botMobileRemoteCopied": "Copied",
"settings.botMobileRemoteCopyFailed": "Copy failed",
"settings.botChannelEntryTitle": "Use Bot Channel",
"settings.botChannelEntryHint": "Connect chat bots for longer mobile sessions.",
"settings.botManageBots": "Bot management",
"settings.botManageBotsHint": "Choose a channel on the left. If it is not configured, scan or add keys; once configured, manage that bot's runtime settings on the right.",
"settings.botBackToOverview": "Back to entry",
"settings.botChannelOpenConfigured": "Configured. You can continue editing settings.",
"settings.botChannelNeedsSetup": "Not configured",
"settings.botChannelTabsLabel": "Bot channels",
"settings.botChannelSetupTitle": "Configure {provider}",
"settings.botChannelSetupHint": "Before setup, only credentials or scan binding are needed. After it succeeds, this becomes the bot's settings page.",
"settings.botSetupStatus": "Bot setup status",
"settings.botSetupStatusConnected": "{online}/{total} online",
"settings.botSetupStatusAccessTrusted": "Trusted list {count}",
"settings.botSetupStatusAccessEveryone": "Everyone allowed",
"settings.botSetupStatusAccessPerBot": "{configured}/{total} bots configured",
"settings.botMode": "Bot settings mode",
"settings.botModeSimple": "Simple",
"settings.botModeAdvanced": "Advanced",
"settings.botShowAdvancedSettings": "Advanced settings",
"settings.botHideAdvancedSettings": "Hide advanced settings",
"settings.botAdvancedSettingsHint": "Lower-frequency controls: routes, queue details, and bot echo filtering.",
"settings.botShowAddBot": "Add channel",
"settings.botStepConnectTitle": "Connect bots",
"settings.botStepConnectHint": "Manual keys for QQ; scan a QR code for Feishu, Lark, and WeChat.",
"settings.botStepConnectEmpty": "Not connected yet",
"settings.botSharedAllowlistHint": "Every channel shares the trusted list and pairing settings from step 2. Edit once and it applies everywhere.",
"settings.botSharedAllowlistGo": "Edit in step 2",
"settings.botGlobalAllowlist": "Legacy global allowlist",
"settings.botGlobalAllowlistHint": "Compatibility for older configs; new bots should set access in their own detail panel.",
"settings.botAccessShowAllPlatforms": "Show all platforms",
"settings.botAccessShowConnectedOnly": "Show connected platforms only",
"settings.botListUsers": "Users",
"settings.botListGroups": "Groups",
"settings.botListApprovers": "Approvers",
"settings.botListAdmins": "Admins",
"settings.botPlatformFeishuLark": "Feishu / Lark",
"settings.botQueueDropLabel": "When the queue is full",
"settings.botQueueDropHint": "How overflow messages are handled once the queue hits its cap.",
"settings.botPairingDetailHint": "The pairing switch lives in step 2 (Who can use it). Tune only the code lifetime and pending cap here.",
"settings.botSimpleAccessTitle": "Who can use it",
"settings.botSimpleAccessTrustedSummary": "Trusted list is active with {count} member or group entries.",
"settings.botSimpleAccessEveryoneSummary": "Everyone can use it right now. Best for short tests only.",
"settings.botAccessTrusted": "Trusted list",
"settings.botAccessTrustedHint": "Only listed users or groups can call the bot.",
"settings.botAccessEveryone": "Everyone",
"settings.botAccessEveryoneHint": "Convenient for quick testing, but not recommended long term.",
"settings.botAccessPairing": "Pair private chats",
"settings.botAccessPairingHint": "Unknown direct messages get a code and wait for local approval.",
"settings.botRoleAccess": "Admins and approvers",
"settings.botRoleAccessHint": "Optional. Leave this empty unless remote approval or management is needed.",
"settings.botQueueModeSimple": "When a new message arrives",
"settings.botQueueModeSimpleHint": "Choose what happens when the bot is already handling a previous message.",
"settings.saveBotSettings": "Save bot settings",
"settings.botEnableBot": "Enable bot",
"settings.botGatewayMasterSwitch": "Master switch",
@@ -1057,8 +1227,48 @@ export const en = {
"settings.botModelHint": "Leave empty to use the default model.",
"settings.botModelDefault": "Use default model",
"settings.botRuntime": "Runtime limits",
"settings.botRuntimeHint": "Gateway-level limits that control how far each bot task can run.",
"settings.botMaxSteps": "Max steps",
"settings.botDebounceMs": "Debounce ms",
"settings.botQueueCap": "Queue cap",
"settings.botQueueMode.steer": "Steer running turn",
"settings.botQueueMode.followup": "Follow up",
"settings.botQueueMode.collect": "Collect",
"settings.botQueueMode.interrupt": "Interrupt",
"settings.botQueueDrop.summarize": "Summarize overflow",
"settings.botQueueDrop.old": "Drop oldest",
"settings.botQueueDrop.new": "Drop newest",
"settings.botIgnoreSelfMessages": "Ignore bot echoes",
"settings.botIgnoreSelfMessagesHint": "Prevents the gateway from reacting to messages sent by configured bot identities.",
"settings.botSelfUserIds": "Bot self IDs",
"settings.botSelfUserIdsHint": "Optional user/open IDs for each platform, used to filter bot echo messages.",
"settings.botPairing": "Private chat pairing",
"settings.botPairingTTL": "TTL minutes",
"settings.botPairingMaxPending": "Max pending",
"settings.botControlApi": "Local control API",
"settings.botControlApiHint": "Loopback HTTP API for status and controlled outbound sends; requires a Bearer token from the named env var.",
"settings.botControlAddr": "Control address",
"settings.botControlTokenEnv": "Token env",
"settings.botGatewayDefaults": "Gateway runtime settings",
"settings.botGatewayDefaultsHint": "Tune queueing, pairing, and self-message filtering without editing TOML.",
"settings.botRoutes": "Bind chats to projects",
"settings.botRoutesHint": "Pin a group, contact, or thread to a workspace, model, or approval mode.",
"settings.botRoutesEmpty": "No bindings yet. Add one to bind a chat, user, or thread to a project or model.",
"settings.botRouteTitle": "Route {n}",
"settings.botAddRoute": "Add route",
"settings.botRouteConnection": "Bot source",
"settings.botRouteAny": "Any",
"settings.botRoutePlatform": "Platform",
"settings.botRouteChatType": "Chat type",
"settings.botRouteChatType.any": "Any",
"settings.botRouteChatType.dm": "DM",
"settings.botRouteChatType.group": "Group",
"settings.botRouteChatType.guild": "Guild",
"settings.botRouteChatType.direct": "Direct",
"settings.botRouteChatType.thread": "Thread",
"settings.botRouteChatId": "Group/chat ID",
"settings.botRouteUserId": "Contact ID",
"settings.botRouteThreadId": "Thread ID",
"settings.botChannels": "Connect channels",
"settings.botChannelsHint": "Pick a channel; QQ uses manual setup, while Feishu, Lark, and WeChat connect by QR.",
"settings.botQQHint": "QQ official Bot API v2.",
@@ -1113,8 +1323,7 @@ export const en = {
"settings.botInstallLarkHint": "Scan to create a Lark PersonalAgent.",
"settings.botInstallWeixinHint": "Scan WeChat and save a local token.",
"settings.botConnectedBots": "Connected bots",
"settings.botConnectedBotsSummary": "{online}/{total} online",
"settings.botConnectionsEmpty": "No bot channel is connected yet.",
"settings.botSelectBotHint": "Select a bot on the left to configure it.",
"settings.botConnectionColumnChannel": "Channel",
"settings.botConnectionColumnName": "Bot",
"settings.botConnectionColumnRemote": "Remote ID",
@@ -1132,16 +1341,17 @@ export const en = {
"settings.botConnectionConnected": "Connected",
"settings.botConnectionConfigured": "Configured",
"settings.botConnectionDisconnected": "Disconnected",
"settings.botManage": "Advanced settings",
"settings.botManage": "Edit config",
"settings.deleteBot": "Delete bot",
"settings.deleteBotShort": "Delete",
"settings.confirmDeleteBot": "Confirm delete",
"settings.deleteBotHint": "Removes this bot from connected bots; saved secrets are not cleared automatically.",
"settings.botChannelModel": "Model",
"settings.botChannelModelHint": "Leave empty to use the bot default model.",
"settings.botChannelModelAuto": "Auto",
"settings.botChannelModelHint": "The model this bot uses when handling messages.",
"settings.botChannelModelAuto": "Use current desktop model",
"settings.botToolApprovalMode": "Tool approval",
"settings.botToolApprovalModeHint": "Controls whether this channel asks before running tools; plan confirmation and ask questions still wait for a reply.",
"settings.botToolApprovalMode.inherit": "Inherit",
"settings.botToolApprovalMode.inherit": "Follow bot",
"settings.botToolApprovalMode.ask": "Ask",
"settings.botToolApprovalMode.auto": "Auto",
"settings.botToolApprovalMode.yolo": "YOLO",
@@ -1164,26 +1374,17 @@ export const en = {
"settings.botDiagnosticReportFailed": "Failed to send diagnostic report.",
"settings.botDiagnosticPrivacy": "Only sanitized connection metadata is included.",
"settings.botAccessControl": "Access control",
"settings.botAccessControlHint": "Whitelist is the default; switch to everyone for quick testing.",
"settings.botAccessMode": "Who can use it",
"settings.botAccessAll": "Everyone",
"settings.botAccessWhitelist": "Whitelist only",
"settings.botEditAllowlist": "Edit allowlist",
"settings.botAllowlist": "Allowlist",
"settings.botAllowlistHint": "Restrict which users or groups can invoke the bot.",
"settings.botAllowlistEnabled": "Enable allowlist",
"settings.botAllowAll": "Allow everyone",
"settings.botAllowAllWarn": "When enabled, every incoming channel message can invoke the bot.",
"settings.botQQAccessRequired": "Add a QQ user/group allowlist, or temporarily allow everyone in Advanced settings, before enabling QQ Bot.",
"settings.botAllowlistEntries": "Users and groups",
"settings.botQQAccessRequired": "Add QQ users/groups in step 2 (Who can use it), or temporarily allow everyone, before enabling QQ Bot.",
"settings.botQQUsers": "QQ users",
"settings.botQQGroups": "QQ groups",
"settings.botFeishuUsers": "Feishu users",
"settings.botFeishuGroups": "Feishu groups",
"settings.botFeishuLarkUsers": "Feishu / Lark users",
"settings.botFeishuLarkGroups": "Feishu / Lark groups",
"settings.botWeixinUsers": "WeChat users",
"settings.botWeixinGroups": "WeChat groups",
"settings.botListPlaceholder": "One ID per line, or comma-separated",
"settings.permissions": "Permissions",
"settings.writerMode": "Writer mode",
@@ -1195,8 +1396,11 @@ export const en = {
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
"settings.addRule": "add {list} rule…",
"settings.sandboxTitle": "Sandbox & workspace",
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. Use Permissions or approval mode for deploy-style commands.",
"settings.reloadSessionConfig": "Apply manual config changes",
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
"settings.shellInterpreter": "Shell interpreter",
"settings.shellAuto": "auto (bash, else PowerShell on Windows)",
"settings.shellAuto": "auto (bash first)",
"settings.shellBash": "bash",
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
"settings.shellPwsh": "pwsh (PowerShell 7+)",
@@ -1206,6 +1410,9 @@ export const en = {
"settings.allowNetwork": "Allow network egress from sandboxed bash",
"settings.workspaceRoot": "Workspace root",
"settings.workspaceDefault": "(default: cwd)",
"settings.effectiveWriteRoots": "Effective write roots",
"settings.effectiveWriteRootsHint": "Expanded for the active workspace. Settings changes apply automatically; use the button above after manually editing config files.",
"settings.noEffectiveWriteRoots": "No active write roots",
"settings.appearance": "Appearance",
"settings.theme": "Theme",
"settings.themeStyle": "Visual style",
@@ -1313,6 +1520,7 @@ export const en = {
"settings.pageDesc.bots": "Configure QQ, Feishu, Lark, and WeChat bot channels, including each bot's model and runtime parameters.",
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
"settings.pageDesc.plugins": "Install and manage plugin packages that can provide skills, hooks, and MCP servers.",
"settings.pageDesc.memory": "Manage preferences, project conventions, and instruction files Reasonix keeps referencing.",
"settings.pageDesc.hooks": "Configure shell hooks around turns, tool calls, compaction, and session lifecycle.",
"settings.pageDesc.shortcuts": "Customize keyboard shortcuts and keep the help sheet in sync.",
@@ -1327,7 +1535,7 @@ export const en = {
"settings.customProviders": "Custom providers",
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
"settings.providerAccessEmptyHint": "Click “Add provider” to choose DeepSeek or a custom OpenAI-compatible access.",
"settings.providerAccessEmptyHint": "Use Recommended preset to quickly add common providers, usually with only an API key. Use Custom provider for OpenAI-compatible or Anthropic-compatible endpoints.",
"settings.builtinProviderBadge": "built-in",
"settings.customProviderBadge": "custom",
"settings.configureProvider": "Configure",
@@ -1339,24 +1547,75 @@ export const en = {
"settings.searchModels": "Search models...",
"settings.noMatchingModels": "No matching models",
"settings.addProvider.chooseTitle": "Add provider",
"settings.addProvider.chooseHint": "Choose an official preset, or add a custom OpenAI-compatible access.",
"settings.addProvider.officialChoice": "Official provider",
"settings.addProvider.officialChoiceHint": "Choose from Reasonix presets for DeepSeek.",
"settings.addProvider.chooseHint": "Choose a recommended preset, or add a custom provider manually.",
"settings.addProvider.officialChoice": "Recommended preset",
"settings.addProvider.officialChoiceHint": "Choose DeepSeek or curated custom-provider presets.",
"settings.addProvider.customChoice": "Custom provider",
"settings.addProvider.customChoiceHint": "Configure base_url, models, key env, and related parameters manually.",
"settings.addProvider.officialTitle": "Add official provider",
"settings.addProvider.officialHint": "Official presets use fixed endpoints and defaults; choose the provider and set a key.",
"settings.addProvider.officialHint": "Recommended presets fill endpoints, models, vision, and reasoning defaults; usually only the key is needed.",
"settings.addProvider.customTitle": "Add custom provider",
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
"settings.addProvider.official.deepseek": "DeepSeek Official",
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
"settings.addProvider.preset.mimoApiDesc": "Xiaomi MiMo direct API for v2.5 pay-as-you-go models.",
"settings.addProvider.preset.mimoAnthropicDesc": "Xiaomi MiMo direct Anthropic-compatible endpoint with MiMo v2.5 models.",
"settings.addProvider.preset.mimoTokenPlanCnDesc": "Xiaomi MiMo token-plan China endpoint.",
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.",
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "Xiaomi MiMo token-plan Singapore endpoint.",
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.",
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "Xiaomi MiMo token-plan Amsterdam endpoint.",
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.",
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax China OpenAI-compatible M-series API endpoint.",
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax international OpenAI-compatible M-series API endpoint.",
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax China Anthropic-compatible M-series endpoint.",
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax international Anthropic-compatible endpoint using Bearer auth.",
"settings.addProvider.preset.glmCnDesc": "Zhipu GLM China API with GLM thinking controls.",
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI international GLM API.",
"settings.addProvider.preset.glmCodingPlanCnDesc": "Zhipu GLM China coding-plan endpoint with GLM-5.2 default.",
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "Zhipu GLM China Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI international coding-plan endpoint with GLM-5.2 default.",
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI international Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go relay with mixed-model capability overrides.",
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax models.",
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic route for Claude and Qwen models.",
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope China standard API endpoint.",
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope international standard API endpoint.",
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan China OpenAI-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan China Anthropic-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan international OpenAI-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan international Anthropic-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.stepfunDesc": "StepFun coding-plan OpenAI-compatible endpoint with step-3.7-flash default.",
"settings.addProvider.preset.stepfunAnthropicDesc": "StepFun coding-plan Anthropic-compatible endpoint with step-3.7-flash default.",
"settings.addProvider.preset.novitaDesc": "NovitaAI multi-model gateway with Kimi, MiniMax, GLM, DeepSeek, and Qwen.",
"settings.addProvider.preset.gmiDesc": "GMI Cloud direct multi-model gateway with GLM, DeepSeek, Kimi, Gemini, Claude, and GPT.",
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway through Anthropic-compatible Messages API.",
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router with Qwen and DeepSeek defaults.",
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM accelerated inference endpoint with Nemotron defaults.",
"settings.addProvider.preset.kilocodeDesc": "Kilo Code gateway with kilo/auto routing defaults.",
"settings.addProvider.preset.ollamaCloudDesc": "Hosted Ollama Cloud endpoint with none-to-max reasoning effort.",
"settings.addProvider.addedBadge": "Added",
"settings.addProvider.modifiedBadge": "Modified",
"settings.addProvider.nameConflictBadge": "Name taken",
"settings.addProvider.similarExistingBadge": "Similar setup",
"settings.addProvider.confirm": "Add provider",
"settings.addProvider.alreadyAddedAction": "Already added",
"settings.addProvider.nameConflictAction": "Resolve name first",
"settings.addProvider.viewPresetProvider": "View config",
"settings.addProvider.viewConflictProvider": "View same-name config",
"settings.addProvider.resetPreset": "Reset to preset",
"settings.addProvider.confirmResetPreset": "Confirm reset",
"settings.customProviderName": "Custom provider name",
"settings.customProviderNamePlaceholder": "",
"settings.providerNamePlaceholder": "Provider name (e.g. deepseek-proxy)",
"settings.providerProtocol": "Connection protocol",
"settings.providerProtocolOpenAI": "OpenAI-compatible",
"settings.providerProtocolOpenAIHint": "For proxies, aggregators, and self-hosted services compatible with the OpenAI API.",
"settings.providerProtocolAnthropic": "Anthropic-compatible",
"settings.providerProtocolAnthropicHint": "For Claude-style Messages API services such as some coding-plan endpoints.",
"settings.providerBaseUrlLabel": "API address",
"settings.providerChatUrlLabel": "Chat request URL",
"settings.providerChatUrlPlaceholder": "e.g. https://api.openai.com/v1/chat/completions",
@@ -1370,12 +1629,29 @@ export const en = {
"settings.testFetchModels": "Test and fetch models",
"settings.testFetchModelsHint": "Uses the API address and key above to verify the connection and fill the model list.",
"settings.manualModels": "Model list",
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually, separated by commas.",
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually; mark image-capable models below.",
"settings.visionModels": "Image-capable models",
"settings.visionModelsHint": "Subset of enabled models that accept image input, separated by commas.",
"settings.providerAdvancedSettings": "Advanced settings",
"settings.providerAdvancedSettings": "Compatibility settings (usually leave unchanged)",
"settings.providerAdvancedExpandHint": "Click to open details",
"settings.providerAdvancedCollapseHint": "Click to collapse",
"settings.providerHeaders": "Extra request headers (optional)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "For gateways such as OpenRouter that require site headers. Use one Header: value per line; the API key still belongs in the key field above.",
"settings.providerExtraBody": "Extra request body (optional)",
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
"settings.fetchingModels": "Refreshing…",
"settings.fetchModelsSuccess": "Fetched {n} models",
"settings.fetchModelsManualFallbackAuth": "The model discovery endpoint rejected the key or permission. Check the API key; if this service does not expose model discovery, enter model IDs manually below.",
"settings.fetchModelsManualFallbackUnsupported": "This service may not support automatic model discovery. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackNetwork": "Could not reach the model discovery endpoint. Check the API address or proxy; if you know the model IDs, enter them manually below.",
"settings.fetchModelsManualFallbackDecode": "The model discovery endpoint returned an unknown format. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackEmpty": "No models were discovered automatically. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackGeneric": "Could not fetch models automatically: {err}. Enter model IDs manually below.",
"settings.fetchModelsUpdatedForProvider": "Updated available models for {provider}, {n} total",
"settings.fetchModelsReadyForProvider": "Found {n} models for {provider}. Review and save the enabled list.",
"settings.enabledModelsSavedForProvider": "Saved {n} enabled models for {provider}",
@@ -1385,12 +1661,18 @@ export const en = {
"settings.fetchModelsAfterKeyFailed": "Key saved, but available models could not be refreshed automatically: {err}",
"settings.fetchModelsAfterKeyFailedForProvider": "Key saved, but available models for {provider} could not be refreshed: {err}",
"settings.newProviderDraftName": "new provider",
"settings.reasoningProtocol": "Reasoning protocol",
"settings.reasoningProtocol.auto": "Auto (detect from model)",
"settings.reasoningProtocol.deepseek": "DeepSeek",
"settings.reasoningProtocol.openai": "OpenAI",
"settings.reasoningProtocol.none": "None (off)",
"settings.reasoningProtocolHint": "Controls the request shape behind effort. Auto uses the model capability registry and official endpoints; switch to OpenAI or None if a proxy is incompatible.",
"settings.reasoningProtocol": "Model capability mode",
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
"settings.reasoningProtocol.deepseek": "DeepSeek thinking (thinking + depth)",
"settings.reasoningProtocol.openai": "OpenAI reasoning (reasoning_effort)",
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
"settings.thinkingMode": "Thinking override",
"settings.thinkingMode.auto": "Auto (provider default)",
"settings.thinkingMode.enabled": "Enabled",
"settings.thinkingMode.disabled": "Disabled",
"settings.thinkingMode.adaptive": "Adaptive (self-adjusting)",
"settings.thinkingModeHint": "Provider-specific override. Use enabled/disabled for compatible gateways, adaptive only for providers that document it.",
"settings.updateKey": "update {env} (saved globally)",
"settings.updateKeyAction": "Update key",
"settings.keyStatus": "Key",
@@ -1505,6 +1787,8 @@ export const en = {
"msg.imageAttachment": "Image",
"msg.workspaceReference": "Workspace reference",
"msg.folderReference": "Folder · Workspace reference",
"msg.pastedExpandTooltip": "Expand pasted text",
"msg.pastedCollapseTooltip": "Collapse pasted text",
"turnActions.summary": "Summary",
"turnActions.rewind": "Rewind",
"transcript.jumpToBottom": "Jump to bottom",
@@ -1586,7 +1870,7 @@ export const en = {
"settings.telemetryLabel": "Anonymous usage ping",
"settings.telemetryHint": "On launch, send a random install id plus version and OS to count active installs, and — if the app crashed on a previous run — resend that crash report. Never includes conversations, keys, or file contents.",
"settings.metricsLabel": "Share aggregate quality metrics",
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, updater error categories, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
"updater.currentVersion": "Current version: {v}",
"updater.channelLabel": "Update channel: {channel}",
"updater.checkButton": "Check for updates",
@@ -1629,10 +1913,20 @@ export const en = {
"context.scopeGlobal": "Scope: Global",
"context.scopeProject": "Project scope · {name}",
"context.windowTitle": "Context window",
"context.windowSubtitle": "Current context window usage",
"context.windowStatusHealthy": "Context healthy",
"context.windowStatusWatch": "Compression soon",
"context.windowStatusPastCompact": "Compression threshold reached",
"context.windowStatusNearLimit": "Near context limit",
"context.windowUsageSummary": "{pct}% used",
"context.windowCompactRemaining": "{used} / {window} · {tokens} until compression",
"context.windowCompactDistance": "To compact",
"context.windowUsedLabel": "Used",
"context.windowUsed": "Used context",
"context.windowCompactThreshold": "Compression threshold",
"context.windowCompactHeadroom": "Before compression",
"context.sessionMetrics": "Session metrics",
"context.runtimeMetrics": "Runtime",
"context.costMetrics": "Cost",
"context.sessionStatus": "Session status",
"context.sessionChanges": "Session changes",
"context.referencedFiles": "Dependency files",
"context.noChanges": "No changed files in this session yet",
@@ -1640,34 +1934,28 @@ export const en = {
"context.changedMeta": "{count} change records",
"context.readMeta": "{count} read records",
"context.viewAll": "View all",
"context.health": "Context health",
"context.healthGood": "Context healthy",
"context.healthGoodShort": "Healthy",
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
"context.healthNearLimit": "Context near limit",
"context.healthNearLimitShort": "Near limit",
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
"context.healthManyFiles": "Many source files",
"context.healthManyFilesShort": "Many files",
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
"context.healthLowCache": "Low cache hit",
"context.healthLowCacheShort": "Low cache",
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
"context.prompt": "Prompt",
"context.completion": "Completion",
"context.reasoning": "Reasoning",
"context.other": "Other",
"context.total": "Total",
"context.usageAnalysis": "Usage analysis",
"context.usageAnalysisView": "Usage analysis view",
"context.usageAnalysisSource": "By source",
"context.usageAnalysisType": "By type",
"context.tokenBreakdown": "Token composition",
"context.cacheHit": "Cache hit",
"context.inputCache": "Input cache",
"context.inputCacheHit": "Input (cache hit)",
"context.inputCacheMiss": "Input (cache miss)",
"context.outputTokens": "Output tokens",
"context.sessionTokens": "Session tokens",
"context.sessionTokens": "Session total tokens",
"context.sessionTokensShort": "Total tokens",
"context.tokensValue": "{value} tokens",
"context.sessionCost": "Session cost",
"context.costBreakdown": "Cost breakdown",
"context.sourceBreakdown": "Model usage breakdown",
"context.sourceShareTokens": "By tokens",
"context.sourceBreakdown": "Usage source",
"context.sourceShareTitle": "Source share",
"context.sourceInput": "Input",
"context.sourceOutput": "Output",
"context.sourceCacheHit": "Hit",
@@ -1683,9 +1971,9 @@ export const en = {
"context.sourceClassifier": "Classifier",
"context.sourceTitle": "Title",
"context.sourceRequests": "{count} req",
"context.moreSources": "{count} more sources",
"context.requests": "Requests",
"context.time": "Time",
"context.compaction": "Compaction",
"context.time": "Runtime",
"context.durationSeconds": "{seconds}s",
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
+347 -59
View File
@@ -134,6 +134,7 @@ export const zhTW: Record<DictKey, string> = {
"caps.summary": "{connected} 個已連線 · {failed} 個異常 · {skills} 個技能",
"caps.connectorsTab": "MCP 伺服器",
"caps.skillsTab": "Agent 技能",
"caps.pluginsTab": "插件",
"caps.servers": "MCP 伺服器",
"caps.noServers": "未設定 MCP 伺服器。用 /mcp add 新增。",
"caps.failureTitle": "{failed} 個 MCP 啟動異常",
@@ -227,6 +228,62 @@ export const zhTW: Record<DictKey, string> = {
"caps.trustedReadOnlyTitle": "已記住為之後規劃和唯讀 research 可用的唯讀工具",
"caps.searchSkills": "搜尋技能…",
"caps.noSkillMatches": "沒有匹配的技能。",
"caps.pluginRefresh": "重新整理插件",
"caps.pluginInstallTitle": "安裝插件",
"caps.pluginInstallHint": "選擇本機插件目錄,或從 Git 倉庫安裝。",
"caps.pluginInstallMethod": "插件安裝方式",
"caps.pluginInstallLocal": "本機目錄",
"caps.pluginInstallGit": "Git 倉庫",
"caps.pluginSource": "插件來源",
"caps.pluginGitSource": "Git 倉庫地址",
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
"caps.pluginChooseLocalFolder": "選擇插件目錄",
"caps.pluginLocalFolder": "本機插件目錄",
"caps.pluginNoLocalFolder": "尚未選擇目錄",
"caps.pluginInstallName": "安裝名稱(可選)",
"caps.pluginInstallNamePlaceholder": "安裝名稱(可選)",
"caps.pluginLink": "開發模式:連結來源目錄",
"caps.pluginLinkHint": "不複製插件,直接使用所選目錄;適合調試插件。移動或刪除該目錄會導致插件失效。",
"caps.pluginReplace": "覆蓋同名插件",
"caps.pluginReplaceHint": "插件名已存在時,允許用目前來源替換它。",
"caps.pluginPreview": "預檢",
"caps.pluginInstall": "安裝插件",
"caps.noPluginsTitle": "尚未安裝插件",
"caps.noPluginsHint": "從 Git 或本機路徑安裝插件包,把技能、hooks 和 MCP 伺服器作為一個統一單元管理。",
"caps.installedPlugins": "已安裝插件",
"caps.pluginsSummary": "{enabled} 個啟用 · 共 {total} 個 · {issues} 個異常",
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
"caps.pluginNoCapabilities": "未匯出能力",
"caps.pluginWarnings": "{count} 個警告",
"caps.pluginEnable": "啟用插件",
"caps.pluginDisable": "停用插件",
"caps.pluginEnabled": "已啟用",
"caps.pluginDisabled": "已停用",
"caps.pluginVersion": "版本",
"caps.pluginRoot": "安裝路徑",
"caps.pluginUpdate": "更新",
"caps.pluginDoctor": "診斷",
"caps.pluginRemove": "移除插件",
"caps.pluginConfirmRemove": "確認移除",
"caps.pluginPlanError": "安裝檢查失敗",
"caps.pluginPlanReady": "安裝計畫",
"caps.pluginAction": "操作",
"caps.pluginPlanStatus": "插件操作:{status}",
"caps.pluginPlanComplete": "插件操作完成",
"caps.pluginPlanInstalled": "插件已安裝。展開下方插件,可查看它匯出的 skills、hooks 和 MCP 伺服器的使用方式。",
"caps.pluginUsageTitle": "使用方法",
"caps.pluginUsageEnabledHint": "啟用的插件會載入到新會話中。可用 /skills 查看技能、用 /<skill> 直接呼叫,或自然描述任務;hooks 和 MCP 伺服器會按正常會話流程自動參與。",
"caps.pluginUsageDisabledHint": "啟用此插件後,它的 skills、hooks 或 MCP 伺服器才會參與會話。",
"caps.pluginNoCapabilityDetails": "此插件暫無可展示的能力明細。",
"caps.pluginSkillsTitle": "Skills",
"caps.pluginSkillsHint": "用 /skills 瀏覽,直接呼叫,或在對話中說出工作流名稱。",
"caps.pluginHooksTitle": "Hooks",
"caps.pluginHooksHint": "Hooks 會在匹配的生命週期事件中自動觸發。",
"caps.pluginMCPTitle": "MCP 伺服器",
"caps.pluginMCPHint": "這些伺服器提供的工具會透過正常 MCP/工具流程可用。",
"caps.pluginNoDescription": "無描述",
"caps.pluginHookNoTarget": "未指定 hook 目標",
"caps.pluginMCPNoTarget": "未指定啟動目標",
"caps.sources": "來源",
"caps.sourcesSummaryActive": "{active} 個有效",
"caps.sourcesSummaryMissing": "{missing} 個缺失",
@@ -290,6 +347,17 @@ export const zhTW: Record<DictKey, string> = {
"composer.removeCurrentProjectDisabled": "不能移除當前專案",
"composer.resize": "拖動調整輸入區高度,雙擊重置",
"composer.send": "傳送(Enter)",
"composer.queueGuidance": "加入引導佇列(Enter)",
"composer.steer": "追加到目前任務(Enter)",
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
"composer.guidanceQueue": "待處理引導",
"composer.guidanceCount": "待處理引導 {n}",
"composer.guidanceRemaining": "還有 {n} 條",
"composer.guidanceCollapse": "收起",
"composer.guidanceMode": "引導",
"composer.guidanceSend": "將這條引導加入資訊流",
"composer.guidanceDismiss": "移除這條引導提示",
"composer.guidanceMore": "更多引導選項",
"composer.stop": "停止(Esc)",
"composer.stopShort": "停止",
"composer.pastedLabel": "[已貼上文字 #{id} · {lines} 行]",
@@ -298,6 +366,9 @@ export const zhTW: Record<DictKey, string> = {
"composer.pastedExpand": "展開",
"composer.pastedRemove": "刪除貼上文字",
"composer.removeImage": "移除圖片",
"imageViewer.clickToPreview": "點擊查看大圖",
"imageViewer.closePreview": "關閉預覽",
"imageViewer.title": "圖片預覽",
"composer.imageInputUnsupported": "目前模型不會直接接收圖片。可切換到支援圖片的模型,或讓 OCR/識圖 MCP 工具讀取附件路徑。",
"composer.contextItems": "上下文項目",
"composer.workspaceReferences": "工作區引用",
@@ -398,6 +469,11 @@ export const zhTW: Record<DictKey, string> = {
"heartbeat.approvalModeAskHint": "每次工具調用都需要手動確認",
"heartbeat.approvalModeAutoHint": "自動批准安全工具,保留拒絕規則",
"heartbeat.approvalModeYoloHint": "跳過所有工具權限提示,任務自動運行",
"heartbeat.notifyChannels": "推送到機器人",
"heartbeat.notifyChannelsOn": "開啟",
"heartbeat.notifyChannelsOff": "關閉",
"heartbeat.notifyChannelsOnHint": "AI 輸出會即時推送到已連線的飛書/QQ 機器人",
"heartbeat.notifyChannelsOffHint": "只建立桌面對話,不推送到機器人",
"heartbeat.fieldNewConversation": "對話模式",
"heartbeat.newConversationEachRunOn": "每次執行新建對話",
"heartbeat.newConversationEachRunOff": "複用同一對話",
@@ -441,6 +517,7 @@ export const zhTW: Record<DictKey, string> = {
"ask.hideDetails": "收起",
"ask.back": "返回",
"ask.next": "下一步",
"ask.customAnswer": "其他答案",
"ask.customPlaceholder": "輸入你自己的答案…",
"ask.justChat": "只是聊聊",
@@ -497,6 +574,18 @@ export const zhTW: Record<DictKey, string> = {
"history.historySessionActions": "歷史會話操作",
"history.trashActions": "回收站操作",
// 會話恢復
"recovery.branch": "恢復分支",
"recovery.badge": "恢復",
"recovery.open": "開啟恢復分支",
"recovery.toast": "未儲存的本機內容已儲存為 {title}。",
"recovery.failedLease": "恢復分支被另一個執行階段占用,未切換過去。",
"recovery.failedUnavailable": "無法開啟恢復分支,目前標籤仍停留在原會話。",
"recovery.banner": "目前會話來自儲存衝突恢復。繼續前建議與原會話對比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父會話:{parent}",
// 專案樹
"projectTree.workspaceTitle": "專案",
"projectTree.pinnedTitle": "置頂",
@@ -612,6 +701,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.tab.capabilities": "MCP 與技能",
"settings.tab.mcp": "MCP 與工具",
"settings.tab.skills": "技能",
"settings.tab.plugins": "插件",
"settings.tab.memory": "記憶",
"settings.tab.shortcuts": "快捷鍵",
"settings.tab.network": "網路",
@@ -625,6 +715,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.tabSub.capabilities": "伺服器、工具與技能",
"settings.tabSub.mcp": "伺服器與工具",
"settings.tabSub.skills": "來源與啟用",
"settings.tabSub.plugins": "包與診斷",
"settings.tabSub.memory": "專案與全域",
"settings.tabSub.shortcuts": "按鍵與幫助",
"settings.tabSub.network": "代理與連線",
@@ -681,6 +772,10 @@ export const zhTW: Record<DictKey, string> = {
"settings.subagentEffort": "子代理 effort",
"settings.subagentEffortDefault": "auto(模型服務預設)",
"settings.subagentHint": "作用於 task 和 runAs=subagent skills;工具呼叫參數或單個 skill 設定會覆蓋它。",
"settings.subagentDepth": "子代理巢狀深度",
"settings.subagentDepthHint": "控制第一層子代理能否再派發一層 reviewer 或 worker 子代理。",
"settings.subagentDepthOne": "1 層",
"settings.subagentDepthTwo": "2 層",
"settings.defaultToolApprovalMode": "新會話預設審批",
"settings.defaultToolApprovalModeHint": "保存在全域設定中,僅用於新建會話;切換或恢復已有會話時不會覆蓋其 Ask / Auto / Yolo 狀態。",
"settings.defaultToolApprovalMode.ask": "詢問",
@@ -703,7 +798,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.coldResumePrune.on": "開",
"settings.coldResumePrune.off": "關",
"settings.reasoningLanguage": "思考語言",
"settings.reasoningLanguageHint": "只影響可見思考過程。自動跟隨對話語言,不額外注入語言指令。",
"settings.reasoningLanguageHint": "只影響可見思考過程。自動在原始提問明顯為中文時錨定中文,引用內容不參與判斷。",
"settings.reasoningLanguage.auto": "自動",
"settings.reasoningLanguage.zh": "中文",
"settings.reasoningLanguage.en": "English",
@@ -738,7 +833,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
"settings.providerModelsUrlHint": "可選。模型發現需要使用不同於聊天請求的位址時填寫。",
"settings.providerModels": "模型(逗號分隔)",
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
"settings.providerApiKeyEnv": "api_key_env(可以不設定)",
"settings.providerBalanceUrl": "餘額查詢 URL(可選)",
"settings.balanceUrlPlaceholder": "餘額查詢介面(如 https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "可選。填了就會查詢並在狀態欄顯示錢包餘額。",
@@ -748,12 +843,22 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto(按模型和端點自動判斷)",
"settings.providerReasoningProtocolHint": "用於 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 參數,openai 使用普通 reasoning_effort,none 表示停用。",
"settings.effortBehavior": "effort 行為",
"settings.effortUseProtocolDefaults": "協定預設",
"settings.effortUseCustom": "自訂覆蓋",
"settings.effortProtocolDefault.auto": "協定預設會把 supported_efforts 儲存為空。Reasonix 會把已知 DeepSeek 模型/端點識別為 high|max,把 OpenAI reasoning 識別為 low|medium|high,不支援的服務不暴露 /effort。",
"settings.effortProtocolDefault.deepseek": "DeepSeek 預設暴露 auto|high|max。Reasonix 會保持 thinking 啟用,並用 reasoning_effort 控制深度。",
"settings.effortProtocolDefault.openai": "OpenAI reasoning 預設暴露 auto|low|medium|high,並傳送普通 reasoning_effort。",
"settings.effortProtocolDefault.minimax": "MiniMax 風格端點使用 adaptive|disabled 這類服務商自訂 thinking 等級。只有端點文件明確支援時才新增自訂等級。",
"settings.effortProtocolDefault.none": "該供應商已停用 effort;Reasonix 不會傳送 reasoning_effort 或 thinking 控制參數。",
"settings.supportedEfforts": "支援的 effort 等級",
"settings.supportedEffortsCustomPlaceholder": "自訂等級(按 Enter)",
"settings.supportedEffortsHint": "勾選該模型服務在 /effort 命令中暴露的等級,或新增自訂名稱。留空則使用內建預設(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
"settings.supportedEffortsNoPresets": "沒有標準預設",
"settings.supportedEffortsNoPresetsHint": "僅在端點文件明確給出 effort 名稱時新增自訂等級。",
"settings.supportedEffortsHint": "自訂等級會覆蓋協定預設。只選擇端點文件支援的等級;不相容組合可能導致模型請求失敗。",
"settings.defaultEffort": "預設 effort",
"settings.defaultEffortAuto": "auto(使用內建預設)",
"settings.defaultEffortHint": "在 /effort 為 auto 或未設定時使用。必須是上方等級之一;為空時回退到第一個等級(無選中時回退到內建預設)。",
"settings.defaultEffortAuto": "未設定(使用協定/自訂預設)",
"settings.defaultEffortHint": "在 /effort 為 auto 或未設定時使用。留空則使用協定預設;自訂等級時回退到第一個自訂等級。",
"settings.proxyMode": "代理模式",
"settings.proxyMode.auto": "自動",
"settings.proxyMode.env": "環境變數",
@@ -780,12 +885,18 @@ export const zhTW: Record<DictKey, string> = {
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
"settings.addRule": "新增 {list} 規則…",
"settings.sandboxTitle": "沙箱與工作區",
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。mvn deploy 這類命令還需要權限規則或核准模式放行。",
"settings.reloadSessionConfig": "套用手動設定修改",
"settings.reloadSessionConfigHint": "設定頁內修改會自動生效;此按鈕用於套用手動編輯 config.toml / reasonix.toml 後的變更。",
"settings.bashSandbox": "Bash 沙箱",
"settings.bashEnforce": "enforce(隔離 bash)",
"settings.bashOff": "off(不受限執行)",
"settings.allowNetwork": "允許沙箱內 bash 存取網路",
"settings.workspaceRoot": "工作區根目錄",
"settings.workspaceDefault": "(預設:當前目錄)",
"settings.effectiveWriteRoots": "實際可寫根",
"settings.effectiveWriteRootsHint": "按目前工作區展開。設定頁內修改會自動生效;手動編輯設定檔後可用上方按鈕刷新目前會話。",
"settings.noEffectiveWriteRoots": "暫無可寫根",
"settings.appearance": "外觀",
"settings.theme": "主題",
"settings.themeStyle": "強調色",
@@ -820,6 +931,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.pageDesc.providers": "管理模型供應商、API 位址、金鑰與可用模型。",
"settings.pageDesc.mcp": "管理 MCP 伺服器連線和工具發現。",
"settings.pageDesc.skills": "瀏覽、啟用和管理智慧體技能與來源。",
"settings.pageDesc.plugins": "安裝和管理插件包,用統一單元提供技能、hooks 與 MCP 伺服器。",
"settings.pageDesc.memory": "管理 Reasonix 會長期參考的偏好、專案約定和指令檔案。",
"settings.pageDesc.network": "代理與網路設定。",
"settings.pageDesc.permissions": "寫操作模式與細粒度工具權限規則。",
@@ -832,7 +944,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.customProviders": "自訂供應商",
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
"settings.providerAccessEmptyHint": "點擊「新增供應商」,選擇 DeepSeek 或自訂 OpenAI-compatible 接入。",
"settings.providerAccessEmptyHint": "使用「推薦預設」可快速接入常用供應商,通常只需填寫 API Key;使用「自訂供應商」可手動設定 OpenAI-compatible 或 Anthropic-compatible 端點。",
"settings.builtinProviderBadge": "內建",
"settings.customProviderBadge": "自訂",
"settings.configureProvider": "設定",
@@ -844,24 +956,75 @@ export const zhTW: Record<DictKey, string> = {
"settings.searchModels": "搜尋模型...",
"settings.noMatchingModels": "沒有匹配的模型",
"settings.addProvider.chooseTitle": "新增供應商",
"settings.addProvider.chooseHint": "選擇官方預設,或新增一個自訂 OpenAI-compatible 接入。",
"settings.addProvider.officialChoice": "官方供應商",
"settings.addProvider.officialChoiceHint": "從 Reasonix 預設的 DeepSeek 接入中選擇。",
"settings.addProvider.chooseHint": "選擇推薦預設,或手動新增一個自訂供應商。",
"settings.addProvider.officialChoice": "推薦預設",
"settings.addProvider.officialChoiceHint": "選擇 DeepSeek 或已適配的自訂供應商預設。",
"settings.addProvider.customChoice": "自訂供應商",
"settings.addProvider.customChoiceHint": "按 base_url、模型、金鑰環境變數等參數手動設定。",
"settings.addProvider.officialTitle": "新增官方供應商",
"settings.addProvider.officialHint": "官方預設會寫入固定端點和預設參數,只需要選擇供應商並設定金鑰。",
"settings.addProvider.officialHint": "推薦預設會填好端點、模型、視覺和推理預設值,通常只需要填 API Key。",
"settings.addProvider.customTitle": "新增自訂供應商",
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直連 API,適合 v2.5 按量付費模型。",
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直連 Anthropic-compatible 端點,自動使用 MiMo v2.5 模型。",
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中國區端點。",
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中國區 Anthropic-compatible 端點。",
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端點。",
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端點。",
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端點。",
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端點。",
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 中國 OpenAI-compatible M 系列 API 端點。",
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 國際 OpenAI-compatible M 系列 API 端點。",
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 中國 Anthropic-compatible M 系列端點。",
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 國際 Anthropic-compatible 端點,自動使用 Bearer 認證。",
"settings.addProvider.preset.glmCnDesc": "智譜 GLM 中國 API,預置 GLM thinking 控制。",
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 國際 GLM API。",
"settings.addProvider.preset.glmCodingPlanCnDesc": "智譜 GLM 中國 coding plan 端點,預設 GLM-5.2。",
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智譜 GLM 中國 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 國際 coding plan 端點,預設 GLM-5.2。",
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 國際 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型閘道,內建每模型能力覆蓋。",
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 線路,預置 Qwen 與 MiniMax 模型。",
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 線路,預置 Claude 與 Qwen 模型。",
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 中國標準 API 端點。",
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 國際標準 API 端點。",
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 中國 OpenAI-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 中國 Anthropic-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 國際 OpenAI-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 國際 Anthropic-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.stepfunDesc": "階躍星辰 StepFun coding plan OpenAI-compatible 端點,預設 step-3.7-flash。",
"settings.addProvider.preset.stepfunAnthropicDesc": "階躍星辰 StepFun coding plan Anthropic-compatible 端點,預設 step-3.7-flash。",
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型閘道,預置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直連閘道,預置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,預置 Qwen 與 DeepSeek 模型。",
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端點,預置 Nemotron 模型。",
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 閘道,預置 kilo/auto 自動路由模型。",
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 託管端點,支援 none 到 max 的 reasoning effort。",
"settings.addProvider.addedBadge": "已新增",
"settings.addProvider.modifiedBadge": "已修改",
"settings.addProvider.nameConflictBadge": "同名占用",
"settings.addProvider.similarExistingBadge": "有相似設定",
"settings.addProvider.confirm": "新增供應商",
"settings.addProvider.alreadyAddedAction": "已新增",
"settings.addProvider.nameConflictAction": "先處理同名設定",
"settings.addProvider.viewPresetProvider": "查看設定",
"settings.addProvider.viewConflictProvider": "查看同名設定",
"settings.addProvider.resetPreset": "重設為預設",
"settings.addProvider.confirmResetPreset": "確認重設",
"settings.customProviderName": "自訂供應商名稱",
"settings.customProviderNamePlaceholder": "",
"settings.providerNamePlaceholder": "供應商名稱(如 deepseek-proxy)",
"settings.providerProtocol": "接入協定",
"settings.providerProtocolOpenAI": "OpenAI-compatible",
"settings.providerProtocolOpenAIHint": "適用於相容 OpenAI API 的代理、聚合平台和自建服務。",
"settings.providerProtocolAnthropic": "Anthropic-compatible",
"settings.providerProtocolAnthropicHint": "適用於 Claude Messages API 風格的服務,例如部分 coding plan 端點。",
"settings.providerBaseUrlLabel": "API 位址",
"settings.providerChatUrlLabel": "完整請求 URL",
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
@@ -875,12 +1038,29 @@ export const zhTW: Record<DictKey, string> = {
"settings.testFetchModels": "測試並取得模型",
"settings.testFetchModelsHint": "會使用上方 API 位址與金鑰確認連線,並用返回結果填充模型列表。",
"settings.manualModels": "模型列表",
"settings.manualModelsHint": "介面不支援模型發現時,可手動填寫多個模型,用英文逗號分隔。",
"settings.manualModelsHint": "介面不支援模型發現時,可手動填寫多個模型;下方可勾選哪些模型支援圖片輸入。",
"settings.visionModels": "支援圖片輸入的模型",
"settings.visionModelsHint": "啟用模型中可接收圖片輸入的子集,用英文逗號分隔。",
"settings.providerAdvancedSettings": "進階設定",
"settings.providerAdvancedSettings": "相容設定(通常不用改)",
"settings.providerAdvancedExpandHint": "點擊展開詳細設定",
"settings.providerAdvancedCollapseHint": "點擊收起",
"settings.providerHeaders": "額外請求標頭(可選)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "用於 OpenRouter 等要求站點來源的中轉站。一行一個 Header: value;API Key 仍透過上方金鑰保存。",
"settings.providerExtraBody": "額外請求體(可選)",
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
"settings.fetchingModels": "重新整理中…",
"settings.fetchModelsSuccess": "已取得 {n} 個模型",
"settings.fetchModelsManualFallbackAuth": "模型發現介面拒絕了金鑰或權限。請檢查 API Key;如果該服務不開放模型發現,也可以在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackUnsupported": "這個服務可能不支援自動取得模型列表。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackNetwork": "暫時無法連接模型發現介面。請檢查 API 位址或代理;如果你知道模型 ID,也可以先手動填寫。",
"settings.fetchModelsManualFallbackDecode": "模型發現介面返回了無法識別的格式。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackEmpty": "沒有自動取得模型。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackGeneric": "無法自動取得模型:{err}。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsUpdatedForProvider": "已更新可用模型:{provider},共 {n} 個",
"settings.fetchModelsEmpty": "沒有取得可用模型",
"settings.fetchModelsEmptyForProvider": "{provider} 沒有返回可用模型",
@@ -888,12 +1068,18 @@ export const zhTW: Record<DictKey, string> = {
"settings.fetchModelsAfterKeyFailed": "金鑰已儲存,但暫時無法自動重新整理可用模型:{err}",
"settings.fetchModelsAfterKeyFailedForProvider": "金鑰已儲存,但暫時無法重新整理 {provider} 的可用模型:{err}",
"settings.newProviderDraftName": "新供應商",
"settings.reasoningProtocol": "推理協定",
"settings.reasoningProtocol.auto": "Auto(按模型自動判斷)",
"settings.reasoningProtocol.deepseek": "DeepSeek",
"settings.reasoningProtocol.openai": "OpenAI",
"settings.reasoningProtocol.none": "None(關閉)",
"settings.reasoningProtocolHint": "控制 effort 的請求格式。Auto 會按模型能力表和官方端點識別;代理不相容時可切到 OpenAI 或 None。",
"settings.reasoningProtocol": "模型能力模式",
"settings.reasoningProtocol.auto": "自動識別(推薦)",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
"settings.reasoningProtocol.none": "普通聊天(不傳送思考參數)",
"settings.reasoningProtocolHint": "預設自動識別。只有代理不相容,或你確定模型需要特定 reasoning 格式時再切換。",
"settings.thinkingMode": "Thinking 覆蓋",
"settings.thinkingMode.auto": "Auto(使用服務預設)",
"settings.thinkingMode.enabled": "Enabled(開啟)",
"settings.thinkingMode.disabled": "Disabled(關閉)",
"settings.thinkingMode.adaptive": "Adaptive(自適應)",
"settings.thinkingModeHint": "服務商專用覆蓋項。相容閘道可用 enabled/disabled;只有服務文件明確支援時才使用 adaptive。",
"settings.updateKey": "更新 {env}(全域儲存)",
"settings.updateKeyAction": "更新金鑰",
"settings.keyStatus": "金鑰",
@@ -947,6 +1133,8 @@ export const zhTW: Record<DictKey, string> = {
"msg.imageAttachment": "圖片",
"msg.workspaceReference": "工作區引用",
"msg.folderReference": "資料夾 · 工作區引用",
"msg.pastedExpandTooltip": "展開貼上文字",
"msg.pastedCollapseTooltip": "收起貼上文字",
"turnActions.summary": "總結",
"turnActions.rewind": "回溯",
"transcript.jumpToBottom": "跳到底部",
@@ -1068,27 +1256,23 @@ export const zhTW: Record<DictKey, string> = {
"context.changedMeta": "{count} 條變更記錄",
"context.readMeta": "{count} 條讀取記錄",
"context.viewAll": "檢視全部",
"context.health": "上下文狀態",
"context.healthGood": "上下文狀態正常",
"context.healthGoodBody": "用量、依據檔案和本會話變更保持可追蹤。",
"context.healthNearLimit": "上下文接近上限",
"context.healthNearLimitBody": "已使用 {pct}%,優先保留關鍵依據並考慮壓縮。",
"context.healthManyFiles": "依據檔案較多",
"context.healthManyFilesBody": "已讀取 {count} 條檔案記錄,可檢視依據檔案確認是否仍相關。",
"context.healthLowCache": "快取命中偏低",
"context.healthLowCacheBody": "當前快取命中 {pct}%,後續長任務可關注上下文穩定性。",
"context.prompt": "提示詞",
"context.completion": "回覆",
"context.reasoning": "推理",
"context.other": "其他",
"context.total": "總計",
"context.usageAnalysis": "用量分析",
"context.usageAnalysisView": "用量分析視圖",
"context.usageAnalysisSource": "按來源",
"context.usageAnalysisType": "按類型",
"context.tokenBreakdown": "Token 構成",
"context.cacheHit": "快取命中",
"context.inputCacheHit": "輸入 tokens(命中快取)",
"context.inputCacheMiss": "輸入 tokens(未命中快取)",
"context.outputTokens": "輸出 tokens",
"context.sessionCost": "本次費用",
"context.requests": "請求數",
"context.time": "耗時",
"context.time": "執行時間",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
@@ -1417,11 +1601,76 @@ export const zhTW: Record<DictKey, string> = {
"settings.toggleOff": "關閉",
"settings.botSetup": "機器人入口",
"settings.botSetupHint": "先開啟入口,再選擇要連線的聊天頻道。",
"settings.botConnectPhoneTitle": "新增 IM Bot",
"settings.botConnectPhoneSubtitle": "QQ 手動配置,飛書、Lark 和微信掃碼接入。",
"settings.botGateway": "機器人閘道器",
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關、模型和執行參數。",
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關和連線狀態。",
"settings.botBrowserPreviewWarning": "目前是瀏覽器預覽,只會儲存 mock 設定,不會寫入本機配置或啟動 QQ/IM Bot gateway。請在桌面應用或 CLI 中配置後再到 QQ 用戶端測試。",
"settings.botSimpleEyebrow": "傻瓜式設定",
"settings.botSimpleTitle": "行動端與聊天機器人",
"settings.botSimpleHint": "先用手機掃碼遠端控制目前工作區;需要長時間從聊天工具存取時,再選擇一個 Bot Channel 配置。",
"settings.botMobileRemoteEyebrow": "獨立入口",
"settings.botMobileRemoteTitle": "手機遠端控制",
"settings.botMobileRemoteHint": "掃碼或複製連結,從行動端打開目前工作區的控制入口;它和 IM Bot 頻道管理分開配置。",
"settings.botMobileRemoteControlApi": "控制連結",
"settings.botMobileRemoteDisabled": "開啟後生成二維碼和連結",
"settings.botMobileRemoteEnableFirst": "開啟後生成二維碼",
"settings.botMobileRemoteReady": "等待手機連線",
"settings.botMobileRemoteWaiting": "手機掃碼連線",
"settings.botMobileRemoteLoopbackNotice": "目前控制 API 仍是本機 loopback,只適合本機預覽;跨裝置 relay 接入後會沿用這個入口。",
"settings.botMobileRemoteCopyLink": "複製連結",
"settings.botMobileRemoteCopied": "已複製",
"settings.botMobileRemoteCopyFailed": "複製失敗",
"settings.botChannelEntryTitle": "使用 Bot Channel",
"settings.botChannelEntryHint": "連接聊天 Bot,適合更長時間的行動端存取。",
"settings.botManageBots": "機器人管理",
"settings.botManageBotsHint": "左側選擇頻道;未配置時掃碼或填寫金鑰,配置後在右側管理這個機器人的執行設定。",
"settings.botBackToOverview": "返回入口",
"settings.botChannelOpenConfigured": "已配置,可繼續修改設定。",
"settings.botChannelNeedsSetup": "未配置",
"settings.botChannelTabsLabel": "機器人頻道",
"settings.botChannelSetupTitle": "配置 {provider}",
"settings.botChannelSetupHint": "未配置前只需要完成憑據或掃碼綁定;成功後會自動切換為具體設定。",
"settings.botSetupStatus": "機器人設定狀態",
"settings.botSetupStatusConnected": "{online}/{total} 在線",
"settings.botSetupStatusAccessTrusted": "受信任名單 {count}",
"settings.botSetupStatusAccessEveryone": "所有人可用",
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 個 Bot 已配置",
"settings.botMode": "機器人設定模式",
"settings.botModeSimple": "普通",
"settings.botModeAdvanced": "高階",
"settings.botShowAdvancedSettings": "高階設定",
"settings.botHideAdvancedSettings": "收起高階設定",
"settings.botAdvancedSettingsHint": "低頻能力:路由、佇列細項和自身訊息過濾。",
"settings.botShowAddBot": "新增頻道",
"settings.botStepConnectTitle": "連接機器人",
"settings.botStepConnectHint": "QQ 手動填密鑰;飛書、Lark、微信掃碼即可接入。",
"settings.botStepConnectEmpty": "還沒有連接",
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「誰可以使用」的名單與配對設定,改一處即可全部生效。",
"settings.botSharedAllowlistGo": "前往第 2 步編輯",
"settings.botGlobalAllowlist": "舊全域白名單",
"settings.botGlobalAllowlistHint": "相容舊配置;新 Bot 建議在各自詳情裡設定誰可以使用。",
"settings.botAccessShowAllPlatforms": "顯示全部平台",
"settings.botAccessShowConnectedOnly": "只看已連接平台",
"settings.botListUsers": "使用者",
"settings.botListGroups": "群組",
"settings.botListApprovers": "審批人",
"settings.botListAdmins": "管理員",
"settings.botPlatformFeishuLark": "飛書 / Lark",
"settings.botQueueDropLabel": "佇列滿時",
"settings.botQueueDropHint": "佇列達到上限後,如何處理放不下的訊息。",
"settings.botPairingDetailHint": "配對開關在第 2 步「誰可以使用」裡;這裡只調整配對碼有效期和待批數量。",
"settings.botSimpleAccessTitle": "誰可以使用",
"settings.botSimpleAccessTrustedSummary": "目前使用受信任名單,已填寫 {count} 條成員或群組。",
"settings.botSimpleAccessEveryoneSummary": "目前允許所有人使用,適合短時間測試。",
"settings.botAccessTrusted": "受信任名單",
"settings.botAccessTrustedHint": "只允許填寫過的使用者或群組呼叫機器人。",
"settings.botAccessEveryone": "所有人可用",
"settings.botAccessEveryoneHint": "臨時測試方便,但不建議長期開啟。",
"settings.botAccessPairing": "私聊先配對",
"settings.botAccessPairingHint": "陌生私聊先發配對碼,本機批准後再放行。",
"settings.botRoleAccess": "管理員與審批人",
"settings.botRoleAccessHint": "可選。普通使用者不用填;需要遠端審批或管理時再配置。",
"settings.botQueueModeSimple": "新訊息怎麼處理",
"settings.botQueueModeSimpleHint": "當機器人還在處理上一條訊息時,選擇下一條訊息的處理方式。",
"settings.saveBotSettings": "儲存機器人設定",
"settings.botEnableBot": "啟用機器人",
"settings.botGatewayMasterSwitch": "總開關",
@@ -1432,8 +1681,48 @@ export const zhTW: Record<DictKey, string> = {
"settings.botModelHint": "留空時使用預設模型。",
"settings.botModelDefault": "使用預設模型",
"settings.botRuntime": "執行限制",
"settings.botRuntimeHint": "閘道器行程級限制,控制每次機器人任務最多走多遠。",
"settings.botMaxSteps": "最大步數",
"settings.botDebounceMs": "合併視窗 ms",
"settings.botQueueCap": "佇列上限",
"settings.botQueueMode.steer": "引導目前回合",
"settings.botQueueMode.followup": "追加跟進",
"settings.botQueueMode.collect": "收集等待",
"settings.botQueueMode.interrupt": "打斷目前",
"settings.botQueueDrop.summarize": "彙總溢出",
"settings.botQueueDrop.old": "丟棄最舊",
"settings.botQueueDrop.new": "丟棄最新",
"settings.botIgnoreSelfMessages": "忽略自身回聲",
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次處理由已配置 Bot 身份發出的訊息。",
"settings.botSelfUserIds": "Bot 自身 ID",
"settings.botSelfUserIdsHint": "可選填寫各平台 Bot 自己的 user/open ID,用於過濾回聲訊息。",
"settings.botPairing": "私聊配對",
"settings.botPairingTTL": "有效分鐘",
"settings.botPairingMaxPending": "待批上限",
"settings.botControlApi": "本機控制 API",
"settings.botControlApiHint": "僅 loopback 的 HTTP API,可查狀態和受控傳送;需要從指定環境變數讀取 Bearer token。",
"settings.botControlAddr": "控制地址",
"settings.botControlTokenEnv": "Token 變數",
"settings.botGatewayDefaults": "閘道器執行設定",
"settings.botGatewayDefaultsHint": "不用編輯 TOML,也能配置排隊、配對和自身訊息過濾。",
"settings.botRoutes": "聊天綁定到專案",
"settings.botRoutesHint": "把某個群組、聯絡人或執行緒固定到指定工作目錄、模型或審批方式。",
"settings.botRoutesEmpty": "還沒有綁定規則。新增一條規則,把某個聊天、使用者或執行緒綁定到專案或模型。",
"settings.botRouteTitle": "路由 {n}",
"settings.botAddRoute": "新增路由",
"settings.botRouteConnection": "來自哪個 Bot",
"settings.botRouteAny": "任意",
"settings.botRoutePlatform": "平台",
"settings.botRouteChatType": "聊天類型",
"settings.botRouteChatType.any": "任意",
"settings.botRouteChatType.dm": "私聊",
"settings.botRouteChatType.group": "群聊",
"settings.botRouteChatType.guild": "頻道",
"settings.botRouteChatType.direct": "Direct",
"settings.botRouteChatType.thread": "Thread",
"settings.botRouteChatId": "群組/聊天 ID",
"settings.botRouteUserId": "聯絡人 ID",
"settings.botRouteThreadId": "執行緒 ID",
"settings.botChannels": "連線頻道",
"settings.botChannelsHint": "選擇頻道;QQ 手動配置,飛書、Lark 和微信掃碼連線。",
"settings.botQQHint": "QQ 官方 Bot API v2。",
@@ -1488,8 +1777,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.botInstallLarkHint": "掃碼建立 Lark PersonalAgent。",
"settings.botInstallWeixinHint": "微信掃碼登入並儲存本地 token。",
"settings.botConnectedBots": "已連線 Bot",
"settings.botConnectedBotsSummary": "{online}/{total} 線上",
"settings.botConnectionsEmpty": "還沒有連線的機器人頻道。",
"settings.botSelectBotHint": "選擇左側 Bot 進入配置。",
"settings.botConnectionColumnChannel": "頻道",
"settings.botConnectionColumnName": "機器人",
"settings.botConnectionColumnRemote": "遠端 ID",
@@ -1507,16 +1795,17 @@ export const zhTW: Record<DictKey, string> = {
"settings.botConnectionConnected": "已連線",
"settings.botConnectionConfigured": "已配置",
"settings.botConnectionDisconnected": "未連線",
"settings.botManage": "高階設定",
"settings.botManage": "修改配置",
"settings.deleteBot": "刪除機器人",
"settings.deleteBotShort": "刪除",
"settings.confirmDeleteBot": "確認刪除",
"settings.deleteBotHint": "從已連線 Bot 中移除這個機器人;不會自動清除已儲存的金鑰。",
"settings.botChannelModel": "模型",
"settings.botChannelModelHint": "留空時使用 Bot 預設模型。",
"settings.botChannelModelAuto": "自動",
"settings.botChannelModelHint": "這個機器人處理訊息時使用的模型。",
"settings.botChannelModelAuto": "使用桌面目前模型",
"settings.botToolApprovalMode": "工具審批",
"settings.botToolApprovalModeHint": "控制該頻道是否在工具執行前請求確認;計劃確認和 ask 問題仍會等待回覆。",
"settings.botToolApprovalMode.inherit": "跟隨預設",
"settings.botToolApprovalMode.inherit": "跟隨機器人",
"settings.botToolApprovalMode.ask": "詢問",
"settings.botToolApprovalMode.auto": "自動",
"settings.botToolApprovalMode.yolo": "YOLO",
@@ -1539,29 +1828,20 @@ export const zhTW: Record<DictKey, string> = {
"settings.botDiagnosticReportFailed": "傳送診斷報告失敗。",
"settings.botDiagnosticPrivacy": "僅包含已脫敏的連線元資訊。",
"settings.botAccessControl": "存取控制",
"settings.botAccessControlHint": "預設使用白名單;臨時測試可以改為允許所有人。",
"settings.botAccessMode": "誰可以使用",
"settings.botAccessAll": "所有人",
"settings.botAccessWhitelist": "僅白名單",
"settings.botEditAllowlist": "編輯白名單",
"settings.botAllowlist": "白名單",
"settings.botAllowlistHint": "限制哪些使用者或群可以呼叫機器人。",
"settings.botAllowlistEnabled": "啟用白名單",
"settings.botAllowAll": "允許所有人",
"settings.botAllowAllWarn": "開啟後所有進入頻道的訊息都可以呼叫機器人。",
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在高階設定裡新增 QQ 使用者/群白名單,或臨時改為允許所有人。",
"settings.botAllowlistEntries": "使用者與群",
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在第 2 步「誰可以使用」裡新增 QQ 使用者/群組,或臨時改為所有人可用。",
"settings.botQQUsers": "QQ 使用者",
"settings.botQQGroups": "QQ群",
"settings.botFeishuUsers": "飛書使用者",
"settings.botFeishuGroups": "飛書群",
"settings.botFeishuLarkUsers": "飛書 / Lark 使用者",
"settings.botFeishuLarkGroups": "飛書 / Lark 群",
"settings.botWeixinUsers": "微信使用者",
"settings.botWeixinGroups": "微信群",
"settings.botListPlaceholder": "每行一個 ID,也可用逗號分隔",
"settings.shellInterpreter": "Shell 直譯器",
"settings.shellAuto": "自動(優先 bash,Windows 無 bash 時用 PowerShell)",
"settings.shellAuto": "自動(bash 優先)",
"settings.shellBash": "bash",
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
"settings.shellPwsh": "pwsh(PowerShell 7+)",
@@ -1711,21 +1991,29 @@ export const zhTW: Record<DictKey, string> = {
"settings.telemetryLabel": "匿名啟動統計",
"settings.telemetryHint": "啟動時傳送隨機安裝 ID、版本號和作業系統用於統計活躍安裝量;若上次執行發生崩潰,則在本次啟動補發該崩潰報告。絕不包含對話、金鑰或檔案內容。",
"settings.metricsLabel": "共享聚合品質指標",
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、更新器錯誤類別、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
"context.windowTitle": "上下文視窗",
"context.windowSubtitle": "當前上下文視窗佔用",
"context.windowStatusHealthy": "上下文充足",
"context.windowStatusWatch": "即將壓縮",
"context.windowStatusPastCompact": "已到壓縮閾值",
"context.windowStatusNearLimit": "接近視窗上限",
"context.windowUsageSummary": "{pct}% 已用",
"context.windowCompactRemaining": "{used} / {window} · 距壓縮還剩 {tokens}",
"context.windowCompactDistance": "距壓縮",
"context.windowUsedLabel": "已用",
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "壓縮閾值",
"context.windowCompactHeadroom": "壓縮前餘量",
"context.sessionMetrics": "會話指標",
"context.runtimeMetrics": "執行指標",
"context.costMetrics": "成本",
"context.sessionStatus": "會話狀態",
"context.healthGoodShort": "正常",
"context.healthNearLimitShort": "接近上限",
"context.healthManyFilesShort": "檔案較多",
"context.healthLowCacheShort": "命中偏低",
"context.sessionTokens": "會話 tokens",
"context.sessionTokens": "會話累計 tokens",
"context.sessionTokensShort": "累計 tokens",
"context.tokensValue": "{value} tokens",
"context.inputCache": "輸入快取",
"context.costBreakdown": "費用來源",
"context.sourceBreakdown": "模型用量明細",
"context.sourceShareTokens": "按 tokens 佔比",
"context.sourceBreakdown": "用量來源",
"context.sourceShareTitle": "來源佔比",
"context.sourceInput": "輸入",
"context.sourceOutput": "輸出",
"context.sourceCacheHit": "命中",
@@ -1741,7 +2029,7 @@ export const zhTW: Record<DictKey, string> = {
"context.sourceClassifier": "計畫分類",
"context.sourceTitle": "標題產生",
"context.sourceRequests": "{count} 次",
"context.compaction": "壓縮狀態",
"context.moreSources": "還有 {count} 個來源",
"crash.send": "傳送報告",
"crash.sending": "傳送中…",
"crash.sent": "已傳送,謝謝!",
+347 -59
View File
@@ -206,6 +206,7 @@ export const zh: Record<DictKey, string> = {
"caps.mcpSummary": "{connected} 个已连接 · {failed} 个异常 · {tools} 个工具",
"caps.connectorsTab": "MCP 服务器",
"caps.skillsTab": "Agent 技能",
"caps.pluginsTab": "插件",
"caps.servers": "MCP 服务器",
"caps.noServers": "未配置 MCP 服务器。用 /mcp add 添加。",
"caps.availableServers": "可用 MCP",
@@ -312,6 +313,62 @@ export const zh: Record<DictKey, string> = {
"caps.trustedReadOnlyTitle": "已记住为之后规划和只读 research 可用的只读工具",
"caps.searchSkills": "搜索技能…",
"caps.noSkillMatches": "没有匹配的技能。",
"caps.pluginRefresh": "刷新插件",
"caps.pluginInstallTitle": "安装插件",
"caps.pluginInstallHint": "选择本地插件目录,或从 Git 仓库安装。",
"caps.pluginInstallMethod": "插件安装方式",
"caps.pluginInstallLocal": "本地目录",
"caps.pluginInstallGit": "Git 仓库",
"caps.pluginSource": "插件来源",
"caps.pluginGitSource": "Git 仓库地址",
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
"caps.pluginChooseLocalFolder": "选择插件目录",
"caps.pluginLocalFolder": "本地插件目录",
"caps.pluginNoLocalFolder": "尚未选择目录",
"caps.pluginInstallName": "安装名称(可选)",
"caps.pluginInstallNamePlaceholder": "安装名称(可选)",
"caps.pluginLink": "开发模式:链接源目录",
"caps.pluginLinkHint": "不复制插件,直接使用所选目录;适合调试插件。移动或删除该目录会导致插件失效。",
"caps.pluginReplace": "覆盖同名插件",
"caps.pluginReplaceHint": "插件名已存在时,允许用当前来源替换它。",
"caps.pluginPreview": "预检",
"caps.pluginInstall": "安装插件",
"caps.noPluginsTitle": "尚未安装插件",
"caps.noPluginsHint": "从 Git 或本地路径安装插件包,把技能、hooks 和 MCP 服务器作为一个统一单元管理。",
"caps.installedPlugins": "已安装插件",
"caps.pluginsSummary": "{enabled} 个启用 · 共 {total} 个 · {issues} 个异常",
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
"caps.pluginNoCapabilities": "未导出能力",
"caps.pluginWarnings": "{count} 个告警",
"caps.pluginEnable": "启用插件",
"caps.pluginDisable": "停用插件",
"caps.pluginEnabled": "已启用",
"caps.pluginDisabled": "已停用",
"caps.pluginVersion": "版本",
"caps.pluginRoot": "安装路径",
"caps.pluginUpdate": "更新",
"caps.pluginDoctor": "诊断",
"caps.pluginRemove": "移除插件",
"caps.pluginConfirmRemove": "确认移除",
"caps.pluginPlanError": "安装检查失败",
"caps.pluginPlanReady": "安装计划",
"caps.pluginAction": "操作",
"caps.pluginPlanStatus": "插件操作:{status}",
"caps.pluginPlanComplete": "插件操作完成",
"caps.pluginPlanInstalled": "插件已安装。展开下方插件,可查看它导出的 skills、hooks 和 MCP 服务器的使用方式。",
"caps.pluginUsageTitle": "使用方法",
"caps.pluginUsageEnabledHint": "启用的插件会加载到新会话中。可用 /skills 查看技能、用 /<skill> 直接调用,或自然描述任务;hooks 和 MCP 服务器会按正常会话流程自动参与。",
"caps.pluginUsageDisabledHint": "启用此插件后,它的 skills、hooks 或 MCP 服务器才会参与会话。",
"caps.pluginNoCapabilityDetails": "此插件暂无可展示的能力明细。",
"caps.pluginSkillsTitle": "Skills",
"caps.pluginSkillsHint": "用 /skills 浏览,直接调用,或在对话中说出工作流名称。",
"caps.pluginHooksTitle": "Hooks",
"caps.pluginHooksHint": "Hooks 会在匹配的生命周期事件中自动触发。",
"caps.pluginMCPTitle": "MCP 服务器",
"caps.pluginMCPHint": "这些服务器提供的工具会通过正常 MCP/工具流程可用。",
"caps.pluginNoDescription": "无描述",
"caps.pluginHookNoTarget": "未指定 hook 目标",
"caps.pluginMCPNoTarget": "未指定启动目标",
"caps.sources": "来源",
"caps.sourcesSummaryActive": "{active} 个有效",
"caps.sourcesSummaryMissing": "{missing} 个缺失",
@@ -412,6 +469,17 @@ export const zh: Record<DictKey, string> = {
"composer.removeCurrentProjectDisabled": "不能移除当前项目",
"composer.resize": "拖动调整输入区高度,双击重置",
"composer.send": "发送(Enter)",
"composer.queueGuidance": "加入引导队列(Enter)",
"composer.steer": "追加到当前任务(Enter)",
"composer.steerPlaceholder": "输入要加入队列的引导…",
"composer.guidanceQueue": "待处理引导",
"composer.guidanceCount": "待处理引导 {n}",
"composer.guidanceRemaining": "还有 {n} 条",
"composer.guidanceCollapse": "收起",
"composer.guidanceMode": "引导",
"composer.guidanceSend": "将这条引导加入信息流",
"composer.guidanceDismiss": "移除这条引导提示",
"composer.guidanceMore": "更多引导选项",
"composer.shellMode": "Shell 模式:给输入前加 !",
"composer.shellModeOn": "Shell 模式已开:点击移除 !",
"composer.stop": "停止(Esc)",
@@ -422,6 +490,9 @@ export const zh: Record<DictKey, string> = {
"composer.pastedExpand": "展开",
"composer.pastedRemove": "删除粘贴文本",
"composer.removeImage": "移除图片",
"imageViewer.clickToPreview": "点击查看大图",
"imageViewer.closePreview": "关闭预览",
"imageViewer.title": "图片预览",
"composer.attachImageFailed": "图片粘贴失败",
"composer.attachFileFailed": "文件附加失败",
"composer.attachDropFailed": "拖放文件附加失败",
@@ -546,6 +617,11 @@ export const zh: Record<DictKey, string> = {
"heartbeat.approvalModeAskHint": "每次工具调用都需要手动确认",
"heartbeat.approvalModeAutoHint": "自动批准安全工具,保留拒绝规则",
"heartbeat.approvalModeYoloHint": "跳过所有工具权限提示,任务自动运行",
"heartbeat.notifyChannels": "推送到机器人",
"heartbeat.notifyChannelsOn": "开启",
"heartbeat.notifyChannelsOff": "关闭",
"heartbeat.notifyChannelsOnHint": "AI 输出会实时推送到已连接的飞书/QQ 机器人",
"heartbeat.notifyChannelsOffHint": "只创建桌面对话,不推送到机器人",
"heartbeat.fieldNewConversation": "对话模式",
"heartbeat.newConversationEachRunOn": "每次执行新建对话",
"heartbeat.newConversationEachRunOff": "复用同一对话",
@@ -590,6 +666,7 @@ export const zh: Record<DictKey, string> = {
"ask.hideDetails": "收起",
"ask.back": "返回",
"ask.next": "下一步",
"ask.customAnswer": "其它答案",
"ask.customPlaceholder": "输入你自己的答案…",
"ask.justChat": "只是聊聊",
@@ -655,6 +732,18 @@ export const zh: Record<DictKey, string> = {
"history.historySessionActions": "历史会话操作",
"history.trashActions": "回收站操作",
// 会话恢复
"recovery.branch": "恢复分支",
"recovery.badge": "恢复",
"recovery.open": "打开恢复分支",
"recovery.toast": "未保存的本地内容已保存为 {title}。",
"recovery.failedLease": "恢复分支被另一个运行时占用,未切换过去。",
"recovery.failedUnavailable": "无法打开恢复分支,当前标签仍停留在原会话。",
"recovery.banner": "当前会话来自保存冲突恢复。继续前建议与原会话对比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父会话:{parent}",
// 项目树
"projectTree.workspaceTitle": "项目",
"projectTree.pinnedTitle": "置顶",
@@ -829,6 +918,7 @@ export const zh: Record<DictKey, string> = {
"settings.tab.capabilities": "MCP 与技能",
"settings.tab.mcp": "MCP 与工具",
"settings.tab.skills": "技能",
"settings.tab.plugins": "插件",
"settings.tab.memory": "记忆",
"settings.tab.hooks": "Hooks",
"settings.tab.shortcuts": "快捷键",
@@ -844,6 +934,7 @@ export const zh: Record<DictKey, string> = {
"settings.tabSub.capabilities": "服务器、工具与技能",
"settings.tabSub.mcp": "服务器与工具",
"settings.tabSub.skills": "来源与启用",
"settings.tabSub.plugins": "包与诊断",
"settings.tabSub.memory": "项目与全局",
"settings.tabSub.hooks": "Shell 自动化",
"settings.tabSub.shortcuts": "按键与帮助",
@@ -925,6 +1016,10 @@ export const zh: Record<DictKey, string> = {
"settings.subagentEffort": "子代理 effort",
"settings.subagentEffortDefault": "auto(模型服务默认)",
"settings.subagentHint": "作用于 task 和 runAs=subagent skills;工具调用参数或单个 skill 配置会覆盖它。",
"settings.subagentDepth": "子代理嵌套深度",
"settings.subagentDepthHint": "控制第一层子代理能否再派发一层 reviewer 或 worker 子代理。",
"settings.subagentDepthOne": "1 层",
"settings.subagentDepthTwo": "2 层",
"settings.defaultToolApprovalMode": "新会话默认审批",
"settings.defaultToolApprovalModeHint": "保存在全局设置中,仅用于新建会话;切换或恢复已有会话时不会覆盖其 Ask / Auto / Yolo 状态。",
"settings.defaultToolApprovalMode.ask": "询问",
@@ -947,7 +1042,7 @@ export const zh: Record<DictKey, string> = {
"settings.coldResumePrune.on": "开",
"settings.coldResumePrune.off": "关",
"settings.reasoningLanguage": "思考语言",
"settings.reasoningLanguageHint": "只影响可见思考过程。自动跟随对话语言,不额外注入语言指令。",
"settings.reasoningLanguageHint": "只影响可见思考过程。自动在原始提问明显为中文时锚定中文,引用内容不参与判断。",
"settings.reasoningLanguage.auto": "自动",
"settings.reasoningLanguage.zh": "中文",
"settings.reasoningLanguage.en": "English",
@@ -1008,7 +1103,7 @@ export const zh: Record<DictKey, string> = {
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
"settings.providerModelsUrlHint": "可选。模型发现需要使用不同于聊天请求的地址时填写。",
"settings.providerModels": "模型(逗号分隔)",
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
"settings.providerApiKeyEnv": "api_key_env(可以不设置)",
"settings.providerBalanceUrl": "余额查询 URL(可选)",
"settings.balanceUrlPlaceholder": "余额查询接口(如 https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "可选。填了就会查询并在状态栏显示钱包余额。",
@@ -1018,12 +1113,22 @@ export const zh: Record<DictKey, string> = {
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto(按模型和端点自动判断)",
"settings.providerReasoningProtocolHint": "用于 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 参数,openai 使用普通 reasoning_effort,none 表示禁用。",
"settings.effortBehavior": "effort 行为",
"settings.effortUseProtocolDefaults": "协议默认",
"settings.effortUseCustom": "自定义覆盖",
"settings.effortProtocolDefault.auto": "协议默认会把 supported_efforts 保存为空。Reasonix 会把已知 DeepSeek 模型/端点识别为 high|max,把 OpenAI reasoning 识别为 low|medium|high,不支持的服务不暴露 /effort。",
"settings.effortProtocolDefault.deepseek": "DeepSeek 默认暴露 auto|high|max。Reasonix 会保持 thinking 开启,并用 reasoning_effort 控制深度。",
"settings.effortProtocolDefault.openai": "OpenAI reasoning 默认暴露 auto|low|medium|high,并发送普通 reasoning_effort。",
"settings.effortProtocolDefault.minimax": "MiniMax 风格端点使用 adaptive|disabled 这类服务商自定义 thinking 等级。只有端点文档明确支持时才添加自定义等级。",
"settings.effortProtocolDefault.none": "该供应商已禁用 effort;Reasonix 不会发送 reasoning_effort 或 thinking 控制参数。",
"settings.supportedEfforts": "支持的 effort 等级",
"settings.supportedEffortsCustomPlaceholder": "自定义等级(按回车)",
"settings.supportedEffortsHint": "勾选该模型服务在 /effort 命令中暴露的等级,或添加自定义名称。留空则使用内置默认(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
"settings.supportedEffortsNoPresets": "没有标准预设",
"settings.supportedEffortsNoPresetsHint": "仅在端点文档明确给出 effort 名称时添加自定义等级。",
"settings.supportedEffortsHint": "自定义等级会覆盖协议默认。只选择端点文档支持的等级;不兼容组合可能导致模型请求失败。",
"settings.defaultEffort": "默认 effort",
"settings.defaultEffortAuto": "auto(使用内置默认)",
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。必须是上方等级之一;为空时回退到第一个等级(无选中时回退到内置默认)。",
"settings.defaultEffortAuto": "未设置(使用协议/自定义默认)",
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。留空则使用协议默认;自定义等级时回退到第一个自定义等级。",
"settings.proxyMode": "代理模式",
"settings.proxyMode.auto": "自动",
"settings.proxyMode.env": "环境变量",
@@ -1044,11 +1149,76 @@ export const zh: Record<DictKey, string> = {
"settings.toggleOff": "关闭",
"settings.botSetup": "机器人入口",
"settings.botSetupHint": "先开启入口,再选择要连接的聊天渠道。",
"settings.botConnectPhoneTitle": "添加 IM Bot",
"settings.botConnectPhoneSubtitle": "QQ 手动配置,飞书、Lark 和微信扫码接入。",
"settings.botGateway": "机器人网关",
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关、模型和运行参数。",
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关和连接状态。",
"settings.botBrowserPreviewWarning": "当前是浏览器预览,只会保存 mock 设置,不会写入本机配置或启动 QQ/IM Bot gateway。请在桌面应用或 CLI 中配置后再到 QQ 客户端测试。",
"settings.botSimpleEyebrow": "傻瓜式配置",
"settings.botSimpleTitle": "移动端与聊天机器人",
"settings.botSimpleHint": "先用手机扫码远程控制当前工作区;需要长期从聊天工具访问时,再选择一个 Bot Channel 配置。",
"settings.botMobileRemoteEyebrow": "独立入口",
"settings.botMobileRemoteTitle": "手机远程控制",
"settings.botMobileRemoteHint": "扫码或复制链接,从移动端打开当前工作区的控制入口;它和 IM Bot 渠道管理分开配置。",
"settings.botMobileRemoteControlApi": "控制链接",
"settings.botMobileRemoteDisabled": "开启后生成二维码和链接",
"settings.botMobileRemoteEnableFirst": "开启后生成二维码",
"settings.botMobileRemoteReady": "等待手机连接",
"settings.botMobileRemoteWaiting": "手机扫码连接",
"settings.botMobileRemoteLoopbackNotice": "当前控制 API 仍是本机 loopback,只适合本机预览;跨设备 relay 接入后会沿用这个入口。",
"settings.botMobileRemoteCopyLink": "复制链接",
"settings.botMobileRemoteCopied": "已复制",
"settings.botMobileRemoteCopyFailed": "复制失败",
"settings.botChannelEntryTitle": "使用 Bot Channel",
"settings.botChannelEntryHint": "连接聊天 Bot,适合更长时间的移动端访问。",
"settings.botManageBots": "机器人管理",
"settings.botManageBotsHint": "左侧选择渠道;未配置时扫码或填写密钥,配置后在右侧管理这个机器人的运行设置。",
"settings.botBackToOverview": "返回入口",
"settings.botChannelOpenConfigured": "已配置,可继续修改设置。",
"settings.botChannelNeedsSetup": "未配置",
"settings.botChannelTabsLabel": "机器人渠道",
"settings.botChannelSetupTitle": "配置 {provider}",
"settings.botChannelSetupHint": "未配置前只需要完成凭据或扫码绑定;成功后会自动切换为具体设置。",
"settings.botSetupStatus": "机器人设置状态",
"settings.botSetupStatusConnected": "{online}/{total} 在线",
"settings.botSetupStatusAccessTrusted": "受信任名单 {count}",
"settings.botSetupStatusAccessEveryone": "所有人可用",
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 个 Bot 已配置",
"settings.botMode": "机器人设置模式",
"settings.botModeSimple": "普通",
"settings.botModeAdvanced": "高级",
"settings.botShowAdvancedSettings": "高级设置",
"settings.botHideAdvancedSettings": "收起高级设置",
"settings.botAdvancedSettingsHint": "低频能力:路由、队列细项和自身消息过滤。",
"settings.botShowAddBot": "新增渠道",
"settings.botStepConnectTitle": "连接机器人",
"settings.botStepConnectHint": "QQ 手动填密钥;飞书、Lark、微信扫码即可接入。",
"settings.botStepConnectEmpty": "还没有连接",
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「谁可以使用」的名单与配对设置,改一处即可全部生效。",
"settings.botSharedAllowlistGo": "前往第 2 步编辑",
"settings.botGlobalAllowlist": "旧全局白名单",
"settings.botGlobalAllowlistHint": "兼容旧配置;新 Bot 推荐在各自详情里设置谁可以使用。",
"settings.botAccessShowAllPlatforms": "显示全部平台",
"settings.botAccessShowConnectedOnly": "只看已连接平台",
"settings.botListUsers": "用户",
"settings.botListGroups": "群",
"settings.botListApprovers": "审批人",
"settings.botListAdmins": "管理员",
"settings.botPlatformFeishuLark": "飞书 / Lark",
"settings.botQueueDropLabel": "队列满时",
"settings.botQueueDropHint": "队列达到上限后,如何处理放不下的消息。",
"settings.botPairingDetailHint": "配对开关在第 2 步「谁可以使用」里;这里只调整配对码有效期和待批数量。",
"settings.botSimpleAccessTitle": "谁可以使用",
"settings.botSimpleAccessTrustedSummary": "当前使用受信任名单,已填写 {count} 条成员或群。",
"settings.botSimpleAccessEveryoneSummary": "当前允许所有人使用,适合短时间测试。",
"settings.botAccessTrusted": "受信任名单",
"settings.botAccessTrustedHint": "只允许填写过的用户或群调用机器人。",
"settings.botAccessEveryone": "所有人可用",
"settings.botAccessEveryoneHint": "临时测试方便,但不建议长期打开。",
"settings.botAccessPairing": "私聊先配对",
"settings.botAccessPairingHint": "陌生私聊先发配对码,本机批准后再放行。",
"settings.botRoleAccess": "管理员与审批人",
"settings.botRoleAccessHint": "可选。普通用户不用填;需要远程审批或管理时再配置。",
"settings.botQueueModeSimple": "新消息怎么处理",
"settings.botQueueModeSimpleHint": "当机器人还在处理上一条消息时,选择下一条消息的处理方式。",
"settings.saveBotSettings": "保存机器人设置",
"settings.botEnableBot": "启用机器人",
"settings.botGatewayMasterSwitch": "总开关",
@@ -1059,8 +1229,48 @@ export const zh: Record<DictKey, string> = {
"settings.botModelHint": "留空时使用默认模型。",
"settings.botModelDefault": "使用默认模型",
"settings.botRuntime": "运行限制",
"settings.botRuntimeHint": "网关进程级限制,控制每次机器人任务最多走多远。",
"settings.botMaxSteps": "最大步数",
"settings.botDebounceMs": "合并窗口 ms",
"settings.botQueueCap": "队列上限",
"settings.botQueueMode.steer": "引导当前回合",
"settings.botQueueMode.followup": "追加跟进",
"settings.botQueueMode.collect": "收集等待",
"settings.botQueueMode.interrupt": "打断当前",
"settings.botQueueDrop.summarize": "汇总溢出",
"settings.botQueueDrop.old": "丢弃最旧",
"settings.botQueueDrop.new": "丢弃最新",
"settings.botIgnoreSelfMessages": "忽略自身回声",
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次处理由已配置 Bot 身份发出的消息。",
"settings.botSelfUserIds": "Bot 自身 ID",
"settings.botSelfUserIdsHint": "可选填写各平台 Bot 自己的 user/open ID,用于过滤回声消息。",
"settings.botPairing": "私聊配对",
"settings.botPairingTTL": "有效分钟",
"settings.botPairingMaxPending": "待批上限",
"settings.botControlApi": "本机控制 API",
"settings.botControlApiHint": "仅 loopback 的 HTTP API,可查状态和受控发送;需要从指定环境变量读取 Bearer token。",
"settings.botControlAddr": "控制地址",
"settings.botControlTokenEnv": "Token 变量",
"settings.botGatewayDefaults": "网关运行设置",
"settings.botGatewayDefaultsHint": "不用编辑 TOML,也能配置排队、配对和自身消息过滤。",
"settings.botRoutes": "聊天绑定到项目",
"settings.botRoutesHint": "把某个群、联系人或线程固定到指定工作目录、模型或审批方式。",
"settings.botRoutesEmpty": "还没有绑定规则。添加一条规则,把某个聊天、用户或线程绑定到项目或模型。",
"settings.botRouteTitle": "路由 {n}",
"settings.botAddRoute": "添加路由",
"settings.botRouteConnection": "来自哪个 Bot",
"settings.botRouteAny": "任意",
"settings.botRoutePlatform": "平台",
"settings.botRouteChatType": "聊天类型",
"settings.botRouteChatType.any": "任意",
"settings.botRouteChatType.dm": "私聊",
"settings.botRouteChatType.group": "群聊",
"settings.botRouteChatType.guild": "频道",
"settings.botRouteChatType.direct": "Direct",
"settings.botRouteChatType.thread": "Thread",
"settings.botRouteChatId": "群/聊天 ID",
"settings.botRouteUserId": "联系人 ID",
"settings.botRouteThreadId": "线程 ID",
"settings.botChannels": "连接渠道",
"settings.botChannelsHint": "选择渠道;QQ 手动配置,飞书、Lark 和微信扫码连接。",
"settings.botQQHint": "QQ 官方 Bot API v2。",
@@ -1115,8 +1325,7 @@ export const zh: Record<DictKey, string> = {
"settings.botInstallLarkHint": "扫码创建 Lark PersonalAgent。",
"settings.botInstallWeixinHint": "微信扫码登录并保存本地 token。",
"settings.botConnectedBots": "已连接 Bot",
"settings.botConnectedBotsSummary": "{online}/{total} 在线",
"settings.botConnectionsEmpty": "还没有连接的机器人渠道。",
"settings.botSelectBotHint": "选择左侧 Bot 进入配置。",
"settings.botConnectionColumnChannel": "渠道",
"settings.botConnectionColumnName": "机器人",
"settings.botConnectionColumnRemote": "远端 ID",
@@ -1134,16 +1343,17 @@ export const zh: Record<DictKey, string> = {
"settings.botConnectionConnected": "已连接",
"settings.botConnectionConfigured": "已配置",
"settings.botConnectionDisconnected": "未连接",
"settings.botManage": "高级设置",
"settings.botManage": "修改配置",
"settings.deleteBot": "删除机器人",
"settings.deleteBotShort": "删除",
"settings.confirmDeleteBot": "确认删除",
"settings.deleteBotHint": "从已连接 Bot 中移除这个机器人;不会自动清除已保存的密钥。",
"settings.botChannelModel": "模型",
"settings.botChannelModelHint": "留空时使用 Bot 默认模型。",
"settings.botChannelModelAuto": "自动",
"settings.botChannelModelHint": "这个机器人处理消息时使用的模型。",
"settings.botChannelModelAuto": "使用桌面当前模型",
"settings.botToolApprovalMode": "工具审批",
"settings.botToolApprovalModeHint": "控制该渠道是否在工具执行前请求确认;计划确认和 ask 问题仍会等待回复。",
"settings.botToolApprovalMode.inherit": "跟随默认",
"settings.botToolApprovalMode.inherit": "跟随机器人",
"settings.botToolApprovalMode.ask": "询问",
"settings.botToolApprovalMode.auto": "自动",
"settings.botToolApprovalMode.yolo": "YOLO",
@@ -1166,26 +1376,17 @@ export const zh: Record<DictKey, string> = {
"settings.botDiagnosticReportFailed": "发送诊断报告失败。",
"settings.botDiagnosticPrivacy": "仅包含已脱敏的连接元信息。",
"settings.botAccessControl": "访问控制",
"settings.botAccessControlHint": "默认使用白名单;临时测试可以改为允许所有人。",
"settings.botAccessMode": "谁可以使用",
"settings.botAccessAll": "所有人",
"settings.botAccessWhitelist": "仅白名单",
"settings.botEditAllowlist": "编辑白名单",
"settings.botAllowlist": "白名单",
"settings.botAllowlistHint": "限制哪些用户或群可以调用机器人。",
"settings.botAllowlistEnabled": "启用白名单",
"settings.botAllowAll": "允许所有人",
"settings.botAllowAllWarn": "开启后所有进入渠道的消息都可以调用机器人。",
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在高级设置里添加 QQ 用户/群白名单,或临时改为允许所有人。",
"settings.botAllowlistEntries": "用户与群",
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在第 2 步「谁可以使用」里添加 QQ 用户/群,或临时改为所有人可用。",
"settings.botQQUsers": "QQ 用户",
"settings.botQQGroups": "QQ群",
"settings.botFeishuUsers": "飞书用户",
"settings.botFeishuGroups": "飞书群",
"settings.botFeishuLarkUsers": "飞书 / Lark 用户",
"settings.botFeishuLarkGroups": "飞书 / Lark 群",
"settings.botWeixinUsers": "微信用户",
"settings.botWeixinGroups": "微信群",
"settings.botListPlaceholder": "每行一个 ID,也可用逗号分隔",
"settings.permissions": "权限",
"settings.writerMode": "写操作模式",
@@ -1197,8 +1398,11 @@ export const zh: Record<DictKey, string> = {
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
"settings.addRule": "添加 {list} 规则…",
"settings.sandboxTitle": "沙箱与工作区",
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。mvn deploy 这类命令还需要权限规则或审批模式放行。",
"settings.reloadSessionConfig": "应用手动配置修改",
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
"settings.shellInterpreter": "Shell 解释器",
"settings.shellAuto": "自动(优先 bash,Windows 无 bash 时用 PowerShell)",
"settings.shellAuto": "自动(bash 优先)",
"settings.shellBash": "bash",
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
"settings.shellPwsh": "pwsh(PowerShell 7+)",
@@ -1208,6 +1412,9 @@ export const zh: Record<DictKey, string> = {
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
"settings.workspaceRoot": "工作区根目录",
"settings.workspaceDefault": "(默认:当前目录)",
"settings.effectiveWriteRoots": "实际可写根",
"settings.effectiveWriteRootsHint": "按当前工作区展开。设置页内修改会自动生效;手动编辑配置文件后可用上方按钮刷新当前会话。",
"settings.noEffectiveWriteRoots": "暂无可写根",
"settings.appearance": "外观",
"settings.theme": "主题",
"settings.themeStyle": "视觉风格",
@@ -1315,6 +1522,7 @@ export const zh: Record<DictKey, string> = {
"settings.pageDesc.bots": "配置 QQ、飞书、Lark 和微信机器人渠道,管理每个 Bot 的模型和运行参数。",
"settings.pageDesc.mcp": "管理 MCP 服务器连接和工具发现。",
"settings.pageDesc.skills": "浏览、启用和管理智能体技能与来源。",
"settings.pageDesc.plugins": "安装和管理插件包,用统一单元提供技能、hooks 与 MCP 服务器。",
"settings.pageDesc.memory": "管理 Reasonix 会长期参考的偏好、项目约定和指令文件。",
"settings.pageDesc.hooks": "配置围绕对话、工具调用、压缩和会话生命周期执行的 shell hooks。",
"settings.pageDesc.shortcuts": "自定义键盘快捷键,并让帮助表保持同步。",
@@ -1329,7 +1537,7 @@ export const zh: Record<DictKey, string> = {
"settings.customProviders": "自定义供应商",
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
"settings.providerAccessEmptyHint": "点击“添加供应商”,选择 DeepSeek 或自定义 OpenAI-compatible 接入。",
"settings.providerAccessEmptyHint": "使用“推荐预设”可快速接入常用供应商,通常只需填写 API Key;使用“自定义供应商”可手动配置 OpenAI-compatible 或 Anthropic-compatible 端点。",
"settings.builtinProviderBadge": "内置",
"settings.customProviderBadge": "自定义",
"settings.configureProvider": "配置",
@@ -1341,24 +1549,75 @@ export const zh: Record<DictKey, string> = {
"settings.searchModels": "搜索模型...",
"settings.noMatchingModels": "没有匹配的模型",
"settings.addProvider.chooseTitle": "添加供应商",
"settings.addProvider.chooseHint": "选择官方预设,或添加一个自定义 OpenAI-compatible 接入。",
"settings.addProvider.officialChoice": "官方供应商",
"settings.addProvider.officialChoiceHint": "从 Reasonix 预设的 DeepSeek 接入中选择。",
"settings.addProvider.chooseHint": "选择推荐预设,或手动添加一个自定义供应商。",
"settings.addProvider.officialChoice": "推荐预设",
"settings.addProvider.officialChoiceHint": "选择 DeepSeek 或已适配的自定义供应商预设。",
"settings.addProvider.customChoice": "自定义供应商",
"settings.addProvider.customChoiceHint": "按 base_url、模型、密钥环境变量等参数手动配置。",
"settings.addProvider.officialTitle": "添加官方供应商",
"settings.addProvider.officialHint": "官方预设会写入固定端点和默认参数,只需要选择供应商并配置密钥。",
"settings.addProvider.officialHint": "推荐预设会填好端点、模型、视觉和推理默认值,通常只需要填 API Key。",
"settings.addProvider.customTitle": "添加自定义供应商",
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直连 API,适合 v2.5 按量付费模型。",
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直连 Anthropic-compatible 端点,自动使用 MiMo v2.5 模型。",
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中国区端点。",
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中国区 Anthropic-compatible 端点。",
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端点。",
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端点。",
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端点。",
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端点。",
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 国内 OpenAI-compatible M 系列 API 端点。",
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 国际 OpenAI-compatible M 系列 API 端点。",
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 国内 Anthropic-compatible M 系列端点。",
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 国际 Anthropic-compatible 端点,自动使用 Bearer 认证。",
"settings.addProvider.preset.glmCnDesc": "智谱 GLM 国内 API,预置 GLM thinking 控制。",
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 国际 GLM API。",
"settings.addProvider.preset.glmCodingPlanCnDesc": "智谱 GLM 国内 coding plan 端点,默认 GLM-5.2。",
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智谱 GLM 国内 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 国际 coding plan 端点,默认 GLM-5.2。",
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 国际 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型网关,内置每模型能力覆盖。",
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 线路,预置 Qwen 与 MiniMax 模型。",
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 线路,预置 Claude 与 Qwen 模型。",
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 国内标准 API 端点。",
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 国际标准 API 端点。",
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 国内 OpenAI-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 国内 Anthropic-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 国际 OpenAI-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 国际 Anthropic-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.stepfunDesc": "阶跃星辰 StepFun coding plan OpenAI-compatible 端点,默认 step-3.7-flash。",
"settings.addProvider.preset.stepfunAnthropicDesc": "阶跃星辰 StepFun coding plan Anthropic-compatible 端点,默认 step-3.7-flash。",
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型网关,预置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直连网关,预置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,预置 Qwen 与 DeepSeek 模型。",
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端点,预置 Nemotron 模型。",
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 网关,预置 kilo/auto 自动路由模型。",
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 托管端点,支持 none 到 max 的 reasoning effort。",
"settings.addProvider.addedBadge": "已添加",
"settings.addProvider.modifiedBadge": "已修改",
"settings.addProvider.nameConflictBadge": "同名占用",
"settings.addProvider.similarExistingBadge": "有相似配置",
"settings.addProvider.confirm": "添加供应商",
"settings.addProvider.alreadyAddedAction": "已添加",
"settings.addProvider.nameConflictAction": "先处理同名配置",
"settings.addProvider.viewPresetProvider": "查看配置",
"settings.addProvider.viewConflictProvider": "查看同名配置",
"settings.addProvider.resetPreset": "重置为预设",
"settings.addProvider.confirmResetPreset": "确认重置",
"settings.customProviderName": "自定义供应商名称",
"settings.customProviderNamePlaceholder": "",
"settings.providerNamePlaceholder": "供应商名称(如 deepseek-proxy)",
"settings.providerProtocol": "接入协议",
"settings.providerProtocolOpenAI": "OpenAI-compatible",
"settings.providerProtocolOpenAIHint": "适用于兼容 OpenAI API 的代理、聚合平台和自建服务。",
"settings.providerProtocolAnthropic": "Anthropic-compatible",
"settings.providerProtocolAnthropicHint": "适用于 Claude Messages API 风格的服务,例如部分 coding plan 端点。",
"settings.providerBaseUrlLabel": "API 地址",
"settings.providerChatUrlLabel": "完整请求 URL",
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
@@ -1372,12 +1631,29 @@ export const zh: Record<DictKey, string> = {
"settings.testFetchModels": "测试并获取模型",
"settings.testFetchModelsHint": "会使用上方 API 地址与密钥确认连接,并用返回结果填充模型列表。",
"settings.manualModels": "模型列表",
"settings.manualModelsHint": "接口不支持模型发现时,可手动填写多个模型,用英文逗号分隔。",
"settings.manualModelsHint": "接口不支持模型发现时,可手动填写多个模型;下方可勾选哪些模型支持图片输入。",
"settings.visionModels": "支持图片输入的模型",
"settings.visionModelsHint": "启用模型中可接收图片输入的子集,用英文逗号分隔。",
"settings.providerAdvancedSettings": "高级设置",
"settings.providerAdvancedSettings": "兼容设置(通常不用改)",
"settings.providerAdvancedExpandHint": "点击展开详细配置",
"settings.providerAdvancedCollapseHint": "点击收起",
"settings.providerHeaders": "额外请求头(可选)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "用于 OpenRouter 等要求站点来源的中转站。一行一个 Header: value;API Key 仍通过上方密钥保存。",
"settings.providerExtraBody": "额外请求体(可选)",
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
"settings.fetchingModels": "刷新中…",
"settings.fetchModelsSuccess": "已获取 {n} 个模型",
"settings.fetchModelsManualFallbackAuth": "模型发现接口拒绝了密钥或权限。请检查 API Key;如果该服务不开放模型发现,也可以在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackUnsupported": "这个服务可能不支持自动获取模型列表。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackNetwork": "暂时无法连接模型发现接口。请检查 API 地址或代理;如果你知道模型 ID,也可以先手动填写。",
"settings.fetchModelsManualFallbackDecode": "模型发现接口返回了无法识别的格式。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackEmpty": "没有自动获取到模型。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackGeneric": "无法自动获取模型:{err}。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsUpdatedForProvider": "已更新可用模型:{provider},共 {n} 个",
"settings.fetchModelsReadyForProvider": "已发现 {provider} 的 {n} 个模型,请确认后保存启用列表。",
"settings.enabledModelsSavedForProvider": "已保存 {provider} 的 {n} 个启用模型",
@@ -1387,12 +1663,18 @@ export const zh: Record<DictKey, string> = {
"settings.fetchModelsAfterKeyFailed": "密钥已保存,但暂时无法自动刷新可用模型:{err}",
"settings.fetchModelsAfterKeyFailedForProvider": "密钥已保存,但暂时无法刷新 {provider} 的可用模型:{err}",
"settings.newProviderDraftName": "新供应商",
"settings.reasoningProtocol": "推理协议",
"settings.reasoningProtocol.auto": "Auto(按模型自动判断)",
"settings.reasoningProtocol.deepseek": "DeepSeek",
"settings.reasoningProtocol.openai": "OpenAI",
"settings.reasoningProtocol.none": "None(关闭)",
"settings.reasoningProtocolHint": "控制 effort 的请求格式。Auto 会按模型能力表和官方端点识别;代理不兼容时可切到 OpenAI 或 None。",
"settings.reasoningProtocol": "模型能力模式",
"settings.reasoningProtocol.auto": "自动识别(推荐)",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
"settings.reasoningProtocol.none": "普通聊天(不发送思考参数)",
"settings.reasoningProtocolHint": "默认自动识别。只有代理不兼容,或你确定模型需要特定 reasoning 格式时再切换。",
"settings.thinkingMode": "Thinking 覆盖",
"settings.thinkingMode.auto": "Auto(使用服务默认)",
"settings.thinkingMode.enabled": "Enabled(开启)",
"settings.thinkingMode.disabled": "Disabled(关闭)",
"settings.thinkingMode.adaptive": "Adaptive(自适应)",
"settings.thinkingModeHint": "服务商专用覆盖项。兼容网关可用 enabled/disabled;只有服务文档明确支持时才使用 adaptive。",
"settings.updateKey": "更新 {env}(全局保存)",
"settings.updateKeyAction": "更新密钥",
"settings.keyStatus": "密钥",
@@ -1507,6 +1789,8 @@ export const zh: Record<DictKey, string> = {
"msg.imageAttachment": "图片",
"msg.workspaceReference": "工作区引用",
"msg.folderReference": "文件夹 · 工作区引用",
"msg.pastedExpandTooltip": "展开粘贴文本",
"msg.pastedCollapseTooltip": "收起粘贴文本",
"turnActions.summary": "总结",
"turnActions.rewind": "回溯",
"transcript.jumpToBottom": "跳到底部",
@@ -1588,7 +1872,7 @@ export const zh: Record<DictKey, string> = {
"settings.telemetryLabel": "匿名启动统计",
"settings.telemetryHint": "启动时发送随机安装 ID、版本号和操作系统用于统计活跃安装量;若上次运行发生崩溃,则在本次启动补发该崩溃报告。绝不包含对话、密钥或文件内容。",
"settings.metricsLabel": "共享聚合质量指标",
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、更新器错误类别、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
"updater.currentVersion": "当前版本:{v}",
"updater.channelLabel": "更新渠道:{channel}",
"updater.checkButton": "检查更新",
@@ -1631,10 +1915,20 @@ export const zh: Record<DictKey, string> = {
"context.scopeGlobal": "范围:全局",
"context.scopeProject": "所属项目 · {name}",
"context.windowTitle": "上下文窗口",
"context.windowSubtitle": "当前上下文窗口占用",
"context.windowStatusHealthy": "上下文充足",
"context.windowStatusWatch": "即将压缩",
"context.windowStatusPastCompact": "已到压缩阈值",
"context.windowStatusNearLimit": "接近窗口上限",
"context.windowUsageSummary": "{pct}% 已用",
"context.windowCompactRemaining": "{used} / {window} · 距压缩还剩 {tokens}",
"context.windowCompactDistance": "距压缩",
"context.windowUsedLabel": "已用",
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "压缩阈值",
"context.windowCompactHeadroom": "压缩前余量",
"context.sessionMetrics": "会话指标",
"context.runtimeMetrics": "运行指标",
"context.costMetrics": "成本",
"context.sessionStatus": "会话状态",
"context.sessionChanges": "本会话变更",
"context.referencedFiles": "依赖文件",
"context.noChanges": "当前会话还没有变更文件",
@@ -1642,34 +1936,28 @@ export const zh: Record<DictKey, string> = {
"context.changedMeta": "{count} 条变更记录",
"context.readMeta": "{count} 条读取记录",
"context.viewAll": "查看全部",
"context.health": "上下文状态",
"context.healthGood": "上下文状态正常",
"context.healthGoodShort": "正常",
"context.healthGoodBody": "用量、依据文件和本会话变更保持可追踪。",
"context.healthNearLimit": "上下文接近上限",
"context.healthNearLimitShort": "接近上限",
"context.healthNearLimitBody": "已使用 {pct}%,优先保留关键依据并考虑压缩。",
"context.healthManyFiles": "依据文件较多",
"context.healthManyFilesShort": "文件较多",
"context.healthManyFilesBody": "已读取 {count} 条文件记录,可查看依据文件确认是否仍相关。",
"context.healthLowCache": "缓存命中偏低",
"context.healthLowCacheShort": "命中偏低",
"context.healthLowCacheBody": "当前缓存命中 {pct}%,后续长任务可关注上下文稳定性。",
"context.prompt": "提示词",
"context.completion": "回复",
"context.reasoning": "推理",
"context.other": "其他",
"context.total": "总计",
"context.usageAnalysis": "用量分析",
"context.usageAnalysisView": "用量分析视图",
"context.usageAnalysisSource": "按来源",
"context.usageAnalysisType": "按类型",
"context.tokenBreakdown": "Token 构成",
"context.cacheHit": "缓存命中",
"context.inputCache": "输入缓存",
"context.inputCacheHit": "输入 tokens(命中缓存)",
"context.inputCacheMiss": "输入 tokens(未命中缓存)",
"context.outputTokens": "输出 tokens",
"context.sessionTokens": "会话 tokens",
"context.sessionTokens": "会话累计 tokens",
"context.sessionTokensShort": "累计 tokens",
"context.tokensValue": "{value} tokens",
"context.sessionCost": "会话费用",
"context.costBreakdown": "费用来源",
"context.sourceBreakdown": "模型用量明细",
"context.sourceShareTokens": "按 tokens 占比",
"context.sourceBreakdown": "用量来源",
"context.sourceShareTitle": "来源占比",
"context.sourceInput": "输入",
"context.sourceOutput": "输出",
"context.sourceCacheHit": "命中",
@@ -1685,9 +1973,9 @@ export const zh: Record<DictKey, string> = {
"context.sourceClassifier": "计划分类",
"context.sourceTitle": "标题生成",
"context.sourceRequests": "{count} 次",
"context.moreSources": "还有 {count} 个来源",
"context.requests": "请求数",
"context.time": "耗时",
"context.compaction": "压缩状态",
"context.time": "运行时间",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
File diff suppressed because it is too large. Load diff
+11
View File
@@ -25,7 +25,9 @@ require (
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -33,6 +35,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/labstack/echo/v4 v4.15.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/larksuite/oapi-sdk-go/v3 v3.9.5 // indirect
@@ -42,6 +45,7 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
@@ -49,6 +53,11 @@ require (
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-javascript v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-python v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-rust v0.24.2 // indirect
github.com/tree-sitter/tree-sitter-typescript v0.23.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
@@ -57,6 +66,8 @@ require (
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/net v0.56.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mvdan.cc/sh/v3 v3.13.1 // indirect
)
replace reasonix => ../
+49
View File
@@ -7,8 +7,12 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
@@ -18,10 +22,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -29,8 +37,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
@@ -54,6 +68,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
@@ -67,6 +83,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
@@ -79,6 +97,34 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c=
github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw=
github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w=
github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ=
github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA=
github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc=
github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og=
github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig=
github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320=
github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ=
github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo=
github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8=
github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74=
github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0=
github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo=
github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA=
github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0=
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -146,7 +192,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+50
View File
@@ -42,6 +42,7 @@ type HeartbeatTask struct {
ApprovalMode string `json:"approvalMode"` // "ask" | "auto" | "yolo"; empty defaults to "yolo"
TimeWindowStart string `json:"timeWindowStart,omitempty"` // "HH:MM" — interval tasks only run after this time (inclusive)
TimeWindowEnd string `json:"timeWindowEnd,omitempty"` // "HH:MM" — interval tasks only run before this time (exclusive)
NotifyChannels *bool `json:"notifyChannels,omitempty"` // true = push to bot channels; nil/false = skip
}
// heartbeatConfig is the on-disk format.
@@ -322,9 +323,21 @@ func (e *HeartbeatEngine) executeTask(t HeartbeatTask) HeartbeatTask {
t.ApprovalMode = mode
e.app.SetToolApprovalModeForTab(tabMeta.ID, mode)
// Attach bot event forwarding if the bot runtime is active and has
// session-mapped targets. The forwarder is set on the tab's event sink
// so AI output events are streamed to connected bot channels in
// real-time alongside the desktop UI.
botForwardAttached := false
if t.NotifyChannels != nil && *t.NotifyChannels {
botForwardAttached = e.attachBotForwarder(tabMeta.ID)
}
// Submit as a plain user turn so scheduled prompts cannot invoke desktop
// shell or slash-command handlers such as "!cmd", "/clear", or "/compact".
if !e.app.submitUserTurnToTab(tabMeta.ID, t.Prompt) {
if botForwardAttached {
e.clearBotForwarder(tabMeta.ID)
}
log.Printf("[heartbeat] submit skipped for %q", t.Title)
return t
}
@@ -806,3 +819,40 @@ func (a *App) HeartbeatGenerateID() string {
}
return string(b)
}
// attachBotForwarder sets up event forwarding to connected bot channels for
// the tab identified by tabID. It is called before submitting a heartbeat
// prompt. If the bot runtime is not active or has no session-mapped targets
// the call is a no-op.
func (e *HeartbeatEngine) attachBotForwarder(tabID string) bool {
runtime := e.app.botRuntime
if runtime == nil || !runtime.Running() {
return false
}
cfg, err := e.app.loadDesktopBotConfig()
if err != nil {
log.Printf("[heartbeat] load config for bot forward: %v", err)
return false
}
targets := runtime.ForwardTargets(cfg)
if len(targets) == 0 {
return false // no session-mapped channels to forward to
}
tab := e.app.tabByID(tabID)
if tab == nil || tab.sink == nil {
return false
}
log.Printf("[heartbeat] bot forwarding attached: %d target(s) for tab %s", len(targets), tabID)
forwarder := newBotEventForwarder(runtime, targets)
tab.sink.SetBotSink(forwarder)
return true
}
func (e *HeartbeatEngine) clearBotForwarder(tabID string) {
tab := e.app.tabByID(tabID)
if tab == nil || tab.sink == nil {
return
}
tab.sink.SetBotSink(nil)
}
+247
View File
@@ -9,9 +9,11 @@ import (
"unicode/utf8"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -86,6 +88,25 @@ func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
assertNoHistoryMemoryContract(t, got[0].Content)
}
func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) {
raw := "<active-goal>\nship the approval redesign\n</active-goal>\n\ncontinue implementation"
msgs := []provider.Message{
{Role: provider.RoleUser, Content: raw},
{Role: provider.RoleAssistant, Content: "done"},
}
got := historyMessages(msgs, control.StripComposePrefixes)
if len(got) != 2 {
t.Fatalf("history length = %d, want 2: %+v", len(got), got)
}
if got[0].Content != "continue implementation" {
t.Fatalf("visible user content = %q, want active-goal stripped", got[0].Content)
}
if strings.Contains(got[0].Content, "<active-goal>") || strings.Contains(got[0].Content, "ship the approval redesign") {
t.Fatalf("active-goal leaked into visible history content: %+v", got[0])
}
}
func TestHistoryMessagesCarryCheckpointTurnsAcrossHiddenSyntheticUsers(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
@@ -828,6 +849,232 @@ func TestRebindTabToLoadedSessionReusesPreloadedTranscript(t *testing.T) {
}
}
func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
currentPath := filepath.Join(dir, "current.jsonl")
targetPath := filepath.Join(dir, "target.jsonl")
writeHistoryTestSession(t, currentPath, "current prompt")
writeHistoryTestSession(t, targetPath, "target prompt")
if err := agent.SaveBranchMetaPreserveUpdated(targetPath, agent.BranchMeta{
TokenMode: boot.TokenModeFull,
Mode: "yolo",
ToolApprovalMode: control.ToolApprovalYolo,
}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated target: %v", err)
}
loaded, err := agent.LoadSession(targetPath)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "current", Sink: event.Discard})
ctrl.SetMode(true, false)
ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
defer ctrl.Close()
app := NewApp()
tab := &WorkspaceTab{
ID: "tab",
Scope: "global",
WorkspaceRoot: root,
SessionPath: currentPath,
Ctrl: ctrl,
Ready: true,
tokenMode: boot.TokenModeEconomy,
mode: "plan",
toolApprovalMode: control.ToolApprovalAuto,
sink: &tabEventSink{tabID: "tab", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil {
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
}
currentMeta, ok, err := agent.LoadBranchMeta(currentPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
}
if currentMeta.TokenMode != boot.TokenModeEconomy || currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto {
t.Fatalf("current session profile = token:%q mode:%q approval:%q, want economy/plan/auto",
currentMeta.TokenMode, currentMeta.Mode, currentMeta.ToolApprovalMode)
}
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
t.Fatalf("rebound token mode = %q, want full", got)
}
if got := currentTabMode(tab); got != "yolo" {
t.Fatalf("rebound mode = %q, want yolo", got)
}
if got := currentTabToolApprovalMode(tab); got != control.ToolApprovalYolo {
t.Fatalf("rebound tool approval = %q, want yolo", got)
}
}
func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
currentPath := filepath.Join(dir, "profile.jsonl")
otherPath := filepath.Join(dir, "other.jsonl")
writeHistoryTestSession(t, currentPath, "profile prompt")
writeHistoryTestSession(t, otherPath, "other prompt")
if err := agent.SaveBranchMetaPreserveUpdated(currentPath, agent.BranchMeta{}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated current: %v", err)
}
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "profile", Sink: event.Discard})
ctrl.SetMode(true, false)
ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
ctrl.SetGoal("finish the review")
app := NewApp()
tab := &WorkspaceTab{
ID: "profile",
Scope: "global",
WorkspaceRoot: root,
SessionPath: currentPath,
Ctrl: ctrl,
Ready: true,
tokenMode: boot.TokenModeEconomy,
mode: "plan",
toolApprovalMode: control.ToolApprovalAuto,
sink: &tabEventSink{tabID: "profile", app: app},
disabledMCP: map[string]ServerView{},
}
other := &WorkspaceTab{
ID: "other",
Scope: "global",
WorkspaceRoot: root,
SessionPath: otherPath,
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs[tab.ID] = tab
app.tabs[other.ID] = other
app.tabOrder = []string{tab.ID, other.ID}
app.activeTabID = tab.ID
if err := app.CloseTab(tab.ID); err != nil {
t.Fatalf("CloseTab: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(currentPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
}
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" {
t.Fatalf("closed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
}
}
func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
keepPath := filepath.Join(dir, "keep.jsonl")
removedPath := filepath.Join(dir, "removed.jsonl")
writeHistoryTestSession(t, keepPath, "keep prompt")
writeHistoryTestSession(t, removedPath, "removed prompt")
if err := agent.SaveBranchMetaPreserveUpdated(removedPath, agent.BranchMeta{}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated removed: %v", err)
}
removedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: removedPath, Label: "removed", Sink: event.Discard})
removedCtrl.SetMode(true, false)
removedCtrl.SetToolApprovalMode(control.ToolApprovalAuto)
removedCtrl.SetGoal("keep this profile")
app := NewApp()
keep := &WorkspaceTab{
ID: "keep",
Scope: "global",
WorkspaceRoot: root,
SessionPath: keepPath,
Ready: true,
disabledMCP: map[string]ServerView{},
}
removed := &WorkspaceTab{
ID: "removed",
Scope: "global",
WorkspaceRoot: root,
SessionPath: removedPath,
Ctrl: removedCtrl,
Ready: true,
tokenMode: boot.TokenModeEconomy,
mode: "plan",
toolApprovalMode: control.ToolApprovalAuto,
sink: &tabEventSink{tabID: "removed", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs[keep.ID] = keep
app.tabs[removed.ID] = removed
app.tabOrder = []string{keep.ID, removed.ID}
app.activeTabID = removed.ID
if _, err := app.keepOnlyVisibleTab(keep.ID); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(removedPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta removed ok=%v err=%v", ok, err)
}
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" {
t.Fatalf("removed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
}
}
func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(dir, "terminal-goal.jsonl")
writeHistoryTestSession(t, sessionPath, "terminal prompt")
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
TokenMode: boot.TokenModeEconomy,
Mode: "plan",
ToolApprovalMode: control.ToolApprovalAuto,
Goal: "stale terminal goal",
}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
}
if err := os.WriteFile(store.SessionGoalState(sessionPath), []byte(`{"goal":"stale terminal goal","status":"complete"}`), 0o644); err != nil {
t.Fatalf("write goal state: %v", err)
}
profile := loadTabSessionProfile(sessionPath)
if profile.goal != "" {
t.Fatalf("loaded profile goal = %q, want terminal goal ignored", profile.goal)
}
if profile.tokenMode != boot.TokenModeEconomy || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
t.Fatalf("loaded profile = token:%q mode:%q approval:%q, want economy/plan/auto",
profile.tokenMode, profile.mode, profile.toolApprovalMode)
}
}
func writeHistoryTestSession(t *testing.T, path, prompt string) {
t.Helper()
session := agent.NewSession("")
+2
View File
@@ -10,6 +10,7 @@ import (
"embed"
"os"
"path/filepath"
goruntime "runtime"
"strings"
"github.com/wailsapp/wails/v2"
@@ -112,6 +113,7 @@ func main() {
Title: "Reasonix",
Width: width,
Height: height,
Frameless: goruntime.GOOS == "windows",
MinWidth: 760,
MinHeight: 480,
// Match the dark UI shell so the initial webview background doesn't flash
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"testing"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/notify"
)
type desktopRecordSink struct {
events []event.Kind
}
func (s *desktopRecordSink) Emit(e event.Event) {
s.events = append(s.events, e.Kind)
}
type desktopRecordSender struct {
messages []notify.Message
}
func (s *desktopRecordSender) Send(m notify.Message) error {
s.messages = append(s.messages, m)
return nil
}
func TestDesktopControllerSinkSendsConfiguredSystemNotifications(t *testing.T) {
inner := &desktopRecordSink{}
sender := &desktopRecordSender{}
app := &App{notificationSender: sender}
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
Enabled: true,
TurnDone: true,
ApprovalRequest: true,
AskRequest: true,
})
sink.Emit(event.Event{Kind: event.TurnDone})
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
}
if len(sender.messages) != 1 {
t.Fatalf("notifications = %d, want 1", len(sender.messages))
}
if sender.messages[0].Body != "Turn finished" {
t.Fatalf("notification body = %q, want Turn finished", sender.messages[0].Body)
}
}
func TestDesktopControllerSinkSkipsSystemNotificationsWhenDisabled(t *testing.T) {
inner := &desktopRecordSink{}
sender := &desktopRecordSender{}
app := &App{notificationSender: sender}
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
Enabled: false,
TurnDone: true,
})
sink.Emit(event.Event{Kind: event.TurnDone})
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
}
if len(sender.messages) != 0 {
t.Fatalf("notifications = %d, want 0", len(sender.messages))
}
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"reasonix/internal/config"
"reasonix/internal/installsource"
"reasonix/internal/pluginpkg"
)
type PluginView struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Root string `json:"root"`
ManifestKind string `json:"manifestKind,omitempty"`
Enabled bool `json:"enabled"`
Skills int `json:"skills"`
Hooks int `json:"hooks"`
MCPServers int `json:"mcpServers"`
SkillDetails []PluginSkillView `json:"skillDetails,omitempty"`
HookDetails []PluginHookView `json:"hookDetails,omitempty"`
MCPServerDetails []PluginMCPServerView `json:"mcpServerDetails,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Error string `json:"error,omitempty"`
}
type PluginInstallOptions struct {
DryRun bool `json:"dryRun,omitempty"`
Link bool `json:"link,omitempty"`
Replace bool `json:"replace,omitempty"`
Name string `json:"name,omitempty"`
}
type PluginSkillView struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Path string `json:"path,omitempty"`
Invocation string `json:"invocation,omitempty"`
RunAs string `json:"runAs,omitempty"`
}
type PluginHookView struct {
Event string `json:"event"`
Match string `json:"match,omitempty"`
Command string `json:"command,omitempty"`
ContextFile string `json:"contextFile,omitempty"`
Description string `json:"description,omitempty"`
}
type PluginMCPServerView struct {
Name string `json:"name"`
Transport string `json:"transport,omitempty"`
Command string `json:"command,omitempty"`
URL string `json:"url,omitempty"`
}
func (a *App) Plugins() []PluginView {
st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
if err != nil {
return []PluginView{{Error: err.Error()}}
}
out := make([]PluginView, 0, len(st.Plugins))
for _, p := range st.Plugins {
view := PluginView{
Name: p.Name,
Version: p.Version,
Description: p.Description,
Source: p.Source,
Root: pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root),
ManifestKind: p.ManifestKind,
Enabled: p.Enabled,
}
if pkg, warnings, err := pluginpkg.ParseDir(view.Root); err == nil {
applyPluginPackageDetails(&view, pkg, warnings)
} else {
view.Error = err.Error()
}
out = append(out, view)
}
return out
}
func applyPluginPackageDetails(view *PluginView, pkg pluginpkg.Package, warnings []string) {
view.Skills, view.Hooks, view.MCPServers = pkg.CapabilityCounts()
view.Warnings = warnings
inv := pkg.Inventory()
view.SkillDetails = make([]PluginSkillView, 0, len(inv.Skills))
for _, sk := range inv.Skills {
view.SkillDetails = append(view.SkillDetails, PluginSkillView{
Name: sk.Name,
Description: sk.Description,
Path: sk.Path,
Invocation: sk.Invocation,
RunAs: sk.RunAs,
})
}
view.HookDetails = make([]PluginHookView, 0, len(inv.Hooks))
for _, hook := range inv.Hooks {
view.HookDetails = append(view.HookDetails, PluginHookView{
Event: hook.Event,
Match: hook.Match,
Command: hook.Command,
ContextFile: hook.ContextFile,
Description: hook.Description,
})
}
view.MCPServerDetails = make([]PluginMCPServerView, 0, len(inv.MCPServers))
for _, server := range inv.MCPServers {
view.MCPServerDetails = append(view.MCPServerDetails, PluginMCPServerView{
Name: server.Name,
Transport: server.Transport,
Command: server.Command,
URL: server.URL,
})
}
}
func (a *App) PlanPluginInstall(source string, opts PluginInstallOptions) (string, error) {
opts.DryRun = true
return a.runPluginInstallSource(source, opts, false)
}
func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, error) {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return "", err
}
out, err := a.runPluginInstallSource(source, opts, true)
if err != nil {
return "", err
}
a.invalidateSkillRootsCache()
if rebuildErr := a.rebuild(); rebuildErr != nil {
return out, rebuildErr
}
return out, nil
}
func (a *App) RemovePlugin(name string) error {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return err
}
raw, _ := json.Marshal(map[string]any{"op": "uninstall", "kind": "plugin", "name": strings.TrimSpace(name), "scope": "global"})
tl := installsource.NewTool(installsource.Options{
ProjectRoot: a.activeWorkspaceRoot(),
OnDisconnect: func(serverName string) bool {
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return false
}
removed, _ := tab.Ctrl.RemoveMCPServer(serverName)
return removed
},
})
if _, err := tl.Execute(context.Background(), raw); err != nil {
return err
}
a.invalidateSkillRootsCache()
return a.rebuild()
}
func (a *App) SetPluginEnabled(name string, enabled bool) error {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return err
}
if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
return err
}
a.invalidateSkillRootsCache()
return a.rebuild()
}
func (a *App) UpdatePlugin(name string) (string, error) {
name = strings.TrimSpace(name)
for _, p := range a.Plugins() {
if p.Name == name {
if strings.TrimSpace(p.Source) == "" {
return "", fmt.Errorf("plugin %q has no recorded source", name)
}
return a.InstallPlugin(p.Source, PluginInstallOptions{Name: name, Replace: true})
}
}
return "", fmt.Errorf("plugin %q is not installed", name)
}
func (a *App) PluginDoctor(name string) PluginView {
name = strings.TrimSpace(name)
for _, p := range a.Plugins() {
if p.Name != name {
continue
}
if p.Error != "" {
return p
}
if p.Root == "" {
p.Error = "missing plugin root"
return p
}
if _, err := os.Stat(p.Root); err != nil {
p.Error = err.Error()
return p
}
return p
}
return PluginView{Name: name, Error: "plugin is not installed"}
}
func (a *App) runPluginInstallSource(source string, opts PluginInstallOptions, apply bool) (string, error) {
mode := "copy"
if opts.Link {
mode = "link"
}
body := map[string]any{
"source": strings.TrimSpace(source),
"kind": "plugin",
"mode": mode,
"replace": opts.Replace,
"apply": apply && !opts.DryRun,
}
if strings.TrimSpace(opts.Name) != "" {
body["name"] = strings.TrimSpace(opts.Name)
}
raw, _ := json.Marshal(body)
tl := installsource.NewTool(installsource.Options{ProjectRoot: a.activeWorkspaceRoot()})
return tl.Execute(context.Background(), raw)
}
+846 -130
View File
File diff suppressed because it is too large. Load diff
+172 -1
View File
@@ -97,6 +97,16 @@ func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
}
}
func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
view := providerViewFromEntry(config.ProviderEntry{
Name: "anthropic",
Thinking: "ADAPTIVE",
}, false, true)
if view.Thinking != "adaptive" {
t.Fatalf("ProviderView.Thinking = %q, want adaptive", view.Thinking)
}
}
func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
@@ -120,6 +130,43 @@ func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
}
}
func TestSettingsExposesEffectiveSandboxWriteRoots(t *testing.T) {
home := isolateDesktopUserDirs(t)
project := robustTempDir(t)
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Sandbox.AllowWrite = []string{
"${HOME}/.m2",
"${HOME}/.m2/repository",
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
}
app.activeTabID = "project"
got := app.Settings().Sandbox
if got.EffectiveWorkspaceRoot != project {
t.Fatalf("EffectiveWorkspaceRoot = %q, want %q", got.EffectiveWorkspaceRoot, project)
}
// Settings expose expanded configured roots; the writer confiner normalizes
// separators later when enforcing them.
want := []string{
project,
home + "/.m2",
home + "/.m2/repository",
}
if !reflect.DeepEqual(got.EffectiveWriteRoots, want) {
t.Fatalf("EffectiveWriteRoots = %v, want %v", got.EffectiveWriteRoots, want)
}
if !reflect.DeepEqual(got.AllowWrite, cfg.Sandbox.AllowWrite) {
t.Fatalf("AllowWrite = %v, want raw configured paths %v", got.AllowWrite, cfg.Sandbox.AllowWrite)
}
}
func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("LOCAL_API_KEY", "")
@@ -382,6 +429,60 @@ func TestSaveProviderFiltersNonChatModels(t *testing.T) {
}
}
func TestSaveProviderPersistsThinkingOverride(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SaveProvider(ProviderView{
Name: "glm-proxy",
Kind: "openai",
BaseURL: "https://proxy.example.com/v1",
Models: []string{"glm-4.5-air"},
APIKeyEnv: "GLM_PROXY_API_KEY",
Thinking: "DISABLED",
}); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
got, ok := cfg.Provider("glm-proxy")
if !ok {
t.Fatal("saved provider not found")
}
if got.Thinking != "disabled" {
t.Fatalf("saved provider thinking = %q, want disabled", got.Thinking)
}
}
func TestSaveProviderPersistsAuthHeader(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SaveProvider(ProviderView{
Name: "minimax-global-anthropic",
Kind: "anthropic",
BaseURL: "https://api.minimax.io/anthropic",
Models: []string{"MiniMax-M3"},
APIKeyEnv: "MINIMAX_API_KEY",
AuthHeader: true,
}); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
got, ok := cfg.Provider("minimax-global-anthropic")
if !ok {
t.Fatal("saved provider not found")
}
if !got.AuthHeader {
t.Fatal("saved provider auth_header = false, want true")
}
view := providerViewFromEntry(*got, false, true)
if !view.AuthHeader {
t.Fatal("provider view authHeader = false, want true")
}
}
func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -427,6 +528,76 @@ func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
t.Fatalf("Settings providers missing sub2api: %+v", view.Providers)
}
func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Providers = []config.ProviderEntry{{
Name: "custom",
Kind: "openai",
BaseURL: "https://proxy.example.com/v1",
Models: []string{"model-a", "model-b"},
Default: "model-a",
APIKeyEnv: "CUSTOM_API_KEY",
Price: &provider.Pricing{Input: 1, Output: 2, Currency: "$"},
Prices: map[string]*provider.Pricing{"model-b": {Input: 3, Output: 4, Currency: "$"}},
Thinking: "adaptive",
Effort: "high",
VisionDetail: "low",
ExtraBody: map[string]any{"enable_thinking": true},
NoProxy: true,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("SaveTo: %v", err)
}
app := NewApp()
settings := app.Settings()
var view ProviderView
found := false
for _, p := range settings.Providers {
if p.Name == "custom" {
view = p
found = true
break
}
}
if !found {
t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
}
if view.ExtraBody["enable_thinking"] != true {
t.Fatalf("settings extra_body = %+v, want enable_thinking=true", view.ExtraBody)
}
if err := app.SaveProvider(view); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
gotCfg := config.LoadForEdit(config.UserConfigPath())
got, ok := gotCfg.Provider("custom")
if !ok {
t.Fatal("saved provider not found")
}
if got.Price == nil || got.Price.Input != 1 || got.Price.Output != 2 || got.Price.Currency != "$" {
t.Fatalf("provider-wide price = %+v, want preserved", got.Price)
}
if got.Prices["model-b"] == nil || got.Prices["model-b"].Input != 3 || got.Prices["model-b"].Output != 4 || got.Prices["model-b"].Currency != "$" {
t.Fatalf("per-model prices = %+v, want model-b price preserved", got.Prices)
}
if got.Thinking != "adaptive" || got.Effort != "high" {
t.Fatalf("thinking/effort = %q/%q, want adaptive/high", got.Thinking, got.Effort)
}
if got.VisionDetail != "low" {
t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
}
if got.ExtraBody["enable_thinking"] != true {
t.Fatalf("extra_body = %+v, want enable_thinking=true", got.ExtraBody)
}
if !got.NoProxy {
t.Fatal("no_proxy = false, want preserved true")
}
}
func TestSaveProviderClearsProviderWideVisionForPerModelSelection(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -658,7 +829,7 @@ func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
}
userComposed := userCtrl.Compose("hi")
if !strings.Contains(userComposed, "Simplified Chinese") {
if !strings.Contains(userComposed, "简体中文") {
t.Fatalf("user-level tab Compose = %q, want zh reasoning language", userComposed)
}
projectComposed := projectCtrl.Compose("hi")
+34
View File
@@ -10,6 +10,40 @@ import (
"reasonix/internal/event"
)
type closeTrackingSink struct {
closed atomic.Bool
}
func (s *closeTrackingSink) Emit(event.Event) {}
func (s *closeTrackingSink) Close() {
s.closed.Store(true)
}
func TestTabEventSinkSetBotSinkClosesPreviousSink(t *testing.T) {
sink := &tabEventSink{}
first := &closeTrackingSink{}
second := &closeTrackingSink{}
sink.SetBotSink(first)
if first.closed.Load() {
t.Fatal("newly attached sink was closed")
}
sink.SetBotSink(second)
if !first.closed.Load() {
t.Fatal("previous sink was not closed when replaced")
}
if second.closed.Load() {
t.Fatal("replacement sink was closed too early")
}
sink.SetBotSink(nil)
if !second.closed.Load() {
t.Fatal("second sink was not closed when cleared")
}
}
func TestTabEventSinkDoesNotBlockOnRuntimeEventsEmit(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
+1 -1
View File
@@ -122,7 +122,7 @@ api_key_env = "PROJECT_API_KEY"
if !got.Supported || got.Current != "auto" || got.Default != "high" {
t.Fatalf("EffortForTab model registry = %+v, want supported auto/high", got)
}
wantLevels := []string{"auto", "high", "max"}
wantLevels := []string{"auto", "disabled", "high", "max"}
if len(got.Levels) != len(wantLevels) {
t.Fatalf("levels = %v, want %v", got.Levels, wantLevels)
}
+897 -114
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -679,6 +680,42 @@ func TestBuildTabControllerReusesOpenSessionPathRuntime(t *testing.T) {
}
}
func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "leased.jsonl")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
defer lease.Release()
app := NewApp()
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "leased")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: "leased", app: app}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.buildTabController(tab)
if tab.Ctrl != nil {
t.Fatalf("tab controller = %T, want nil when lease is held", tab.Ctrl)
}
if !tab.Ready {
t.Fatal("tab should be ready with startup error")
}
if !strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) {
t.Fatalf("startup error = %q, want lease-held error", tab.StartupErr)
}
}
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
+339 -5
View File
@@ -1567,6 +1567,53 @@ func TestAutoTitleTopicStripsReasoningLanguagePrefix(t *testing.T) {
}
}
func TestAutoTitleTopicRefreshesOnThirdUserTurn(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
firstTurn := strings.Join([]string{
`{"role":"user","content":"帮我看看"}`,
`{"role":"assistant","content":"可以"}`,
}, "\n") + "\n"
if err := os.WriteFile(sessionPath, []byte(firstTurn), 0o644); err != nil {
t.Fatalf("write first session: %v", err)
}
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
if !updated || title != "帮我看看" {
t.Fatalf("first auto title = %q updated=%v, want 帮我看看/true", title, updated)
}
thirdTurn := strings.Join([]string{
`{"role":"user","content":"帮我看看"}`,
`{"role":"assistant","content":"可以"}`,
`{"role":"user","content":"继续"}`,
`{"role":"assistant","content":"继续分析"}`,
`{"role":"user","content":"实现自动更新会话标题"}`,
`{"role":"assistant","content":"已实现"}`,
}, "\n") + "\n"
if err := os.WriteFile(sessionPath, []byte(thirdTurn), 0o644); err != nil {
t.Fatalf("write third session: %v", err)
}
title, updated = autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
if !updated || title != "实现自动更新会话标题" {
t.Fatalf("third-turn auto title = %q updated=%v, want 实现自动更新会话标题/true", title, updated)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "实现自动更新会话标题" {
t.Fatalf("stored title = %q, want 实现自动更新会话标题", got)
}
meta := loadTopicAutoTitleMeta(projectRoot)[topic.ID]
if meta.Stage != 3 {
t.Fatalf("auto title stage = %d, want 3", meta.Stage)
}
}
func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -1592,6 +1639,50 @@ func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
}
}
func TestAutoTitleDoesNotOverrideManualSessionTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{CustomTitle: "手动会话标题"}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
t.Fatalf("manual session title should not auto-update, title=%q updated=%v", title, updated)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want default title", got)
}
}
func TestRenameTopicBlankKeepsManualTitleSource(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := app.RenameTopic(topic.ID, " "); err != nil {
t.Fatalf("rename blank topic: %v", err)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
t.Fatalf("title source = %q, want manual", got)
}
}
func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -1956,7 +2047,7 @@ func TestTrashTopicCancelsRunningSessionRuntime(t *testing.T) {
}
}
func TestTrashTopicFallbackCreatesIndexedTopic(t *testing.T) {
func TestTrashTopicFallbackCreatesUnindexedBlank(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
@@ -1998,14 +2089,257 @@ func TestTrashTopicFallbackCreatesIndexedTopic(t *testing.T) {
t.Fatalf("fallback should create exactly one visible tab, got %d", len(app.tabs))
}
for id, tab := range app.tabs {
if strings.TrimSpace(tab.TopicID) == "" {
t.Fatalf("fallback tab %q has empty topic ID", id)
if strings.TrimSpace(tab.TopicID) != "" {
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
}
if strings.TrimSpace(tab.SessionPath) == "" {
t.Fatalf("fallback tab %q has no precreated session path", id)
}
f := loadProjectsFile()
if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, tab.TopicID) {
t.Fatalf("fallback topic %q was not indexed in project topics %#v", tab.TopicID, f.Projects)
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, topicID) {
t.Fatalf("deleted topic %q should be removed without indexing a replacement: %#v", topicID, f.Projects)
}
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 0 {
t.Fatalf("transient fallback blank should stay out of project tree: %+v", nodes)
}
}
func TestTransientFallbackIndexesOnFirstUserTurn(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_only"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
defer ctrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{
"only": {
ID: "only",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Only topic",
Ctrl: ctrl,
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"only"},
activeTabID: "only",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
var fallback *WorkspaceTab
for _, tab := range app.tabs {
fallback = tab
}
if fallback == nil {
t.Fatal("fallback tab missing")
}
if fallback.TopicID != "" {
t.Fatalf("fallback topic before first turn = %q, want empty", fallback.TopicID)
}
app.ensureTabTopicIndexedForUserTurn(fallback)
if strings.TrimSpace(fallback.TopicID) == "" {
t.Fatal("first user turn should assign a topic ID")
}
f := loadProjectsFile()
if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, fallback.TopicID) {
t.Fatalf("first user turn should index fallback topic %q: %#v", fallback.TopicID, f.Projects)
}
meta, ok, err := agent.LoadBranchMeta(fallback.SessionPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta(%q): ok=%v err=%v", fallback.SessionPath, ok, err)
}
if meta.TopicID != fallback.TopicID || meta.TopicTitle != defaultTopicTitle {
t.Fatalf("fallback session meta = %+v, want topic %q title %q", meta, fallback.TopicID, defaultTopicTitle)
}
}
func TestTransientFallbackDiscardedWhenSingleSurfaceNavigatesAway(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
dir := desktopSessionDir(projectRoot)
transientPath, err := createEmptySessionFile(dir, "test")
if err != nil {
t.Fatalf("create transient session: %v", err)
}
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
Scope: "project",
WorkspaceRoot: projectRoot,
}); err != nil {
t.Fatalf("save transient meta: %v", err)
}
targetTopicID := "topic_target"
if err := setTopicTitle(projectRoot, targetTopicID, "Target"); err != nil {
t.Fatalf("set target topic title: %v", err)
}
targetPath := writeTopicSession(t, dir, "target.jsonl", targetTopicID, "Target", projectRoot)
app := &App{
tabs: map[string]*WorkspaceTab{
"transient": {
ID: "transient",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicTitle: defaultTopicTitle,
SessionPath: transientPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
"target": {
ID: "target",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: targetTopicID,
TopicTitle: "Target",
SessionPath: targetPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"transient", "target"},
activeTabID: "transient",
}
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
if _, ok := app.tabs["transient"]; ok {
t.Fatal("transient tab should be removed after single-surface navigation")
}
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
}
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
t.Fatalf("transient session meta should be removed, stat err = %v", err)
}
f := loadProjectsFile()
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, "") {
t.Fatalf("transient blank should not be indexed in project topics: %#v", f.Projects)
}
}
func TestCloseTabDiscardsUnusedTransientBlankSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
dir := desktopSessionDir(projectRoot)
transientPath, err := createEmptySessionFile(dir, "test")
if err != nil {
t.Fatalf("create transient session: %v", err)
}
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
Scope: "project",
WorkspaceRoot: projectRoot,
}); err != nil {
t.Fatalf("save transient meta: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"transient": {
ID: "transient",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicTitle: defaultTopicTitle,
SessionPath: transientPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
"other": {
ID: "other",
Scope: "global",
TopicID: "topic_other",
TopicTitle: "Other",
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"transient", "other"},
activeTabID: "transient",
}
if err := app.CloseTab("transient"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if _, ok := app.tabs["transient"]; ok {
t.Fatal("transient tab should be closed")
}
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
}
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
t.Fatalf("transient session meta should be removed, stat err = %v", err)
}
}
func TestCloseTabKeepsIndexedBlankSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_indexed_blank"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, defaultTopicTitle); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := desktopSessionDir(projectRoot)
indexedPath, err := createEmptySessionFile(dir, "test")
if err != nil {
t.Fatalf("create indexed blank session: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"indexed": {
ID: "indexed",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: defaultTopicTitle,
SessionPath: indexedPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
"other": {
ID: "other",
Scope: "global",
TopicID: "topic_other",
TopicTitle: "Other",
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"indexed", "other"},
activeTabID: "indexed",
}
if err := app.CloseTab("indexed"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if _, err := os.Stat(indexedPath); err != nil {
t.Fatalf("indexed blank session should be preserved, stat err = %v", err)
}
}
func TestTrashTopicTrashConflictKeepsRunningRuntime(t *testing.T) {
+16 -14
View File
@@ -33,36 +33,38 @@ import (
// has no Wails dependency so the logic is unit-tested directly; updater_app.go is
// the thin Wails binding that wires these into App methods and progress events.
// Manifest endpoints — R2 CDN first (fast, especially in CN), GitHub releases as
// fallback. The build channel picks the rolling pointer so a canary build polls
// the canary line and a stable build polls latest; the two never cross.
// Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
// worker release gateway. The build channel picks the rolling pointer so a
// canary build polls the canary line and a stable build polls latest; the two
// never cross. The gateway deliberately avoids GitHub's repository-wide
// /releases/latest shortcut because CLI tags (v*) and desktop tags (desktop-v*)
// are separate release lines in the same repo.
const (
r2Base = "https://dl.reasonix.io"
ghReleasesBase = "https://github.com/esengine/reasonix/releases"
httpTimeout = 15 * time.Second
r2Base = "https://dl.reasonix.io"
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
downloadPageURL = "https://reasonix.io/#start"
httpTimeout = 15 * time.Second
)
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
// for the running build's channel.
func manifestEndpoints() []string {
if channel == "canary" {
// Canary publishes only to R2 (no GitHub release), so there is no
// GitHub fallback for this channel.
return []string{r2Base + "/canary/latest.json"}
return []string{
r2Base + "/canary/latest.json",
releaseGatewayBase + "/canary/latest.json",
}
}
return []string{
r2Base + "/latest/latest.json",
ghReleasesBase + "/latest/download/latest.json",
releaseGatewayBase + "/stable/latest.json",
}
}
// downloadPage is the human-facing releases page shown when self-update is
// unavailable (macOS) or the manifest omits its own link.
func downloadPage() string {
if channel == "canary" {
return ghReleasesBase // lists pre-releases too
}
return ghReleasesBase + "/latest"
return downloadPageURL
}
// UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
+13 -1
View File
@@ -27,6 +27,7 @@ func (a *App) Version() string { return version }
func (a *App) CheckUpdate() (*UpdateInfo, error) {
c, err := httpClient()
if err != nil {
a.recordUpdateError(err)
return &UpdateInfo{
Current: version,
Channel: channel,
@@ -41,6 +42,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
defer cancel()
m, err := fetchManifest(ctx, c)
if err != nil {
a.recordUpdateError(err)
return &UpdateInfo{
Current: version,
Channel: channel,
@@ -55,7 +57,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
return &info, nil
}
// OpenDownloadPage opens the releases page in the browser — the macOS manual-update
// OpenDownloadPage opens the install page in the browser — the macOS manual-update
// path and a fallback link elsewhere.
func (a *App) OpenDownloadPage() {
page := downloadPage()
@@ -208,6 +210,16 @@ func (a *App) emitProgress(phase string, received, total int64, errMsg string) {
// failUpdate emits an error progress event and returns the error to the caller.
func (a *App) failUpdate(err error) error {
a.recordUpdateError(err)
a.emitProgress("error", 0, 0, err.Error())
return err
}
func (a *App) recordUpdateError(err error) {
if err == nil || version == "dev" {
return
}
if m := a.metrics.Load(); m != nil {
m.inc("updater_error", errorClass(err.Error()))
}
}
+13 -2
View File
@@ -98,6 +98,14 @@ func TestChannelSelectsDistinctPointers(t *testing.T) {
if !strings.Contains(stable[0], "/latest/latest.json") {
t.Errorf("stable primary = %q, want the latest/ pointer", stable[0])
}
if stable[1] != releaseGatewayBase+"/stable/latest.json" {
t.Errorf("stable fallback = %q, want the release gateway", stable[1])
}
for _, u := range append(stable, canary...) {
if strings.Contains(u, "/releases/latest") {
t.Errorf("manifest endpoint uses GitHub's repository-wide latest release: %q", u)
}
}
for _, u := range canary {
if strings.Contains(u, "/latest/") {
t.Errorf("canary endpoint hits the stable latest/ pointer: %q", u)
@@ -106,8 +114,11 @@ func TestChannelSelectsDistinctPointers(t *testing.T) {
if !strings.Contains(canary[0], "/canary/latest.json") {
t.Errorf("canary primary = %q, want the canary/ pointer", canary[0])
}
if downloadPage() == (ghReleasesBase + "/latest") {
t.Error("canary download page should not be the stable latest releases page")
if canary[1] != releaseGatewayBase+"/canary/latest.json" {
t.Errorf("canary fallback = %q, want the release gateway", canary[1])
}
if strings.Contains(downloadPage(), "/releases/latest") {
t.Errorf("download page should not use GitHub's repository-wide latest release: %q", downloadPage())
}
}
+41
View File
@@ -0,0 +1,41 @@
package main
import "github.com/wailsapp/wails/v2/pkg/runtime"
// MinimiseMainWindow backs the Windows frameless titlebar controls.
func (a *App) MinimiseMainWindow() {
if a.ctx == nil {
return
}
runtime.WindowMinimise(a.ctx)
}
// ToggleMaximiseMainWindow backs the Windows frameless titlebar controls.
func (a *App) ToggleMaximiseMainWindow() {
if a.ctx == nil {
return
}
runtime.WindowToggleMaximise(a.ctx)
}
// IsMainWindowMaximised reports the native maximise state for the Windows
// frameless titlebar controls.
func (a *App) IsMainWindowMaximised() bool {
if a.ctx == nil {
return false
}
return runtime.WindowIsMaximised(a.ctx)
}
// CloseMainWindow preserves Reasonix's configured close behavior for the
// Windows frameless titlebar close button.
func (a *App) CloseMainWindow() {
if a.ctx == nil {
return
}
if a.beforeClose(a.ctx) {
return
}
a.forceQuit.Store(true)
runtime.Quit(a.ctx)
}
+192 -30
View File
@@ -6,13 +6,14 @@
&nbsp;·&nbsp;
<a href="./GUIDE.md">General guide</a>
> For desktop users. This guide explains how to connect Feishu, Lark, WeChat, and QQ
> bots, how to use Reasonix from IM, and how approvals, Ask questions, YOLO, and
> bot commands work.
> For desktop and CLI users. This guide explains how to connect Feishu, Lark,
> WeChat, and QQ bots, how to use Reasonix from IM, and how approvals, Ask
> questions, YOLO, and bot commands work.
## Contents
- [What the bot does](#what-the-bot-does)
- [Where it runs](#where-it-runs)
- [Connect the four channels](#connect-the-four-channels)
- [Run the bot headlessly](#run-the-bot-headlessly)
- [Usage flow](#usage-flow)
@@ -24,9 +25,10 @@
## What the bot does
After a bot is connected, you can send Reasonix messages from Feishu, Lark, or
WeChat. The desktop app handles the model, tools, permissions, sandboxing, and
local context, then sends progress and results back to the IM channel.
After a bot is connected, you can send Reasonix messages from Feishu, Lark,
WeChat, or QQ. The desktop app or `reasonix bot start` process handles the
model, tools, permissions, sandboxing, and local context, then sends progress
and results back to the IM channel.
Common uses:
@@ -37,6 +39,26 @@ Common uses:
- Open the matching desktop IM session to inspect context, cost, tokens, and tool
traces.
## Where it runs
The bot gateway is a shared Go runtime. The same core behavior works on
Windows, macOS, and Linux; platform differences mostly come from each IM
provider's credentials, network reachability, callback/WebSocket setup, and
saved local account state.
There are two supported entry points:
- **Desktop runtime**: configure bots in **Settings -> Bots**. The desktop app
starts the gateway, keeps status in the app, persists per-connection tool
approval mode changes, and lets you open matching local IM sessions.
- **CLI runtime**: run `reasonix bot start` for a headless long-lived process.
It uses the same config, allowlist, routes, queue settings, pairing store,
adapters, and project/session index as the desktop runtime.
The normal `reasonix run` command does not automatically start the IM gateway.
Remote bot behavior is active only while the desktop bot runtime is running or
while a `reasonix bot start` process is alive.
## Connect the four channels
Open the Reasonix desktop app and go to **Settings -> Bots**. In **Add IM Bot**,
@@ -77,7 +99,14 @@ flowchart LR
Feishu and Lark share the same capability set, but they are saved as separate
connections. You can give them different models, working directories, or tool
approval modes.
approval modes. Bot text replies are sent as standalone Interactive Card JSON
2.0 markdown, which avoids Feishu/Lark platform quote prefixes while preserving
CommonMark formatting. If a card is too large for the platform limit, Reasonix
falls back to plain text automatically.
For webhook mode, configure a verification token. Incoming webhook events are
verified fail-closed: an empty or missing configured token rejects callers
instead of silently opening the webhook.
### WeChat
@@ -87,8 +116,9 @@ approval modes.
4. Wait until the page shows the connection as connected.
5. Send the WeChat bot a message.
WeChat does not provide interactive card buttons here, so approvals and Ask
questions are handled through text commands.
WeChat does not provide interactive card buttons here, so approvals use numeric
or text commands. Ask questions can be answered by replying with normal text,
option numbers, or `/answer <id> <answer>`.
### QQ
@@ -99,13 +129,16 @@ questions are handled through text commands.
5. Send the QQ bot a message.
QQ Bot uses the official QQ Bot platform API. It supports inline keyboard
buttons for approvals. Ask questions are sent as text; for single-choice
questions you can reply with the option number, or use `/answer <id> <option>`.
When a button expires or the platform reports an action failure, copy the ID
shown in the card and send the equivalent text command.
buttons for approvals. Ask questions are sent as text; reply with normal text,
option numbers, or `/answer <id> <answer>`. When a button expires or the
platform reports an action failure, copy the ID shown in the card and send the
equivalent text command.
QQ does not support QR-code scanning for connection setup. You must
configure the App ID and App Secret manually.
QQ does not support QR-code scanning for connection setup. You must configure
the App ID and App Secret manually. The adapter reads only the configured
`app_secret_env` value; it does not fall back to an unrelated `QQ_SECRET`
environment variable. QQ and WeChat HTTP calls use bounded clients so a stalled
provider request cannot block the gateway indefinitely.
## Run the bot headlessly
@@ -114,7 +147,8 @@ runtime itself can also run as a long-lived headless gateway:
```sh
reasonix bot doctor
reasonix bot start --channels feishu,lark,weixin --dir /path/to/project
reasonix bot doctor --deep
reasonix bot start --channels qq,feishu,lark,weixin --dir /path/to/project
```
Use `--channels` to choose which configured IM inputs to accept. `feishu` and
@@ -135,17 +169,96 @@ The headless gateway uses the same config records as the desktop app:
- `workspace_root`, `model`, and `tool_approval_mode` can be set per
connection. This lets different IM channels route to different local projects
or approval postures.
- `access` can also be set per connection with `enabled`, `allow_all`,
`pairing_enabled`, `users`, `groups`, `admins`, and `approvers`. When a
connection has active access settings, they are checked before the legacy
global `[bot.allowlist]`.
- `[[bot.routes]]` adds finer routing by connection, platform, chat type, chat
ID, user ID, or thread ID. Empty match fields are wildcards; the first matching
route wins and can override `workspace_root`, `model`, and
`tool_approval_mode`.
- `session_mappings` are filled from inbound messages with the remote chat ID
and scope. The desktop UI can open the matching conversation once the mapping
also has a local `session_id` target, such as a saved `path:` session target
from a desktop-managed bot runtime or a manually configured mapping.
- The bot's project/session index is intentionally bounded to configured
`workspace_root` values, route workspaces, active bot sessions, and saved
`session_mappings`. Commands such as `/use project` and `/attach session`
can only jump to those indexed targets; arbitrary local directories are not
accepted from IM text.
Access control is still mandatory. Either enable an allowlist under
`[bot.allowlist]` with at least one relevant platform user ID, or set
`allow_all = true` deliberately. Group IDs are optional additional scoping for
group chats; they do not replace the required user allowlist. Remote users go
through the same controller, permission policy, tool approval mode, and sandbox
rules as local desktop or CLI turns.
Access control is still mandatory. New desktop-created bots should normally set
access inside that bot's own detail panel, which saves to `[[bot.connections]]`
or `[bot.qq].access`. The legacy global `[bot.allowlist]` remains a fallback for
older configs and for connections without active per-bot access. You can
deliberately set `allow_all = true`, or enable `pairing_enabled` for a single
bot / `[bot.pairing]` globally so an unknown DM sender receives a one-time
pairing code. That code must be approved locally with
`reasonix bot pairing approve <code>` before the sender can drive the bot; when
the request is tied to a connection, approval adds the sender to that
connection's access list. Users listed in `admins` / `approvers` or the legacy
`*_admins` / `*_approvers` also receive base bot admission, so they do not need
to be duplicated in `users` / `*_users`. Group chats are not opened by DM
pairing or role admission; group IDs remain an additional narrowing layer.
Use these commands to manage pending requests:
```sh
reasonix bot pairing list
reasonix bot pairing approve CODE
reasonix bot pairing reject CODE
```
If `qq_admins`, `feishu_admins`, `weixin_admins`, or the matching
`*_approvers` lists are configured, `/yolo` and `/mode` are admin-only while
`/projects`, `/use project`, `/sessions`, `/attach session`, and `/search all`
are also admin-only. `/approve` and `/deny` require an approver or admin. When
no role lists are set, existing allowlisted users keep the previous command
behavior for compatibility. Remote users go through the same controller,
permission policy, tool approval mode, and sandbox rules as local desktop or CLI
turns.
```toml
[bot.allowlist]
enabled = true
feishu_users = ["ou_member"]
feishu_admins = ["ou_admin"]
feishu_approvers = ["ou_approver"]
```
`ignore_self_messages = true` is enabled by default. The gateway remembers the
platform `message_id` values it just sent and ignores matching echo events. If a
platform does not echo the same message ID reliably, configure the bot's own user
IDs under `[bot.self_user_ids]` as a second layer of loop protection. `/status`
also includes the current queue mode and adapter health, such as
`feishu-lark=running` or `weixin-weixin=degraded`.
The optional `[bot.control]` section exposes a local loopback HTTP API and is
disabled by default. When enabled, `token_env` must point to an environment
variable and every request must include `Authorization: Bearer <token>`. The
server only binds to `localhost`, `127.0.0.1`, or `::1`. Current endpoints are
`GET /status` for session and adapter health snapshots, `GET /metrics` for
Prometheus text metrics, and `POST /send` for sending text or media through a
configured connection.
Example:
```sh
export REASONIX_BOT_CONTROL_TOKEN="change-me"
curl -H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
http://127.0.0.1:37913/status
curl -X POST http://127.0.0.1:37913/send \
-H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"connection_id": "feishu-lark",
"domain": "lark",
"chat_id": "oc_xxx",
"chat_type": "dm",
"text": "hello from local control API"
}'
```
## Usage flow
@@ -153,7 +266,7 @@ rules as local desktop or CLI turns.
sequenceDiagram
participant U as "User"
participant IM as "Feishu / Lark / WeChat / QQ"
participant R as "Reasonix desktop"
participant R as "Reasonix desktop or bot start"
participant T as "Local tools and model"
U->>IM: "Send a request"
@@ -195,8 +308,8 @@ without exposing real account IDs, local paths, or private chat content.
| --- | --- | --- | --- | --- |
| Feishu | Scan to create a PersonalAgent | Interactive card buttons, or commands | Interactive card buttons, or commands | Feishu workspaces, DMs, and groups |
| Lark | Scan to create a PersonalAgent | Interactive card buttons, or commands | Interactive card buttons, or commands | International Lark workspaces |
| WeChat | Scan with WeChat | Reply `1` / `2`, or commands | Single-choice questions can use a number, or commands | Lightweight personal/mobile testing |
| QQ | Manual setup (App ID + App Secret) | Inline keyboard buttons, numeric replies, or commands | Single-choice questions can use a number, or commands | QQ groups, DMs, and official QQ Bot platform |
| WeChat | Scan with WeChat | Reply `1` / `2`, or commands | Reply with normal text, option numbers, or commands | Lightweight personal/mobile testing |
| QQ | Manual setup (App ID + App Secret) | Inline keyboard buttons, numeric replies, or commands | Reply with normal text, option numbers, or commands | QQ groups, DMs, and official QQ Bot platform |
Feishu and Lark card buttons are converted into commands such as
`/approve <id>`, `/deny <id>`, or `/answer <id> <option>`. QQ approval buttons
@@ -210,7 +323,7 @@ These commands work in Feishu, Lark, WeChat, and QQ.
| Command | Purpose | Example |
| --- | --- | --- |
| `/help` | Show available commands | `/help` |
| `/status` | Show active tasks, retained sessions, and tool approval mode | `/status` |
| `/status` | Show active tasks, queue state, tool approval mode, and adapter health | `/status` |
| `/stop` | Stop the current task | `/stop` |
| `/new` | Start a fresh session | `/new` |
| `/reset` | Reset the current session | `/reset` |
@@ -225,14 +338,63 @@ These commands work in Feishu, Lark, WeChat, and QQ.
| `/mode yolo` | Switch to YOLO | `/mode yolo` |
| `/mode ask` | Switch to Ask mode | `/mode ask` |
| `/mode auto` | Switch to Auto mode | `/mode auto` |
| `/queue status` | Show the current queue mode | `/queue status` |
| `/queue steer` | Treat mid-run messages as guidance for the current task | `/queue steer` |
| `/queue followup` | Queue mid-run messages as later turns | `/queue followup` |
| `/queue collect` | Merge queued messages into one later turn | `/queue collect` |
| `/queue interrupt` | Cancel the current task and keep the newest message | `/queue interrupt` |
| `/projects [query]` | List indexed project workspaces | `/projects reasonix` |
| `/use project <id\|name>` | Route this remote session to an indexed project | `/use project p1` |
| `/use project default` | Clear the project override and return to configured routing | `/use project default` |
| `/sessions search <query>` | Search indexed desktop/bot sessions | `/sessions search release bug` |
| `/attach session <id\|query>` | Continue this remote session from an indexed `path:` transcript | `/attach session s1` |
| `/search all <query>` | Search file contents across indexed project roots | `/search all TODO` |
Shortcut replies:
- When an approval is pending, reply `1` to approve and `2` to deny.
- When a single-choice Ask question is pending, reply with the option number.
- When an Ask question is pending, reply with any normal non-slash text. Option
numbers still work for choice questions.
- Slash commands such as `/stop`, `/mode`, or `/answer ...` are not captured as
Ask shortcut replies.
- If there is no pending operation, `1` / `2` are treated as normal text or
produce guidance.
The default queue mode is `steer`: when the same session is already running, a
new message is injected as mid-turn guidance instead of waiting for the whole
turn to finish. `queue_cap` and `queue_drop` bound backlog growth in config.
`reasonix bot doctor --deep` reports queue, pairing, and role diagnostics.
Queue modes:
- `steer`: mid-run messages become guidance for the current turn when possible.
- `followup`: mid-run messages are queued as later turns.
- `collect`: queued messages are merged into one later turn.
- `interrupt`: the active turn is canceled and the newest message is kept as the
next turn.
Project and session navigation:
- `/projects [query]` lists workspaces from configured bot routes, connection
workspaces, active bot sessions, and saved session mappings.
- `/use project <id|name>` pins the current remote session to one indexed
project. `/use project default` clears the override.
- `/sessions search <query>` searches indexed desktop and bot session metadata.
- `/attach session <id|query>` continues the remote session from an indexed
`path:` transcript.
- `/search all <query>` searches file contents across indexed project roots.
Reasonix uses `rg` when available and falls back to a bounded Go scanner.
These navigation commands never accept arbitrary paths typed from IM. They only
jump to indexed targets and, when role lists are configured, require an admin.
When an adapter supplies media URLs, the gateway downloads those files into the
current workspace's `.reasonix/attachments` directory and passes them to
Reasonix as `@.reasonix/attachments/...` references. If an attachment cannot be
saved, the bot sends a short warning and continues with the available text. The
built-in Feishu, Weixin, and QQ adapters currently focus on text events; ordinary
IM attachment extraction can be added at the adapter layer.
## Approvals and YOLO
Reasonix bots use the same permission system as the desktop app. Ask mode is the
@@ -299,11 +461,11 @@ You may need to bind again if:
| Symptom | What to check |
| --- | --- |
| QR code says the link expired | Generate a new QR code in Settings; QR codes expire (Feishu, Lark, WeChat only — QQ uses manual setup and has no QR code). |
| Connected but no reply | Make sure the Reasonix desktop app is running, the bot connection is enabled, and the sender ID is allowlisted or access is open. |
| Connected but no reply | Make sure the desktop bot runtime or `reasonix bot start` process is running, the bot connection is enabled, and the sender ID is allowlisted, paired, or access is open. |
| Feishu or Lark button action fails | Send the text command from the card, such as `/approve <id>` or `/deny <id>`. |
| QQ button action fails | Same as Feishu/Lark — send the text command from the card, such as `/approve <id>` or `/deny <id>`. |
| WeChat reply `1` does nothing | Numeric shortcuts only work when an approval or single-choice Ask is pending; use the full command if needed. |
| QQ reply `1` does nothing | Same as WeChat — numeric shortcuts only work when an approval or single-choice Ask is pending; use the full command if needed. |
| WeChat reply `1` does nothing | Numeric shortcuts only work when an approval or Ask is pending; use the full command if needed. |
| QQ reply `1` does nothing | Same as WeChat — numeric shortcuts only work when an approval or Ask is pending; use the full command if needed. |
| Need to confirm the current mode | Send `/status` or `/yolo status`. |
| Need a fresh context | Send `/new` or `/reset`. |
| Need to stop the current task | Send `/stop`. |
+171 -23
View File
@@ -6,12 +6,13 @@
&nbsp;·&nbsp;
<a href="./GUIDE.zh-CN.md">通用指南</a>
> 面向桌面端用户。本文说明如何连接飞书、Lark、微信和 QQ 机器人,如何在 IM
> 里使用 Reasonix,以及审批、问答、YOLO 和常用命令的交互方式。
> 面向桌面端和 CLI 用户。本文说明如何连接飞书、Lark、微信和 QQ 机器人,
> 如何在 IM 里使用 Reasonix,以及审批、问答、YOLO 和常用命令的交互方式。
## 目录
- [能做什么](#能做什么)
- [在哪里运行](#在哪里运行)
- [连接四个渠道](#连接四个渠道)
- [无界面运行 Bot](#无界面运行-bot)
- [使用流程](#使用流程)
@@ -23,8 +24,9 @@
## 能做什么
连接机器人后,你可以在飞书、Lark 或微信里给 Reasonix 发消息,让桌面端
Reasonix 在本机执行同一套模型、工具、权限与沙盒逻辑。
连接机器人后,你可以在飞书、Lark、微信或 QQ 里给 Reasonix 发消息,让桌面端
Reasonix 或 `reasonix bot start` 进程在本机执行同一套模型、工具、权限与
沙盒逻辑。
典型场景:
@@ -34,6 +36,24 @@ Reasonix 在本机执行同一套模型、工具、权限与沙盒逻辑。
- 对临时测试任务开启 YOLO,跳过普通工具审批。
- 打开桌面端对应 IM 会话,继续查看上下文、成本、tokens 和工具轨迹。
## 在哪里运行
Bot gateway 是一套共享的 Go runtime。核心行为在 Windows、macOS 和 Linux
上都生效;实际差异主要来自各 IM 平台的凭据、网络、回调或 WebSocket 配置,
以及本机保存的账号状态。
目前有两个入口:
- **桌面端 runtime**:在 **设置 -> 机器人** 中配置。桌面端会启动 gateway,
在应用内维护状态,持久化每个连接的工具审批模式变化,并允许打开匹配的
本地 IM 会话。
- **CLI runtime**:执行 `reasonix bot start` 启动无界面长期进程。它复用
与桌面端相同的配置、白名单、路由、队列设置、配对存储、适配器和
项目/会话索引。
普通 `reasonix run` 不会自动启动 IM 网关。只有桌面端 bot runtime 正在运行,
或存在一个存活的 `reasonix bot start` 进程时,远端 IM bot 能力才会生效。
## 连接四个渠道
打开桌面端 Reasonix,进入 **设置 -> 机器人**。在 **添加 IM Bot** 区域选择
@@ -73,7 +93,12 @@ flowchart LR
5. 给 Lark Bot 发送消息。
飞书和 Lark 使用同一套能力,但作为两个独立连接保存。你可以给它们设置不同
模型、工作目录或工具审批模式。
模型、工作目录或工具审批模式。Bot 文本回复会以独立 Interactive Card JSON
2.0 markdown 发送,避免飞书/Lark 平台级引用前缀,同时保留 CommonMark
格式;如果卡片超过平台限制,Reasonix 会自动降级为纯文本。
Webhook 模式需要配置 verification token。传入事件会 fail-closed 校验:如果
配置中的 token 为空或缺失,调用方会被拒绝,不会静默开放 webhook。
### 微信
@@ -83,7 +108,8 @@ flowchart LR
4. 等待页面显示已连接。
5. 给微信 Bot 发送消息。
微信没有交互卡片按钮,因此审批和问答主要通过文字命令完成。
微信没有交互卡片按钮,因此审批通过数字或文字命令完成。Ask 问题可以直接
回复普通文本、选项编号,或使用 `/answer <id> <回答>`。
### QQ
@@ -94,11 +120,13 @@ flowchart LR
5. 给 QQ Bot 发送消息。
QQ Bot 使用官方 QQ Bot 平台 API。它支持内联键盘按钮来完成审批。
Ask 问答会以文字形式发送;单选题可以直接回复选项编号,也可以用
`/answer <id> <选项>`。当按钮过期或平台提示操作失败时,可以直接
复制卡片里的 ID,用文字命令继续。
Ask 问答会以文字形式发送;可以直接回复普通文本、选项编号,或使用
`/answer <id> <回答>`。当按钮过期或平台提示操作失败时,可以直接复制
卡片里的 ID,用文字命令继续。
QQ 不支持扫码连接,必须手动配置 App ID 和 App Secret。
QQ 不支持扫码连接,必须手动配置 App ID 和 App Secret。适配器只读取配置的
`app_secret_env`,不会再回退读取无关的 `QQ_SECRET` 环境变量。QQ 和微信的
HTTP 调用使用带超时的 client,避免平台请求卡住后无限阻塞 gateway。
## 无界面运行 Bot
@@ -107,7 +135,8 @@ QQ 不支持扫码连接,必须手动配置 App ID 和 App Secret。
```sh
reasonix bot doctor
reasonix bot start --channels feishu,lark,weixin --dir /path/to/project
reasonix bot doctor --deep
reasonix bot start --channels qq,feishu,lark,weixin --dir /path/to/project
```
`--channels` 用来选择接受哪些已配置的 IM 输入。`feishu` 和 `lark` 会选择对应
@@ -124,15 +153,88 @@ Bot。`--dir` 用来把远端消息绑定到某个项目工作区,`--model`
密钥仍保存在环境变量或 Reasonix 用户凭据中。
- `workspace_root`、`model` 和 `tool_approval_mode` 可以按连接单独设置,
因此不同 IM 渠道可以路由到不同本地项目或审批模式。
- `access` 也可以按连接单独设置,包括 `enabled`、`allow_all`、
`pairing_enabled`、`users`、`groups`、`admins` 和 `approvers`。当某个连接
有启用中的 access 配置时,会优先检查该连接自己的访问控制,再退回旧的全局
`[bot.allowlist]`。
- `[[bot.routes]]` 可以继续按远端连接、平台、会话类型、chat ID、用户 ID
或 thread ID 做更细粒度路由;空匹配字段表示通配,按配置顺序第一个命中。
命中后可覆盖 `workspace_root`、`model` 和 `tool_approval_mode`。
- `session_mappings` 会根据收到的远端消息自动填充远端 chat ID 和作用域。
只有当该映射同时具备本地 `session_id` 目标时,桌面端才能打开对应会话;
例如桌面端托管的 Bot runtime 保存了 `path:` 会话目标,或用户手动配置了
映射目标。
- Bot 的项目/会话索引只来自已配置的 `workspace_root`、route workspace、
当前活跃 bot 会话,以及已保存的 `session_mappings`。`/use project` 和
`/attach session` 只能跳到这些已索引目标;IM 文本里临时输入的任意本地
目录不会被接受。
访问控制仍然是必需项。你需要在 `[bot.allowlist]` 下为对应平台至少配置一个
用户 ID,或者有意设置 `allow_all = true`。群 ID 是群聊里的额外收窄条件,
不能替代用户白名单。远端用户进入的是同一个 Reasonix controller、权限策略、
工具审批模式和沙盒边界,和本地桌面端或 CLI 回合一致。
访问控制仍然是必需项。桌面端新建的 Bot 通常建议在该 Bot 自己的详情面板里
设置谁可以使用,这会保存到 `[[bot.connections]]` 或 `[bot.qq].access`。
旧的全局 `[bot.allowlist]` 仍然作为兼容兜底,适用于旧配置或没有启用单
Bot access 的连接。你可以有意设置 `allow_all = true`,也可以为单个 Bot
启用 `pairing_enabled`,或全局启用 `[bot.pairing]`,让未知私聊用户先收到
一次性配对码。配对码需要在本机执行 `reasonix bot pairing approve <code>`
后才会放行;如果该请求带有连接 ID,批准后会把发送者加入对应 Bot 自己的
access 名单。列在 `admins` / `approvers` 或旧的 `*_admins` /
`*_approvers` 里的用户也会获得基础 bot 准入,不需要再重复写进 `users` /
`*_users`。群聊不会因为私聊配对或角色准入自动开放,群 ID 仍是额外收窄
条件。常用配对管理命令:
```sh
reasonix bot pairing list
reasonix bot pairing approve CODE
reasonix bot pairing reject CODE
```
如果配置了 `qq_admins`、`feishu_admins`、`weixin_admins` 或对应
`*_approvers`,`/yolo`、`/mode` 等运行模式命令只允许 admin 使用;
`/projects`、`/use project`、`/sessions`、`/attach session` 和
`/search all` 也只允许 admin 使用。`/approve` 和 `/deny` 只允许 approver
或 admin 使用。没有配置角色列表时,为兼容旧配置,已允许的用户保持原有
命令能力。远端用户进入的是同一个 Reasonix controller、权限策略、工具
审批模式和沙盒边界,和本地桌面端或 CLI 回合一致。
```toml
[bot.allowlist]
enabled = true
feishu_users = ["ou_member"]
feishu_admins = ["ou_admin"]
feishu_approvers = ["ou_approver"]
```
默认启用 `ignore_self_messages = true`。网关会记录刚发出的平台
`message_id`,并忽略平台回传的同 ID 消息;如果某个平台不会稳定回传同一
消息 ID,可以在 `[bot.self_user_ids]` 里配置 bot 自己的用户 ID 作为第二层
回声防护。`/status` 会显示当前会话队列模式和各连接的健康状态,例如
`feishu-lark=running` 或 `weixin-weixin=degraded`。
可选的 `[bot.control]` 提供本机 loopback HTTP API,默认关闭。启用后必须
设置 `token_env` 对应的环境变量,所有请求都需要
`Authorization: Bearer <token>`;地址只能绑定 `localhost`、`127.0.0.1`
或 `::1`。当前接口包括 `GET /status`(会话与连接健康快照)和
`GET /metrics`(Prometheus 文本指标)以及 `POST /send`(向指定连接发送
文本或媒体消息)。
示例:
```sh
export REASONIX_BOT_CONTROL_TOKEN="change-me"
curl -H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
http://127.0.0.1:37913/status
curl -X POST http://127.0.0.1:37913/send \
-H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"connection_id": "feishu-lark",
"domain": "lark",
"chat_id": "oc_xxx",
"chat_type": "dm",
"text": "hello from local control API"
}'
```
## 使用流程
@@ -140,7 +242,7 @@ Bot。`--dir` 用来把远端消息绑定到某个项目工作区,`--model`
sequenceDiagram
participant U as "用户"
participant IM as "飞书 / Lark / 微信 / QQ"
participant R as "Reasonix 桌面端"
participant R as "Reasonix 桌面端或 bot start"
participant T as "本机工具与模型"
U->>IM: "发送需求"
@@ -180,8 +282,8 @@ sequenceDiagram
| --- | --- | --- | --- | --- |
| 飞书 | 扫码创建 PersonalAgent | 交互卡片按钮,也可用命令 | 交互卡片按钮,也可用命令 | 国内飞书工作流、群聊或个人助手 |
| Lark | 扫码创建 PersonalAgent | 交互卡片按钮,也可用命令 | 交互卡片按钮,也可用命令 | 国际版 Lark 工作流 |
| 微信 | 微信扫码登录 | 回复 `1` / `2` 或命令 | 单选可直接回复编号,也可用命令 | 微信个人测试、轻量移动触发 |
| QQ | 手动配置(App ID + App Secret) | 内联键盘按钮、数字回复或命令 | 单选可直接回复编号,也可用命令 | QQ 群聊、个人会话和官方 QQ Bot 平台 |
| 微信 | 微信扫码登录 | 回复 `1` / `2` 或命令 | 回复普通文本、选项编号或命令 | 微信个人测试、轻量移动触发 |
| QQ | 手动配置(App ID + App Secret) | 内联键盘按钮、数字回复或命令 | 回复普通文本、选项编号或命令 | QQ 群聊、个人会话和官方 QQ Bot 平台 |
飞书和 Lark 的卡片按钮会在后台转换为命令,例如 `/approve <id>`、
`/deny <id>` 或 `/answer <id> <选项>`。QQ 的审批按钮也是如此。
@@ -195,7 +297,7 @@ sequenceDiagram
| 命令 | 作用 | 示例 |
| --- | --- | --- |
| `/help` | 查看可用命令 | `/help` |
| `/status` | 查看活跃任务、保留会话数和工具审批模式 | `/status` |
| `/status` | 查看活跃任务、队列、工具审批模式和连接健康 | `/status` |
| `/stop` | 停止当前任务 | `/stop` |
| `/new` | 开始新会话 | `/new` |
| `/reset` | 重置当前会话 | `/reset` |
@@ -210,13 +312,59 @@ sequenceDiagram
| `/mode yolo` | 切换到 YOLO | `/mode yolo` |
| `/mode ask` | 切换到询问模式 | `/mode ask` |
| `/mode auto` | 切换到自动模式 | `/mode auto` |
| `/queue status` | 查看当前队列模式 | `/queue status` |
| `/queue steer` | 运行中消息作为当前任务补充 | `/queue steer` |
| `/queue followup` | 运行中消息排队为后续回合 | `/queue followup` |
| `/queue collect` | 合并排队消息为一个后续回合 | `/queue collect` |
| `/queue interrupt` | 取消当前任务并处理最新消息 | `/queue interrupt` |
| `/projects [关键词]` | 查看已索引项目工作区 | `/projects reasonix` |
| `/use project <id\|名称>` | 将当前远端会话路由到已索引项目 | `/use project p1` |
| `/use project default` | 清除项目覆盖,恢复配置路由 | `/use project default` |
| `/sessions search <关键词>` | 搜索已索引桌面/bot 会话 | `/sessions search 发布 bug` |
| `/attach session <id\|关键词>` | 从已索引 `path:` transcript 继续当前远端会话 | `/attach session s1` |
| `/search all <关键词>` | 跨已索引项目检索文件内容 | `/search all TODO` |
快捷回复:
- 有待审批操作时,回复 `1` 表示批准,回复 `2` 表示拒绝。
- 有单选 Ask 问题时,可以直接回复选项编号。
- 有待处理 Ask 问题时,可以直接回复任意普通非 slash 文本;选择题仍然可以
直接回复选项编号。
- `/stop`、`/mode`、`/answer ...` 等 slash 命令不会被 Ask 快捷回复截获。
- 如果没有待处理操作,`1` / `2` 会被当作普通消息或收到提示。
默认队列模式是 `steer`:同一会话正在运行时,新消息会作为当前任务的
mid-turn guidance 注入,而不是等完整回合结束。`queue_cap` 和 `queue_drop`
可以在配置里限制排队堆积;`reasonix bot doctor --deep` 会显示当前队列、
配对和角色诊断信息。
队列模式:
- `steer`:运行中的消息会尽量作为当前回合的补充指导。
- `followup`:运行中的消息排队成为后续回合。
- `collect`:把排队消息合并成一个后续回合。
- `interrupt`:取消当前回合,并保留最新消息作为下一回合。
项目与会话跳转:
- `/projects [关键词]` 会列出来自 bot route、连接工作区、活跃 bot 会话和
已保存 session mapping 的工作区。
- `/use project <id|名称>` 会把当前远端会话固定到某个已索引项目;
`/use project default` 会清除覆盖。
- `/sessions search <关键词>` 搜索已索引桌面端和 bot 会话元数据。
- `/attach session <id|关键词>` 从已索引 `path:` transcript 继续当前远端
会话。
- `/search all <关键词>` 跨已索引项目根目录检索文件内容;有 `rg` 时优先用
`rg`,否则使用带边界的 Go fallback scanner。
这些跳转命令不会接受 IM 中临时输入的任意本地路径,只能跳到索引内目标;
配置了角色列表时,这些命令需要 admin 权限。
当适配器提供媒体 URL 时,gateway 会把文件下载到当前工作区的
`.reasonix/attachments`,并以 `@.reasonix/attachments/...` 形式传给
Reasonix。保存失败的附件会在 IM 中提示,文本内容仍会继续处理。内置
Feishu、Weixin、QQ 适配器当前仍以文本事件为主,普通 IM 附件抽取可以继续在
适配器层补齐。
## 审批与 YOLO
Reasonix 的机器人沿用桌面端权限系统。默认是询问模式:写文件、执行命令等
@@ -277,11 +425,11 @@ YOLO 的边界很重要:
| 现象 | 可以检查 |
| --- | --- |
| 扫码提示链接失效 | 回到设置页重新生成二维码;二维码有有效期(飞书、Lark、微信;QQ 不使用扫码,请检查手动配置)。 |
| 已连接但没有回复 | 确认桌面端 Reasonix 正在运行,Bot 连接已开启,用户 ID 在白名单内或允许所有人。 |
| 已连接但没有回复 | 确认桌面端 bot runtime 或 `reasonix bot start` 进程正在运行,Bot 连接已开启,用户 ID 在白名单内、已配对或允许所有人。 |
| 飞书或 Lark 按钮提示失败 | 直接发送卡片里的命令,例如 `/approve <id>` 或 `/deny <id>`。 |
| QQ 按钮提示失败 | 与飞书/Lark 相同 —— 直接发送卡片里的命令,例如 `/approve <id>` 或 `/deny <id>`。 |
| 微信回复 `1` 没反应 | 只有存在待审批或单选 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
| QQ 回复 `1` 没反应 | 与微信相同 —— 只有存在待审批或单选 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
| 微信回复 `1` 没反应 | 只有存在待审批或 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
| QQ 回复 `1` 没反应 | 与微信相同 —— 只有存在待审批或 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
| 想确认当前模式 | 发送 `/status` 或 `/yolo status`。 |
| 想重新开始上下文 | 发送 `/new` 或 `/reset`。 |
| 想停止当前任务 | 发送 `/stop`。 |
+5
View File
@@ -117,6 +117,11 @@ complete chat request URL, set `chat_url`; Reasonix will use it directly and wil
not append `/chat/completions`. If model discovery needs a separate address, set
`models_url`.
If a gateway requires vendor-specific top-level request body fields, set
`extra_body`, for example `extra_body = { enable_thinking = true }`. These values
are merged into the OpenAI-compatible chat JSON request body without allowing
core fields such as `model`, `messages`, `tools`, or `stream` to be overridden.
## Global `.env`
`<Reasonix home>/.env` is the single runtime source for provider API keys saved
+29 -1
View File
@@ -117,7 +117,7 @@ Hooks 让 Reasonix 在会话、用户输入、工具调用、模型返回、压
| `UserPromptSubmit` | 用户输入提交后、本轮模型调用前 | 是 | 无特殊作用 |
| `Stop` | 一轮对话结束后 | 否 | 无特殊作用 |
| `PostLLMCall` | 模型流式返回完成后,reasoning 入库前 | 否 | exit 0 且 stdout 非空时,用 stdout 替换展示的 reasoning |
| `SessionStart` | 会话第一次变为活跃,或 `/new`、清空后新会话开始 | 否 | 无特殊作用 |
| `SessionStart` | 会话第一次变为活跃,或 `/new`、清空后新会话开始 | 否 | stdout 会作为下一轮模型上下文注入 |
| `SessionEnd` | 会话关闭、切换、`/new`、清空或控制器释放时 | 否 | 无特殊作用 |
| `SubagentStop` | 前台 `task` 子代理完成后 | 否 | 无特殊作用 |
| `Notification` | 需要用户注意时,例如等待工具审批 | 否 | 无特殊作用 |
@@ -166,9 +166,37 @@ stdout 和 stderr 会被捕获、去掉首尾空白,并限制单路输出最
特殊 stdout 行为:
- `PostLLMCall`:exit 0 且 stdout 非空时,stdout 会替换用户看到的 reasoning。若 provider 的 reasoning 带签名,Reasonix 会保留原始 signed reasoning 用于后续请求,同时仍展示 hook 转换后的文本。
- `SessionStart`:exit 0 且 stdout 非空时,stdout 会作为一次性 `<hook-context event="SessionStart">` 注入下一轮真实用户输入。纯文本 stdout 会原样作为上下文;也可以输出 Claude Code / Codex 兼容 JSON:
```json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Load the workspace conventions before editing."
}
}
```
`hookEventName` 必须与当前事件一致。该上下文不会写入 system prompt、工具 schema 或项目记忆;它只影响下一轮模型请求。单个 hook 上下文最多保留约 10000 字符,总量最多约 20000 字符,超出会截断并标记。
- `PreCompact`:所有非空 stdout 会按换行拼接,作为本次压缩摘要的额外指导。
- 其它事件:stdout 只在非通过结果中作为提示文本使用,不会自动进入模型上下文。
## 示例:SessionStart 注入启动上下文
```json
{
"hooks": {
"SessionStart": [
{
"command": "printf '%s\\n' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Before coding, check the available skills and follow matching workflows.\"}}'"
}
]
}
}
```
这适合把插件或工作流的 bootstrap 说明带入会话。比如 Superpowers 不需要内置到 Reasonix;可以让它自己的 `hooks/session-start-codex` 在 `SessionStart` 输出 `additionalContext`,或让插件根目录 `CLAUDE.md` 被插件包兼容层直接作为 `SessionStart` 上下文读取,Reasonix 会在下一轮把这段说明注入模型上下文。插件包兼容层也会读取 `.claude/settings.json` 里的 command hooks,并按同名事件映射到 Reasonix hooks。Reasonix 默认允许 `max_subagent_depth = 2`,因此 Superpowers 的父会话或第一层 workflow subagent 可以再派发 reviewer/implementer subagent;第二层不会继续获得递归委派工具。若要恢复旧的单层边界,设 `agent.max_subagent_depth = 1`。这会改变子代理可见工具面,可能影响子代理请求的 prompt cache,但不会把 Superpowers 写进 Reasonix 的稳定 system prompt。
## 示例:阻止危险 bash 命令
`.reasonix/settings.json`:
+141 -12
View File
@@ -17,6 +17,7 @@
- [Configuration paths](./CONFIG_PATHS.md)
- [Reasoning language](./REASONING_LANGUAGE.md)
- [Custom OpenAI-compatible providers](#custom-openai-compatible-providers)
- [Desktop hooks](#desktop-hooks)
- [Keyboard shortcuts](#keyboard-shortcuts)
- [Permissions & sandbox](#permissions--sandbox)
- [Plugins (MCP)](#plugins-mcp)
@@ -60,9 +61,11 @@ planner_max_steps = 0 # user/global only; planner read-only tool-call
reasoning_language = "auto" # visible reasoning text: auto|zh|en
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only custom tools only;
# # does not unlock blocked tools or unsafe bash
# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # extra read-only shell prefixes for planning
# planner_model = "deepseek-pro" # optional low-frequency planner
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
# max_subagent_depth = 2 # nested delegation depth; set 1 for the old single-layer boundary
auto_plan = "off" # user-level only; off|on; off keeps plan mode manual
# auto_plan_classifier = "deepseek-flash" # optional; only borderline tasks call it
tool_result_snip_ratio = 0.6 # shorten stale tool output before summary compaction
@@ -125,6 +128,18 @@ compatibility escape valve. It never unlocks known blocked plan-mode tools such
as `bash`, `task`, writers, installers, or memory mutation tools, and it never
bypasses bash's plan-mode safety checks.
Use `[agent].plan_mode_read_only_commands` when plan-mode research needs a
specific shell command that Reasonix cannot classify but you know is read-only,
such as `gh issue view` or an internal query CLI. Entries are concrete command
prefixes, not tool names: `["gh issue view"]` permits `gh issue view 4572`, while
`bash`, `sh`, and other shell interpreters are ignored. Shell operators,
redirection, command substitution, background execution, and unsafe built-in
command flags remain blocked while planning. In interactive plan mode, Reasonix
can also ask you to trust a concrete unknown query prefix the first time it is
needed; the persistent choice writes the same
`[agent].plan_mode_read_only_commands` entry. Auto/YOLO approval never answers
this trust prompt.
### Environment variables
Most day-to-day settings belong in `config.toml` or the global Reasonix `.env`
@@ -203,7 +218,29 @@ user-global `default_model`.
In the desktop app, open **Settings -> Model -> Access -> Add model service ->
Custom provider** for proxies, aggregators, or self-hosted services that speak
the OpenAI-compatible chat API.
the OpenAI-compatible chat API or Anthropic-compatible Messages API.
For common providers, choose **Add model service -> Recommended preset** instead.
Reasonix can prefill editable custom-provider entries for Kimi CN, Kimi Global,
Kimi Coding Plan, MiMo API, MiMo Anthropic, MiMo Token Plan CN/SGP/AMS and their
Anthropic-compatible variants, MiniMax CN/Global API, MiniMax CN/Global
Anthropic, GLM CN, Z.AI Global, GLM/Z.AI Coding Plan OpenAI-compatible and
Anthropic-compatible endpoints, OpenCode Go, OpenCode Go Anthropic, OpenCode Zen
Anthropic, Qwen/DashScope CN/Global, Qwen Coding Plan CN/Global
OpenAI-compatible and Anthropic-compatible endpoints, StepFun OpenAI-compatible
and Anthropic-compatible endpoints, NovitaAI, GMI Cloud, Vercel AI Gateway,
HuggingFace Router, NVIDIA NIM, KiloCode, and Ollama Cloud. Plan names describe
the access/payment route; they include CN/Global only when the provider exposes
distinct regional endpoints. Kimi Coding Plan is therefore a dedicated plan
endpoint, while Kimi direct API is split into CN and Global. The preset path
usually needs only the provider API key: the key value is stored in Reasonix home
`.env`, while `config.toml` stores the endpoint, model list, key
environment-variable name, context window, vision model metadata, proxy bypass
for China-only endpoints, MiniMax `reasoning_split`, GLM/MiniMax thinking
heuristics, Anthropic-compatible Bearer auth where needed, Ollama Cloud
max-effort support, and OpenCode Go per-model reasoning overrides. After adding
a preset, open its provider card if you need to change models, headers,
endpoint, or compatibility settings.
Fill **API address** with the provider endpoint that should receive the standard
chat path. In this mode Reasonix previews and sends chat requests to:
@@ -219,13 +256,90 @@ preview under the field shows the exact request URL that will be used.
Model discovery uses the API address to try likely model-list URLs such as
`/models` and `/v1/models`. If the gateway requires a separate model-list
endpoint, open **Advanced settings** and set `models_url`, for example
endpoint, open **Compatibility settings** and set `models_url`, for example
`https://gateway.example.com/v1/models`. If discovery is not available, fill the
model list manually.
**Full URL** still uses the OpenAI-compatible chat request body. It does not
switch the request schema to the OpenAI Responses API.
### Compatibility settings
The **Compatibility settings (usually leave unchanged)** section is for gateways
whose authentication, model-list endpoint, or reasoning/thinking request shape
differs from the normal OpenAI-compatible defaults. Leave these fields at their
defaults unless the provider documentation or a proxy error tells you otherwise.
For Anthropic-compatible services, such as some coding-plan endpoints, choose
**Anthropic-compatible** as the connection protocol before saving.
| Field | What it controls | When to change it |
| --- | --- | --- |
| `api_key_env` | The environment-variable name used for this provider's API key. Desktop-saved key values are stored in Reasonix home `.env` under this name; the TOML config stores only the name. | Change it when several providers need distinct keys, or leave it blank for a service that does not require an API key. |
| `models_url` | The URL used only for model discovery. Chat requests still use the API address or Full URL above. | Set it when `/models` or `/v1/models` is not where the gateway exposes its model list. |
| Extra request headers | Static HTTP headers, one `Header: value` per line. | Use for gateways such as OpenRouter that require `HTTP-Referer`, `X-Title`, or similar site headers. Keep bearer/API keys in the key field instead of duplicating them here. |
| Extra request body | A JSON object merged into the top-level chat request body. | Use only for provider-specific flags such as `{"enable_thinking": true}`. Reasonix still owns core fields such as `model`, `messages`, `tools`, `stream`, and `thinking`, and null values are rejected. |
| Authorization: Bearer | For Anthropic-compatible providers, sends the saved API key as `Authorization: Bearer <key>` instead of `x-api-key`. | Enable it only when the gateway documents Bearer auth, such as MiniMax Global or Vercel AI Gateway. |
| Model capability mode | Which reasoning request protocol Reasonix should use for this provider. | Keep **Auto-detect** unless the gateway is misdetected or the model docs require a specific reasoning format. |
| Thinking override | Provider-specific override for `thinking.type`. | Keep **Auto** unless the backend documents `enabled`, `disabled`, or `adaptive`. Unsupported values can make some OpenAI-compatible gateways reject the request. |
| Balance URL | Optional endpoint for wallet/balance lookup. | Set it when the provider exposes a balance endpoint and you want the desktop status bar to show it. |
| Context window | The maximum number of tokens this provider keeps in context. `0` means provider default. | Set it when the model's real context size differs from Reasonix's default or built-in metadata. |
Model capability mode options:
| Option | Effect |
| --- | --- |
| Auto-detect (recommended) | Reasonix chooses the request shape from model capability metadata and endpoint detection. |
| DeepSeek thinking | Uses DeepSeek-style thinking control, including `thinking.type` and DeepSeek-supported reasoning depth. |
| OpenAI reasoning | Uses the standard OpenAI-compatible `reasoning_effort` levels. |
| Plain chat | Sends no reasoning or thinking control fields. Use this for text-only proxies that reject reasoning parameters. |
Thinking override options:
| Option | Effect |
| --- | --- |
| Auto (provider default) | Does not write an explicit provider-level `thinking` override. Reasonix uses the provider/model default behavior. |
| Enabled | Sends `thinking.type = "enabled"` for compatible providers. |
| Disabled | Sends `thinking.type = "disabled"` for compatible providers. On DeepSeek-style providers this also avoids sending a reasoning depth hint. |
| Adaptive (self-adjusting) | Sends or preserves `thinking.type = "adaptive"` only for providers that document adaptive thinking, such as MiniMax-M3-style endpoints. |
Some OpenAI-compatible gateways require non-standard top-level request body
fields. Add them with `extra_body` on the provider entry:
```toml
[[providers]]
name = "spark"
kind = "openai"
base_url = "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2"
models = ["xopglm52"]
api_key_env = "SPARK_API_KEY"
extra_body = { enable_thinking = true }
```
`extra_body` is merged into the chat JSON request body. Reasonix keeps core
fields such as `model`, `messages`, `tools`, `stream`, and `thinking` under its
own control.
## Desktop hooks
Desktop hooks run local commands at lifecycle events such as `SessionStart`,
`UserPromptSubmit`, `PreToolUse`, and `PreCompact`. A successful `SessionStart`
hook may write plain text to stdout, or return JSON with
`hookSpecificOutput.additionalContext`; Reasonix injects that text once into the
next real user turn as `<hook-context event="SessionStart">...</hook-context>`.
This is intended for plugin or workflow bootstrap context, including
Superpowers-style startup instructions, without baking that workflow into
Reasonix's system prompt.
Plugin packages can provide this startup context through
`hooks/session-start-codex` or a plugin-root `CLAUDE.md`. Claude-style
`.claude/settings.json` command hooks are also mapped to matching Reasonix hook
events.
The injected hook context is dynamic current-turn context. It does not change
the stable system prompt, memory prefix, or tool schema, though dynamic content
can still reduce cache reuse for that turn. The detailed desktop hook schema and
trust model are documented in [the Chinese desktop hooks guide](./DESKTOP_HOOKS.zh-CN.md).
## Keyboard shortcuts
Shortcuts are documented by client because users usually look for the keys that
@@ -302,7 +416,8 @@ Chat and transcript shortcuts:
| `Ctrl+L` or `/cls` | Clears only the visible transcript | The LLM context, session file, tools, memory, and plugins stay loaded. Use `/clear` when you want to discard the conversation context. |
| `Esc` | Backs out of the current action | It un-sends a just-submitted turn before any reply, cancels a running turn, or clears non-empty input. |
| Double `Esc` on an empty idle composer | Opens the rewind picker | Same entry point as `/rewind`. |
| Transcript text selection | Copies transcript text | The full-screen TUI enables mouse reporting, so drag in the transcript to select text in-app, then press `Ctrl+C`, `Super+C`, `Meta+C`, or right-click the active selection to copy it. |
| Transcript text selection | Copies transcript text | The full-screen TUI enables mouse reporting, so drag in the transcript to select text in-app; releasing the mouse copies it automatically, and `Ctrl+C`/`Super+C`/`Meta+C` or right-clicking the active selection copy it again. |
| `/mouse` | Toggles in-app mouse capture | Off hands the mouse back to your terminal, restoring its native click-drag selection and right-click context menu, at the cost of in-app drag-select, the transcript scrollbar, and wheel-scroll. Set `REASONIX_DISABLE_MOUSE=1` to start every session with it off. |
| `Ctrl+C` | Copies, cancels, clears, or quits | Copies an active transcript selection first. Otherwise it cancels a running turn, clears non-empty input, or quits on a second empty-composer press. |
| `Ctrl+D` | Quits the TUI | Immediate quit. |
| `Ctrl+V`, `Ctrl+Shift+V`, `Meta+V`, or `Super+V` | Pastes clipboard content | The CLI tries an image first, then falls back to text or file references. |
@@ -363,13 +478,14 @@ edits stay in the project), resolving symlinks and `..` so a link can't tunnel
out. `forbid_read` optionally hides sensitive directories from the agent's
read/list/search tools; use absolute paths or `${HOME}` / `${VAR}` references,
not `~`, because config expansion is environment-variable based. `bash` is
itself jailed on macOS by default (`[sandbox] bash`, Seatbelt): commands may
write only those same roots (plus temp and toolchain caches), cannot read
configured `forbid_read` roots while the OS sandbox is active, and reach the
network only when `[sandbox] network` is set. Other platforms fall back to
running unconfined when no OS sandbox is available (see
itself jailed by default when an OS sandbox is available (`[sandbox] bash`,
Seatbelt on macOS and bubblewrap on Linux): commands may write only those same
roots (plus temp and toolchain caches), cannot read configured `forbid_read`
roots while the OS sandbox is active, and reach the network only when
`[sandbox] network` is set. When no OS sandbox is available, `bash = "enforce"`
refuses bash execution instead of running unconfined (see
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt and
Linux support still to come).
broader OS support still to come).
## Plugins (MCP)
@@ -472,8 +588,10 @@ archives, and saved facts on demand instead of injecting that dynamic state into
the stable system prompt. `/forget <name>` archives a saved fact rather than
deleting it permanently; the CLI/TUI and desktop memory panel can show those
archived files for traceability, but they are not searched as active memory.
Agent-initiated `remember` and `forget` calls always ask for fresh approval and
show a compact preview of the saved or archived memory before they run.
Agent-initiated `remember` and `forget` calls always ask for fresh human approval
and show a compact preview of the saved or archived memory before they run.
Guardian review cannot answer for the user; non-interactive runs refuse these
tools instead of auto-approving them.
Retrieval keeps the top BM25 result while trimming weak common-word matches, and
0-result responses suggest narrower, more distinctive follow-up searches.
Memory v5 is enabled by default across the CLI/TUI, `reasonix serve`, and the
@@ -611,11 +729,22 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
run them on another configured model, or use `subagent_models` to override only
specific skills such as `review` or `security_review`.
Subagents may delegate one more layer by default: the root session is depth 0,
first-layer subagents are depth 1, and the maximum `max_subagent_depth = 2`
means a depth-1 workflow can dispatch a depth-2 reviewer or implementer. Depth-2
subagents do not receive recursive agent/skill tools. Set
`agent.max_subagent_depth = 1` to restore the old single-layer boundary. This is
intended for workflows such as Superpowers where a workflow skill may dispatch a
reviewer subagent, while still avoiding unbounded recursion and background
fanout.
Use `read_only_task` when planning needs isolated, deeper research without
granting write-capable delegation. Use `read_only_skill` when the same need is
best expressed through an existing skill. Both run ephemeral read-only
subagents with only read-only research tools plus safe foreground bash, return
only the final answer, and do not create resumable subagent transcripts. In
only the final answer, and do not create resumable subagent transcripts.
Read-only nested delegation may be available until `max_subagent_depth` is
reached, but writer-capable `task` / `run_skill` remain unavailable. In
token economy mode, connect only this narrow surface with
`connect_tool_source(source="read_only_skill")`; the full `skills` source still
enables writer-capable skill tools and remains blocked in plan mode.
+79 -4
View File
@@ -37,6 +37,8 @@ Reasonix 全局 `<Reasonix home>/.env`。项目 `.env`、home `.env`、继承的
桌面端和 CLI 端的可见思考语言设置,见 [思考语言](./REASONING_LANGUAGE.zh-CN.md)。
桌面端 Hooks 的 JSON 配置、事件 key 和 payload 字段,见 [桌面端 Hooks](./DESKTOP_HOOKS.zh-CN.md)。
`SessionStart` hook 可通过 stdout 或 `hookSpecificOutput.additionalContext` 把插件/工作流 bootstrap 内容一次性注入下一轮真实用户输入上下文,而不是写入稳定 system prompt。
插件包可通过 `hooks/session-start-codex` 或插件根目录 `CLAUDE.md` 提供该启动上下文;Claude 风格 `.claude/settings.json` command hooks 也会按同名事件映射到 Reasonix hooks。
```toml
default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可加规划器
@@ -52,9 +54,11 @@ planner_max_steps = 0 # 仅用户/全局;规划器只读工具调
reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
# plan_mode_allowed_tools = ["custom_reader"] # 仅声明额外只读自定义工具;
# # 不能解锁被计划模式阻断的工具或 unsafe bash
# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # 计划模式额外只读 shell 前缀
# planner_model = "deepseek-pro" # 可选的低频规划器
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
# max_subagent_depth = 2 # 子代理嵌套委派深度;设为 1 可恢复旧的单层边界
auto_plan = "off" # 仅用户级生效;off|on;off 表示计划模式仅手动开启
# auto_plan_classifier = "deepseek-flash" # 可选;只在边界任务上调用
tool_result_snip_ratio = 0.6 # 在摘要 compaction 前先缩短旧工具输出
@@ -113,6 +117,14 @@ planner / read-only research 可用的可信只读工具。优先使用 MCP 只
再在 plugin 上写 `trusted_read_only_tools`,`plan_mode_allowed_tools` 保留为兼容逃生阀。它不再解锁 `bash`、`task`、
写文件工具、安装器、记忆变更工具等计划模式已知阻断项,也不会绕过 bash 在计划模式下的安全检查。
当计划阶段需要运行 Reasonix 尚不能自动分类、但你确认只读的 shell 查询命令时,使用
`[agent].plan_mode_read_only_commands`,例如 `gh issue view`、`gh pr diff` 或内部只读查询 CLI。
这里声明的是具体命令前缀,不是工具名:`["gh issue view"]` 会允许 `gh issue view 4572`,
但 `bash`、`sh` 等 shell 解释器前缀会被忽略。shell 操作符、重定向、命令替换、后台进程、
以及内置命令里的写能力参数在计划模式下仍会被阻断。交互式计划模式第一次需要某个未知查询前缀时,
Reasonix 也可以提示你确认是否信任它为只读;选择持久信任会写入同一个
`[agent].plan_mode_read_only_commands` 配置。Auto/YOLO 审批不会替用户回答这个信任提示。
### 环境变量
多数日常设置应写在 `config.toml` 或前文提到的 Reasonix 全局 `.env` 中。下面这些变量是进程级高级开关;
@@ -178,7 +190,25 @@ Goal、由 `todo_write` 工具驱动的实时 Todo 面板,以及已配置 prov
## 自定义 OpenAI-compatible provider
在桌面端打开 **设置 -> 模型 -> 接入 -> 添加模型服务 -> 自定义供应商**,用于接入代理、
聚合平台或自建 OpenAI-compatible chat API 服务。
聚合平台或自建 OpenAI-compatible chat API / Anthropic-compatible Messages API 服务。
常用服务优先使用 **添加模型服务 -> 推荐预设**。Reasonix 可以预填可编辑的自定义 provider:
Kimi CN、Kimi Global、Kimi Coding Plan、MiMo API、MiMo Anthropic、MiMo Token Plan
CN/SGP/AMS 及其 Anthropic-compatible 变体、MiniMax CN/Global API、MiniMax
CN/Global Anthropic、GLM CN、Z.AI Global、GLM/Z.AI Coding Plan 的
OpenAI-compatible 与 Anthropic-compatible 端点、OpenCode Go、OpenCode Go
Anthropic、OpenCode Zen Anthropic、Qwen/DashScope CN/Global、Qwen Coding Plan
CN/Global 的 OpenAI-compatible 与 Anthropic-compatible 端点、StepFun
OpenAI-compatible 与 Anthropic-compatible 端点、NovitaAI、GMI Cloud、Vercel AI
Gateway、HuggingFace Router、NVIDIA NIM、KiloCode 和 Ollama Cloud。Plan 表示
访问/付费形态;只有服务商确实提供不同区域端点时,预设名才同时带 CN/Global。
因此 Kimi Coding Plan 是独立 plan 端点,Kimi 直连 API 才拆成 CN 和 Global。
预设路径通常只需要填写服务商 API Key:真实 key 会写入 Reasonix home `.env`,
`config.toml` 只保存端点、模型列表、key 环境变量名、上下文窗口、视觉模型元数据、
中国区端点直连、MiniMax `reasoning_split`、GLM/MiniMax thinking heuristic、
Anthropic-compatible 网关需要的 Bearer 认证、Ollama Cloud max-effort 支持,
以及 OpenCode Go 的每模型 reasoning 覆盖。添加后仍然可以打开 provider 卡片,
继续修改模型、请求头、端点或兼容设置。
**API 地址** 填写服务端点。默认模式下,Reasonix 会预览并把聊天请求发送到:
@@ -191,12 +221,49 @@ Goal、由 `todo_write` 工具驱动的实时 Todo 面板,以及已配置 prov
输入框下方的预览就是最终请求地址。
模型发现会基于 API 地址尝试 `/models`、`/v1/models` 等候选地址。如果网关要求单独的
模型列表端点,在 **高级设置** 中填写 `models_url`,例如
模型列表端点,在 **兼容设置** 中填写 `models_url`,例如
`https://gateway.example.com/v1/models`。如果接口不支持模型发现,也可以手动填写模型列表。
**完整 URL** 仍使用 OpenAI-compatible chat 请求体;它不会切换成 OpenAI Responses API
的请求 schema。
### 兼容设置
**兼容设置(通常不用改)** 用于处理认证变量、模型发现地址、请求头、以及 reasoning/thinking
请求格式和普通 OpenAI-compatible 默认行为不一致的网关。除非服务商文档明确要求,或代理报错说明
不兼容,否则保持默认值即可。Kimi Coding Plan、MiniMax CN/Global Anthropic 这类 Anthropic-compatible 服务,
保存前在基础区域把接入协议切到 **Anthropic-compatible**。
| 字段 | 作用 | 什么时候改 |
| --- | --- | --- |
| `api_key_env` | 该 provider 使用的 API key 环境变量名。桌面端保存的真实 key 会写入 Reasonix home `.env` 的同名变量;TOML 配置里只保存变量名。 | 多个 provider 需要不同 key 时改名;服务不需要 API key 时可以留空。 |
| `models_url` | 只用于自动发现模型列表的 URL。聊天请求仍使用上方的 API 地址或完整 URL。 | `/models` 或 `/v1/models` 不是该网关模型列表地址时填写。 |
| 额外请求头 | 静态 HTTP header,一行一个 `Header: value`。 | OpenRouter 等网关要求 `HTTP-Referer`、`X-Title` 或类似站点来源 header 时使用。API key 仍放在上方密钥字段,不要重复写到这里。 |
| 额外请求体 | 合并到聊天请求体顶层的 JSON 对象。 | 仅用于服务商专用开关,例如 `{"enable_thinking": true}`。`model`、`messages`、`tools`、`stream`、`thinking` 等核心字段仍由 Reasonix 控制,且不接受 `null` 值。 |
| Authorization: Bearer | 对 Anthropic-compatible provider,把已保存的 API key 用 `Authorization: Bearer <key>` 发送,而不是 `x-api-key`。 | MiniMax Global、Vercel AI Gateway 等网关文档明确要求 Bearer 认证时开启。 |
| 模型能力模式 | 指定 Reasonix 对该 provider 使用哪种 reasoning 请求协议。 | 默认用“自动识别”。只有网关被误判,或模型文档要求特定 reasoning 格式时再切换。 |
| Thinking 覆盖 | provider 专用的 `thinking.type` 覆盖项。 | 默认用 Auto。只有后端文档明确支持 `enabled`、`disabled` 或 `adaptive` 时再手动指定;不支持的值可能让中转站拒绝请求。 |
| 余额查询 URL | 可选的钱包余额查询接口。 | 服务商提供余额接口,且希望桌面端状态栏显示余额时填写。 |
| 上下文窗口 | 该 provider 可保留的最大上下文 token 数。`0` 表示使用模型服务默认值。 | 模型实际上下文大小和 Reasonix 默认值或内置元数据不一致时填写。 |
模型能力模式选项:
| 选项 | 作用 |
| --- | --- |
| 自动识别(推荐) | Reasonix 根据模型能力元数据和端点自动选择请求格式。 |
| DeepSeek 思考 | 使用 DeepSeek 风格的 thinking 控制,包括 `thinking.type` 和 DeepSeek 支持的推理深度。 |
| OpenAI reasoning | 使用标准 OpenAI-compatible 的 `reasoning_effort` 档位。 |
| 普通聊天(不发送思考参数) | 不发送 reasoning 或 thinking 控制字段。适合会拒绝 reasoning 参数的普通文本代理。 |
Thinking 覆盖选项:
| 选项 | 作用 |
| --- | --- |
| Auto(使用服务默认) | 不写 provider 级 `thinking` 覆盖,让 Reasonix 使用 provider/model 默认行为。 |
| Enabled(开启) | 对兼容 provider 发送 `thinking.type = "enabled"`。 |
| Disabled(关闭) | 对兼容 provider 发送 `thinking.type = "disabled"`。DeepSeek 风格 provider 下还会避免继续发送推理深度提示。 |
| Adaptive(自适应) | 仅在服务文档明确支持 adaptive thinking 时使用,例如 MiniMax-M3 风格端点;语义是发送或保留 `thinking.type = "adaptive"`。 |
## 快捷键
这里按使用端来写,因为用户通常是先知道“我现在在桌面端/CLI”,再找对应按键。
@@ -409,7 +476,8 @@ headers = { Authorization = "Bearer ${STRIPE_KEY}" }
compaction archive 和已保存事实;这些动态内容不会被塞进稳定的 system prompt 前缀。
`/forget <name>` 会把已保存事实归档而不是永久删除;CLI/TUI 和桌面记忆面板能显示归档文件用于追溯,
但它们不会作为 active memory 被检索。检索会保留 BM25 最强命中,同时裁掉弱的泛词命中;
agent 发起的 `remember` 和 `forget` 每次都会要求新的人工确认,并在执行前展示将保存或归档的记忆摘要。
agent 发起的 `remember` 和 `forget` 每次都会要求新的人工确认,并在执行前展示将保存或归档的记忆摘要;
Guardian 审查不能代替用户批准,非交互运行会拒绝这类工具而不是自动批准。
0 结果会提示 agent 改用更少、更有区分度的词继续查。
Memory v5 在 CLI/TUI、`reasonix serve` 和桌面端默认开启,因为这些入口共用同一套本地
controller。它会把本地、按项目隔离的执行轨迹和编译器状态写在 Reasonix home 下,并且只有
@@ -512,10 +580,17 @@ Planner 会看到已加载的 `REASONIX.md` / `AGENTS.md` 记忆,并拿到一
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
Subagent 默认允许再委派一层:根会话是 depth 0,第一层 subagent 是 depth 1,
`max_subagent_depth = 2` 表示 depth 1 的 workflow 可以再派 depth 2 的 reviewer
或 implementer;depth 2 不再拿到递归 agent/skill 工具。设
`agent.max_subagent_depth = 1` 可恢复旧的单层边界。这主要用于 Superpowers 这类
workflow skill 派发 reviewer subagent 的场景,同时避免无限递归和后台 fanout。
当计划阶段需要隔离上下文做更深的调研时,用 `read_only_task`,而不是放开可写的
`task`。如果这类调研更适合复用已有 skill,用 `read_only_skill`。两者都会启动
ephemeral 只读 subagent,只暴露只读研究工具和安全前台 bash,只返回最终答案,不创建
可续接的 subagent transcript。在 token economy 模式下,只用
可续接的 subagent transcript。只读嵌套委派会在 `max_subagent_depth` 内可用,但
可写的 `task` / `run_skill` 仍不可用。在 token economy 模式下,只用
`connect_tool_source(source="read_only_skill")` 连接这条窄入口;完整的 `skills`
source 仍会启用可写 skill 工具,plan mode 下继续阻断。
+17 -8
View File
@@ -103,14 +103,23 @@ and DeepSeek prefix-cache–oriented design.
external tools**: `[agent].plan_mode_allowed_tools` now only declares extra
read-only custom/external tools. It no longer unlocks known blocked plan-mode
tools such as `bash`, `task`, writers, installers, or memory mutation tools, and
unsafe bash commands still remain blocked. An MCP/plugin tool whose read-only
status comes from the server's untrusted `readOnlyHint` is confirmed the first
time an interactive plan-mode run needs it; choose the persistent option to
write the plugin-level `trusted_read_only_tools` raw-name list. Auto/YOLO tool
approval does not answer this trust prompt, although a session or persistent
trust choice prevents repeat prompts for the same MCP tool. Non-interactive
runs still fail closed, so pre-seed `trusted_read_only_tools` or declare a
concrete `mcp__<server>__<tool>` when no user can approve. In the desktop MCP
unsafe bash commands still remain blocked. To migrate old
`plan_mode_allowed_tools = ["bash", ...]` configs, move concrete read-only
shell prefixes such as `gh issue view` or internal query CLIs to
`[agent].plan_mode_read_only_commands`; do not declare shell interpreters or
writer-capable commands there. Interactive plan-mode runs can also ask you to
trust a concrete unknown query prefix once, and the persistent choice writes
the same `plan_mode_read_only_commands` entry. Auto/YOLO tool approval does
not answer this bash trust prompt. Use `read_only_task` / `read_only_skill`
instead of trying to unlock `task` / `run_skill` while planning. An MCP/plugin tool
whose read-only status comes from the server's untrusted `readOnlyHint` is
confirmed the first time an interactive plan-mode run needs it; choose the
persistent option to write the plugin-level `trusted_read_only_tools` raw-name
list. Auto/YOLO tool approval does not answer this trust prompt, although a
session or persistent trust choice prevents repeat prompts for the same MCP
tool. Non-interactive runs still fail closed, so pre-seed
`trusted_read_only_tools` or declare a concrete `mcp__<server>__<tool>` when no
user can approve. In the desktop MCP
panel, expand a server and use **Pre-trust read-only** for currently listed
`readOnlyHint` tools, per-tool **Pre-trust** for audited readers, or
**Untrust** to remove a tool; those actions write the same
+262
View File
@@ -0,0 +1,262 @@
# Reasonix Plugin Packages
Reasonix plugin packages bundle skills, hooks, and MCP servers behind one
installable unit.
## CLI Mode
Use `reasonix plugin` when installing or managing plugin packages from a
terminal. Plugin packages are installed globally under the Reasonix home
directory.
### Install From CLI
`install` accepts one source:
- A GitHub repository, such as `git:github.com/obra/superpowers` or
`https://github.com/obra/superpowers`.
- A GitHub branch or subdirectory URL, such as
`https://github.com/owner/repo/tree/main/path/to/plugin`.
- A local directory that contains `reasonix-plugin.json` or
`.codex-plugin/plugin.json`.
Preview the install plan without writing files:
```bash
reasonix plugin install git:github.com/obra/superpowers --dry-run
```
Install a plugin after reviewing the plan:
```bash
reasonix plugin install git:github.com/obra/superpowers --yes
```
Install with an explicit name or replace an installed plugin with the same name:
```bash
reasonix plugin install git:github.com/obra/superpowers --name superpowers --replace --yes
```
Use a local directory in developer mode:
```bash
reasonix plugin install /path/to/plugin --link --replace --yes
```
CLI install flags:
- `--dry-run` plans and validates the install without writing files.
- `--yes` is required for any install that writes files.
- `--replace` allows the source to replace an installed plugin with the same
name.
- `--name <name>` or `--name=<name>` overrides the name from the plugin
manifest for this install.
- `--link` links a local plugin directory instead of copying it into Reasonix's
plugin storage. Moving or deleting that directory breaks the linked plugin.
Running `reasonix plugin install <source>` without `--dry-run` or `--yes`
refuses to write files and prints a reminder to rerun with one of those flags.
Install and remove commands print the structured JSON response from the same
install-source backend used by the desktop UI.
Installed plugin state is stored in:
```text
~/.reasonix/plugin-packages.json
~/.reasonix/plugins/<name>/
```
### Manage From CLI
List installed plugins:
```bash
reasonix plugin list
```
Show one plugin's metadata, root, source, and exported capability counts:
```bash
reasonix plugin show superpowers
```
`show` also prints the concrete capability inventory when available:
- **skills** include suggested `/<skill>` invocations and descriptions.
- **hooks** list lifecycle events, matchers, and commands or context files.
- **mcpServers** list server names, transports, and launch targets.
Check that the manifest and skill roots are readable:
```bash
reasonix plugin doctor superpowers
```
Enable or disable a plugin without uninstalling it:
```bash
reasonix plugin disable superpowers
reasonix plugin enable superpowers
```
Remove a plugin:
```bash
reasonix plugin remove superpowers --yes
```
`remove` also accepts `uninstall` as an alias. It requires `--yes` because it
writes state and removes copied plugin content. For linked local plugins, the
external source directory is left in place.
### Use Installed Plugins From CLI
Installed plugins do not open a separate chat surface. When a plugin is enabled,
Reasonix loads its capabilities into normal interactive sessions:
- Run `/plugins` inside an interactive session to list installed plugin
packages. Run `/plugins show <name>` to inspect a plugin's exported skills,
hooks, MCP servers, and usage hints without leaving the chat.
- **Skills** appear in `/skills`. Invoke a skill with `/<skill> [args]`, or ask
naturally and let the agent choose a matching skill by description.
- **Hooks** run automatically at their configured lifecycle events, such as
`SessionStart`, `UserPromptSubmit`, `PreToolUse`, or `PostToolUse`.
- **MCP servers** join the normal MCP/tool flow. Ask for the task you want done;
Reasonix can call the plugin's tools when they are relevant.
After installing, enabling, disabling, or updating a plugin from a separate
terminal while a session is already running, start a new `reasonix` session or
reopen `/skills` to verify the current session sees the expected skills.
## Desktop Settings
Open **Settings -> Plugins** to install and manage plugin packages without using
the CLI.
### Install Plugins
The installer has two modes:
- **Local folder**: click **Choose plugin folder** and select a plugin directory
on disk. The selected path is shown next to the button.
- **Git repository**: enter a Git source such as
`git:github.com/obra/superpowers`. **Install name (optional)** can override
the plugin manifest name for this install or overwrite.
Use the action buttons after choosing the source and options:
- **Preview** validates the source and shows the planned install actions without
writing files.
- **Install plugin** installs the selected source using the current options.
- **Refresh plugins** reloads the installed-plugin list from disk and config.
Installer options:
- **Overwrite same-name plugin** allows the current source to replace an
installed plugin with the same name. Leave it off when duplicate-name installs
should fail instead of replacing existing content.
- **Developer mode: link source folder** appears for **Local folder** installs.
It links the selected directory instead of copying it into Reasonix's plugin
storage. Use it while developing or debugging a plugin. Moving or deleting the
selected directory will break the linked plugin.
Preview is the safest first step for a new Git source or local plugin directory.
### Manage Installed Plugins
The installed-plugin list shows each plugin package and its exported skills,
hooks, and MCP servers. Use **Refresh plugins** after editing plugin files or
changing config outside the app.
Expand a plugin row to manage it:
- Enable or disable the plugin.
- Read **How to use** for the plugin's exported skills, hooks, and MCP servers.
- **Update** pulls or refreshes an installed plugin when an update source is
available.
- **Doctor** checks the plugin manifest and reports warnings or diagnostics.
- **Remove plugin** uninstalls the package after confirmation.
### Use Installed Plugins From Desktop
The desktop settings page uses the same runtime model as the CLI:
- Expand an installed plugin to see its **How to use** section.
- In any desktop session, type `/plugins` to list installed plugins, or
`/plugins show <name>` to see the same usage details from the chat surface.
- Skills are shown with suggested direct commands such as `/plan`; they are also
discoverable from `/skills` in a session.
- Hooks and MCP servers are listed for transparency. They do not need a manual
"run" button: enabled hooks trigger automatically, and MCP tools are available
through ordinary tool use.
- If a currently open session does not reflect a plugin change, refresh the
plugin list and open a new session.
## Native Manifest
Reasonix plugins can declare `reasonix-plugin.json` at the plugin root:
```json
{
"name": "example",
"version": "1.0.0",
"description": "Example plugin",
"skills": "skills",
"hooks": {
"SessionStart": [
{
"command": "hooks/session-start",
"description": "Load startup context"
}
]
},
"mcpServers": {
"helper": {
"command": "bin/helper"
}
}
}
```
Relative paths are resolved inside the plugin root. Reasonix does not run
third-party install scripts during plugin installation.
## Codex Compatibility
Reasonix also reads Codex plugin manifests at `.codex-plugin/plugin.json`.
For packages such as Superpowers and Claude-style skill packs, Reasonix maps:
- `skills` to Reasonix skill roots.
- `hooks/session-start-codex` to the Reasonix `SessionStart` hook when present.
- A plugin-root `CLAUDE.md` file to a built-in `SessionStart` context hook. The
file is read directly by Reasonix, without spawning a shell command.
- `.claude/settings.json` command hooks to Reasonix hook events when the event
names match. Claude's `matcher` field maps to Reasonix `match`; hook commands
run as shell commands with the plugin root as `cwd`; Claude `timeout` values
are interpreted as seconds.
Unsupported Claude hook item types are skipped with a warning. Reasonix does not
run third-party install scripts or implement marketplace-specific install
protocols.
Plugin hooks receive these environment variables:
- `REASONIX_PLUGIN_ROOT`
- `REASONIX_PLUGIN_NAME`
- `REASONIX_PLUGIN_VERSION`
- `REASONIX_HOME`
- `REASONIX_WORKSPACE_ROOT`
- `CLAUDE_PROJECT_DIR`
## Desktop Backend Methods
Desktop exposes plugin package operations through Wails methods:
- `Plugins`
- `PlanPluginInstall`
- `InstallPlugin`
- `RemovePlugin`
- `SetPluginEnabled`
- `UpdatePlugin`
- `PluginDoctor`
+242
View File
@@ -0,0 +1,242 @@
# Reasonix 插件包
Reasonix 插件包把 skills、hooks 和 MCP servers 组织成一个可安装单元。
## CLI 模式
在终端里使用 `reasonix plugin` 安装和管理插件包。插件包当前按全局范围安装,
写入 Reasonix home 目录。
### 通过 CLI 安装
`install` 接收一个来源:
- GitHub 仓库,例如 `git:github.com/obra/superpowers` 或
`https://github.com/obra/superpowers`。
- GitHub 分支或子目录 URL,例如
`https://github.com/owner/repo/tree/main/path/to/plugin`。
- 本地目录,目录内需要包含 `reasonix-plugin.json` 或
`.codex-plugin/plugin.json`。
只预览安装计划,不写文件:
```bash
reasonix plugin install git:github.com/obra/superpowers --dry-run
```
确认计划后安装:
```bash
reasonix plugin install git:github.com/obra/superpowers --yes
```
指定安装名称,或覆盖已安装的同名插件:
```bash
reasonix plugin install git:github.com/obra/superpowers --name superpowers --replace --yes
```
以开发模式使用本地目录:
```bash
reasonix plugin install /path/to/plugin --link --replace --yes
```
CLI 安装参数:
- `--dry-run` 只规划和校验安装,不写文件。
- `--yes` 用于确认执行会写文件的安装。
- `--replace` 允许当前来源替换已安装的同名插件。
- `--name <name>` 或 `--name=<name>` 覆盖插件 manifest 里的名称,
作为本次安装名称。
- `--link` 链接本地插件目录,而不是复制到 Reasonix 的插件存储目录。
移动或删除该目录会导致这个链接插件失效。
如果运行 `reasonix plugin install <source>` 时既没有 `--dry-run`,
也没有 `--yes`,CLI 会拒绝写文件,并提示使用其中一个参数重新运行。
安装和移除命令会输出结构化 JSON,来源于桌面端同一套 install-source 后端。
插件状态和内容写入:
```text
~/.reasonix/plugin-packages.json
~/.reasonix/plugins/<name>/
```
### 通过 CLI 管理
列出已安装插件:
```bash
reasonix plugin list
```
查看某个插件的元数据、根目录、来源以及导出的能力数量:
```bash
reasonix plugin show superpowers
```
如果能读取到能力明细,`show` 也会输出具体清单:
- **skills** 会展示建议的 `/<skill>` 调用方式和描述。
- **hooks** 会展示生命周期事件、matcher、命令或上下文文件。
- **mcpServers** 会展示服务器名称、传输方式和启动目标。
检查 manifest 和 skill roots 是否可读:
```bash
reasonix plugin doctor superpowers
```
在不卸载的情况下启用或禁用插件:
```bash
reasonix plugin disable superpowers
reasonix plugin enable superpowers
```
移除插件:
```bash
reasonix plugin remove superpowers --yes
```
`remove` 也可以写成 `uninstall`。它需要 `--yes`,
因为会写入状态并删除复制安装的插件内容。如果是链接模式安装的本地插件,
外部源目录会保留。
### 在 CLI 中使用已安装插件
已安装插件不会打开一个独立聊天界面。插件启用后,Reasonix 会把它的能力加载到普通交互会话里:
- 在交互会话里运行 `/plugins` 可以列出已安装插件包。
运行 `/plugins show <name>` 可以在不离开聊天的情况下查看该插件导出的
skills、hooks、MCP servers 和使用提示。
- **Skills** 会出现在 `/skills` 中。可以用 `/<skill> [args]` 直接调用,
也可以自然描述任务,让 agent 按 description 选择匹配的 skill。
- **Hooks** 会在配置的生命周期事件里自动运行,例如 `SessionStart`、
`UserPromptSubmit`、`PreToolUse` 或 `PostToolUse`。
- **MCP servers** 会进入正常 MCP/工具流程。用户只需要描述任务,
Reasonix 会在相关时调用插件提供的工具。
如果是在另一个终端里安装、启用、禁用或更新插件,而当前已有 `reasonix` 会话正在运行,
建议开启新会话,或重新打开 `/skills` 确认当前会话能看到预期技能。
## 桌面端设置
打开 **设置 -> 插件**,可以不用 CLI 直接安装和管理插件包。
### 安装插件
安装区有两种模式:
- **本地目录**:点击 **选择插件目录**,从磁盘选择一个插件目录。
选中路径会显示在按钮右侧。
- **Git 仓库**:填写 Git 来源,例如 `git:github.com/obra/superpowers`。
**安装名称(可选)** 可覆盖插件 manifest 声明的名称,用于本次安装或覆盖。
选择来源和选项后,再使用操作按钮:
- **预检** 校验来源并展示计划安装动作,不写入文件。
- **安装插件** 按当前来源和选项执行安装。
- **刷新插件** 从磁盘和配置重新读取已安装插件列表。
安装选项:
- **覆盖同名插件** 允许当前来源替换已安装的同名插件。关闭时,同名安装会失败,
而不是覆盖已有内容。
- **开发模式:链接源目录** 只在 **本地目录** 模式出现。它不会复制插件,
而是直接链接所选目录;适合开发或调试插件。移动或删除该目录会导致这个链接插件失效。
对新的 Git 来源或本地插件目录,建议先点 **预检**。
### 管理已安装插件
已安装插件列表会展示每个插件包以及它导出的 skills、hooks 和 MCP servers。
通过应用外编辑插件文件或配置后,可点 **刷新插件** 重新读取。
展开插件行后可以:
- 启用或禁用插件。
- 查看 **使用方法**,了解该插件导出的 skills、hooks 和 MCP servers。
- 使用 **更新** 拉取或刷新具备更新来源的插件。
- 使用 **诊断** 检查插件 manifest,并查看警告或诊断信息。
- 使用 **移除插件**,确认后卸载该插件包。
### 在桌面端使用已安装插件
桌面端设置页和 CLI 使用同一套运行模型:
- 展开已安装插件,可以看到 **使用方法** 区域。
- 在任意桌面会话里输入 `/plugins` 可以列出已安装插件;
输入 `/plugins show <name>` 可以直接从聊天界面查看同一套使用详情。
- Skills 会展示建议的直接命令,例如 `/plan`;在会话中也可以通过 `/skills` 浏览。
- Hooks 和 MCP servers 作为透明能力清单展示。它们不需要单独的“运行”按钮:
启用的 hooks 会自动触发,MCP 工具会通过普通工具调用流程可用。
- 如果当前打开的会话没有反映插件变更,刷新插件列表并开启新会话。
## 原生 Manifest
Reasonix 原生插件在根目录声明 `reasonix-plugin.json`:
```json
{
"name": "example",
"version": "1.0.0",
"description": "Example plugin",
"skills": "skills",
"hooks": {
"SessionStart": [
{
"command": "hooks/session-start",
"description": "Load startup context"
}
]
},
"mcpServers": {
"helper": {
"command": "bin/helper"
}
}
}
```
相对路径都按插件根目录解析。Reasonix 安装插件时不会执行第三方安装脚本。
## Codex 兼容
Reasonix 也会读取 `.codex-plugin/plugin.json`。对于 Superpowers 和 Claude 风格
skill 包,Reasonix 会映射:
- `skills` 到 Reasonix skill root。
- 如果存在 `hooks/session-start-codex`,映射为 Reasonix `SessionStart` hook。
- 插件根目录的 `CLAUDE.md` 会映射为内置的 `SessionStart` 上下文 hook。
Reasonix 会直接读取该文件,不通过 shell 命令。
- `.claude/settings.json` 里的 command hooks 会按同名事件映射到 Reasonix hooks。
Claude 的 `matcher` 字段会映射到 Reasonix `match`;hook 命令以插件根目录作为
`cwd` 执行;Claude `timeout` 按秒解析。
不支持的 Claude hook item type 会跳过并产生 warning。Reasonix 不会执行第三方安装脚本,
也不会实现 marketplace 专用安装协议。
插件 hook 会收到这些环境变量:
- `REASONIX_PLUGIN_ROOT`
- `REASONIX_PLUGIN_NAME`
- `REASONIX_PLUGIN_VERSION`
- `REASONIX_HOME`
- `REASONIX_WORKSPACE_ROOT`
- `CLAUDE_PROJECT_DIR`
## 桌面端后端方法
Desktop 通过 Wails 方法暴露插件包操作:
- `Plugins`
- `PlanPluginInstall`
- `InstallPlugin`
- `RemovePlugin`
- `SetPluginEnabled`
- `UpdatePlugin`
- `PluginDoctor`
+11 -5
View File
@@ -19,7 +19,9 @@ explicit without changing the stable system prompt or tool definitions.
The setting is intentionally small:
- `auto` follows the conversation language and injects no extra instruction.
- `auto` anchors visible reasoning to Chinese when the raw user prompt is
clearly Chinese, ignoring injected reference context such as `@file` contents;
English and ambiguous turns add no extra instruction.
- `zh` asks visible reasoning to prefer Simplified Chinese.
- `en` asks visible reasoning to prefer English.
@@ -89,11 +91,15 @@ argument.
## Cache Behavior
`auto` is the cache-friendliest choice. It injects nothing and relies on the
existing stable language policy.
`auto` is still cache-friendly. When the raw user prompt clearly looks Chinese,
Reasonix adds the same small transient `<reasoning-language>` block for that
turn; English and ambiguous turns inject nothing and rely on the existing stable
language policy. Injected reference context such as `@file` contents is ignored
for this auto decision.
When set to `zh` or `en`, Reasonix adds a small transient
`<reasoning-language>` block to the user turn. It does not change:
When set to `zh` or `en`, Reasonix always adds a small transient
`<reasoning-language>` block to the user turn. In all modes, this does not
change:
- the system prompt
- tool schema bytes or ordering
+3 -3
View File
@@ -14,7 +14,7 @@
取值只有三种:
- `auto`:跟随当前对话语言,不额外注入语言指令。
- `auto`:用户原始提问明显为中文时锚定中文,并忽略 `@file` 等注入的引用内容;英文或不确定时不额外注入语言指令。
- `zh`:可见思考过程优先使用简体中文。
- `en`:可见思考过程优先使用英文。
@@ -79,9 +79,9 @@ reasoning_language = "auto" # auto|zh|en
## 缓存影响
`auto` 对缓存最友好。它不会注入任何额外内容,只复用已有的稳定语言策略。
`auto` 仍然对缓存友好。用户原始提问明显是中文时,Reasonix 会为这一轮加入同样很小的 `<reasoning-language>` 临时 block;英文或信号不明确时不注入,只复用已有的稳定语言策略。`@file` 等注入的引用内容不会参与这个自动判断。
当设置为 `zh` 或 `en` 时,Reasonix 会把一个很小的 `<reasoning-language>` 临时 block 放进本次 user turn。它不会改变:
当设置为 `zh` 或 `en` 时,Reasonix 总是会把一个很小的 `<reasoning-language>` 临时 block 放进本次 user turn。所有模式下,它都不会改变:
- system prompt
- 工具 schema 的字节或顺序
+57
View File
@@ -0,0 +1,57 @@
# Reasoning controls by provider
Reasonix exposes a single `/effort` knob (and the per-provider `effort` /
`thinking` config fields), but OpenAI-compatible backends disagree on *how*
chain-of-thought is requested on the wire. The `openai` provider adapts the
request shape per backend; this table is the reference for which protocol each
known backend uses and which parameters it honours or ignores.
## Auto-detected backends
These are recognised by base URL (see `internal/provider/openai/host.go`) and
get a tailored request shape automatically — no extra config needed.
| Provider | Base URL | Reasoning control | `/effort` levels | Notes |
|----------|----------|-------------------|------------------|-------|
| DeepSeek | `api.deepseek.com`, `*.deepseek.com` | `thinking.type` + `reasoning_effort` (depth) | `auto`, `disabled`, `high`, `max` | Thinking on by default; `disabled` turns it off via `thinking.type=disabled`. |
| MiniMax M3 | `api.minimaxi.com`, `*.minimaxi.com` | `thinking.type` (`adaptive`\|`disabled`) | `auto`, `adaptive`, `disabled` | No depth scale; `reasoning_effort` is omitted. |
| Zhipu GLM | `open.bigmodel.cn` / `*.bigmodel.cn`, `api.z.ai` / `*.z.ai` | `thinking.type` (`enabled`\|`disabled`) | `auto`, `enabled`, `disabled` | **`reasoning_effort` is silently ignored** by the endpoint, so reasoning is driven purely through `thinking.type`. |
## Everything else (standard `reasoning_effort`)
Any other OpenAI-compatible backend falls through to the standard
`reasoning_effort` scale (`low`\|`medium`\|`high`). Surveyed popular providers
that need **no special handling** because they already follow this convention:
Qwen (`dashscope.aliyuncs.com`), Moonshot/Kimi (`api.moonshot.cn`), Yi
(`api.01.ai`), SiliconFlow (`api.siliconflow.cn`), Stepfun (`api.stepfun.com`),
Groq (`api.groq.com`), Together (`api.together.xyz`), OpenRouter
(`openrouter.ai`), Perplexity (`api.perplexity.ai`), xAI (`api.x.ai`).
For a backend that uses a binary `thinking.type` toggle but is **not**
auto-detected, set the vendor-agnostic `thinking` field on the provider entry:
```toml
[[providers]]
name = "my-glm-proxy"
kind = "openai"
base_url = "https://my-gateway.example.com/v1"
model = "glm-4.6"
api_key_env = "MY_API_KEY"
thinking = "disabled" # enabled | disabled — emits thinking.type
```
## Troubleshooting
If a model keeps thinking when you asked it not to (or vice versa):
1. Check the table above — a backend may **ignore** the parameter you set
(e.g. Zhipu ignores `reasoning_effort`; use `thinking`/`/effort` instead).
2. If the backend isn't auto-detected, set the explicit `thinking` field.
3. If the backend uses a non-OpenAI protocol entirely (e.g. Baidu Wenxin), the
`openai` kind cannot drive its thinking mode — that needs a dedicated
provider kind.
Distinguishing "provider ignores the field" from a Reasonix bug starts here:
the request shape Reasonix emits is fixed per the table, so a mismatch between
the table and observed behaviour is the provider's, not Reasonix's.
+8 -3
View File
@@ -18,7 +18,7 @@ provides the pre-release buffer instead of a long-lived branch.
| Surface | Stable | Pre-release buffer |
|---|---|---|
| npm | `latest` (0.x), `next` (1.x) | `canary` (`npm i reasonix@canary`) |
| Desktop | R2 `latest/` pointer | R2 `canary/` pointer (R2-only — never on the GitHub releases page) |
| Desktop | R2 `latest/` pointer + release gateway | R2 `canary/` pointer + release gateway proxy (never on the GitHub releases page) |
A canary build is isolated: it **never** moves `latest` / `next` / desktop `latest/`.
Testers opt in explicitly. (Desktop builds carry `-X main.channel=canary`; npm versions
@@ -68,8 +68,13 @@ the `release` environment deployment.
- Canary version numbers use the workflow `run_number`, so the desktop and CLI canary
numbers differ (e.g. `canary.11` vs `canary.2`). Only monotonicity per channel matters.
- A stable `-rc` tag (e.g. `npm-v1.4.0-rc.1`) still ships under `next`, not `canary`.
- Desktop in-app updates use R2 first. Stable has a GitHub release fallback; canary is
R2-only and never appears on the GitHub releases page.
- Desktop in-app updates use R2 first, then the `crash.reasonix.io` desktop release
gateway. The gateway resolves the `desktop-v*` release line directly and never uses
GitHub's repository-wide `/releases/latest`, because plain `v*` tags are the CLI
release line. Stable CLI releases also carry a compatibility `latest.json` asset so
older desktop builds that still use GitHub `latest` do not 404.
- Canary uses R2 plus the same gateway proxy for the `canary/` pointer; it never
appears on the GitHub releases page.
- Windows and Linux apply downloaded, minisign-verified artifacts in place. macOS
applies in-app only for Developer ID signed and notarized builds; ad-hoc/local
builds fall back to the download page.
+3
View File
@@ -169,8 +169,11 @@ time.
The controller treats these tools like plan approval:
- Auto approval and YOLO/full-access mode do not bypass them.
- Guardian/safety review cannot allow them on the user's behalf.
- Session grants and persistent allow rules are not created for them.
- Pending memory approvals are not drained when the user toggles auto approval.
- Non-interactive headless runs and sub-agents refuse them instead of treating
`Ask` as autonomous allow.
The approval subject is generated from the tool arguments before the
`ApprovalRequest` event is emitted:
+33 -17
View File
@@ -226,8 +226,11 @@ Long tasks eventually fill the model's context window. Reasonix manages this wit
history when exact original wording or tool output matters.
- Agent-initiated `remember` and `forget` calls require a fresh human approval
each time, even when tool auto-approval or YOLO/full-access mode is enabled.
The approval request includes a compact preview of the memory being saved or
archived, while external notification hooks only receive the tool name.
Guardian/safety review cannot answer these prompts on the user's behalf. In
non-interactive headless runs or sub-agents, these tools are refused rather
than auto-approved. The approval request includes a compact preview of the
memory being saved or archived, while external notification hooks only receive
the tool name.
User-initiated memory edits in the local UI are already explicit user actions.
See [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md) for the
detailed implementation contract.
@@ -313,8 +316,16 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
decision: `auto`, `yolo`, and the approved-plan execution window do not answer
it, but an explicit session grant still prevents repeat prompts for the same
tool. Non-interactive sessions and declined approvals remain fail-closed.
Writers, installers, memory mutation, process
control, and `complete_step` (read-only yet post-approval only, so it
Bash is gated separately: built-in read-only commands and concrete prefixes
declared in `[agent].plan_mode_read_only_commands` may run. Interactive
controllers may also ask once before running an unknown query-shaped prefix
and may remember a persistent approval as the same
`plan_mode_read_only_commands` entry. This bash trust prompt is also a fresh
user decision: `auto`, `yolo`, and the approved-plan execution window do not
answer it, while explicit session/persistent trust prevents repeat prompts for
that prefix. Shell operators, background execution, shell interpreters, and
unsafe arguments stay blocked while planning. Writers, installers, memory
mutation, process control, and `complete_step` (read-only yet post-approval only, so it
self-reports plan-unsafe) are refused; the enforced invariant is
PlanSafe ⇒ ReadOnly. An untrusted read-only MCP/plugin tool is therefore
blocked until the user approves or pre-trusts it, and it is excluded from
@@ -520,6 +531,7 @@ memory_compiler = { enabled = true, verbosity = "observe" } # user/global only
reasoning_language = "auto" # visible reasoning text: auto|zh|en
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only declarations for custom tools;
# # cannot unlock known blocked tools or unsafe bash
# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # extra read-only shell prefixes for plan mode
# planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner)
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
@@ -632,12 +644,14 @@ by default (root = cwd), so edits stay in the project while the agent can still
update its own global config. `forbid_read` lists directories the agent should
not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansion
and should be absolute, or use `${HOME}` for home-relative secrets such as
`${HOME}/.ssh`. `bash` is itself jailed on macOS by default
(`[sandbox] bash = "enforce"`, Seatbelt): each command runs under sandbox-exec
allowed to write only the same roots (+ temp and toolchain caches), denied reads
under `forbid_read`, and allowed to reach the network only when `network = true`.
Unsupported platforms fall back to running unconfined when no OS sandbox is
available. The escape-prompt and Linux support are Phase 1's remainder (§9).
`${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is
available (`[sandbox] bash = "enforce"`: Seatbelt on macOS, bubblewrap on
Linux): each command is allowed to write only the same roots (+ temp and
toolchain caches), denied reads under `forbid_read`, and allowed to reach the
network only when `network = true`.
When no OS sandbox is available, `bash = "enforce"` refuses bash execution
instead of running unconfined. The escape-prompt and broader OS support are Phase
1's remainder (§9).
## 6. Error Handling
@@ -664,13 +678,15 @@ available. The escape-prompt and Linux support are Phase 1's remainder (§9).
## 9. Roadmap (not in current scope)
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
file-writer built-ins (Phase 0) — are confined to the workspace. **macOS
(Seatbelt via `sandbox-exec`) ships, on by default** (see §5). Remaining: (a)
the escape-prompt — detect a sandbox-denied failure and offer to re-run the
command unconfined via the permission gate (in `reasonix run`, the command just
fails and the model adapts), which completes the "allow inside the box, prompt
at its edge" model; (b) Linux (bubblewrap / landlock). Shells out to OS tooling
so the binary stays dependency-free; Windows is out of scope. With this in
file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on
macOS and bubblewrap on Linux ship, on by default when available** (see §5).
Remaining: (a)
the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and
offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the
command just fails and the model adapts), which completes the "allow inside the
box, prompt at its edge" model; (b) broader OS sandbox support beyond current
Seatbelt/bubblewrap hosts. Shells out to OS tooling so the binary stays
dependency-free. With this in
place, "always allow" rule persistence becomes optional rather than load-bearing.
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
+2 -2
View File
@@ -49,7 +49,7 @@ Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工
- 显式 `deny` 规则仍然阻止对应工具或命令。
- 显式 `ask` 规则仍然会弹出审批。
- 计划模式的“开始执行”确认仍然需要你选择。
- `remember` / `forget` 等记忆写入或删除操作仍需要新鲜审批。
- `remember` / `forget` 等记忆写入或删除操作仍需要新鲜人工审批,Guardian/安全审查不能代替用户批准。
- ask 问题仍然等待你回答,不会由自动模式代选。
### 建议使用场景
@@ -86,7 +86,7 @@ Yolo 模式用于最大化连续执行。开启后,普通工具审批会被跳
- Yolo 只跳过工具权限审批,不会跳过计划确认和 ask 问题。
- Yolo 不会绕过后端的 `deny` 规则、系统权限、工作区限制或沙箱限制。
- 记忆写入/删除、计划确认等强制新鲜审批仍然会等待你处理。
- 记忆写入/删除、计划确认等强制新鲜审批仍然会等待你处理;非交互运行和子代理会拒绝这类工具,而不是自动批准。
- 不建议在生产环境、敏感目录、密钥文件、发布流程或需求不清楚的任务里开启。
## 与协作方式的组合
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more