Compare commits

...
67 Commits
Author SHA1 Message Date
SivanCola ece9b77191 Merge pull request #3553 from taibai233/feature/empty-enter-scroll-bottom
feat(tui): empty enter scrolls viewport to bottom
2026-06-09 14:46:02 +08:00
YHH ccbdc79998 feat(desktop): Developer ID signing + notarization for macOS release (#3619)
Replace the ad-hoc macOS codesign with Developer ID signing (hardened runtime + entitlements + timestamp), notarize and staple the .app and .dmg via notarytool. Gated on HAS_APPLE_CERT from the APPLE_* secrets; ad-hoc fallback when unset.
2026-06-08 23:10:56 -07:00
athamas huang b49b2d121e fix(desktop): preserve curated provider model lists (#3613) 2026-06-08 23:09:32 -07:00
taibai 82dc750d28 test(tui): add regression test for empty-enter scroll-to-bottom in both idle and running state 2026-06-09 13:43:43 +08:00
taibai d5dc8df1a5 fix(tui): also scroll to bottom on empty enter during running state 2026-06-09 13:43:43 +08:00
taibai bd2b29bdd5 feat(tui): empty enter scrolls viewport to bottom 2026-06-09 13:43:43 +08:00
Li-Charles-OneandLi-Charles-One 272bc9d116 fix(desktop): prevent step limit segmented buttons from wrapping on narrow screens (#3596)
Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 22:35:15 -07:00
jiaxindeyangandCodex 368dad0e47 fix(desktop): honor user font in UI chrome (#3605)
Co-authored-by: Codex <[email protected]>
2026-06-08 22:34:59 -07:00
CVEngineerandwufengfan c8007a6b6c fix(desktop): restore project to sidebar when resuming session from history, add toast notification system (#3603)
Three fixes for resuming sessions from the history panel:

1. OpenProjectTab now calls addProject() and emitProjectTreeChanged(), so
   opening a session from a previously removed project automatically restores
   it to the sidebar. No more silent switch to the wrong workspace tab.

2. openProjectTab/openGlobalTab no longer swallow exceptions (catch { return
   undefined }). Errors propagate to onResumeSession which shows a user-facing
   toast instead of silently falling back to the current tab.

3. A reusable ToastProvider + useToast() hook (info/warn/error levels, 2.5s
   auto-dismiss, stackable, click-to-dismiss) is introduced. Used here for
   session-open failures and available to other contributors for notifications.

Co-authored-by: wufengfan <[email protected]>
2026-06-08 22:25:06 -07:00
SivanCola 823fdce746 feat(codegraph): harden runtime bootstrap (#3623) 2026-06-08 22:24:56 -07:00
SivanCola f311a475d4 fix(permission): remember approval scopes by rule (#3549) 2026-06-08 22:24:46 -07:00
SuMuxi66 3141dddae5 fix(composer): refresh @ mention picker on workspace/project switch (#3601) (#3606)
When switching between projects, the @ mention picker retained stale
candidates from the previous project because dirCache/searchCache keys
did not include workspace context and the ListDir/SearchFileRefs effects
did not depend on cwd.

Changes:
- Add cwdRef snapshot pattern to detect workspace switches in async
  openPastChats callback, discarding stale ListSessions responses
- Add useEffect([cwd]) to invalidate all @ mention state (caches,
  entries, past chats, loading, active index) when cwd changes
- Use prevCwdRef to skip the effect on initial mount, avoiding
  unnecessary re-renders with empty-array references
- Add cwd to ListDir and SearchFileRefs effect dependencies so the
  picker re-fetches when the workspace changes while the menu is open
- Conditionally reset loadingPastChats in openPastChats finally block
  to prevent stale async responses from overwriting new workspace state

Closes #3601
2026-06-08 22:24:36 -07:00
ttmouse b30215a0d1 fix(desktop): prevent context menu and popover flash-close during streaming generation (#3608)
Root cause: AnchoredPopover and ContextMenu both listen for scroll events
on window to close themselves. During AI streaming generation, the transcript
area auto-scrolls on every incoming token (Transcript.tsx:218-226), which
triggers window scroll events and instantly closes any open menu/popover
before the user can interact with it.

Fix: remove the close-on-scroll listener from both components. Menus still
close on Escape key, window resize, click-outside (ContextMenu via pointerdown,
AnchoredPopover via backdrop), which are the standard and predictable ways
to dismiss a menu.

Also fixes AnchoredPopover's useLayoutEffect missing dependency array
[open, align, offset, placement] to reduce unnecessary position recalculations.
2026-06-08 22:24:26 -07:00
Li-Charles-OneandLi-Charles-One 7c7285d4f1 fix(desktop): 拖动窗口图标时不再崩溃 (#3573) (#3616)
Wails 的 EnableFileDrop 全局开启后,拖动窗口左上角图标
(非磁盘文件)会触发 ResolveFilePaths 抛出未捕获错误,
导致整个应用崩溃。

在 onFilesDropped 中注册全局 error/unhandledrejection 拦截器,
吞掉 'additional File object is not a file on the disk' 错误,
仅允许真实的文件拖拽到达回调。

Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 22:24:15 -07:00
Laik b73ce718a8 feat(CLI):优化Termux体验,解决在termux上无法移动聊天上下文等问题 (#3611) 2026-06-08 18:56:57 -07:00
OoooorangeCloverandERroooooR 58f444a692 feat(run): add --resume flag for precise session file targeting (#3597)
Co-authored-by: ERroooooR <[email protected]>
2026-06-08 18:56:16 -07:00
ashish acc0aebda3 feat(cli): add /sandbox status command (#3316) (#3585) 2026-06-08 18:55:32 -07:00
CVEngineerandwufengfan 4441e6b5fb fix(agent): reduce evidence verification false signals — remove duplicate finalReadinessCheck, add session fallback for complete_step command matching, and drop stale todo indicator (#3587)
Three changes that all reduce false signals in the evidence/complete_step/todo
verification system:

1. Remove duplicate complete_step ordering check from finalReadinessCheck.
   The todo_write tool's own guard (verifyTodoCompletionTransitions) already
   rejects marking items completed without a matching complete_step receipt,
   and is order-independent. The finalReadinessCheck duplicate was stricter
   (required complete_step after the latest writer), causing false positives
   when the model correctly completes steps in a different order.

2. Add session-fallback command verification in complete_step.
   The per-turn Ledger is reset each turn, so cross-turn references, non-bash
   tool calls (e.g. the `ls` tool cited as command="ls ."), and truncated
   command strings all fail the strict receipt match. verifyCommandFromSession
   scans the full conversation transcript with normalized matching (strip …,
   collapse whitespace, prefix match with 8-char minimum) and tool-name
   fallback, eliminating three common false negatives.

3. Remove stale "Progress may be stale" indicator from desktop TodoPanel.
   The heuristic (≥2 tools after last todo_write, final assistant message,
   readiness notices) triggered false alerts in normal completion flows.
   The todo count and transcript itself provide better progress visibility.

Fixes #3469

Co-authored-by: wufengfan <[email protected]>
2026-06-08 18:53:49 -07:00
athamas huang bbf76100e6 fix(web_fetch): reuse network proxy resolution (#3614) 2026-06-08 18:53:43 -07:00
Li-Charles-OneandLi-Charles-One e2568bd07c fix(desktop): unify font family for model names and UI labels (#3468) (#3595)
Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 08:55:09 -07:00
Li-Charles-OneandLi-Charles-One d4422b651d fix(desktop): 修复粘贴附件保存到错误的工作区目录 (#3600)
Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 08:54:50 -07:00
55andlizhengwu 10fecbaa92 fix(tui): submit exact completion on enter (#3593)
Co-authored-by: lizhengwu <[email protected]>
2026-06-08 08:54:27 -07:00
YHHandxbzhao 8b2028e824 feat(openai): support adjusting thinking level for MiniMax-M3 (#3594)
MiniMax-M3 exposes a single binary thinking knob (adaptive|disabled)
on its OpenAI-compatible endpoint at api.minimaxi.com, but our
provider unconditionally emits reasoning_effort — which M3 rejects.
Detect api.minimaxi.com as a separate wire shape alongside
api.deepseek.com and:

  - emit thinking.type=adaptive|disabled in buildRequest
  - omit reasoning_effort entirely (M3 has no level scale)
  - translate /effort auto to 'adaptive' (the M3 default, since M3
    ships with thinking on out of the box and 'auto' semantically
    means 'don't override the model default')
  - map legacy level names from other vendors so a stale /effort
    value still resolves to a valid M3 level:
      off        → disabled  (retired DeepSeek 'no thinking' — M3
                              actually supports 'thinking off')
      low/medium/high → adaptive
      xhigh/max  → disabled
  - in NormalizeEffort, surface a MiniMax-specific usage hint
    (auto|adaptive|disabled) when an unknown level is supplied

internal/config/effort.go mirrors the change: M3 entries now expose
/levels=[auto,adaptive,disabled] with default=adaptive. Tests cover
the wire shape (TestBuildRequestMiniMaxThinking) and the
config-layer remap (TestNormalizeEffortMiniMax,
TestNormalizeEffortMiniMaxRejectsGarbage,
TestEffectiveEffortMiniMax).

Refactor: the host-matching logic was duplicated across
internal/provider/openai/openai.go (private *BaseURL helpers) and
internal/config/effort.go (private *Entry wrappers). Extract the
shared primitive into internal/provider/openai/host.go as
matchesVendorHost, plus thin exported wrappers IsDeepSeek and
IsMiniMax. The *Entry wrappers in effort.go now just gate on the
openai kind and delegate. Adding a new gateway is now a one-line
change in host.go rather than a four-line change across two files.

Add internal/provider/openai/host_test.go pinning the matching
rule (canonical hostname exact match, plus any subdomain of the
apex) directly at the source.

Co-authored-by: xbzhao <xbzhao@local>
2026-06-08 06:07:54 -07:00
ba094b9f5e feat(web_fetch): proxy support with SSRF-safe HTTP CONNECT / SOCKS5 tunnelling (#3581)
* feat(web_fetch): proxy support with SSRF-safe HTTP CONNECT / SOCKS5 tunnelling

web_fetch intentionally bypasses netclient proxy settings (source comment
in netclient.go). This means users behind corporate firewalls or GFW
cannot use web_fetch to reach external sites (GitHub, npm, etc.), even
when they have a working proxy configured in [network].

Wire web_fetch to the existing [network] proxy configuration while
preserving full SSRF protection:

1. netclient.ResolveProxyURL — new exported function that resolves
   ProxySpec to URL string (reuses existing customProxyURL logic)
2. webFetch.proxyURL — new field set during tool registration
3. ssrfGuardedClient(proxyURL) — enhanced to tunnel through proxy:
   - HTTP/HTTPS proxy: dial proxy -> CONNECT with ORIGINAL hostname
     (proxy resolves DNS remotely, essential for GFW users) -> tunnel
   - IP literal targets still get SSRF-checked via net.ParseIP
   - SOCKS5/SOCKS5h: Transport.Proxy (Go standard library handles it)
   - No proxy: existing direct SSRF-safe behavior unchanged
4. ConfineWebFetch + boot.go plumbing for both workspace and fallback
   paths

| File | Change |
|------|--------|
| internal/netclient/netclient.go | +ResolveProxyURL() |
| internal/tool/builtin/webfetch.go | webFetch.proxyURL, ssrfGuardedClient proxy tunnel |
| internal/tool/builtin/workspace.go | Workspace.ProxyURL |
| internal/tool/builtin/confine.go | +ConfineWebFetch() |
| internal/boot/boot.go | addBuiltins proxyURL param + fallback fix |
| internal/boot/boot_test.go | test signature update |
| internal/tool/builtin/web_fetch_proxy_test.go | new proxy tests |

- go build ./... compiles
- go test ./internal/tool/builtin/ PASS
- go test ./internal/boot/ PASS
- SSRF still blocks 169.254.169.254, 10.0.0.1, 192.168.1.1 through proxy

* fix(web_fetch): keep SSRF guard usable with a SOCKS5 proxy

The SOCKS branch only set Transport.Proxy, so the proxy itself was dialed
through the SSRF-checking directDialContext — a SOCKS proxy on a private/LAN
address (anything but loopback) was refused by the guard. Route SOCKS through
golang.org/x/net/proxy with a plain dialer to the trusted proxy, and keep the
IP-literal target SSRF check in a wrapping DialContext, mirroring the HTTP
CONNECT path. Adds tests for both the preserved block and the proxy-not-blocked
case.

---------

Co-authored-by: ningqipeng <[email protected]>
Co-authored-by: reasonix <[email protected]>
2026-06-08 05:18:31 -07:00
SivanCola f5363dd741 feat(desktop): render chat attachments as cards (#3577) 2026-06-08 05:01:17 -07:00
LaikandYour Name 2a8b2446bb fix:解决Termux下无法呼出手机键盘问题 (#3563)
* fix:Fix the issue where the phone keyboard cannot be used in the Termux environment.

* fix:测试环境加上termux环境判断

---------

Co-authored-by: Your Name <[email protected]>
2026-06-08 11:54:13 +00:00
jiaxindeyangandCodex f6e6673d25 feat(desktop): add text size shortcuts (#3576)
Co-authored-by: Codex <[email protected]>
2026-06-08 04:52:33 -07:00
Li-Charles-OneandLi-Charles-One c5372f53db fix(desktop): 字体设置对加粗字符生效 (#3459) (#3569)
移除 .settings-model-picker__option strong 中强制的 font-family: var(--mono),
让模型名称等加粗文本跟随用户选择的字体设置。

Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 04:52:20 -07:00
Li-Charles-OneandLi-Charles-One a63fecaf91 fix(desktop): center workspace panel resizer line to eliminate gap (#3443) (#3566)
The workspace-panel-resizer ::after line was positioned at left: 0,
leaving 7px of empty space on the right side of the 8px resizer area.
This created a visible gap between the resizer line and the workbench dock.

Move the line to left: 50% with translateX(-50%) to center it within
the resizer, matching the sidebar-resizer behavior.

Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 04:52:07 -07:00
Li-Charles-OneandLi-Charles-One 91eac0a110 fix(desktop): prevent middle-click from navigating webview (#3505) (#3564)
Add onAuxClick and onMouseDown handlers to <a> in Markdown component
to intercept middle-click events and open links via openExternal()
instead of allowing default browser navigation behavior.

Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 04:51:56 -07:00
wade1999andwade19990814-hue 55e683dabe fix(desktop): restore maximized window from tray (#3568)
* fix(desktop): restore maximized window from tray

* fix(desktop): avoid tray restore window flash

---------

Co-authored-by: wade19990814-hue <[email protected]>
2026-06-08 04:51:45 -07:00
SivanCola cf7e483201 Add clear alias for fresh sessions (#3575) 2026-06-08 04:51:23 -07:00
SivanCola 9328c48fb0 fix(desktop): classify proxy providers separately from official access (#3571) 2026-06-08 04:51:12 -07:00
YHHandreasonix 98280862c6 fix(desktop): virtualize composer / and @ menus, drop item caps (#3579)
* fix(desktop): virtualize composer / and @ menus, drop item caps

The slash menu was sliced to 8 and the @ file menu to 10, so commands and
files past those were unreachable even by scrolling — Commands() alone
returns 10+ builtins before skills/custom/mcp are added. Remove both caps
and render the menus through a shared @tanstack/react-virtual list, so they
scale to any directory size while only mounting the visible rows.

* build(desktop): update pnpm-lock for @tanstack/react-virtual

The build uses pnpm (wails.json frontend:install); the new dep must be in
pnpm-lock.yaml or pnpm install --frozen-lockfile fails in CI.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-08 04:41:47 -07:00
YHHandreasonix 8feb6b3f17 fix(openai): harden request building for strict OpenAI-compatible backends (#3574)
* fix(provider): emit a valid schema for no-param tools

An empty tool schema (common for no-argument MCP tools) is an empty
json.RawMessage, which makes json.Marshal of the whole chat request fail
with "unexpected end of JSON input" — bricking the entire provider for
every request with a cryptic error. Canonicalize empty to {"type":"object"}.

* fix(openai): send null content for pure tool-call assistant turns

Strict OpenAI-compatible backends can reject an assistant tool_calls
turn whose content is the empty string. null is the OpenAI-spec form and
is accepted by DeepSeek (verified against a live multi-tool session), so
emit null for that one case and keep the string form (empty included)
for every other role. Refs #3526.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-08 03:37:10 -07:00
binwei 0d015fcbf7 fix: modify workspace git display (#3507)
Optimize the display of the Git area in the workspace.
2026-06-08 03:10:08 -07:00
SivanColaandreasonix 5cf486f985 fix(agent): prevent executor handoff role confusion / 防止执行器交接角色混淆 (#3541)
* fix(agent): keep executor from answering as planner

* fix(agent): scope handoff-confusion match to role-identity phrases

Bare permission claims (no write access / 没有写入权限) are the exact
vocabulary a correctly-executing model uses to report a real blocker;
matching them mis-fired the planner-confusion guard and could hard-error
a legitimately-blocked task. Drop those phrases, keep role-identity ones,
and lock it with a regression test.

* fix(agent): detect executor punt by behavior, not keywords

The keyword list matched on vocabulary, so it both missed paraphrases
and false-flagged legitimate blocker reports ("no write access" is the
exact phrasing the handoff prompt asks the executor to use). Replace it
with a structural signal: if the executor reaches a final answer in
handoff mode having called zero tools, nudge once with the executor
instructions, then trust it — no keyword table, no hard error. Any tool
use (including read-only) exempts it, so only a true punt triggers the
nudge. Language-independent.

Tests cover both the nudge and the no-nudge-when-acting path; verified
end-to-end with a live deepseek-pro planner / deepseek-flash executor
run that wrote and ran the file without tripping the guard.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-08 03:04:32 -07:00
Antoine Reichelandreasonix f9a7c08cc0 feat(cli): add reasonix review subcommand for code review (#3518)
* feat(cli): add reasonix review subcommand for code review

Add a new `reasonix review` subcommand that runs the built-in review skill as
a subagent against git changes and outputs the result. This gives users a
one-shot, non-interactive way to review code without opening a chat session.

Usage:
  reasonix review                      # review uncommitted working-tree changes
  reasonix review --base main          # review current branch vs main
  reasonix review --commit abc123      # review a specific commit
  reasonix review --model deepseek     # use a specific model
  reasonix review --instructions ... # add extra review instructions

Implementation: 3 files, +154 lines
- cli/review.go:    reviewCommand + getReviewDiff + buildReviewTask
- cli/review_test.go:  TestBuildReviewTask (3 sub-cases)
- cli/cli.go:       register review case in Run()

* fix(cli): use fmt.Fprint instead of WriteString(fmt.Sprint) in review

staticcheck QF1012 — keeps the lint job green.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-08 09:25:12 +00:00
Greg Lyu 9373980a80 fix(chooser): separate Enter (confirm) from Space (toggle) in multi-select (#3530)
Multi-select mode now uses Space only for toggling individual options,
while Enter (and number keys) confirms current selections and advances
to the next question or submits, matching the single-select flow.
Previously both keys toggled, making it impossible to commit without
toggling the highlighted option.

The desktop AskCard already has this correct via explicit confirm
buttons and was unaffected.
2026-06-08 09:10:32 +00:00
Li-Charles-OneandLi-Charles-One 7f5fa3df77 fix(desktop): allow history panel to expand in narrow windows (#3544)
The .history-content grid used minmax(340px, 0.38fr) for the list column,
which forced a 340px minimum even in narrow viewports and squeezed the
preview pane on the right. Lower the floor to 0 and cap the list at
40vh inside the <=760px breakpoint so the preview gets room to breathe.

Fixes #3538

Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 09:09:49 +00:00
SivanCola 6969e96f23 fix(desktop): disable native drags for internal images (#3547) 2026-06-08 01:59:40 -07:00
YHHandreasonix d5c5d3bd54 docs: note Windows code signing via SignPath Foundation (#3551)
Required for the SignPath Foundation open-source program: the download
reference must mention that the project uses SignPath for code signing.
Added to both README.md and README.zh-CN.md.

Co-authored-by: reasonix <[email protected]>
2026-06-08 01:33:43 -07:00
YHHandreasonix 7fffa2f94a fix(desktop): persist yolo composer mode across reload (#3546)
* fix(desktop): persist yolo composer mode across reload (#3517)

persistedTabMode dropped yolo (only plan was saved), so selecting yolo and
relaunching reverted to normal — the tab persist/restore machinery already
round-trips the mode, yolo was just filtered out. Persist plan and yolo
(a restored yolo tab keeps its status-bar indicator); normal stays the
un-persisted default.

Closes #3517

* test: flip the yolo-persist tab test to the new behavior

TestSaveTabsDoesNotPersistYoloMode encoded the old drop-yolo behavior;
renamed to TestSaveTabsPersistsYoloMode and flipped to assert yolo survives
the real saveTabsLocked/loadTabsFile round-trip. Drops the redundant unit
test added alongside the fix.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-08 01:13:45 -07:00
YHHandreasonix c0bb1796c5 fix(permission): make session/persist approval grants tool-wide (#3542)
* fix(permission): make session/persist approval grants tool-wide

"Allow for this session" and "Allow persistently" pinned the exact
subject (file path / command), so writing a different file or running a
different command re-prompted every time — users read the labels as
"trust this tool" and reasonably expected no further prompts. Both now
grant the whole tool: the session grant keys on the tool name, and the
persisted rule is the bare tool name. Deny rules still take precedence on
every call. Adds a full agent-turn e2e proving a second file write under
the same tool runs without a second prompt.

Closes #3498
Closes #3520

* test: gofmt the e2e approval test

---------

Co-authored-by: reasonix <[email protected]>
2026-06-08 00:43:21 -07:00
SivanCola 0d248f7d21 fix(agent): make planner step limit configurable (#3532) 2026-06-08 00:20:22 -07:00
YHHandreasonix d43d0e7d44 ci(release): keep canary off the GitHub releases page (R2-only) (#3531)
Canary desktop builds no longer publish a GitHub release — the version
showed up on the public releases page looking like a 1.4 stable. Canary
now uploads its signed dist as a workflow artifact that the mirror job
pushes to R2 (canary/ + desktop-canary/); the updater's canary channel is
R2-only. Stable is unchanged. Docs updated.

Co-authored-by: reasonix <[email protected]>
2026-06-07 23:58:02 -07:00
YHHandreasonix d0ce7651e4 ci(release): gate stable releases behind the release environment (#3528)
Stable publish jobs (CLI goreleaser, npm tag push, desktop tag/stable
dispatch) now run in the `release` environment, which requires esengine's
approval before anything goes public. Canary dispatches use the open
`canary` environment so any maintainer can self-serve a pre-release. Adds
docs/RELEASING.md documenting the trunk+tags model, channels, and who can
ship what.

Co-authored-by: reasonix <[email protected]>
2026-06-07 23:24:51 -07:00
YHHandreasonix 78b19bf105 feat: add opt-in canary update channel (desktop + CLI) (#3514)
* feat(desktop): add opt-in canary update channel

A canary build (-X main.channel=canary) polls R2's canary/ pointer instead of
latest/, so a stable install can never auto-update onto it. Folded into the
existing desktop release workflow as a dispatch-only channel input: build/sign/
manifest are shared, only version/publish/mirror branch on channel. Tag/version/
channel resolution is centralized in scripts/resolve-desktop-release.sh. CLI is
unchanged (stays on the npm next tag).

* feat(cli): add opt-in canary npm dist-tag

A version containing -canary. publishes to the canary dist-tag instead of
next/latest, so npm i reasonix@canary opts in and 1.x (next) / 0.x (latest)
users are untouched. release-npm.yml gains a workflow_dispatch canary mode
that builds the current ref as <base>-canary.<run_number>.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-07 21:17:46 -07:00
c816d2dc8f perf(desktop): coalesce streaming text/reasoning deltas per animation frame (#3114) (#3501)
* perf(desktop): coalesce streaming text/reasoning deltas per animation frame

The agent stream pushes a text/reasoning delta per Webview event; at 200
tok/s that is one state update every ~5ms, while rAF is 16ms — so the
reducer was running 3-4 times per visible frame and the React tree was
re-rendering the entire Message list each time.

Add lib/rafBatch.ts: a tiny rAF-coalescing queue with a synchronous
drain() so non-text events (tool_dispatch, usage, notice, turn_started/
done, message) flush any pending deltas first to keep causal ordering
intact. Drain is also called on unmount so a turn-end that races the
teardown doesn't strand the last few tokens in the buffer.

tsc --noEmit passes; Go build ./desktop/... is clean.

* perf(desktop): coalesce streaming text/reasoning deltas per animation frame

The agent stream pushes a text/reasoning delta per Webview event; at 200
tok/s that is one state update every ~5ms, while rAF is 16ms — so the
reducer was running 3-4 times per visible frame and the React tree was
re-rendering the entire Message list each time.

Add lib/rafBatch.ts: a tiny rAF-coalescing queue with a synchronous
drain() so non-text events (tool_dispatch, usage, notice, turn_started/
done, message) flush any pending deltas first to keep causal ordering
intact. Drain is also called on unmount so a turn-end that races the
teardown doesn't strand the last few tokens in the buffer.

tsc --noEmit passes; Go build ./desktop/... is clean.

---------

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: reasonix <[email protected]>
2026-06-07 20:25:40 -07:00
HUQIANTAOandHUQIANTAO f50604412a fix(desktop): anchor send button to bottom-right of composer (#3448)
The send button uses the default flex alignment (center), so it floats
vertically-centred in the composer.  As the textarea grows to 2, 3, 4+
lines the button drifts downward with each new line — the muscle memory
target keeps moving.

Add align-self: flex-end to .composer__btn so the button hugs the
bottom-right corner regardless of how tall the input is.  The caret (›)
stays vertically centred (the default align-items: center) which keeps
it visually anchored to the first line of text.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-07 20:16:08 -07:00
HUQIANTAOandHUQIANTAO afb1466640 fix(tool-card): collapse sub-agent tree with parent chevron (#3140)
The parent task tool card chevron only toggled args/output visibility;
nested sub-agent calls (read_file / grep / ls from an /explore run)
always rendered underneath.  A 50-step /explore flooded the transcript
with its full step list, with no way to fold it back.

Now the open state also controls nested items.  A single chevron click
collapses or expands the whole subagent tree.  Behavior:
- Open by default while the sub-agent is still "running" so the user
  can watch progress.
- Closed by default once it settles, so a finished explore folds to
  a single summary line ("12 steps") on first paint.
- Individual sub-calls can still be expanded/collapsed independently.

This is the ToolCard half of the original #3140.  The streaming-markdown
half has been merged via #3444.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-07 20:11:15 -07:00
6cc7396122 feat(desktop): branch switcher dropdown in changes panel (#3482) (#3493)
* feat(desktop): add branch switcher dropdown in changes panel

Show current git branch name in the Changed-files view with a dropdown
to switch branches and a refresh button to reload changes.

Backend:
- Add GitBranch field to WorkspaceChangesView
- Add workspaceGitBranch() helper
- Add GitBranches() and GitCheckout() bindings

Frontend:
- Add branch indicator bar with dropdown menu
- Add refresh button with spinning animation
- Add workspace-branch CSS styles
- Add mock implementations for GitBranches/GitCheckout
- Update WorkspaceChangesView type with gitBranch field

Tests:
- Add GitBranch assertion to existing git status test
- Add TestWorkspaceChangesGitBranchDetachedHead
- Add gitOutput test helper

* fix(desktop): branch menu add background and increase height

---------

Co-authored-by: ttmouse <[email protected]>
Co-authored-by: reasonix <[email protected]>
2026-06-07 19:59:11 -07:00
ttmouse fbc80b8352 feat(desktop): wire up ⌘K command palette (#3480)
The CommandPalette component existed in the codebase since its original
PR but was never imported or rendered in App.tsx. Wire it up:

- Import CommandPalette and PaletteItem type
- Add paletteOpen state with ⌘K/Ctrl+K toggle listener
- Add buildPaletteItems() generating session-switch + action items
- Render <CommandPalette> below existing overlays
- Add palette CSS styles (backdrop, panel, input, list, footer)
2026-06-07 19:44:01 -07:00
7cef20389a fix(desktop): classify attachments by real file type (#3474)
Treat .reasonix/attachments refs as images only when the attachment is an actual image extension, so yaml/zip attachments resolve as file refs and no longer render as misleading image placeholders.

Co-authored-by: Eason Sun <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-07 19:40:44 -07:00
Jim K. Ding f8bd98c462 fix(desktop): keep desktop-workspace pointer in sync across all navigation paths (#3436)
* fix(desktop): clear active workspace pointer when removing current project

RemoveWorkspace() was cleaning up desktop-workspaces.json and
desktop-projects.json but never cleared the desktop-workspace pointer
file. When the removed project was the active workspace, this left a
stale reference that caused subsequent sessions to use the wrong
workspace root — attachments and memory would land in a deleted
project's directory.

Now RemoveWorkspace checks whether the removed dir matches the active
pointer and either falls back to the first remaining project or clears
the pointer entirely when no projects remain.

Fixes #1623

* fix(desktop): update active workspace pointer in OpenProjectTab

SwitchWorkspace correctly saves the active pointer, but clicking an
existing project in the sidebar goes through OpenProjectTab instead,
which never called saveWorkspace. This meant that after switching
projects via the sidebar, desktop-workspace still pointed to whichever
project was active at app startup — causing attachments and memory to
land in the wrong project directory.

Now OpenProjectTab also calls saveWorkspace so the pointer stays in
sync regardless of how the user navigates between projects.

Also adds a test verifying OpenProjectTab updates the pointer.
2026-06-07 19:40:38 -07:00
44486ff91c fix(desktop): only highlight composer resize grip on direct hover (#3461) (#3472)
Co-authored-by: Eason Sun <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-07 19:40:31 -07:00
SivanCola cd313db1ff fix desktop skill detail expand control (#3462) 2026-06-07 19:13:56 -07:00
TaoMu 2532584975 fix(config): preserve LSP config when rendering TOML (#3471) 2026-06-07 19:13:49 -07:00
SuMuxi66 60f98f698e fix(config): filter non-chat models from chat candidates (#3487) 2026-06-07 19:13:33 -07:00
SivanCola 3f75e4e22c Merge pull request #3336 from SuMuxi66/feature/past-chats-reference
feat(composer): add past chats reference flow
2026-06-08 00:54:07 +08:00
reasonix-local 82a820baea fix(composer): preserve file default in at menu 2026-06-08 00:23:03 +08:00
SuMuxi66 19f9ccfd52 fix(composer): return focus after picking past chat + fix CSS brace 2026-06-07 17:22:23 +08:00
SuMuxi66 9331bc054e fix(composer): make past chats a first-class at-menu item 2026-06-07 17:20:14 +08:00
SuMuxi66 7ca6f1b835 fix(composer): submit guard ref+state and docs trailing whitespace 2026-06-07 17:20:14 +08:00
SuMuxi66 bc0885de9d fix(composer): keyboard navigation for past:chats and submit guard 2026-06-07 17:20:14 +08:00
SuMuxi66 46af735c89 docs(composer): document past chats reference architecture 2026-06-07 17:20:14 +08:00
SuMuxi66 7efa8ad160 feat(composer): add past chats reference flow
Squash the verified past:chats implementation chain into one reviewable
feature commit for PR preparation.

Includes:
- Add past:chats entry to the @ reference menu
- Show historical chat sessions and selected reference cards
- Include referenced chat context at send time
- Filter past chats locally in the reference menu
- Preview referenced chats on hover with Tooltip

Original verified commits:
- cdec11d3 feat(composer): add past chats reference entry
- bab0be5d feat(composer): include referenced chats in prompt
- 81ca7602 feat(composer): filter referenced chats
- a805e3bd feat(composer): preview referenced chats on hover
2026-06-07 17:20:14 +08:00
139 changed files with 8518 additions and 1045 deletions

No files matched your search

+110 -23
View File
@@ -9,14 +9,29 @@ name: Release desktop
# 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).
#
# 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/
# pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary
# and uploads only to R2's canary/ pointer — no GitHub release, so canary never
# shows on the releases page and never touches latest/.
on:
push:
tags: ["desktop-v*"]
workflow_dispatch:
inputs:
channel:
description: "Release channel"
type: choice
options: [stable, canary]
default: stable
tag:
description: "Tag to publish (e.g. desktop-v1.1.0). Required when run manually."
required: true
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
base_version:
description: "canary: intended next stable version, e.g. 1.5.0"
required: false
type: string
permissions:
@@ -89,18 +104,57 @@ jobs:
if: runner.os == 'macOS'
run: brew install create-dmg
# macOS signing: import the Developer ID cert into a throwaway keychain and
# stage the notarization key. No-ops (and the build ad-hoc signs) when the
# APPLE_* secrets aren't set, so forks still build.
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$APPLE_CERT_P12" ]; then
echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASS="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
# Prepend the signing keychain to the search list so codesign / find-identity see it.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
rm -f "$RUNNER_TEMP/cert.p12"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/[email protected]
- name: Resolve version
id: ver
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#desktop-}" >> "$GITHUB_OUTPUT"
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
- name: Build and package
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}"
env:
# macOS Developer ID + notarization path turns on only when all five
# APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
# Harmless on Windows/Linux runners (only the darwin branch reads these).
HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}"
- name: Sign artifacts (minisign)
working-directory: desktop
@@ -119,6 +173,9 @@ jobs:
name: publish release
needs: build
runs-on: ubuntu-latest
# Stable publishes go through the `release` environment (esengine must
# approve); canary uses the open `canary` environment so maintainers self-serve.
environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v4
@@ -136,13 +193,14 @@ jobs:
- name: Resolve version
id: ver
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
VERSION="${TAG#desktop-}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# A version with a suffix (v1.0.0-rc1) is a prerelease.
case "$VERSION" in *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT";; *) echo "prerelease=false" >> "$GITHUB_OUTPUT";; esac
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
# Generate latest.json with GitHub release download URLs; the mirror step
# rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
@@ -150,7 +208,11 @@ 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.
- name: Publish GitHub release
if: steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -158,6 +220,14 @@ jobs:
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
- name: Upload canary dist for mirror
if: steps.ver.outputs.channel == 'canary'
uses: actions/upload-artifact@v4
with:
name: canary-dist
path: dist/*
if-no-files-found: error
mirror:
name: mirror to R2
needs: publish
@@ -167,15 +237,30 @@ jobs:
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
- name: Resolve tag
- uses: actions/checkout@v4
- name: Resolve version
id: ver
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
case "${TAG#desktop-}" in *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT";; *) echo "prerelease=false" >> "$GITHUB_OUTPUT";; esac
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
# Canary has no GitHub release — pull the signed dist from the workflow
# artifact. Stable pulls from the published release.
- name: Download canary dist
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary'
uses: actions/download-artifact@v4
with:
name: canary-dist
path: assets
- name: Download release assets
if: env.HAS_R2 == 'true'
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -216,9 +301,11 @@ jobs:
run: |
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT"
# Prereleases publish to their own tag prefix but must not move the
# updater's latest/ pointer; stable releases do.
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
# The pointer a release moves is its channel's alone: canary writes
# canary/, stable writes latest/. A stable prerelease (rc) moves neither.
if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then
aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT"
elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "prerelease — leaving R2 latest/ untouched"
else
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
+26 -6
View File
@@ -10,12 +10,20 @@ name: Release npm
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# build.mjs decides the dist-tag: only the 0.x stable line is `latest`; the 1.x
# line and every prerelease ship under `next`. Promote a 1.x stable to the default
# install with a manual `npm dist-tag add [email protected] latest`.
# A manual workflow_dispatch with base_version publishes an opt-in canary to the
# `canary` dist-tag (npm i reasonix@canary) from the current ref; it never moves
# `next`/`latest`. build.mjs decides the dist-tag: 0.x stable is `latest`; a
# `-canary.` build is `canary`; the 1.x line and rc prereleases are `next`. Promote
# a 1.x stable to the default install with `npm dist-tag add [email protected] latest`.
on:
push:
tags: ['npm-v*']
workflow_dispatch:
inputs:
base_version:
description: "canary: intended next stable version, e.g. 1.5.0"
required: true
type: string
permissions:
contents: read
@@ -36,6 +44,9 @@ jobs:
name: publish npm packages
needs: cache-guard
runs-on: ubuntu-latest
# A tag push (npm-v*) publishes a stable/next release and goes through the
# `release` environment (esengine approves); a canary dispatch runs free.
environment: ${{ github.event_name == 'workflow_dispatch' && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -46,8 +57,17 @@ jobs:
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# build.mjs strips the leading `npm-`/`v`, so it derives the bare semver from
# the `npm-v*` tag (e.g. npm-v1.3.0-rc.1 -> 1.3.0-rc.1).
- run: node npm/build.mjs "${GITHUB_REF_NAME}" --publish
# Tag push uses the npm-v* tag; a canary dispatch synthesizes a
# <base>-canary.<run_number> version. build.mjs strips the leading `npm-`/`v`.
- name: Resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
base="${{ inputs.base_version }}"; base="${base#v}"
echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
else
echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+52
View File
@@ -30,6 +30,9 @@ jobs:
name: archives + checksums + homebrew tap
needs: cache-guard
runs-on: ubuntu-latest
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
# `release` environment so only esengine can approve it going public.
environment: release
steps:
- uses: actions/checkout@v4
with:
@@ -45,3 +48,52 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
mirror-codegraph:
name: mirror CodeGraph runtime to R2
needs: goreleaser
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'esengine' }}
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
- uses: actions/checkout@v4
- name: Download CodeGraph release assets
if: env.HAS_R2 == 'true'
run: |
version="$(awk '/^CODEGRAPH_VERSION[[:space:]]*:=/ { print $3; exit }' Makefile)"
[ -n "$version" ] || { echo "CODEGRAPH_VERSION not found" >&2; exit 1; }
base="https://github.com/colbymchenry/codegraph/releases/download/${version}"
mkdir -p codegraph-assets
curl -fsSL "$base/SHA256SUMS" -o codegraph-assets/SHA256SUMS
for asset in \
codegraph-darwin-arm64.tar.gz \
codegraph-darwin-x64.tar.gz \
codegraph-linux-arm64.tar.gz \
codegraph-linux-x64.tar.gz \
codegraph-win32-arm64.zip \
codegraph-win32-x64.zip
do
curl -fsSL "$base/$asset" -o "codegraph-assets/$asset"
want="$(awk -v name="$asset" '$2 == name { print $1 }' codegraph-assets/SHA256SUMS)"
got="$(shasum -a 256 "codegraph-assets/$asset" | awk '{ print $1 }')"
[ "$got" = "$want" ] || { echo "checksum mismatch for $asset" >&2; exit 1; }
done
echo "CODEGRAPH_VERSION=$version" >> "$GITHUB_ENV"
- name: Configure AWS CLI for R2
if: env.HAS_R2 == 'true'
run: |
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
- name: Mirror CodeGraph to R2
if: env.HAS_R2 == 'true'
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws s3 cp codegraph-assets/ "s3://${R2_BUCKET}/codegraph/${CODEGRAPH_VERSION}/" --recursive --endpoint-url "$ENDPOINT"
+30 -8
View File
@@ -71,6 +71,12 @@ brew install esengine/reasonix/reasonix # macOS
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
### Code signing
Windows builds are code-signed with a free certificate provided by the
[SignPath Foundation](https://signpath.org/), with signing through
[SignPath.io](https://signpath.io/).
### Build from source
```sh
@@ -100,6 +106,8 @@ default_model = "deepseek-flash" # executor; set [agent].planner_model to add
# language = "zh" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG
[agent]
max_steps = 0 # executor tool-call rounds; 0 = no limit
planner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit
# planner_model = "mimo-pro" # optional low-frequency planner
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
@@ -125,8 +133,8 @@ bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local ca
[permissions]
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
allow = ["bash(go test*)"] # never prompted
deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode
allow = ["Bash(go test:*)"] # never prompted
[sandbox]
# workspace_root = "" # file-writers confined here; empty = current dir
@@ -137,10 +145,15 @@ name = "example"
command = "reasonix-plugin-example"
```
Permissions gate each tool call: `deny` > `ask` > `allow` > fallback (readers
always allow; writers fall back to `mode`). `reasonix chat` prompts before writers
(`y` once · `a` this session · `n` no); `reasonix run` stays autonomous but still
honours `deny`. See [`docs/SPEC.md`](docs/SPEC.md) for the full schema and contract.
Permissions gate each tool call: `deny` > `ask` > `allow` > fallback. Bash and
file mutation tools require approval by default; read-only tools generally do
not. Approvals are stored and matched as permission rules, not button labels:
for example `Bash(npm run build)`, `Bash(npm run test:*)`, and `Edit(docs/**)`.
`reasonix chat` can grant Bash as an exact command or as a conservative command
prefix (for example `Bash(go test:*)`), while file-editing tools share session
edit grants and persist path-scoped rules such as `Edit(src/app.go)`.
`reasonix run` stays autonomous but still honours `deny`. See
[`docs/SPEC.md`](docs/SPEC.md) for the full schema and contract.
Permissions are *policy* (which calls to allow / prompt). The **sandbox** is
*enforcement*: the file-writers (`write_file` / `edit_file` / `multi_edit`)
@@ -207,8 +220,10 @@ convenient.
### Slash commands
In `reasonix chat`, built-in commands (`/compact`, `/new`, `/rewind`, `/tree`,
In `reasonix chat`, built-in commands (`/compact`, `/new`/`/clear`, `/rewind`, `/tree`,
`/branch`, `/switch`, `/todo`, `/model`, `/effort`, `/mcp`, `/memory`, `/help`) run locally.
`/new` starts a fresh model context while saving the previous transcript for
history/resume; `/clear` is the Claude Code-compatible alias.
`/tree` shows saved conversation branches, `/branch [name]` forks the current
conversation tip, `/branch <turn> [name]` forks from an earlier checkpointed turn,
and `/switch <id|name>` loads another branch. **Custom commands** are Markdown files under
@@ -248,11 +263,18 @@ separate cache-stable sessions) is a one-line edit afterwards — set
```toml
[agent]
planner_model = "deepseek-pro" # used as the low-frequency planner
planner_max_steps = 12 # read-only tool-call rounds before pausing
```
The planner sees loaded `REASONIX.md` / `AGENTS.md` memory and a small read-only
research tool set, so it can inspect relevant files before handing a plan to the
executor. Writer and workflow tools remain executor-only.
executor. Writer and workflow tools remain executor-only. `max_steps` limits the
executor; `planner_max_steps` limits only the planner, and either can be set to
`0` for no round limit.
Keep personal step-limit preferences in the user config. Add them to a project's
`./reasonix.toml` only when that repository needs a shared override, such as a
larger planner limit for a very large codebase.
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
+21 -5
View File
@@ -68,6 +68,11 @@ brew install esengine/reasonix/reasonix # macOS
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
### 代码签名
Windows 构建使用 [SignPath 基金会](https://signpath.org/) 提供的免费代码签名证书,
通过 [SignPath.io](https://signpath.io/) 完成签名。
### 从源码构建
```sh
@@ -96,6 +101,8 @@ default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可
# language = "zh" # 界面语言;为空则按 $LANG / $REASONIX_LANG 自动检测
[agent]
max_steps = 0 # 执行器工具调用轮数;0 表示不限
planner_max_steps = 12 # 规划器只读工具调用轮数;0 表示不限
# planner_model = "mimo-pro" # 可选的低频规划器
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
@@ -121,8 +128,8 @@ bash_timeout_seconds = 120 # 前台安全上限;设为 0 表示不设工具
[permissions]
mode = "ask" # 无规则命中时 writer 的兜底:ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # 任何模式下都硬阻断
allow = ["bash(go test*)"] # 从不询问
deny = ["Bash(rm -rf*)", "Bash(git push*)"] # 任何模式下都硬阻断
allow = ["Bash(go test:*)"] # 从不询问
[sandbox]
# workspace_root = "" # 文件写工具被限制在此目录;留空 = 当前目录
@@ -133,8 +140,11 @@ name = "example"
command = "reasonix-plugin-example"
```
权限逐次调用把关:`deny` > `ask` > `allow` > 兜底(只读工具永远 allow,writer 落到
`mode`)。`reasonix chat` 会在 writer 调用前征求同意(`1` 本次 · `2` 本会话 · `3` 拒绝,兼容 `y/a/n`);
权限逐次调用把关:`deny` > `ask` > `allow` > 兜底。Bash 和文件修改都要审核;
只读工具一般不需要。审核规则不是按“按钮文案”存,而是按权限规则匹配,比如
`Bash(npm run build)`、`Bash(npm run test:*)`、`Edit(docs/**)` 这种形式。
`reasonix chat` 会在 writer 调用前征求同意(普通工具为 `1` 本次 · `2` 本会话允许此范围 · `3` 总是允许此范围(保存) · `4` 拒绝;Bash 可额外选择命令前缀授权);
其中 Bash 默认按具体命令记,也可按安全推导出的命令前缀记(如 `Bash(go test:*)`);文件编辑类工具的本会话授权按编辑能力记,持久授权则写入 `Edit(<path>)` 文件路径规则;
`reasonix run` 保持自主运行但仍然遵守 `deny`。完整 schema 与契约见
[`docs/SPEC.md`](docs/SPEC.md)。
@@ -190,7 +200,8 @@ headers = { Authorization = "Bearer ${STRIPE_KEY}" }
### 斜杠命令
`reasonix chat` 里,内置命令(`/compact`、`/new`、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。
`reasonix chat` 里,内置命令(`/compact`、`/new`(`/clear`)、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。
`/new` 会开启干净的模型上下文,同时保存之前的 transcript 供历史记录和恢复使用;`/clear` 是兼容 Claude Code 习惯的同义命令。
`/tree` 查看已保存的对话分支,`/branch [name]` 从当前对话末端分支,`/branch <turn> [name]`
从较早的 checkpoint 轮次分支,`/switch <id|name>` 切换到另一个分支。**自定义命令**
是放在 `.reasonix/commands/`(项目)或 `~/.config/reasonix/commands/`(用户)下的 Markdown 文件——
@@ -224,10 +235,15 @@ session),向导后手动在 `reasonix.toml` 加一行即可:
```toml
[agent]
planner_model = "deepseek-pro" # 作为低频规划器
planner_max_steps = 12 # 暂停前允许的只读工具调用轮数
```
Planner 会看到已加载的 `REASONIX.md` / `AGENTS.md` 记忆,并拿到一小组只读研究工具,
因此可以先检查相关文件再把计划交给执行器。写入类和流程类工具仍只给执行器使用。
`max_steps` 限制执行器;`planner_max_steps` 只限制规划器,两者都可设为 `0` 表示不限。
个人偏好的轮数上限建议放在用户级配置。只有当某个仓库确实需要团队共享覆盖时,
再写进项目的 `./reasonix.toml`,例如超大代码库需要更高的 planner 上限。
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
+7
View File
@@ -12,6 +12,13 @@
/build/windows/installer/*
!/build/windows/installer/project.nsi
# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh
# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT
# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first.
!/build/darwin
/build/darwin/*
!/build/darwin/entitlements.plist
# Go test binaries
*.test
+116 -32
View File
@@ -69,9 +69,10 @@ type App struct {
activeTabID string
readyHook func()
forceQuit atomic.Bool
trayReady bool
tray *desktopTray
forceQuit atomic.Bool
backgroundMaximised atomic.Bool
trayReady bool
tray *desktopTray
mediaTokens *mediaTokenStore
}
@@ -278,6 +279,7 @@ func (a *App) beforeClose(ctx context.Context) bool {
cfg = config.LoadForEdit(config.UserConfigPath())
}
if cfg.DesktopCloseBehavior() == "background" {
a.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))
a.saveWindowStateSync()
a.snapshotAllTabs()
hideForBackground(ctx)
@@ -288,7 +290,7 @@ func (a *App) beforeClose(ctx context.Context) bool {
func (a *App) showMainWindow() {
if a.ctx != nil {
showFromBackground(a.ctx)
showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
}
}
@@ -312,18 +314,40 @@ func hideForBackground(ctx context.Context) {
runtime.WindowHide(ctx)
}
func showFromBackground(ctx context.Context) {
func showFromBackground(ctx context.Context, wasMaximised bool) {
if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
runtime.Show(ctx)
}
plan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)
if plan.maximiseBeforeShow {
runtime.WindowMaximise(ctx)
}
runtime.WindowShow(ctx)
runtime.WindowUnminimise(ctx)
if plan.unminimiseAfterShow {
runtime.WindowUnminimise(ctx)
}
}
func backgroundCloseUsesApplicationHide(goos string) bool {
return goos == "darwin"
}
type backgroundRestorePlan struct {
maximiseBeforeShow bool
unminimiseAfterShow bool
}
func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {
if backgroundRestoreShouldMaximise(goos, wasMaximised) {
return backgroundRestorePlan{maximiseBeforeShow: true}
}
return backgroundRestorePlan{unminimiseAfterShow: true}
}
func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
return wasMaximised && !backgroundCloseUsesApplicationHide(goos)
}
// restoreOrBuildTabs restores the tabs from the last session, or creates a
// default Global tab on first launch.
func (a *App) restoreOrBuildTabs() {
@@ -559,13 +583,21 @@ func (a *App) CancelTab(tabID string) {
// Approve answers a pending approval_request by ID: allow runs the call, session
// also remembers the grant for the rest of the session.
func (a *App) Approve(id string, allow, session, persist bool) {
a.ApproveTab("", id, allow, session, persist)
a.ApproveWithScope(id, allow, session, persist, "")
}
func (a *App) ApproveWithScope(id string, allow, session, persist bool, scope string) {
a.ApproveTabWithScope("", id, allow, session, persist, scope)
}
func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
a.ApproveTabWithScope(tabID, id, allow, session, persist, "")
}
func (a *App) ApproveTabWithScope(tabID, id string, allow, session, persist bool, scope string) {
ctrl := a.ctrlByTabID(tabID)
if ctrl != nil {
ctrl.Approve(id, allow, session, persist)
ctrl.ApproveWithScope(id, allow, session, persist, scope)
}
}
@@ -1127,6 +1159,17 @@ func (a *App) RemoveWorkspace(dir string) error {
if err := removeProject(dir); err != nil {
return err
}
// If the removed workspace was the active one, clear the pointer
// so we don't leave a stale reference to a deleted project.
if loadWorkspace() == dir {
if remaining := loadProjectsFile(); len(remaining.Projects) > 0 {
// Fall back to the first remaining project
saveWorkspace(remaining.Projects[0].Root)
} else {
// No projects left; clear the active pointer entirely
clearWorkspace()
}
}
a.emitProjectTreeChanged()
return nil
}
@@ -1806,12 +1849,7 @@ func (a *App) Capabilities() CapabilitiesView {
if loadedCfg != nil && !seen["codegraph"] {
status := "disabled"
if loadedCfg.Codegraph.Enabled {
switch loadedCfg.Codegraph.ResolvedTier() {
case "background", "eager":
status = "initializing"
default:
status = "deferred"
}
status = "initializing"
}
if s, ok := disabled["codegraph"]; ok {
s.Status = "disabled"
@@ -2318,8 +2356,8 @@ func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (
}
// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
// retired tier field, so this only affects the active session before the next
// config reload.
// retired tier field; for CodeGraph this now means "enable and start in the
// background".
func (a *App) SetMCPServerTier(name, tier string) error {
if name == "codegraph" {
return a.setCodegraphTier(tier)
@@ -2394,14 +2432,13 @@ func (a *App) setCodegraphEnabled(enabled bool) error {
return nil
}
func (a *App) setCodegraphTier(tier string) error {
tier = normalizeMCPTier(tier)
func (a *App) setCodegraphTier(_ string) error {
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
cfg.Codegraph.Enabled = true
cfg.Codegraph.Tier = tier
cfg.Codegraph.Tier = ""
if err := cfg.SaveTo(path); err != nil {
return err
}
@@ -2415,7 +2452,7 @@ func (a *App) setCodegraphTier(tier string) error {
a.mu.Lock()
delete(tab.disabledMCP, "codegraph")
a.mu.Unlock()
if tier != "lazy" && !mcpConnected(tab.Ctrl, "codegraph") {
if !mcpConnected(tab.Ctrl, "codegraph") {
if _, err := tab.Ctrl.ConnectCodegraphMCPServer(cfg); err != nil {
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
return nil
@@ -2737,7 +2774,7 @@ func (a *App) ModelsForTab(tabID string) []ModelInfo {
if !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {
continue
}
for _, m := range p.ModelList() {
for _, m := range p.ChatModelList() {
ref := p.Name + "/" + m
out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
}
@@ -2938,18 +2975,48 @@ func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort stri
return err
}
}
canonicalName := config.CanonicalDesktopOfficialProviderName(entry.Name)
if canonicalName != entry.Name {
if _, ok := cfg.Provider(canonicalName); ok {
if err := cfg.SetProviderEffort(canonicalName, effort); err != nil {
return err
}
for _, name := range providerEffortTargetNames(cfg, entry) {
if err := cfg.SetProviderEffort(name, effort); err != nil {
return err
}
}
return cfg.SetProviderEffort(entry.Name, effort)
return nil
})
}
func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {
if cfg == nil || entry == nil {
return nil
}
out := []string{entry.Name}
seen := map[string]bool{entry.Name: true}
kind := officialProviderKindFromEntry(*entry)
if kind == "" {
return out
}
var family []string
switch kind {
case "deepseek":
family = []string{"deepseek", "deepseek-flash", "deepseek-pro"}
case "mimo-token-plan":
family = []string{"mimo-token-plan", "mimo-pro", "mimo-flash"}
case "mimo-api":
family = []string{"mimo-api"}
}
for _, name := range family {
if seen[name] {
continue
}
p, ok := cfg.Provider(name)
if !ok || officialProviderKindFromEntry(*p) != kind {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// DirEntry is one entry in the "@" file-reference menu.
type DirEntry struct {
Name string `json:"name"`
@@ -2983,6 +3050,7 @@ type WorkspaceChangesView struct {
Files []WorkspaceChangeView `json:"files"`
GitAvailable bool `json:"gitAvailable"`
GitErr string `json:"gitErr,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
}
// workspaceNoiseNames are local cache/vendor entries hidden from the file tree
@@ -3390,16 +3458,32 @@ func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, e
return entry, nil
}
// SavePastedImage stores a browser clipboard image data URL under
// .reasonix/attachments and returns the relative @-reference path.
// SavePastedImage stores a browser clipboard image data URL under the active
// tab's workspace .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedImage(dataURL string) (string, error) {
root := a.activeWorkspaceRoot()
if root != "" && root != "." {
if prev, err := os.Getwd(); err == nil {
if err := os.Chdir(root); err == nil {
defer func() { _ = os.Chdir(prev) }()
}
}
}
return control.SaveImageDataURL(dataURL)
}
// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
// as a data URL but not a real path) under .reasonix/attachments and returns the
// relative @-reference path.
// as a data URL but not a real path) under the active tab's workspace
// .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedFile(name, dataURL string) (string, error) {
root := a.activeWorkspaceRoot()
if root != "" && root != "." {
if prev, err := os.Getwd(); err == nil {
if err := os.Chdir(root); err == nil {
defer func() { _ = os.Chdir(prev) }()
}
}
}
return control.SaveAttachmentDataURL(name, dataURL)
}
+105 -6
View File
@@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
@@ -129,6 +130,54 @@ func TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {
}
}
func TestBackgroundRestoreMaximiseStrategy(t *testing.T) {
tests := []struct {
goos string
maximised bool
want bool
}{
{goos: "windows", maximised: true, want: true},
{goos: "linux", maximised: true, want: true},
{goos: "darwin", maximised: true, want: false},
{goos: "windows", maximised: false, want: false},
}
for _, tt := range tests {
if got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {
t.Fatalf("backgroundRestoreShouldMaximise(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
}
}
}
func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {
tests := []struct {
name string
goos string
maximised bool
want backgroundRestorePlan
}{
{
name: "maximised Windows window",
goos: "windows",
maximised: true,
want: backgroundRestorePlan{maximiseBeforeShow: true},
},
{
name: "normal Windows window",
goos: "windows",
maximised: false,
want: backgroundRestorePlan{unminimiseAfterShow: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := backgroundRestorePlanFor(tt.goos, tt.maximised)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("backgroundRestorePlanFor(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
}
})
}
}
func TestEmitReadyInvokesReadyHook(t *testing.T) {
app := NewApp()
var calls int32
@@ -325,6 +374,9 @@ api_key_env = "DEEPSEEK_API_KEY"
if p.Name != "deepseek" {
continue
}
if !p.BuiltIn {
t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
}
if !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
}
@@ -336,6 +388,53 @@ api_key_env = "DEEPSEEK_API_KEY"
t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
}
func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
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(`
default_model = "deepseek/deepseek-v4-Flash"
[desktop]
provider_access = ["deepseek"]
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://opencode.ai/zen/go/v1"
models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"]
default = "deepseek-v4-Flash"
api_key_env = "DEEPSEEK_API_KEY"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
got := NewApp().Settings()
var custom *ProviderView
for i := range got.Providers {
if got.Providers[i].Name == "deepseek" {
custom = &got.Providers[i]
break
}
}
if custom == nil {
t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
}
if custom.BuiltIn {
t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom)
}
if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" {
t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom)
}
for _, p := range got.OfficialProviders {
if p.Name == "deepseek" && p.Added {
t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p)
}
}
}
func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
@@ -1674,7 +1773,7 @@ tier = "lazy"
t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
}
func TestSetMCPServerTierPersistsCodegraphConfig(t *testing.T) {
func TestSetMCPServerTierEnablesCodegraphAndIgnoresLegacyTier(t *testing.T) {
t.Setenv("HOME", robustTempDir(t))
t.Setenv("USERPROFILE", robustTempDir(t))
t.Setenv("XDG_CONFIG_HOME", robustTempDir(t))
@@ -1695,7 +1794,7 @@ auto_install = true
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerTier("codegraph", "background"); err != nil {
if err := app.SetMCPServerTier("codegraph", "eager"); err != nil {
t.Fatalf("SetMCPServerTier(codegraph): %v", err)
}
cfg, err := config.Load()
@@ -1703,17 +1802,17 @@ auto_install = true
t.Fatal(err)
}
if !cfg.Codegraph.Enabled {
t.Fatal("codegraph enabled = false, want true after selecting a startup tier")
t.Fatal("codegraph enabled = false, want true after legacy tier update")
}
if got := cfg.Codegraph.Tier; got != "" {
t.Fatalf("codegraph tier = %q, want migrated empty", got)
t.Fatalf("codegraph tier = %q, want ignored legacy tier", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if !userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = false, want true after selecting a startup tier")
t.Fatal("user codegraph enabled = false, want true after legacy tier update")
}
if got := userCfg.Codegraph.Tier; got != "" {
t.Fatalf("user codegraph tier = %q, want migrated empty", got)
t.Fatalf("user codegraph tier = %q, want ignored legacy tier", got)
}
if !mcpFailed(app.activeCtrl(), "codegraph") {
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened-runtime entitlements required to notarize a Wails (Go + WKWebView)
app. The web view's JS engine needs JIT and writable/executable memory;
the Go binary links system libs that aren't team-signed. Kept minimal — no
network/file entitlements, since this is a hardened-runtime carve-out, not
an App Sandbox profile. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+28
View File
@@ -8,6 +8,7 @@
"name": "reasonix-desktop-frontend",
"version": "0.0.0",
"dependencies": {
"@tanstack/react-virtual": "^3.14.2",
"highlight.js": "^11.10.0",
"katex": "^0.17.0",
"lucide-react": "^0.460.0",
@@ -1171,6 +1172,33 @@
"win32"
]
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
"integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+5 -6
View File
@@ -10,11 +10,12 @@
"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": "tsx src/__tests__/math-golden.test.ts",
"test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts"
"test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts"
},
"dependencies": {
"@tanstack/react-virtual": "^3.14.2",
"highlight.js": "^11.10.0",
"katex": "^0.17.0",
"lucide-react": "^0.460.0",
@@ -35,9 +36,7 @@
"typescript": "^5.6.3",
"vite": "^6.0.7"
},
"pnpm": {
"overrides": {
"mdast-util-gfm-autolink-literal": "2.0.0"
}
"overrides": {
"mdast-util-gfm-autolink-literal": "2.0.0"
}
}
+20 -13
View File
@@ -11,6 +11,9 @@ importers:
.:
dependencies:
'@tanstack/react-virtual':
specifier: ^3.14.2
version: 3.14.2([email protected]([email protected]))([email protected])
highlight.js:
specifier: ^11.10.0
version: 11.11.1
@@ -517,79 +520,66 @@ packages:
resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
@@ -621,6 +611,15 @@ packages:
cpu: [x64]
os: [win32]
'@tanstack/[email protected]':
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/[email protected]':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@types/[email protected]':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1627,6 +1626,14 @@ snapshots:
'@rollup/[email protected]':
optional: true
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/virtual-core': 3.17.0
react: 18.3.1
react-dom: 18.3.1([email protected])
'@tanstack/[email protected]': {}
'@types/[email protected]':
dependencies:
'@babel/parser': 7.29.7
+120 -47
View File
@@ -18,6 +18,7 @@ import {
Trash2,
} from "lucide-react";
import logoWordmark from "./assets/logo-wordmark.svg";
import { useToast } from "./lib/toast";
import { asArray } from "./lib/array";
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT } from "./lib/i18n";
import { useController, type Item, type LiveStream } from "./lib/useController";
@@ -39,6 +40,7 @@ import { OnboardingOverlay } from "./components/OnboardingOverlay";
import { TabBar } from "./components/TabBar";
import { ProjectTree } from "./components/ProjectTree";
import { CopyButton } from "./components/CopyButton";
import { CommandPalette, type PaletteItem } from "./components/CommandPalette";
import { parseTodos } from "./lib/tools";
import { shouldShowTodoPanel } from "./lib/todoVisibility";
import type { ComposerInsertRequest, Meta, Mode, SessionMeta, SettingsTab, TabMeta } from "./lib/types";
@@ -55,6 +57,7 @@ import {
themeForStyle,
type Theme,
} from "./lib/theme";
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
import { useWindowStatePersistence } from "./lib/windowState";
const SIDEBAR_COLLAPSED_KEY = "reasonix.sidebar.collapsed";
@@ -337,6 +340,26 @@ function ShellHotkeys() {
return null;
}
/** Global hotkey handler for text-size shortcuts (Ctrl/Cmd + Plus/Minus/0). */
function TextSizeHotkeys() {
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key !== "+" && e.key !== "=" && e.key !== "-" && e.key !== "0") return;
e.preventDefault();
if (e.key === "0") {
applyTextSize(DEFAULT_TEXT_SIZE);
return;
}
applyTextSize(nextTextSize(getTextSize(), e.key === "-" ? -1 : 1));
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
return null;
}
export default function App() {
const {
state,
@@ -382,6 +405,7 @@ export default function App() {
const [needsOnboarding, setNeedsOnboarding] = useState<boolean | null>(null);
const [settingsTarget, setSettingsTarget] = useState<SettingsTab | null>(null);
const [histView, setHistView] = useState<HistoryViewState | null>(null);
const { showToast } = useToast();
const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed);
const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
const [sidebarResizing, setSidebarResizing] = useState(false);
@@ -399,6 +423,7 @@ export default function App() {
const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
const [topicTitleDraft, setTopicTitleDraft] = useState("");
const [topicExportOpen, setTopicExportOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const topicRenameSkipCommitRef = useRef(false);
const topicRenameCommitHandledRef = useRef(false);
@@ -611,39 +636,6 @@ export default function App() {
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
const [dismissedTodo, setDismissedTodo] = useState<string | null>(null);
const showTodos = shouldShowTodoPanel(todoItem?.id, dismissedTodo, todos);
const [todoNow, setTodoNow] = useState(() => Date.now());
const todoSeenRef = useRef<{ id: string; at: number } | null>(null);
useEffect(() => {
if (!todoItem) {
todoSeenRef.current = null;
return;
}
if (todoSeenRef.current?.id !== todoItem.id) {
todoSeenRef.current = { id: todoItem.id, at: Date.now() };
setTodoNow(Date.now());
}
}, [todoItem]);
useEffect(() => {
if (!showTodos) return;
const id = window.setInterval(() => setTodoNow(Date.now()), 15000);
return () => window.clearInterval(id);
}, [showTodos]);
const todoStale = useMemo(() => {
if (!showTodos || !todoEntry) return false;
const after = state.items.slice(todoEntry.index + 1);
const completedToolsAfter = after.filter(
(it) => it.kind === "tool" && it.name !== "todo_write" && !it.parentId && (it.status === "done" || it.status === "error"),
).length;
const finalAssistantAfter = after.some((it) => it.kind === "assistant" && !it.streaming && it.text.trim() !== "");
const readinessNoticeAfter = after.some(
(it) => it.kind === "notice" && /final-answer readiness|todo_write|complete_step/i.test(it.text),
);
const staleByTime = state.running && todoSeenRef.current?.id === todoEntry.item.id && todoNow - todoSeenRef.current.at > 90_000;
return completedToolsAfter >= 2 || finalAssistantAfter || readinessNoticeAfter || staleByTime;
}, [showTodos, state.items, state.running, todoEntry, todoNow]);
// useDeferredValue lets React prioritise Composer input (high-priority) over
// Transcript re-renders (low-priority) during streaming. When a keystroke
@@ -1080,6 +1072,17 @@ export default function App() {
setTabRevealSignal((signal) => signal + 1);
}, [activeTab?.scope, activeTab?.workspaceRoot, openGlobalTab, openProjectTab, refreshTabMetas, state.meta?.cwd]);
// ── Command palette (⌘K) ────────────────────────────────────────────
useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
event.preventDefault();
setPaletteOpen((open) => !open);
};
window.addEventListener("keydown", onKeyDown, { capture: true });
return () => window.removeEventListener("keydown", onKeyDown, { capture: true });
}, []);
const handleMessageAction = useCallback(async (turn: number, scope: string) => {
await rewind(turn, scope);
if (scope === "fork") {
@@ -1117,24 +1120,85 @@ export default function App() {
setHistView({ kind: "trash", sessions: await listTrashedSessions() });
}, [listTrashedSessions]);
const closeHistory = useCallback(() => setHistView(null), []);
// ── Command palette (⌘K) item builder ───────────────────────────────
const buildPaletteItems = useCallback((): PaletteItem[] => {
const items: PaletteItem[] = [];
for (const tab of tabMetas) {
items.push({
id: `tab:${tab.id}`,
title: tab.topicTitle || "Untitled",
hint: tab.scope === "global" ? "Global" : tab.workspaceName || tab.workspaceRoot,
group: t("sidebar.conversations"),
keywords: [tab.label],
run: () => void handleTabChange(tab.id),
});
}
items.push(
{
id: "action:new-session",
title: t("topbar.newSession"),
group: "Actions",
keywords: ["new", "chat", "session"],
run: () => void handleNewTab(),
},
{
id: "action:history",
title: t("sidebar.allHistory"),
group: "Actions",
keywords: ["history", "sessions", "past"],
run: () => void openAllHistory(),
},
{
id: "action:settings",
title: t("topbar.settings"),
group: "Actions",
keywords: ["preferences", "config", "options"],
run: () => setSettingsTarget("general"),
},
{
id: "action:trash",
title: t("sidebar.trash"),
group: "Actions",
keywords: ["deleted", "bin"],
run: () => void openTrash(),
},
);
return items;
}, [tabMetas, handleTabChange, handleNewTab, openAllHistory, openTrash, t]);
const onResumeSession = useCallback(
async (session: SessionMeta) => {
if (state.running) return;
setHistView(null);
const scope = session.scope || (session.workspaceRoot ? "project" : "global");
let targetTab: TabMeta | undefined;
if (scope === "project" && session.workspaceRoot && session.topicId) {
targetTab = await openProjectTab(session.workspaceRoot, session.topicId);
} else if (scope === "global" && session.topicId) {
targetTab = await openGlobalTab(session.topicId);
}
await resumeSession(session.path, targetTab?.id);
if (targetTab) {
try {
let targetTab: TabMeta;
if (scope === "project" && session.workspaceRoot && session.topicId) {
targetTab = await openProjectTab(session.workspaceRoot, session.topicId);
} else if (scope === "global" && session.topicId) {
targetTab = await openGlobalTab(session.topicId);
} else {
throw new Error(scope === "global" && !session.topicId
? t("history.failedOpenSession")
: (session.topicId ? "Missing workspaceRoot" : t("history.failedOpenSession")));
}
setHistView(null);
await resumeSession(session.path, targetTab.id);
await refreshTabMetas();
setTabRevealSignal((signal) => signal + 1);
} catch (err: any) {
setHistView(null);
if (scope === "project" && session.workspaceRoot) {
const name = workspaceDisplayName(session.workspaceRoot);
showToast(t("history.failedOpenProject", { name, path: session.workspaceRoot }));
} else {
showToast(err?.message || String(err));
}
}
},
[openGlobalTab, openProjectTab, refreshTabMetas, state.running, resumeSession],
[openGlobalTab, openProjectTab, refreshTabMetas, state.running, resumeSession, t, showToast],
);
// Delete / rename act on disk, then re-fetch so the panel reflects the change.
const onDeleteSession = useCallback(
@@ -1278,6 +1342,7 @@ export default function App() {
return (
<ShellExpandProvider>
<ShellHotkeys />
<TextSizeHotkeys />
<div className={`app app--${desktopPlatform}`}>
<div
ref={layoutRef}
@@ -1310,7 +1375,7 @@ export default function App() {
{sidebarCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
<div className="app-chrome__identity" aria-label="Reasonix">
<img src={logoWordmark} alt="" className="app-chrome__logo" />
<img src={logoWordmark} alt="" className="app-chrome__logo" draggable={false} />
<span className="app-chrome__separator">/</span>
<span className="app-chrome__scope">{appChromeScopeLabel(activeTab, state.meta)}</span>
</div>
@@ -1533,15 +1598,15 @@ export default function App() {
</main>
<footer className="footer" ref={footerRef}>
{showTodos && <TodoPanel todos={todos} stale={todoStale} onDismiss={() => setDismissedTodo(todoItem!.id)} />}
{showTodos && <TodoPanel todos={todos} onDismiss={() => setDismissedTodo(todoItem!.id)} />}
{state.approval && (
<ApprovalModal
approval={state.approval}
onAnswer={(allow, session, persist) => {
onAnswer={(allow, session, persist, scope) => {
// Approving an exit_plan_mode plan leaves plan mode; sync the
// tab-local indicator and persisted safe mode immediately.
if (state.approval!.tool === "exit_plan_mode" && allow) applyMode("normal");
approve(state.approval!.id, allow, session, persist);
approve(state.approval!.id, allow, session, persist, scope);
}}
onRevisePlan={(text) => {
setPendingPlanRevision(text);
@@ -1719,6 +1784,14 @@ export default function App() {
)}
{needsOnboarding && <OnboardingOverlay onComplete={() => setNeedsOnboarding(false)} />}
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
items={buildPaletteItems()}
placeholder="Quick actions…"
emptyText="No matches"
/>
</div>
</ShellExpandProvider>
);
@@ -0,0 +1,57 @@
// Run: tsx src/__tests__/provider-model-refresh.test.ts
import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels";
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;
}
}
console.log("\nprovider model refresh");
eq(
mergedFetchedProviderModels(["coding-pro"], ["coding-pro", "chat", "vision"]),
["coding-pro", "chat", "vision"],
"appends discovered models without removing curated ones",
);
eq(
mergedFetchedProviderModels(["coding-pro"], ["coding-pro", "chat", "vision"], { preserveCurated: true }),
["coding-pro"],
"background refresh preserves manually curated model list",
);
eq(
mergedFetchedProviderModels(["coding-pro"], ["chat", "vision"], { preserveCurated: true }),
["coding-pro"],
"background refresh does not re-add deleted models",
);
eq(
mergedFetchedProviderModels([], ["coding-pro", "chat"], { preserveCurated: true }),
["coding-pro", "chat"],
"background refresh can populate an empty model list",
);
eq(
providerDefaultModel("coding-pro", ["coding-pro", "chat"]),
"coding-pro",
"preserves current default when it remains available",
);
eq(
providerDefaultModel("deleted", ["coding-pro", "chat"]),
"coding-pro",
"falls back to first saved model when default is unavailable",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,29 @@
// Run: tsx src/__tests__/text-size.test.ts
import { DEFAULT_TEXT_SIZE, nextTextSize } from "../lib/textSize";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (a === 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;
}
}
console.log("\ntext size shortcuts");
eq(nextTextSize("small", 1), DEFAULT_TEXT_SIZE, "increase from small");
eq(nextTextSize("default", 1), "large", "increase from default");
eq(nextTextSize("large", 1), "xlarge", "increase from large");
eq(nextTextSize("xlarge", 1), "xlarge", "increase clamps at largest");
eq(nextTextSize("xlarge", -1), "large", "decrease from xlarge");
eq(nextTextSize("large", -1), DEFAULT_TEXT_SIZE, "decrease from large");
eq(nextTextSize("default", -1), "small", "decrease from default");
eq(nextTextSize("small", -1), "small", "decrease clamps at smallest");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -73,26 +73,19 @@ export function AnchoredPopover({
if (!anchor || !menu) return;
const next = calculatePosition(anchor, menu, align, offset, placement);
setPosition((current) => (samePosition(current, next) ? current : next));
});
}, [open, align, offset, placement]);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
const closeOnScroll = (event: Event) => {
const target = event.target;
if (target instanceof Node && popoverRef.current?.contains(target)) return;
onClose();
};
const closeOnViewportChange = () => onClose();
window.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", closeOnViewportChange);
window.addEventListener("scroll", closeOnScroll, true);
return () => {
window.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", closeOnViewportChange);
window.removeEventListener("scroll", closeOnScroll, true);
};
}, [onClose, open]);
@@ -10,7 +10,7 @@ export function ApprovalModal({
onExitPlan,
}: {
approval: WireApproval;
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
onAnswer: (allow: boolean, session: boolean, persist: boolean, scope?: string) => void;
onRevisePlan?: (text: string) => void;
onExitPlan?: () => void;
}) {
@@ -21,8 +21,14 @@ export function ApprovalModal({
const cardRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const isPlanApproval = approval.tool === "exit_plan_mode";
const isBashApproval = approval.tool === "bash";
const subject = approval.subject.trim();
const bashPrefix = isBashApproval ? bashCommandPrefix(subject) : "";
const hasBashPrefix = bashPrefix !== "";
const subjectSummary = subject.split("\n").find((line) => line.trim())?.trim() ?? "";
const exactSessionRule = approvalSessionRule(approval.tool, subject);
const exactPersistentRule = approvalPersistentRule(approval.tool, subject);
const prefixRule = hasBashPrefix ? `Bash(${bashPrefix})` : "";
const choosePlanAction = (key: string) => {
if (key === "1") setRevisionOpen((open) => !open);
@@ -32,7 +38,9 @@ export function ApprovalModal({
const chooseToolAction = (key: string) => {
if (key === "1") onAnswer(true, false, false);
else if (key === "2" && hasBashPrefix) onAnswer(true, true, false, "prefix");
else if (key === "2") onAnswer(true, true, false);
else if (key === "3" && hasBashPrefix) onAnswer(true, true, true, "prefix");
else if (key === "3") onAnswer(true, true, true);
else if (key === "4" || key === "Escape") onAnswer(false, false, false);
};
@@ -56,7 +64,7 @@ export function ApprovalModal({
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isPlanApproval, onAnswer, onExitPlan]);
}, [hasBashPrefix, isPlanApproval, onAnswer, onExitPlan]);
useEffect(() => {
if (revisionOpen) inputRef.current?.focus();
@@ -142,9 +150,35 @@ export function ApprovalModal({
/>
)}
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => onAnswer(true, false, false)} selected />
<PromptAction keyLabel="2" label={t("approval.allowSession")} onClick={() => onAnswer(true, true, false)} />
<PromptAction keyLabel="3" label={t("approval.allowPersistent")} onClick={() => onAnswer(true, true, true)} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => onAnswer(false, false, false)} />
{hasBashPrefix ? (
<>
<PromptAction
keyLabel="2"
label={<RuleActionLabel label={t("approval.allowRuleSession")} rule={prefixRule} />}
onClick={() => onAnswer(true, true, false, "prefix")}
/>
<PromptAction
keyLabel="3"
label={<RuleActionLabel label={t("approval.allowRulePersistent")} rule={prefixRule} />}
onClick={() => onAnswer(true, true, true, "prefix")}
/>
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => onAnswer(false, false, false)} />
</>
) : (
<>
<PromptAction
keyLabel="2"
label={<RuleActionLabel label={t("approval.allowRuleSession")} rule={exactSessionRule} />}
onClick={() => onAnswer(true, true, false)}
/>
<PromptAction
keyLabel="3"
label={<RuleActionLabel label={t("approval.allowRulePersistent")} rule={exactPersistentRule} />}
onClick={() => onAnswer(true, true, true)}
/>
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => onAnswer(false, false, false)} />
</>
)}
</>
}
>
@@ -154,3 +188,60 @@ export function ApprovalModal({
</PromptShelf>
);
}
function RuleActionLabel({ label, rule }: { label: string; rule: string }) {
return (
<span className="approval-rule-label">
<span>{label}</span>
<code>{rule}</code>
</span>
);
}
function approvalSessionRule(tool: string, subject: string): string {
if (tool === "bash" && subject) return `Bash(${subject})`;
if (isFileMutationTool(tool)) return "Edit";
return tool;
}
function approvalPersistentRule(tool: string, subject: string): string {
if (tool === "bash" && subject) return `Bash(${subject})`;
if (isFileMutationTool(tool)) return subject ? `Edit(${subject})` : "Edit";
return tool;
}
function bashCommandPrefix(subject: string): string {
const command = subject.trim();
if (!command || command.includes("`") || command.includes("$(") || /[;|&<>\n]/.test(command)) return "";
const fields = command.split(/\s+/).filter(Boolean);
if (fields.length < 2) return "";
if (dangerousBashCommand(command)) return "";
const base = fields[0].toLowerCase();
if ((base === "npm" || base === "pnpm" || base === "yarn" || base === "bun") && fields[1]?.toLowerCase() === "run") {
return fields.length >= 3 ? `${fields[0]} ${fields[1]} ${fields[2]}:*` : "";
}
return `${fields[0]} ${fields[1]}:*`;
}
function dangerousBashCommand(command: string): boolean {
return /^rm\s+-[^\s]*[rf][^\s]*\b/.test(command)
|| /^git\s+push\b.*\s--force\b/.test(command)
|| /^git\s+push\b.*\s-f\b/.test(command)
|| /^git\s+reset\s+--hard\b/.test(command)
|| /^git\s+clean\s+-f\b/.test(command)
|| /^chmod\s+(?:-R\s+)?777\b/.test(command)
|| /^chown\b/.test(command)
|| /^sudo\b/.test(command)
|| /^mkfs\b/.test(command)
|| /^dd\s+if=/.test(command)
|| /^fdisk\b/.test(command);
}
function isFileMutationTool(tool: string): boolean {
return tool === "write_file"
|| tool === "edit_file"
|| tool === "multi_edit"
|| tool === "notebook_edit"
|| tool === "delete_range"
|| tool === "delete_symbol";
}
@@ -1219,7 +1219,11 @@ function SkillRow({
</Tooltip>
</div>
<div className="cap-skill-card__desc">{expanded ? skill.description : summary}</div>
{canExpand && <div className="cap-skill-card__more">{expanded ? t("common.collapse") : t("common.expand")}</div>}
{canExpand && (
<button className="cap-skill-card__more" type="button" onClick={onToggle} aria-expanded={expanded}>
{expanded ? t("common.collapse") : t("common.expand")}
</button>
)}
</div>
);
}
+567 -26
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { AlertTriangle, ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, List, Search, Square, Trash2, X, Zap } from "lucide-react";
import { AlertTriangle, ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, List, MessageSquare, Search, Square, Trash2, X, Zap } from "lucide-react";
import { asArray } from "../lib/array";
import { DedupIndex, sha256 } from "../lib/attachDedup";
import { app, onFilesDropped } from "../lib/bridge";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import type { CommandInfo, ComposerInsertRequest, DirEntry, EffortInfo, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
import type { CommandInfo, ComposerInsertRequest, DirEntry, EffortInfo, HistoryMessage, Mode, SessionMeta, SessionReference, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
import {
formatWorkspaceReference,
parseWorkspaceReference,
@@ -14,7 +15,7 @@ import {
} from "../lib/workspaceDrag";
import { SlashMenu } from "./SlashMenu";
import { ArgMenu } from "./ArgMenu";
import { FileMenu } from "./FileMenu";
import { VirtualMenu } from "./VirtualMenu";
import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { Tooltip } from "./Tooltip";
@@ -25,6 +26,11 @@ interface Attachment {
previewUrl?: string;
}
interface AttachmentDedupKey {
hash: string;
source: string;
}
interface WorkspaceReference {
path: string;
isDir?: boolean;
@@ -100,6 +106,34 @@ function fmtElapsed(ms: number): string {
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
// --- past:chats hover preview helpers (PR-C2) ---
// Pure formatting helpers used by the past:chats list tooltip. They never read
// from disk, never call PreviewSession — they only shape the data that already
// lives in the SessionMeta snapshot we fetched on entry.
const PAST_CHAT_PREVIEW_MAX = 200;
function truncatePreview(value?: string, max = PAST_CHAT_PREVIEW_MAX): string {
const text = (value || "").trim();
if (text.length <= max) return text;
return `${text.slice(0, max)}...`;
}
function fmtSessionTime(value?: number): string {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
function pastChatTitle(session: SessionMeta): string {
return session.title || session.topicTitle || session.preview || "Untitled";
}
function useTick(on: boolean): number {
const [, setN] = useState(0);
useEffect(() => {
@@ -127,6 +161,91 @@ function isImeKeyEvent(
);
}
// --- past:chats session reference → prompt context (PR-B) ---
// Send-side helpers for "@past:chats" session references. PR-A wired the menu and
// the composer-context card; this layer reads each referenced session through the
// existing PreviewSession API and prepends a compact "user / 助手" transcript to
// submitText so the model sees the referenced chat as background context.
const SESSION_REF_MAX_MESSAGES = 30;
const SESSION_REF_MAX_CHARS = 20_000;
const SESSION_CONTEXT_HEADER = "以下是用户引用的历史会话上下文:";
const SESSION_CONTEXT_FOOTER = "当前用户问题:";
const PAST_CHATS_MENU_ITEM = "past:chats";
// limitSessionMessages keeps the most recent useful messages within a char budget.
// Walks from the end so the truncation is always "drop the oldest", which matches
// the intuition that the latest turns are the relevant ones for follow-up.
function limitSessionMessages(
messages: HistoryMessage[],
maxMessages = SESSION_REF_MAX_MESSAGES,
maxChars = SESSION_REF_MAX_CHARS,
): { messages: HistoryMessage[]; truncated: boolean } {
const useful = messages
.filter(
(m) =>
(m.role === "user" || m.role === "assistant") &&
typeof m.content === "string" &&
m.content.trim().length > 0,
)
.slice(-maxMessages);
const result: HistoryMessage[] = [];
let total = 0;
let truncated = useful.length >= maxMessages;
for (let i = useful.length - 1; i >= 0; i--) {
const msg = useful[i];
const content = msg.content.trim();
if (total + content.length > maxChars) {
truncated = true;
break;
}
result.unshift({ ...msg, content });
total += content.length;
}
if (result.length < useful.length) truncated = true;
return { messages: result, truncated };
}
// formatSessionContext renders one referenced session as a labelled transcript.
// Falls back to a "no usable messages" note when filtering empties the list so
// the model still sees that something was referenced.
function formatSessionContext(
ref: SessionReference,
messages: HistoryMessage[],
truncated: boolean,
): string {
const body = messages
.map((m) => `${m.role === "user" ? "用户" : "助手"}:${m.content.trim()}`)
.join("\n\n");
return [
`[会话:${ref.title}]`,
truncated ? "注意:该会话内容较长,以下只包含最近部分内容。" : "",
body || "注意:该会话没有可引用的用户/助手消息。",
]
.filter(Boolean)
.join("\n");
}
// buildSessionContext reads each referenced session, formats the most recent
// slice, and joins them with a separator. A single failed read must not block
// the others; the user gets a clear "读取失败" note for the bad one and the
// remaining refs still flow through.
async function buildSessionContext(refs: SessionReference[]): Promise<string> {
if (refs.length === 0) return "";
let context = `${SESSION_CONTEXT_HEADER}\n\n`;
for (const ref of refs) {
try {
const raw = await app.PreviewSession(ref.path);
const limited = limitSessionMessages(asArray(raw));
context += `${formatSessionContext(ref, limited.messages, limited.truncated)}\n\n---\n\n`;
} catch (error) {
console.error("[past:chats] failed to preview session", ref.path, error);
context += `[会话:${ref.title}]\n注意:该会话读取失败,已跳过。\n\n---\n\n`;
}
}
context += `${SESSION_CONTEXT_FOOTER}\n`;
return context;
}
export function Composer({
running,
mode,
@@ -207,6 +326,12 @@ export function Composer({
const [composerResizing, setComposerResizing] = useState(false);
const [textareaAutoHeight, setTextareaAutoHeight] = useState<number | null>(null);
const [textareaAutoOverflow, setTextareaAutoOverflow] = useState(false);
const [showPastChats, setShowPastChats] = useState(false);
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
const [pastChatQuery, setPastChatQuery] = useState("");
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
const [loadingPastChats, setLoadingPastChats] = useState(false);
const [submitting, setSubmitting] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(null);
const composerCardRef = useRef<HTMLDivElement>(null);
const workspaceAnchorRef = useRef<HTMLDivElement>(null);
@@ -215,6 +340,13 @@ export function Composer({
const lastCompositionEndAt = useRef(0);
const lastSelectionRef = useRef({ start: 0, end: 0 });
const consumedInsertIdRef = useRef(0);
const submittingRef = useRef(false);
// Snapshot of the current cwd so async callbacks (openPastChats) can detect
// workspace switches and discard stale responses (issue #3601).
const cwdRef = useRef(cwd);
cwdRef.current = cwd;
const attachmentDedupRef = useRef(new DedupIndex());
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
useEffect(() => {
if (wasRunning.current && !running && text.trim() === "") {
@@ -236,7 +368,7 @@ export function Composer({
return text.slice(1).toLowerCase();
}, [text]);
const slashMatches = useMemo(
() => (slashQuery === null ? [] : commands.filter((c) => c.name.toLowerCase().includes(slashQuery)).slice(0, 8)),
() => (slashQuery === null ? [] : commands.filter((c) => c.name.toLowerCase().includes(slashQuery))),
[slashQuery, commands],
);
@@ -303,6 +435,27 @@ export function Composer({
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
// 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;
dirCache.current = {};
searchCache.current = {};
setEntries([]);
setSearchEntries([]);
setShowPastChats(false);
setPastChats([]);
setPastChatQuery("");
setLoadingPastChats(false);
setActive(0);
setDismissed(false);
}, [cwd]);
useEffect(() => {
if (atRaw === null) return;
const cached = dirCache.current[atDir];
@@ -322,8 +475,8 @@ export function Composer({
return () => {
live = false;
};
// re-fetch only when the menu opens or the directory level changes
}, [atRaw === null, atDir]);
// re-fetch when the menu opens or the directory level changes
}, [atRaw === null, atDir, cwd]);
useEffect(() => {
if (atRaw === null || atDir !== "" || atFrag === "") {
setSearchEntries([]);
@@ -347,7 +500,7 @@ export function Composer({
return () => {
live = false;
};
}, [atRaw === null, atDir, atFrag]);
}, [atRaw === null, atDir, atFrag, cwd]);
const atMatches = useMemo(
() => {
if (atRaw === null) return [];
@@ -357,11 +510,28 @@ export function Composer({
const basename = e.name.split("/").pop()?.toLowerCase() ?? "";
return basename.includes(atFrag) && !seen.has(e.name);
});
return [...local, ...searched].slice(0, 10);
return [...local, ...searched];
},
[atRaw, atFrag, entries, searchEntries],
);
// Unified menu item model for the @ menu. "past:chats" is a real selectable
// item (kind "pastChats"), not an active===0 special case.
type AtMenuItem =
| { kind: "pastChats" }
| { kind: "file"; entry: DirEntry };
const includePastChatsItem = atRaw !== null && atDir === "" && (atFrag === "" || PAST_CHATS_MENU_ITEM.startsWith(atFrag));
const atMenuItems = useMemo<AtMenuItem[]>(
() => [
...(includePastChatsItem ? [{ kind: "pastChats" as const }] : []),
...atMatches.map((entry) => ({ kind: "file" as const, entry })),
],
[includePastChatsItem, atMatches],
);
// --- which menu (if any) is open --- (slash command names win; then slash
// arguments; then @-refs — they're rarely valid at once)
const menuMode: "slash" | "slasharg" | "at" | null =
@@ -369,16 +539,16 @@ export function Composer({
? "slash"
: argRes && argRes.items.length > 0 && !dismissed
? "slasharg"
: atMatches.length > 0 && !dismissed
: atRaw !== null && !dismissed
? "at"
: null;
const count =
const countBase =
menuMode === "slash"
? slashMatches.length
: menuMode === "slasharg"
? argRes!.items.length
: menuMode === "at"
? atMatches.length
? atMenuItems.length
: 0;
// Reset highlight + un-dismiss whenever the active query changes.
@@ -387,6 +557,17 @@ export function Composer({
setDismissed(false);
}, [slashQuery, atRaw]);
// 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.
useEffect(() => {
if (menuMode !== "at" && showPastChats) {
setShowPastChats(false);
setPastChatQuery("");
setActive(0);
}
}, [menuMode]);
const setTextCaretEnd = (next: string) => {
setText(next);
requestAnimationFrame(() => {
@@ -456,20 +637,64 @@ export function Composer({
return expanded;
};
const submit = () => {
if (disabled) return;
const rememberAttachment = (path: string, key: AttachmentDedupKey) => {
attachmentDedupRef.current.add(key.hash, key.source);
attachmentDedupKeysRef.current[path] = key;
};
const forgetAttachment = (path: string) => {
const key = attachmentDedupKeysRef.current[path];
if (key) {
attachmentDedupRef.current.forget(key.hash, key.source);
delete attachmentDedupKeysRef.current[path];
}
};
const clearAttachments = () => {
setAttachments([]);
attachmentDedupRef.current.clear();
attachmentDedupKeysRef.current = {};
};
const removeAttachment = (path: string) => {
forgetAttachment(path);
setAttachments((prev) => prev.filter((x) => x.path !== path));
requestAnimationFrame(() => taRef.current?.focus());
};
const fileDedupKey = async (file: File): Promise<AttachmentDedupKey> => ({
hash: await sha256(file),
source: `file:${file.name}:${file.size}:${file.lastModified}`,
});
const submit = async () => {
if (disabled || submittingRef.current) return;
const t = text.trim();
if ((!t && attachments.length === 0 && workspaceRefs.length === 0) || pendingPaste > 0) return;
submittingRef.current = true;
setSubmitting(true);
try {
const refs = [
...workspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
...attachments.map((a) => `@${a.path}`),
].join(" ");
const displayText = [t, refs].filter(Boolean).join(t && refs ? " " : "");
const submitText = [expandPastedBlocks(t), refs].filter(Boolean).join(t && refs ? " " : "");
// PR-B: when past:chats refs are attached, prepend their formatted transcript
// to submitText only (displayText stays unchanged so the user still sees their
// original prompt in the input preview). With no refs we keep the original
// submitText verbatim — no header, no rewording, byte-identical to pre-PR-B.
const sessionContext = sessionRefs.length === 0 ? "" : await buildSessionContext(sessionRefs);
const baseSubmitText = [expandPastedBlocks(t), refs].filter(Boolean).join(t && refs ? " " : "");
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
onSend(displayText, submitText);
setText("");
setAttachments([]);
clearAttachments();
setWorkspaceRefs([]);
setSessionRefs([]);
} finally {
submittingRef.current = false;
setSubmitting(false);
}
};
const readFileAsDataURL = (file: File) =>
@@ -486,9 +711,12 @@ export function Composer({
for (const file of images) {
setPendingPaste((n) => n + 1);
try {
const key = await fileDedupKey(file);
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
const dataUrl = await readFileAsDataURL(file);
const path = await app.SavePastedImage(dataUrl);
const previewUrl = await app.AttachmentDataURL(path);
rememberAttachment(path, key);
setAttachments((prev) => [...prev, { path, previewUrl }]);
} catch {
// non-fatal: a failed image attach must not block normal text input
@@ -506,8 +734,11 @@ export function Composer({
for (const file of others) {
setPendingPaste((n) => n + 1);
try {
const key = await fileDedupKey(file);
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
const dataUrl = await readFileAsDataURL(file);
const path = await app.SavePastedFile(file.name, dataUrl);
rememberAttachment(path, key);
setAttachments((prev) => [...prev, { path }]);
} catch {
// non-fatal: a failed attach must not block normal text input
@@ -530,10 +761,13 @@ export function Composer({
for (const path of paths) {
setPendingPaste((n) => n + 1);
try {
const key = { hash: "", source: `path:${path}` };
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
const item = await app.AttachDropped(path);
if (item.kind === "workspace") {
addWorkspaceReference({ path: item.path, isDir: item.isDir });
} else {
rememberAttachment(item.path, key);
setAttachments((prev) => [...prev, { path: item.path, previewUrl: item.previewUrl }]);
}
} catch {
@@ -839,6 +1073,107 @@ export function Composer({
setTextCaretEnd(prefix + "@" + atDir + e.name + (e.isDir ? "/" : " "));
};
// --- past:chats session reference ---
const openPastChats = async () => {
const snapshotCwd = cwdRef.current;
setShowPastChats(true);
setActive(0);
setPastChatQuery("");
setLoadingPastChats(true);
try {
const sessions = await app.ListSessions();
// Discard stale response if workspace changed while the request was in-flight.
if (cwdRef.current !== snapshotCwd) return;
const sorted = asArray(sessions)
.filter((s) => !s.current)
.sort((a, b) => {
const at = a.lastActivityAt || a.modTime || a.createdAt || 0;
const bt = b.lastActivityAt || b.modTime || b.createdAt || 0;
return bt - at;
})
.slice(0, 50);
setPastChats(sorted);
} catch {
if (cwdRef.current !== snapshotCwd) return;
setPastChats([]);
} finally {
if (cwdRef.current === snapshotCwd) setLoadingPastChats(false);
}
};
// PR-C1: client-side filter for the past:chats list. Matches against the
// human-visible fields (title, topic, preview, path, workspace) so users
// can narrow long session lists without a backend round-trip. Lowercased
// substring match keeps the behaviour predictable across locales.
const filteredPastChats = useMemo(() => {
const q = pastChatQuery.trim().toLowerCase();
if (!q) return pastChats;
return pastChats.filter((session) =>
[
session.title,
session.topicTitle,
session.preview,
session.path,
session.workspaceRoot,
]
.map((value) => String(value ?? "").toLowerCase())
.some((value) => value.includes(q)),
);
}, [pastChats, pastChatQuery]);
// Final menu item count: when the past:chats list is open, count the
// filtered sessions instead of file entries + the "past:chats" row.
const count = menuMode === "at" && showPastChats
? filteredPastChats.length
: countBase;
// Clamp active index when the menu item count changes (e.g. switching
// between file list and past:chats list, or filtering sessions).
useEffect(() => {
const maxIdx = Math.max(0, count - 1);
setActive((prev) => (prev > maxIdx ? 0 : prev));
}, [count]);
const removeAtToken = (value: string) => {
return value.replace(/(?:^|\s)@[^\s]*$/, "").trimEnd();
};
const pickSession = (session: SessionMeta) => {
setSessionRefs((prev) => {
if (prev.some((x) => x.path === session.path)) {
return prev;
}
return [
...prev,
{
path: session.path,
title: session.title || session.topicTitle || session.preview || "Untitled",
preview: session.preview,
turns: session.turns,
createdAt: session.createdAt,
lastActivityAt: session.lastActivityAt,
},
];
});
setText((prev) => removeAtToken(prev));
setPastChatQuery("");
setShowPastChats(false);
setActive(0);
// Return focus to textarea so the user can keep typing immediately.
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.setSelectionRange(
taRef.current.value.length,
taRef.current.value.length,
);
});
};
const removeSessionRef = (path: string) => {
setSessionRefs((prev) => prev.filter((ref) => ref.path !== path));
};
// pickArg replaces just the current token with the suggestion. A "descend" item
// (e.g. "/skill show ") ends with a space, so the effect re-fetches the next
// level; a terminal item leaves the menu (next fetch returns nothing).
@@ -848,9 +1183,30 @@ export function Composer({
};
const pickActive = () => {
if (menuMode === "slash") pickCommand(slashMatches[active]);
else if (menuMode === "slasharg" && argRes) pickArg(argRes.items[active]);
else if (menuMode === "at") pickEntry(atMatches[active]);
if (menuMode === "slash") {
const item = slashMatches[active];
if (item) pickCommand(item);
return;
}
if (menuMode === "slasharg" && argRes) {
const item = argRes.items[active];
if (item) pickArg(item);
return;
}
if (menuMode === "at") {
if (showPastChats) {
const session = filteredPastChats[active];
if (session) pickSession(session);
return;
}
const item = atMenuItems[active];
if (!item) return;
if (item.kind === "pastChats") {
void openPastChats();
return;
}
pickEntry(item.entry);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
@@ -866,12 +1222,12 @@ export function Composer({
}
if (menuMode && !composing) {
if (e.key === "ArrowDown") {
if (e.key === "ArrowDown" && count > 0) {
e.preventDefault();
setActive((i) => (i + 1) % count);
return;
}
if (e.key === "ArrowUp") {
if (e.key === "ArrowUp" && count > 0) {
e.preventDefault();
setActive((i) => (i - 1 + count) % count);
return;
@@ -883,7 +1239,13 @@ export function Composer({
}
if (e.key === "Escape") {
e.preventDefault();
setDismissed(true);
if (showPastChats) {
setPastChatQuery("");
setShowPastChats(false);
setActive(0);
} else {
setDismissed(true);
}
return;
}
}
@@ -901,6 +1263,29 @@ export function Composer({
}
};
// Keydown handler for the past:chats search <input>. The search input is a
// sibling of the <textarea>, so keyboard events never reach the textarea's
// onKeyDown. We intercept navigation keys here and delegate to the same
// menu logic. Regular typing keys (letters, Backspace, etc.) pass through
// so the user can type a search query.
const onPastChatSearchKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === "Tab" || e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
if (e.key === "ArrowDown" && count > 0) {
setActive((i) => (i + 1) % count);
} else if (e.key === "ArrowUp" && count > 0) {
setActive((i) => (i - 1 + count) % count);
} else if (e.key === "Enter" || e.key === "Tab") {
pickActive();
} else if (e.key === "Escape") {
setPastChatQuery("");
setShowPastChats(false);
setActive(0);
}
}
};
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
@@ -1013,7 +1398,139 @@ export function Composer({
{menuMode === "slasharg" && argRes && (
<ArgMenu items={argRes.items} activeIndex={active} onPick={pickArg} onHover={setActive} />
)}
{menuMode === "at" && <FileMenu items={atMatches} activeIndex={active} onPick={pickEntry} onHover={setActive} />}
{menuMode === "at" && (
showPastChats ? (
<div className="slashmenu" role="listbox">
{loadingPastChats ? (
<div className="slashmenu__item slashmenu__item--empty">
<span className="slashmenu__name">正在加载历史会话...</span>
</div>
) : pastChats.length === 0 ? (
<div className="slashmenu__item slashmenu__item--empty">
<span className="slashmenu__name">暂无历史会话</span>
</div>
) : (
<>
<div className="slashmenu__item slashmenu__item--search" onMouseDown={(ev) => ev.preventDefault()}>
<Search size={13} className="filemenu__icon" />
<input
className="slashmenu__search"
type="text"
placeholder="搜索历史会话…"
value={pastChatQuery}
autoFocus
onChange={(ev) => {
setPastChatQuery(ev.target.value);
setActive(0);
}}
onKeyDown={onPastChatSearchKeyDown}
/>
</div>
{filteredPastChats.length === 0 ? (
<div className="slashmenu__item slashmenu__item--empty">
<span className="slashmenu__name">没有匹配的历史会话</span>
</div>
) : (
filteredPastChats.map((session, i) => {
// PR-C2: hover preview uses only the SessionMeta fields we
// already have on hand — no extra PreviewSession call, no
// backend round-trip, no read of the full transcript.
const turns = typeof session.turns === "number";
const ts = session.lastActivityAt || session.modTime || session.createdAt;
const preview = truncatePreview(session.preview);
const pathText = session.workspaceRoot || session.path;
const tooltipLabel =
turns || ts || preview || pathText ? (
<div className="past-chat-hover">
<div className="past-chat-hover__title">{pastChatTitle(session)}</div>
{preview && <div className="past-chat-hover__preview">{preview}</div>}
{(turns || ts) && (
<div className="past-chat-hover__meta">
{turns && <span>{session.turns} 轮</span>}
{ts && <span>· {fmtSessionTime(ts)}</span>}
</div>
)}
{pathText && <div className="past-chat-hover__path">{pathText}</div>}
</div>
) : null;
return (
<Tooltip key={session.path} block label={tooltipLabel}>
<button
className={`slashmenu__item ${i === active ? "slashmenu__item--active" : ""}`}
onMouseDown={(ev) => {
ev.preventDefault();
pickSession(session);
}}
onMouseMove={() => setActive(i)}
>
<MessageSquare size={13} className="filemenu__icon" />
<span className="slashmenu__name slashmenu__name--file">
{pastChatTitle(session)}
{turns ? ` (${session.turns} 轮)` : ""}
</span>
</button>
</Tooltip>
);
})
)}
</>
)}
<button
className="slashmenu__item slashmenu__item--back"
onMouseDown={(ev) => {
ev.preventDefault();
setPastChatQuery("");
setShowPastChats(false);
setActive(0);
}}
>
<span className="slashmenu__name">← 返回文件列表</span>
</button>
</div>
) : (
<VirtualMenu
items={atMenuItems}
activeIndex={active}
itemKey={(it) => (it.kind === "pastChats" ? "past:chats" : (it.entry.isDir ? "d:" : "f:") + it.entry.name)}
renderItem={(it, i) =>
it.kind === "pastChats" ? (
<button
className={`slashmenu__item${i === active ? " slashmenu__item--active" : ""}`}
onMouseDown={(ev) => {
ev.preventDefault();
void openPastChats();
}}
onMouseMove={() => setActive(i)}
>
<MessageSquare size={13} className="filemenu__icon" />
<span className="slashmenu__name">{PAST_CHATS_MENU_ITEM}</span>
</button>
) : (
<button
role="option"
aria-selected={i === active}
className={`slashmenu__item ${i === active ? "slashmenu__item--active" : ""}`}
onMouseDown={(ev) => {
ev.preventDefault();
pickEntry(it.entry);
}}
onMouseMove={() => setActive(i)}
>
{it.entry.isDir ? (
<Folder size={13} className="filemenu__icon filemenu__icon--dir" />
) : (
<FileText size={13} className="filemenu__icon" />
)}
<span className="slashmenu__name slashmenu__name--file">
{it.entry.name}
{it.entry.isDir ? "/" : ""}
</span>
</button>
)
}
/>
)
)}
<div className="composer-toolbar">
<div className="composer-modebar" role="toolbar" aria-label={t("composer.modeTitle")}>
{modeOptions.map((option) => (
@@ -1043,7 +1560,7 @@ export function Composer({
</div>
)}
</div>
{(attachments.length > 0 || workspaceRefs.length > 0) && (
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0) && (
<div className="composer-context" aria-label={t("composer.contextItems")}>
{attachments.map((a) => (
<div
@@ -1052,14 +1569,14 @@ export function Composer({
>
<Tooltip label={a.path}>
<span className="composer-context__label">
{a.previewUrl ? <img src={a.previewUrl} alt="" /> : <FileText size={15} />}
{a.previewUrl ? <img src={a.previewUrl} alt="" draggable={false} /> : <FileText size={15} />}
<span>{a.path.split("/").pop()}</span>
</span>
</Tooltip>
<Tooltip label={t("composer.removeImage")}>
<button
type="button"
onClick={() => setAttachments((prev) => prev.filter((x) => x.path !== a.path))}
onClick={() => removeAttachment(a.path)}
>
<X size={14} />
</button>
@@ -1087,6 +1604,30 @@ export function Composer({
</Tooltip>
</div>
))}
{sessionRefs.map((ref) => (
<div
className="composer-context__item composer-context__item--session"
key={ref.path}
>
<Tooltip label={ref.preview || ref.title}>
<span className="composer-context__label">
<MessageSquare size={15} />
<span>
{ref.title}
{typeof ref.turns === "number" ? ` (${ref.turns} 轮)` : ""}
</span>
</span>
</Tooltip>
<Tooltip label="移除引用会话">
<button
type="button"
onClick={() => removeSessionRef(ref.path)}
>
<X size={13} />
</button>
</Tooltip>
</div>
))}
</div>
)}
{activePastedBlocks.length > 0 && (
@@ -1171,7 +1712,7 @@ export function Composer({
<button
className="composer__btn composer__btn--send"
onClick={submit}
disabled={pendingPaste > 0 || (!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) || disabled}
disabled={submitting || pendingPaste > 0 || (!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) || disabled}
>
<ArrowUp size={16} />
</button>
@@ -75,22 +75,15 @@ export function ContextMenu({
onClose();
};
const close = () => onClose();
const closeOnScroll = (event: Event) => {
const target = event.target;
if (target instanceof Node && menuRef.current?.contains(target)) return;
onClose();
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
window.addEventListener("resize", close);
window.addEventListener("scroll", closeOnScroll, true);
window.addEventListener("keydown", closeOnEscape);
return () => {
window.removeEventListener("pointerdown", closeOnOutsidePointerDown, true);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", closeOnScroll, true);
window.removeEventListener("keydown", closeOnEscape);
};
}, [open, onClose]);
@@ -1,52 +0,0 @@
import { useEffect, useRef } from "react";
import { Folder, FileText } from "lucide-react";
import type { DirEntry } from "../lib/types";
// FileMenu is the "@" file-reference dropdown above the composer. Like SlashMenu,
// it's presentational — the Composer owns navigation and the one-level-at-a-time
// descend logic. Reuses the .slashmenu container styling.
export function FileMenu({
items,
activeIndex,
onPick,
onHover,
}: {
items: DirEntry[];
activeIndex: number;
onPick: (e: DirEntry) => void;
onHover: (i: number) => void;
}) {
// Keep the keyboard-selected item in view (the list overflows at 280px).
const activeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
return (
<div className="slashmenu" role="listbox">
{items.map((e, i) => (
<button
key={(e.isDir ? "d:" : "f:") + e.name}
ref={i === activeIndex ? activeRef : undefined}
role="option"
aria-selected={i === activeIndex}
className={`slashmenu__item ${i === activeIndex ? "slashmenu__item--active" : ""}`}
onMouseDown={(ev) => {
ev.preventDefault();
onPick(e);
}}
onMouseMove={() => onHover(i)}
>
{e.isDir ? (
<Folder size={13} className="filemenu__icon filemenu__icon--dir" />
) : (
<FileText size={13} className="filemenu__icon" />
)}
<span className="slashmenu__name slashmenu__name--file">
{e.name}
{e.isDir ? "/" : ""}
</span>
</button>
))}
</div>
);
}
@@ -37,6 +37,13 @@ const components: Components = {
e.preventDefault();
if (href) openExternal(href);
}}
onAuxClick={(e) => {
e.preventDefault();
if (href) openExternal(href);
}}
onMouseDown={(e) => {
if (e.button === 1) e.preventDefault();
}}
>
{children}
</a>
+30 -3
View File
@@ -1,8 +1,9 @@
import { memo, useState } from "react";
import { ChevronDown, GitBranch, RotateCcw, ScrollText } from "lucide-react";
import { ChevronDown, FileText, Folder, GitBranch, Image, RotateCcw, ScrollText } from "lucide-react";
import { Markdown } from "./Markdown";
import { CopyButton } from "./CopyButton";
import { ProcessBrainIcon, ProcessCard, ProcessStatusIcon } from "./ProcessCard";
import { parseAttachmentRefsForDisplay } from "../lib/attachmentDisplay";
import { useT } from "../lib/i18n";
import type { Item, MessageActionScope } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
@@ -10,6 +11,12 @@ import type { CheckpointMeta } from "../lib/types";
type AssistantItem = Extract<Item, { kind: "assistant" }>;
export type TurnActionMenu = "summary" | "rewind";
function attachmentIcon(kind: "image" | "file" | "folder") {
if (kind === "image") return <Image size={15} />;
if (kind === "folder") return <Folder size={15} />;
return <FileText size={15} />;
}
export function UserMessage({
text,
turn,
@@ -19,11 +26,31 @@ export function UserMessage({
turn?: number;
anchorId?: string;
}) {
const displayText = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]");
const t = useT();
const { text: displayText, attachments } = parseAttachmentRefsForDisplay(text);
return (
<div className="msg msg--user" id={anchorId} data-question-anchor={anchorId} data-turn={turn}>
<div className="msg__body">
<div className="msg__text">{displayText}</div>
{displayText && <div className="msg__text">{displayText}</div>}
{attachments.length > 0 && (
<div className="msg-attachments" aria-label={t("msg.attachments")}>
{attachments.map((attachment, index) => (
<div className="msg-attachment" key={`${attachment.path}:${index}`} title={attachment.path}>
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
{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")}`}
</span>
</span>
</div>
))}
</div>
)}
</div>
</div>
);
@@ -43,7 +43,7 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) {
return (
<div className="onboarding">
<div className="onboarding__card">
<img src={logo} className="onboarding__logo" alt="Reasonix" />
<img src={logo} className="onboarding__logo" alt="Reasonix" draggable={false} />
<div className="onboarding__title">{t("onboarding.title")}</div>
<div className="onboarding__tag">{t("onboarding.tagline")}</div>
+211 -74
View File
@@ -3,6 +3,7 @@ import { Check, ChevronDown } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n";
import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels";
import { useUpdater } from "../lib/useUpdater";
import {
THEME_STYLES,
@@ -400,7 +401,9 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
noProxy: "",
proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
};
const agent = view.agent ?? { temperature: 0, maxSteps: 0, systemPrompt: "" };
const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "" };
agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 12;
agent.maxSteps = Number.isFinite(agent.maxSteps) ? Math.max(0, Math.trunc(agent.maxSteps)) : 0;
return {
...view,
providers: asArray(view.providers).map((p) => ({
@@ -531,6 +534,80 @@ function GeneralSection({ s, busy, apply }: SectionProps) {
);
}
function StepLimitControl({
value,
presets,
busy,
onChange,
}: {
value: number;
presets: number[];
busy: boolean;
onChange: (value: number) => void;
}) {
const t = useT();
const normalized = normalizeStepLimit(value);
const presetSet = new Set(presets.map(normalizeStepLimit));
const [custom, setCustom] = useState(String(normalized));
useEffect(() => setCustom(String(normalized)), [normalized]);
const isCustom = !presetSet.has(normalized);
const commitCustom = () => {
const next = normalizeStepLimit(Number(custom));
setCustom(String(next));
if (next !== normalized) onChange(next);
};
return (
<div className="step-limit-control">
<div className="set-seg">
{presets.map((preset) => {
const n = normalizeStepLimit(preset);
return (
<button
key={n}
type="button"
className={`set-seg__btn${normalized === n ? " set-seg__btn--on" : ""}`}
disabled={busy}
onClick={() => n !== normalized && onChange(n)}
>
{stepLimitLabel(n, t)}
</button>
);
})}
<button
type="button"
className={`set-seg__btn${isCustom ? " set-seg__btn--on" : ""}`}
disabled={busy}
onClick={() => {
if (!isCustom) setCustom(String(normalized || 12));
}}
>
{t("settings.stepLimit.custom")}
</button>
</div>
<input
className="mem-input step-limit-control__custom"
value={custom}
disabled={busy}
inputMode="numeric"
aria-label={t("settings.stepLimit.custom")}
onChange={(e) => setCustom(e.target.value.replace(/[^\d]/g, ""))}
onBlur={commitCustom}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
</div>
);
}
function normalizeStepLimit(value: number): number {
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
}
function stepLimitLabel(value: number, t: ReturnType<typeof useT>): string {
return value === 0 ? t("settings.stepLimit.unlimited") : String(value);
}
function NetworkSection({ s, busy, apply }: SectionProps) {
const t = useT();
const savedNetwork = normalizeNetworkView(s.network);
@@ -662,6 +739,10 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
const providerLabel = defaultProvider ? modelProviderLabel(defaultProvider, defaultProviderView, t) : t("common.none");
const plannerLabel = plannerSelectRef || t("settings.plannerNone");
const keyStatusLabel = defaultProviderView?.keySet ? t("settings.keySet") : t("settings.noKey");
const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "" };
const setAgentSteps = (maxSteps: number, plannerMaxSteps: number) => (
app.SetAgentParams(agent.temperature, maxSteps, plannerMaxSteps, agent.systemPrompt)
);
useEffect(() => {
if (subtab !== "usage") return;
@@ -681,9 +762,10 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
try {
const fetched = await app.FetchProviderModels(provider);
if (fetched.length === 0) continue;
const currentDefault = provider.default && fetched.includes(provider.default) ? provider.default : fetched[0];
if (sameStringList(provider.models, fetched) && provider.default === currentDefault) continue;
await app.SaveProvider({ ...provider, models: fetched, default: currentDefault });
const models = mergedFetchedProviderModels(provider.models, fetched, { preserveCurated: true });
const currentDefault = providerDefaultModel(provider.default, models);
if (sameStringList(provider.models, models) && provider.default === currentDefault) continue;
await app.SaveProvider({ ...provider, models, default: currentDefault });
} catch {
// Background discovery is opportunistic; manual refresh shows errors.
}
@@ -713,68 +795,88 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
</div>
{subtab === "usage" ? (
<SettingsSection title={t("settings.modelUsage")}>
<SettingsField label={t("settings.defaultModel")}>
<ModelPicker
s={s}
refs={refs}
value={toRef(s.defaultModel, s)}
disabled={busy}
onPick={(ref) => void apply(() => app.SetDefaultModel(ref))}
/>
</SettingsField>
<>
<SettingsSection title={t("settings.modelUsage")}>
<SettingsField label={t("settings.defaultModel")}>
<ModelPicker
s={s}
refs={refs}
value={toRef(s.defaultModel, s)}
disabled={busy}
onPick={(ref) => void apply(() => app.SetDefaultModel(ref))}
/>
</SettingsField>
<SettingsField label={t("settings.plannerModel")}>
<ModelPicker
s={s}
refs={refs}
value={plannerSelectRef}
disabled={busy}
includeSameDefault
onPick={(ref) => void apply(() => app.SetPlannerModel(ref))}
/>
</SettingsField>
<SettingsField label={t("settings.plannerModel")}>
<ModelPicker
s={s}
refs={refs}
value={plannerSelectRef}
disabled={busy}
includeSameDefault
onPick={(ref) => void apply(() => app.SetPlannerModel(ref))}
/>
</SettingsField>
<SettingsField label={t("settings.subagentModel")}>
<ModelPicker
s={s}
refs={refs}
value={subagentRef}
disabled={busy}
emptyOptionLabel={t("settings.subagentModelDefault")}
emptyOptionHint={t("common.auto")}
onPick={(ref) => void apply(() => app.SetSubagentModel(ref))}
/>
</SettingsField>
<SettingsField label={t("settings.subagentModel")}>
<ModelPicker
s={s}
refs={refs}
value={subagentRef}
disabled={busy}
emptyOptionLabel={t("settings.subagentModelDefault")}
emptyOptionHint={t("common.auto")}
onPick={(ref) => void apply(() => app.SetSubagentModel(ref))}
/>
</SettingsField>
<SettingsField label={t("settings.subagentEffort")} hint={t("settings.subagentHint")}>
<select
className="mem-select set-grow"
value={s.subagentEffort || ""}
disabled={busy}
onChange={(e) => void apply(() => app.SetSubagentEffort(e.target.value))}
>
<option value="">{t("settings.subagentEffortDefault")}</option>
{EFFORT_PRESETS.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</SettingsField>
<SettingsField label={t("settings.subagentEffort")} hint={t("settings.subagentHint")}>
<select
className="mem-select set-grow"
value={s.subagentEffort || ""}
disabled={busy}
onChange={(e) => void apply(() => app.SetSubagentEffort(e.target.value))}
>
<option value="">{t("settings.subagentEffortDefault")}</option>
{EFFORT_PRESETS.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</SettingsField>
<div className="settings-model-current" aria-label={t("settings.modelCurrentStatus")}>
<div>
<span>{t("settings.modelCurrentStatus")}</span>
<strong>{currentModelLabel}</strong>
<div className="settings-model-current" aria-label={t("settings.modelCurrentStatus")}>
<div>
<span>{t("settings.modelCurrentStatus")}</span>
<strong>{currentModelLabel}</strong>
</div>
<div className="settings-model-current__meta">
<span>{providerLabel}</span>
<span>{plannerLabel}</span>
<span>{keyStatusLabel}</span>
</div>
</div>
<div className="settings-model-current__meta">
<span>{providerLabel}</span>
<span>{plannerLabel}</span>
<span>{keyStatusLabel}</span>
</div>
</div>
</SettingsSection>
</SettingsSection>
<SettingsSection title={t("settings.agentRuntime")} description={t("settings.agentRuntimeHint")}>
<SettingsField label={t("settings.executorMaxSteps")} hint={t("settings.executorMaxStepsHint")}>
<StepLimitControl
value={agent.maxSteps}
presets={[0, 10, 25, 50]}
busy={busy}
onChange={(next) => void apply(() => setAgentSteps(next, agent.plannerMaxSteps))}
/>
</SettingsField>
<SettingsField label={t("settings.plannerMaxSteps")} hint={plannerSelectRef ? t("settings.plannerMaxStepsHint") : t("settings.plannerMaxStepsDisabledHint")}>
<StepLimitControl
value={agent.plannerMaxSteps}
presets={[6, 12, 25, 0]}
busy={busy}
onChange={(next) => void apply(() => setAgentSteps(agent.maxSteps, next))}
/>
</SettingsField>
</SettingsSection>
</>
) : (
<ProvidersSection s={s} busy={busy} apply={apply} />
)}
@@ -1043,11 +1145,12 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
});
return;
}
const currentDefault = p.default && fetched.includes(p.default) ? p.default : fetched[0];
await app.SaveProvider({ ...p, models: fetched, default: currentDefault });
const models = mergedFetchedProviderModels(p.models, fetched);
const currentDefault = providerDefaultModel(p.default, models);
await app.SaveProvider({ ...p, models, default: currentDefault });
setGroupFetchResult(group.id, {
kind: "ok",
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: fetched.length }),
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }),
});
});
} finally {
@@ -1072,11 +1175,12 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
try {
const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv });
if (fetched.length > 0) {
const currentDefault = probe.default && fetched.includes(probe.default) ? probe.default : fetched[0];
await app.SaveProvider({ ...probe, apiKeyEnv, models: fetched, default: currentDefault });
const models = mergedFetchedProviderModels(probe.models, fetched, { preserveCurated: true });
const currentDefault = providerDefaultModel(probe.default, models);
await app.SaveProvider({ ...probe, apiKeyEnv, models, default: currentDefault });
setGroupFetchResult(group.id, {
kind: "ok",
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: fetched.length }),
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }),
});
return;
}
@@ -1479,6 +1583,7 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
const groups = new Map<string, ProviderAccessGroup>();
for (const p of providers) {
const id = providerGroupID(p);
const builtIn = id.startsWith("builtin:");
const existing = groups.get(id);
if (existing) {
existing.providers.push(p);
@@ -1490,7 +1595,7 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
id,
label: providerGroupLabel(p, t),
description: providerGroupDescription(p, t),
builtIn: p.builtIn,
builtIn,
providers: [p],
apiKeyEnv: p.apiKeyEnv,
keySet: p.keySet,
@@ -1502,13 +1607,45 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
return Array.from(groups.values());
}
function providerBaseHost(baseUrl: string): string {
try {
return new URL(baseUrl).hostname.toLowerCase();
} catch {
return "";
}
}
function canonicalOfficialProviderName(name: string): string {
switch (name.trim()) {
case "deepseek-flash":
case "deepseek-pro":
return "deepseek";
case "mimo":
case "xiaomi-mimo":
case "xiaomi_mimo":
return "mimo-api";
case "mimo-pro":
case "mimo-flash":
return "mimo-token-plan";
default:
return name.trim();
}
}
function officialProviderKind(p: ProviderView): string {
if (!p.builtIn) return "";
const name = canonicalOfficialProviderName(p.name);
const host = providerBaseHost(p.baseUrl);
if (name === "deepseek" && host === "api.deepseek.com") return "deepseek";
if (name === "mimo-token-plan" && host === "token-plan-cn.xiaomimimo.com") return "mimo-token-plan";
if (name === "mimo-api" && host === "api.xiaomimimo.com") return "mimo-api";
return "";
}
function providerGroupID(p: ProviderView): string {
if (!p.builtIn) return `custom:${p.name}`;
const base = p.baseUrl.toLowerCase();
if (p.apiKeyEnv === "DEEPSEEK_API_KEY" || base.includes("deepseek")) return "builtin:deepseek";
if (base.includes("token-plan-cn.xiaomimimo.com")) return "builtin:mimo-token-plan";
if (base.includes("api.xiaomimimo.com") || base.includes("mimo") || base.includes("xiaomimimo")) return "builtin:mimo-api";
return `builtin:${p.name}`;
const official = officialProviderKind(p);
if (official) return `builtin:${official}`;
return `custom:${p.name}`;
}
function providerGroupLabel(p: ProviderView, t?: ReturnType<typeof useT>): string {
+10 -16
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef } from "react";
import { useT } from "../lib/i18n";
import type { CommandInfo } from "../lib/types";
import { VirtualMenu } from "./VirtualMenu";
// SlashMenu is the "/" autocomplete dropdown above the composer. Presentational:
// the Composer owns filtering, the active index, and key handling; this renders
// the list and reports hover/pick. Uses mousedown (not click) so picking an item
// doesn't blur the textarea first.
// the (virtualized) list and reports hover/pick. Uses mousedown (not click) so
// picking an item doesn't blur the textarea first.
export function SlashMenu({
items,
activeIndex,
@@ -18,13 +18,6 @@ export function SlashMenu({
onHover: (i: number) => void;
}) {
const t = useT();
// Keep the keyboard-selected item scrolled into view: the list is capped at
// 280px and overflows, so ArrowDown past the visible window would otherwise
// hide the active row. block:"nearest" only scrolls when it's actually off-screen.
const activeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
// builtin commands get no tag; custom (project) and mcp commands are labelled.
const kindTag = (kind: CommandInfo["kind"]) =>
kind === "custom"
@@ -35,11 +28,12 @@ export function SlashMenu({
? t("slash.skill")
: "";
return (
<div className="slashmenu" role="listbox">
{items.map((c, i) => (
<VirtualMenu
items={items}
activeIndex={activeIndex}
itemKey={(c) => c.kind + ":" + c.name}
renderItem={(c, i) => (
<button
key={c.kind + ":" + c.name}
ref={i === activeIndex ? activeRef : undefined}
role="option"
aria-selected={i === activeIndex}
className={`slashmenu__item ${i === activeIndex ? "slashmenu__item--active" : ""}`}
@@ -54,7 +48,7 @@ export function SlashMenu({
<span className="slashmenu__desc">{c.description}</span>
{kindTag(c.kind) && <span className="slashmenu__kind">{kindTag(c.kind)}</span>}
</button>
))}
</div>
)}
/>
);
}
@@ -69,7 +69,7 @@ export function StartupSplash({ hold, onDone }: { hold: boolean; onDone: () => v
<div className="startup-splash" data-leaving={leaving} onClick={() => finish(true)}>
<div className="startup-splash__card">
<div className="startup-splash__mark" aria-hidden="true">
<img src={logoSymbol} alt="" />
<img src={logoSymbol} alt="" draggable={false} />
</div>
<div className="startup-splash__name">Reasonix</div>
<div className="startup-splash__sub">{t("app.splashSubtitle")}</div>
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, RefreshCw, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, X } from "lucide-react";
import { useT } from "../lib/i18n";
import type { Todo } from "../lib/tools";
import { Tooltip } from "./Tooltip";
@@ -11,7 +11,7 @@ import { Tooltip } from "./Tooltip";
// shows the current item so the footer stays compact during a long run. The ✕
// dismisses it (onDismiss) when the user abandons the task; a fresh todo_write
// brings it back.
export function TodoPanel({ todos, stale, onDismiss }: { todos: Todo[]; stale?: boolean; onDismiss: () => void }) {
export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () => void }) {
const t = useT();
const [open, setOpen] = useState(true);
const currentRef = useRef<HTMLLIElement | null>(null);
@@ -35,12 +35,6 @@ export function TodoPanel({ todos, stale, onDismiss }: { todos: Todo[]; stale?:
<span className="todobar__count">
{done}/{todos.length}
</span>
{stale && (
<span className="todobar__stale">
<RefreshCw size={11} />
{t("todo.stale")}
</span>
)}
{!open && current && (
<span className="todobar__current">{current.activeForm || current.content}</span>
)}
+10 -3
View File
@@ -72,10 +72,17 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
: summarize(item.name, item.args, item.output, item.error);
// edit diffs are the point of the card, so they're shown inline; everything
// else folds its args/output away by default. Nested children always show.
// Shell commands default to open so the output is immediately visible.
// else folds its args/output away by default. Nested sub-agent calls (an
// /explore's read_file / grep chain) follow the parent's expand state — a
// single chevron now collapses the whole subagent tree to one line. Open by
// default while the sub-agent is still running so the user can watch it
// progress; closed by default once it settles.
const hasArgsOrOutput = diffs.length === 0 && (!!item.args || !!item.output);
const [open, setOpen] = useState(item.isShell && hasArgsOrOutput);
const [open, setOpen] = useState(() => {
if (item.isShell) return hasArgsOrOutput;
if (hasNested) return item.status === "running";
return false;
});
const [showAll, setShowAll] = useState(false);
// Register this shell card's toggle with the global ShellExpand context so
@@ -2,6 +2,7 @@ import { createContext, memo, type CSSProperties, type MouseEvent as ReactMouseE
import type { Item, LiveStream } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
import { useT } from "../lib/i18n";
import { replaceAttachmentRefsForDisplay } from "../lib/attachmentDisplay";
import { AssistantMessage, TurnActions, UserMessage } from "./Message";
import { ProcessCard, ProcessCompactIcon, ProcessInfoIcon, ProcessPhaseIcon, ProcessStatusIcon } from "./ProcessCard";
import { ToolCard } from "./ToolCard";
@@ -46,7 +47,7 @@ function questionAnchorId(id: string): string {
}
function compactQuestionText(text: string): string {
const cleaned = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]").replace(/\s+/g, " ").trim();
const cleaned = replaceAttachmentRefsForDisplay(text).replace(/\s+/g, " ").trim();
if (cleaned.length <= 80) return cleaned;
return cleaned.slice(0, 80);
}
@@ -84,7 +85,7 @@ function repinIfWasPinned(
// Summarise a warm turn for its compact card.
function warmUserPreview(text: string): string {
const cleaned = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]").replace(/\s+/g, " ").trim();
const cleaned = replaceAttachmentRefsForDisplay(text).replace(/\s+/g, " ").trim();
return cleaned.length <= 80 ? cleaned : cleaned.slice(0, 77) + "...";
}
@@ -0,0 +1,51 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
// VirtualMenu is the shared scroll container for the composer's "/" and "@"
// dropdowns. Rows are virtualized so a directory with thousands of entries (or a
// long command list) only ever mounts the visible rows plus a small overscan —
// no truncation, no jank. The caller owns the item data, the active index, and
// per-row markup; this owns layout and keeping the active row in view.
export function VirtualMenu<T>({
items,
activeIndex,
itemKey,
renderItem,
}: {
items: T[];
activeIndex: number;
itemKey: (item: T, index: number) => string;
renderItem: (item: T, index: number) => ReactNode;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 34,
overscan: 10,
});
useEffect(() => {
if (activeIndex >= 0 && activeIndex < items.length) {
virtualizer.scrollToIndex(activeIndex, { align: "auto" });
}
}, [activeIndex, items.length, virtualizer]);
return (
<div ref={scrollRef} className="slashmenu" role="listbox">
<div className="slashmenu__sizer" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={itemKey(items[row.index], row.index)}
data-index={row.index}
ref={virtualizer.measureElement}
className="slashmenu__row"
style={{ transform: `translateY(${row.start}px)` }}
>
{renderItem(items[row.index], row.index)}
</div>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ export function Welcome({ onPrompt }: { onPrompt: (text: string) => void }) {
const examples = [t("welcome.ex1"), t("welcome.ex2"), t("welcome.ex3")];
return (
<div className="welcome">
<img src={logoWordmark} className="welcome__logo" alt="Reasonix" />
<img src={logoWordmark} className="welcome__logo" alt="Reasonix" draggable={false} />
<div className="welcome__tag">{t("welcome.tagline")}</div>
<div className="welcome__hints">
@@ -24,8 +24,9 @@ import {
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import type { DirEntry, FilePreview, WorkspaceChangeView, WorkspaceChangesView } from "../lib/types";
import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView } from "../lib/types";
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
import { cleanGitDiff } from "../lib/diff";
import { CodeViewer } from "./CodeViewer";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
@@ -116,7 +117,7 @@ function renderMediaPreview(preview: FilePreview): JSX.Element | null {
if (preview.kind === "image") {
return (
<div className="workspace-media workspace-media--image">
<img src={preview.url} alt={basename(preview.path)} decoding="async" />
<img src={preview.url} alt={basename(preview.path)} decoding="async" draggable={false} />
</div>
);
}
@@ -160,15 +161,16 @@ function formatBytes(n: number): string {
return `${n} B`;
}
function isDeletedChange(row: WorkspaceChangeView): boolean {
return !!row.gitStatus && row.gitStatus.includes("D");
}
function changeDetail(row: WorkspaceChangeView): string {
if (row.latestPrompt) return row.latestPrompt;
if (row.oldPath) return `← ${row.oldPath}`;
if (row.turns && row.turns.length > 0) return `#${row.turns.join(", #")}`;
return row.path;
function formatCommitDate(dateStr: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const day = String(d.getDate()).padStart(2, "0");
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const month = monthNames[d.getMonth()];
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
return `${day} ${month} ${year} ${hours}:${minutes}`;
}
export function WorkspacePanel({
@@ -209,12 +211,14 @@ export function WorkspacePanel({
const [preview, setPreview] = useState<FilePreview | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
const [changes, setChanges] = useState<WorkspaceChangesView | null>(null);
const [loadingChanges, setLoadingChanges] = useState(false);
const [gitHistory, setGitHistory] = useState<GitCommitView[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
const [commitDetail, setCommitDetail] = useState<GitCommitDetailView | null>(null);
const [loadingCommit, setLoadingCommit] = useState(false);
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; path: string } | null>(null);
const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
const changesRequestRef = useRef(0);
const [filter, setFilter] = useState("");
const [treeVisible, setTreeVisible] = useState(true);
const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth);
@@ -232,22 +236,46 @@ export function WorkspacePanel({
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
}, []);
const loadChanges = useCallback(async () => {
const requestId = changesRequestRef.current + 1;
changesRequestRef.current = requestId;
setLoadingChanges(true);
const loadGitHistory = useCallback(async () => {
setLoadingHistory(true);
try {
const next = await app.WorkspaceChanges();
if (changesRequestRef.current === requestId) setChanges(next);
const result = await app.WorkspaceGitHistory(selectedPath || "");
setGitHistory(result || []);
} catch (err) {
if (changesRequestRef.current === requestId) {
setChanges({ files: [], gitAvailable: false, gitErr: String((err as Error)?.message ?? err) });
}
setGitHistory([]);
} finally {
if (changesRequestRef.current === requestId) setLoadingChanges(false);
setLoadingHistory(false);
}
}, [selectedPath]);
const toggleCommit = useCallback((hash: string) => {
setExpandedCommit((prev) => (prev === hash ? null : hash));
}, []);
useEffect(() => {
if (!open) return;
if (expandedCommit) {
let live = true;
setLoadingCommit(true);
app
.WorkspaceGitCommitDetail(expandedCommit, selectedPath || "")
.then((detail) => {
if (live) setCommitDetail(detail);
})
.catch(() => {
if (live) setCommitDetail(null);
})
.finally(() => {
if (live) setLoadingCommit(false);
});
return () => {
live = false;
};
} else {
setCommitDetail(null);
}
}, [expandedCommit, selectedPath, open]);
const selectFile = useCallback(
(path: string) => {
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
@@ -270,31 +298,38 @@ export function WorkspacePanel({
setSelectedPath(null);
setOpenTabs([]);
setPreview(null);
setChanges(null);
setGitHistory([]);
setExpandedCommit(null);
setCommitDetail(null);
setSelectionMenu(null);
setTreeMenu(null);
setFilter("");
setTreeVisible(true);
setViewMode(initialViewMode);
void loadDir("");
}, [cwd, initialViewMode, loadDir, open]);
}, [cwd, loadDir, open]);
useEffect(() => {
if (!open) return;
setViewMode(initialViewMode);
if (initialViewMode === "changed") void loadChanges();
}, [initialViewMode, loadChanges, open]);
if (initialViewMode === "changed") {
void loadGitHistory();
}
}, [initialViewMode, loadGitHistory, open]);
useEffect(() => {
if (!open) return;
void loadChanges();
}, [cwd, loadChanges, open]);
if (viewMode === "changed") {
void loadGitHistory();
}
}, [selectedPath, viewMode, loadGitHistory, open]);
useEffect(() => {
if (!open || !refreshKey) return;
void loadChanges();
if (viewMode === "changed") {
void loadGitHistory();
}
openDirsRef.current.forEach((dir) => void loadDir(dir));
}, [loadChanges, loadDir, open, refreshKey]);
}, [loadGitHistory, loadDir, open, refreshKey, viewMode]);
useEffect(() => {
if (!selectionMenu && !treeMenu) return;
@@ -320,13 +355,13 @@ export function WorkspacePanel({
setSelectionMenu(null);
setTreeMenu(null);
if (viewMode === "changed") {
void loadChanges();
void loadGitHistory();
return;
}
const dirs = Array.from(openDirsRef.current);
setEntriesByDir({});
dirs.forEach((dir) => void loadDir(dir));
}, [loadChanges, loadDir, viewMode]);
}, [loadGitHistory, loadDir, viewMode]);
const refreshSelected = useCallback(() => {
if (!selectedPath) return;
@@ -415,16 +450,10 @@ export function WorkspacePanel({
.sort((a, b) => a.path.localeCompare(b.path));
}, [entriesByDir, filter]);
const changedRows = useMemo(() => {
const rows = changes?.files ?? [];
const q = filter.trim().toLowerCase();
if (!q) return rows;
return rows.filter((row) => `${row.path} ${row.oldPath ?? ""} ${row.gitStatus ?? ""}`.toLowerCase().includes(q));
}, [changes?.files, filter]);
const searchPlaceholder = viewMode === "changed" ? t("workspace.filterChanges") : t("workspace.filter");
const searchPlaceholder = t("workspace.filter");
const effectiveTreeWidth = useMemo(() => clampWorkspaceTreeWidth(treeWidth, panelWidth), [panelWidth, treeWidth]);
const previewVisible = openTabs.length > 0 || selectedPath !== null;
const previewVisible = viewMode === "changed" || openTabs.length > 0 || selectedPath !== null;
const selectedFileVisible = selectedPath !== null;
const compactTreeSplit =
treeVisible && selectedFileVisible && panelWidth !== undefined && panelWidth < WORKSPACE_DUAL_PANEL_MIN_WIDTH;
@@ -590,41 +619,6 @@ export function WorkspacePanel({
}
};
const renderChangedRows = () => {
if (loadingChanges) return <div className="workspace-empty">{t("workspace.loadingChanges")}</div>;
if (!changes) return null;
if (changedRows.length === 0) return <div className="workspace-empty">{t("workspace.noChanges")}</div>;
return changedRows.map((row) => {
const deleted = isDeletedChange(row);
return (
<button
key={`${row.path}-${row.sources.join("-")}`}
className={`workspace-change${selectedPath === row.path ? " workspace-change--active" : ""}${deleted ? " workspace-change--disabled" : ""}`}
draggable
onDragStart={(event) => startTreeDrag(event, row.path, false)}
onContextMenu={(event) => openTreeMenu(event, row.path, false)}
onClick={() => {
if (!deleted) selectFile(row.path);
}}
type="button"
>
<FileText size={14} className="workspace-tree__icon" />
<span className="workspace-change__body">
<span className="workspace-change__name">{basename(row.path)}</span>
<span className="workspace-change__path">{row.path}</span>
<span className="workspace-change__detail">{changeDetail(row)}</span>
</span>
<span className="workspace-change__meta">
{row.gitStatus && <span className="workspace-change__badge workspace-change__badge--git">{row.gitStatus}</span>}
{deleted && <span className="workspace-change__badge">{t("workspace.deleted")}</span>}
{row.sources.includes("session") && <span className="workspace-change__badge">{t("workspace.sourceSession")}</span>}
{row.sources.includes("git") && <span className="workspace-change__badge">{t("workspace.sourceGit")}</span>}
</span>
</button>
);
});
};
const renderRows = (dir: string, depth: number): JSX.Element[] => {
const entries = entriesByDir[dir] ?? [];
return entries.flatMap((entry) => {
@@ -637,7 +631,17 @@ export function WorkspacePanel({
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
onClick={() => {
if (entry.isDir) {
toggleDir(path);
} else {
if (selectedPath === path) {
setSelectedPath(null);
} else {
selectFile(path);
}
}
}}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
style={{ paddingLeft: 8 + depth * 14 }}
>
@@ -792,7 +796,101 @@ export function WorkspacePanel({
</div>
<div className="workspace-preview__body" ref={previewBodyRef} onContextMenu={openSelectionMenu}>
{!selectedPath ? (
{viewMode === "changed" && !selectedPath ? (
<div className="workspace-git-history">
{loadingHistory ? (
<div className="workspace-empty">{t("workspace.loading")}</div>
) : gitHistory.length === 0 ? (
<div className="workspace-empty">{t("workspace.noChanges")}</div>
) : (
<div className="workspace-git-history__list">
{gitHistory.map((commit) => (
<div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
<button
className="workspace-git-history__head"
onClick={() => void toggleCommit(commit.hash)}
>
<div className="workspace-git-history__head-top">
{expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="workspace-git-history__message">{commit.message}</span>
</div>
<div className="workspace-git-history__head-bottom">
<span className="workspace-git-history__author">{commit.author}</span>
<span className="workspace-git-history__date">
{formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
</span>
</div>
</button>
{expandedCommit === commit.hash && (
<div className="workspace-git-history__detail">
{loadingCommit ? (
<div className="workspace-empty">{t("workspace.loading")}</div>
) : commitDetail?.diff ? (
<CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
) : commitDetail?.files ? (
<div className="workspace-git-history__files">
{commitDetail.files.map((file) => (
<button
key={file}
className="workspace-git-history__file"
onClick={() => selectFile(file)}
>
<FileText size={14} /> {file}
</button>
))}
</div>
) : (
<div className="workspace-empty">No details available</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
) : viewMode === "changed" && selectedPath ? (
<div className="workspace-git-history">
{loadingHistory ? (
<div className="workspace-empty">{t("workspace.loading")}</div>
) : gitHistory.length === 0 ? (
<div className="workspace-empty">{t("workspace.noChanges")}</div>
) : (
<div className="workspace-git-history__list">
{gitHistory.map((commit) => (
<div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
<button
className="workspace-git-history__head"
onClick={() => void toggleCommit(commit.hash)}
>
<div className="workspace-git-history__head-top">
{expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="workspace-git-history__message">{commit.message}</span>
</div>
<div className="workspace-git-history__head-bottom">
<span className="workspace-git-history__author">{commit.author}</span>
<span className="workspace-git-history__date">
{formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
</span>
</div>
</button>
{expandedCommit === commit.hash && (
<div className="workspace-git-history__detail">
{loadingCommit ? (
<div className="workspace-empty">{t("workspace.loading")}</div>
) : commitDetail?.diff ? (
<CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
) : (
<div className="workspace-empty">No details available</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
) : !selectedPath ? (
<div className="workspace-empty">{t("workspace.pickFile")}</div>
) : loadingPreview ? (
<div className="workspace-empty">{t("workspace.loading")}</div>
@@ -887,7 +985,7 @@ export function WorkspacePanel({
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => {
setViewMode("changed");
void loadChanges();
void loadGitHistory();
}}
>
<GitBranch size={13} />
@@ -897,7 +995,7 @@ export function WorkspacePanel({
)}
{showViewTabs && (
<Tooltip label={t("workspace.refreshChanges")}>
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
<button className="workspace-iconbtn" onClick={() => void loadGitHistory()}>
<RefreshCw size={14} />
</button>
</Tooltip>
@@ -909,13 +1007,8 @@ export function WorkspacePanel({
<Search size={14} />
<input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={searchPlaceholder} />
</div>
{viewMode === "changed" && changes && !changes.gitAvailable && changes.gitErr && (
<div className="workspace-note workspace-note--compact">{t("workspace.gitUnavailable")}</div>
)}
<div className="workspace-tree" onContextMenu={openTreeBlankMenu}>
{viewMode === "changed"
? renderChangedRows()
: flattened
{flattened
? flattened.map(({ path, entry }) => {
const dir = parentPath(path);
return (
@@ -924,7 +1017,17 @@ export function WorkspacePanel({
className={`workspace-tree__row workspace-tree__row--search${selectedPath === path ? " workspace-tree__row--active" : ""}`}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
onClick={() => {
if (entry.isDir) {
toggleDir(path);
} else {
if (selectedPath === path) {
setSelectedPath(null);
} else {
selectFile(path);
}
}
}}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
>
{entry.isDir ? (
+5
View File
@@ -58,6 +58,11 @@ export class DedupIndex {
this.paths.add(path);
}
forget(hash: string, path: string): void {
if (hash) this.hashes.delete(hash);
this.paths.delete(path);
}
clear(): void {
this.hashes.clear();
this.paths.clear();
@@ -0,0 +1,102 @@
const attachmentRefRe = /@(\.reasonix\/attachments\/[^\s]+)/g;
const referenceRefRe = /(^|\s)@([^\s]+)/g;
const trailingPunctuationRe = /[.,;!?)\]},。;!?)】]+$/;
export interface DisplayAttachment {
path: string;
name: string;
kind: "image" | "file" | "folder";
source: "attachment" | "workspace";
ext: string;
}
function splitTrailingPunctuation(token: string): { core: string; suffix: string } {
const m = token.match(trailingPunctuationRe);
if (!m || m.index === undefined) return { core: token, suffix: "" };
return { core: token.slice(0, m.index), suffix: m[0] };
}
function baseName(path: string): string {
const clean = path.replace(/\/+$/, "");
const idx = clean.lastIndexOf("/");
return idx >= 0 ? clean.slice(idx + 1) : clean;
}
function isImageAttachmentRef(path: string): boolean {
const ext = attachmentExt(path);
switch (ext) {
case ".png":
case ".jpg":
case ".jpeg":
case ".gif":
case ".webp":
case ".bmp":
case ".svg":
case ".tif":
case ".tiff":
return true;
default:
return false;
}
}
function attachmentExt(path: string): string {
const name = baseName(path).toLowerCase();
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot) : "";
}
export function replaceAttachmentRefsForDisplay(text: string): string {
return text.replace(attachmentRefRe, (_full, token: string) => {
const { core, suffix } = splitTrailingPunctuation(token);
if (!core) return _full;
if (isImageAttachmentRef(core)) return `[image]${suffix}`;
const name = baseName(core) || "attachment";
return `[file:${name}]${suffix}`;
});
}
export function parseAttachmentRefsForDisplay(text: string): { text: string; attachments: DisplayAttachment[] } {
const attachments: DisplayAttachment[] = [];
const cleaned = text
.replace(referenceRefRe, (_full, lead: string, token: string) => {
const { core, suffix } = splitTrailingPunctuation(token);
if (!core || !isDisplayReference(core)) return _full;
const name = baseName(core) || "attachment";
const attachment = displayAttachment(core, name);
attachments.push(attachment);
return lead + suffix;
})
.replace(/[ \t]+([.,;!?)\]},。;!?)】])/g, "$1")
.replace(/[ \t]{2,}/g, " ")
.trim();
return { text: cleaned, attachments };
}
function isDisplayReference(path: string): boolean {
if (path.startsWith(".reasonix/attachments/")) return true;
if (path.endsWith("/")) return true;
if (path.includes("/")) return true;
return attachmentExt(path) !== "";
}
function displayAttachment(path: string, name: string): DisplayAttachment {
if (path.startsWith(".reasonix/attachments/")) {
const kind = isImageAttachmentRef(path) ? "image" : "file";
return {
path,
name,
kind,
source: "attachment",
ext: attachmentExt(path).replace(/^\./, "").toUpperCase(),
};
}
const isDir = path.endsWith("/");
return {
path,
name,
kind: isDir ? "folder" : "file",
source: "workspace",
ext: isDir ? "" : attachmentExt(path).replace(/^\./, "").toUpperCase(),
};
}
+68 -13
View File
@@ -42,6 +42,8 @@ import type {
UpdateProgress,
WireEvent,
WorkspaceChangesView,
GitCommitView,
GitCommitDetailView,
WorkspaceView,
} from "./types";
@@ -86,7 +88,9 @@ export interface AppBindings {
Cancel(): Promise<void>;
CancelTab(tabID: string): Promise<void>;
Approve(id: string, allow: boolean, session: boolean, persist: boolean): Promise<void>;
ApproveWithScope(id: string, allow: boolean, session: boolean, persist: boolean, scope: string): Promise<void>;
ApproveTab(tabID: string, id: string, allow: boolean, session: boolean, persist: boolean): Promise<void>;
ApproveTabWithScope(tabID: string, id: string, allow: boolean, session: boolean, persist: boolean, scope: string): Promise<void>;
AnswerQuestion(id: string, answers: QuestionAnswer[]): Promise<void>;
AnswerQuestionForTab(tabID: string, id: string, answers: QuestionAnswer[]): Promise<void>;
SetPlanMode(on: boolean): Promise<void>;
@@ -142,6 +146,10 @@ export interface AppBindings {
SearchFileRefs(query: string): Promise<DirEntry[]>;
ReadFile(rel: string): Promise<FilePreview>;
WorkspaceChanges(): Promise<WorkspaceChangesView>;
GitBranches(): Promise<string[]>;
GitCheckout(branch: string): Promise<void>;
WorkspaceGitHistory(path: string): Promise<GitCommitView[]>;
WorkspaceGitCommitDetail(hash: string, path: string): Promise<GitCommitDetailView>;
OpenWorkspacePath(rel: string): Promise<void>;
RevealWorkspacePath(rel: string): Promise<void>;
RevealPath(path: string): Promise<void>;
@@ -183,7 +191,7 @@ export interface AppBindings {
SetDesktopLanguage(lang: string): Promise<void>;
SetDesktopAppearance(theme: string, style: string): Promise<void>;
MigrateDesktopPreferences(language: string, theme: string, style: string): Promise<void>;
SetAgentParams(temperature: number, maxSteps: number, systemPrompt: string): Promise<void>;
SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string): Promise<void>;
SetTrayLocale(locale: "en" | "zh"): Promise<void>;
// SetBypass toggles YOLO mode (auto-approve every tool call this session; deny
// rules still apply). Runtime-only — not written to config.
@@ -288,10 +296,32 @@ export function onUpdaterProgress(cb: (p: UpdateProgress) => void): () => void {
export function onFilesDropped(cb: (paths: string[]) => void): () => void {
const rt = typeof window !== "undefined" ? window.runtime : undefined;
if (!rt?.OnFileDrop) return () => {};
// Wails' internal ResolveFilePaths throws when a non-file object (e.g. the
// window icon) is dragged onto the webview. The error is uncaught and crashes
// the app. Intercept it here so only real file drops reach the callback.
const suppressNonFileDragError = (e: ErrorEvent) => {
if (e.message?.includes("additional File object is not a file on the disk")) {
e.preventDefault();
}
};
const suppressNonFileDragRejection = (e: PromiseRejectionEvent) => {
const msg = e.reason?.message ?? String(e.reason);
if (msg.includes("additional File object is not a file on the disk")) {
e.preventDefault();
}
};
window.addEventListener("error", suppressNonFileDragError);
window.addEventListener("unhandledrejection", suppressNonFileDragRejection);
rt.OnFileDrop((_x, _y, paths) => {
if (Array.isArray(paths) && paths.length > 0) cb(paths);
}, true);
return () => rt.OnFileDropOff?.();
return () => {
rt.OnFileDropOff?.();
window.removeEventListener("error", suppressNonFileDragError);
window.removeEventListener("unhandledrejection", suppressNonFileDragRejection);
};
}
// onReady subscribes to the agent:ready event fired when boot.Build completes.
@@ -555,7 +585,7 @@ function makeMockApp(): AppBindings {
{ name: "mimo-api", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
{ name: "mimo-token-plan", builtIn: true, added: false, kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
],
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["bash(rm *)"] },
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [] },
network: {
proxyMode: "auto",
@@ -563,7 +593,7 @@ function makeMockApp(): AppBindings {
noProxy: "",
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
},
agent: { temperature: 0.2, maxSteps: 0, systemPrompt: "You are Reasonix, a coding agent." },
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "You are Reasonix, a coding agent." },
desktopLanguage: "",
desktopTheme: "dark",
desktopThemeStyle: "graphite",
@@ -917,17 +947,24 @@ function makeMockApp(): AppBindings {
await this.Cancel();
},
async Approve(_id, allow, session, persist) {
await this.ApproveWithScope(_id, allow, session, persist, "");
},
async ApproveWithScope(_id, allow, session, persist, scope) {
if (!pendingApprovalPreview) return;
pendingApprovalPreview = false;
const suffix = persist ? "persisted" : session ? "allowed for session" : "allowed once";
emit({
kind: "message",
text: `approval preview answered: ${allow ? suffix : "denied"}`,
});
pendingApprovalPreview = false;
const scopeLabel = scope === "prefix" ? "prefix" : "scope";
const suffix = persist ? `${scopeLabel} grant saved` : session ? `${scopeLabel} grant active this session` : "allowed once";
emit({
kind: "message",
text: `approval preview answered: ${allow ? suffix : "denied"}`,
});
emit({ kind: "turn_done" });
},
async ApproveTab(_tabID, id, allow, session, persist) {
await this.Approve(id, allow, session, persist);
await this.ApproveTabWithScope(_tabID, id, allow, session, persist, "");
},
async ApproveTabWithScope(_tabID, id, allow, session, persist, scope) {
await this.ApproveWithScope(id, allow, session, persist, scope);
},
async AnswerQuestion(_id, answers) {
if (!pendingAskPreview) return;
@@ -1328,6 +1365,7 @@ function makeMockApp(): AppBindings {
async WorkspaceChanges() {
return {
gitAvailable: true,
gitBranch: "main",
files: [
{
path: "desktop/frontend/src/components/WorkspacePanel.tsx",
@@ -1342,6 +1380,23 @@ function makeMockApp(): AppBindings {
],
};
},
async GitBranches() {
return ["main", "dev", "feature/branch-switcher"];
},
async GitCheckout(_branch: string) {
console.info("mock GitCheckout", _branch);
},
async WorkspaceGitHistory(path: string) {
return [
{ hash: "abcdef123456", author: "Mock Author", date: new Date().toISOString(), message: "Mock commit message for " + path },
];
},
async WorkspaceGitCommitDetail(_hash: string, path: string) {
if (path) {
return { diff: "--- a/mock\n+++ b/mock\n@@ -1,1 +1,1 @@\n-mock\n+mock diff" };
}
return { files: ["mock_file_1.ts", "mock_file_2.ts"] };
},
async OpenWorkspacePath(rel: string) {
console.info("mock OpenWorkspacePath", rel);
},
@@ -1526,8 +1581,8 @@ function makeMockApp(): AppBindings {
settings.desktopThemeStyle = style;
}
},
async SetAgentParams(temperature: number, maxSteps: number, systemPrompt: string) {
settings.agent = { temperature, maxSteps, systemPrompt };
async SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string) {
settings.agent = { temperature, maxSteps, plannerMaxSteps, systemPrompt };
},
async SetTrayLocale(_locale: "en" | "zh") {},
async SetBypass(on: boolean) {
+31
View File
@@ -34,3 +34,34 @@ export function diffLines(a: string, b: string): DiffRow[] {
while (j < m) rows.push({ type: "add", text: y[j++] });
return rows;
}
// cleanGitDiff strips standard git diff headers (diff --git, index, ---, +++)
// and hunk headers (@@ -x,y +x,y @@) so the view focuses directly on the changed lines.
export function cleanGitDiff(diff: string): string {
const lines = diff.split("\n");
const cleaned: string[] = [];
let inHunk = false;
for (const line of lines) {
if (line.startsWith("@@ ")) {
inHunk = true;
// Skip the @@ line itself, optionally we could keep context if needed,
// but the user wants pure code changes.
continue;
}
if (inHunk) {
cleaned.push(line);
}
}
// If no hunks were found (unlikely for a valid diff), fallback to original logic
if (cleaned.length === 0) {
const match = diff.match(/^@@\s/m);
if (match && match.index !== undefined) {
return diff.slice(match.index).replace(/^@@.*$\n?/gm, "");
}
return diff;
}
return cleaned.join("\n");
}
+2
View File
@@ -6,6 +6,7 @@
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import css from "highlight.js/lib/languages/css";
import diff from "highlight.js/lib/languages/diff";
import go from "highlight.js/lib/languages/go";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
@@ -20,6 +21,7 @@ import { ALIASES } from "./lang";
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("css", css);
hljs.registerLanguage("diff", diff);
hljs.registerLanguage("go", go);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("json", json);
@@ -0,0 +1,21 @@
export function mergedFetchedProviderModels(current: string[], fetched: string[], options: { preserveCurated?: boolean } = {}): string[] {
const saved = uniqueStrings(current);
if (options.preserveCurated && saved.length > 0) return saved;
return uniqueStrings([...saved, ...fetched]);
}
export function providerDefaultModel(currentDefault: string, models: string[]): string {
return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? "";
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
const model = value.trim();
if (!model || seen.has(model)) continue;
seen.add(model);
out.push(model);
}
return out;
}
+49
View File
@@ -0,0 +1,49 @@
// Coalesces text/reasoning stream deltas into one flush per animation frame.
// Non-text events must drain() first so causal ordering is preserved.
type Flush<T> = (batch: T[]) => void;
interface BatchHandle<T> {
push: (item: T) => void;
drain: () => void;
size: () => number;
}
export function createRafBatch<T>(flush: Flush<T>): BatchHandle<T> {
let buffer: T[] = [];
let scheduled: number | null = null;
const run = () => {
scheduled = null;
// Snapshot + clear before flushing so a re-entrant push() lands next frame.
const out = buffer;
buffer = [];
if (out.length > 0) flush(out);
};
const handle: BatchHandle<T> = {
push(item: T) {
buffer.push(item);
if (scheduled === null && typeof requestAnimationFrame !== "undefined") {
scheduled = requestAnimationFrame(run);
} else if (scheduled === null) {
// No rAF (SSR / JSDOM) — fall back to a microtask.
scheduled = 1;
Promise.resolve().then(run);
}
},
drain() {
if (scheduled !== null) {
if (typeof cancelAnimationFrame !== "undefined" && scheduled !== 1) {
cancelAnimationFrame(scheduled);
}
scheduled = null;
}
run();
},
size() {
return buffer.length;
},
};
return handle;
}
+6
View File
@@ -10,6 +10,12 @@ export function isTextSize(value: unknown): value is TextSize {
return typeof value === "string" && (TEXT_SIZES as readonly string[]).includes(value);
}
export function nextTextSize(current: TextSize, delta: -1 | 1): TextSize {
const index = TEXT_SIZES.indexOf(current);
const nextIndex = Math.min(TEXT_SIZES.length - 1, Math.max(0, index + delta));
return TEXT_SIZES[nextIndex];
}
export function getTextSize(): TextSize {
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(TEXT_SIZE_KEY) : null;
return isTextSize(stored) ? stored : DEFAULT_TEXT_SIZE;
+57
View File
@@ -0,0 +1,57 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
export interface Toast {
id: number;
text: string;
level: "info" | "warn" | "error";
}
export interface ToastContextValue {
toasts: Toast[];
showToast: (text: string, level?: Toast["level"]) => void;
}
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
export function useToast() {
return useContext(ToastContext);
}
let nextId = 1;
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 id = nextId++;
setToasts((prev) => [...prev, { id, text, level }]);
const timer = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timers.current.delete(id);
}, 2500);
timers.current.set(id, timer);
}, []);
const dismissToast = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) clearTimeout(timer);
timers.current.delete(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, showToast }}>
{children}
<div className="toast-container" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast toast--${t.level}`} onClick={() => dismissToast(t.id)}>
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
{t.level === "error" && <span className="toast__icon">❌</span>}
<span className="toast__text">{t.text}</span>
</div>
))}
</div>
</ToastContext.Provider>
);
}
+24
View File
@@ -241,6 +241,16 @@ export interface SessionMeta {
topicTitle?: string;
}
// SessionReference is a session selected via @ past:chats for context injection.
export interface SessionReference {
path: string;
title: string;
preview?: string;
turns?: number;
createdAt?: number;
lastActivityAt?: number;
}
export interface WorkspaceView {
path: string;
name: string;
@@ -311,6 +321,19 @@ export interface WorkspaceChangesView {
files: WorkspaceChangeView[];
gitAvailable: boolean;
gitErr?: string;
gitBranch?: string;
}
export interface GitCommitView {
hash: string;
author: string;
date: string;
message: string;
}
export interface GitCommitDetailView {
diff?: string;
files?: string[];
}
export interface ComposerInsertRequest {
@@ -510,6 +533,7 @@ export interface NetworkView {
export interface AgentView {
temperature: number;
maxSteps: number;
plannerMaxSteps: number;
systemPrompt: string;
}
+25 -19
View File
@@ -6,6 +6,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { asArray } from "./array";
import { app, onEvent, onReady } from "./bridge";
import { createRafBatch } from "./rafBatch";
import { t } from "./i18n";
import type {
BalanceInfo,
@@ -561,10 +562,18 @@ export function useController() {
}, [dispatchTo, loadSessionDataForTab]);
useEffect(() => {
const textBatch = createRafBatch<{ tabId: string; e: WireEvent }>((batch) => {
for (const { tabId, e } of batch) dispatchTo(tabId, { type: "event", e });
});
const off = onEvent((e) => {
const targetTabId = e.tabId || activeTabIdRef.current;
if (!targetTabId) return;
dispatchTo(targetTabId, { type: "event", e });
if (e.kind === "text" || e.kind === "reasoning") {
textBatch.push({ tabId: targetTabId, e });
} else {
textBatch.drain();
dispatchTo(targetTabId, { type: "event", e });
}
if (e.kind === "turn_done") {
app
.ContextUsageForTab(targetTabId)
@@ -590,7 +599,7 @@ export function useController() {
void syncActiveTabFromBackend();
return () => { off(); offReady(); };
return () => { textBatch.drain(); off(); offReady(); };
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
const send = useCallback((displayText: string, submitText = displayText) => {
@@ -627,10 +636,10 @@ export function useController() {
return undefined;
}, [activeTabId, dispatchTo]);
const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean) => {
const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean, scope = "") => {
if (!activeTabId) return;
dispatchTo(activeTabId, { type: "clearApproval" });
app.ApproveTab(activeTabId, id, allow, session, persist).catch(() => {});
app.ApproveTabWithScope(activeTabId, id, allow, session, persist, scope).catch(() => {});
}, [activeTabId, dispatchTo]);
const answerQuestion = useCallback((id: string, answers: QuestionAnswer[]) => {
@@ -662,8 +671,9 @@ export function useController() {
const messages = asArray(
await (tabId ? app.ResumeSessionForTab(tabId, path) : app.ResumeSession(path)).catch(() => [] as HistoryMessage[]),
);
if (messages.length === 0) return;
dispatchTo(targetTabId, { type: "reset" });
if (messages.length) dispatchTo(targetTabId, { type: "history", messages });
dispatchTo(targetTabId, { type: "history", messages });
app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {});
void refreshCheckpoints(targetTabId);
}, [activeTabId, dispatchTo, refreshCheckpoints, waitForTabReady]);
@@ -773,22 +783,18 @@ export function useController() {
} catch { /* ignore */ }
}, [reconcileTabRuntime]);
const openProjectTab = useCallback(async (workspaceRoot: string, topicId: string): Promise<TabMeta | undefined> => {
try {
const meta = await app.OpenProjectTab(workspaceRoot, topicId);
setActiveTabId(meta.id);
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
return meta;
} catch { return undefined; }
const openProjectTab = useCallback(async (workspaceRoot: string, topicId: string): Promise<TabMeta> => {
const meta = await app.OpenProjectTab(workspaceRoot, topicId);
setActiveTabId(meta.id);
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
return meta;
}, [loadSessionDataForTab]);
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta | undefined> => {
try {
const meta = await app.OpenGlobalTab(topicId);
setActiveTabId(meta.id);
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
return meta;
} catch { return undefined; }
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
const meta = await app.OpenGlobalTab(topicId);
setActiveTabId(meta.id);
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
return meta;
}, [loadSessionDataForTab]);
const closeTab = useCallback(async (tabId: string) => {
+29 -5
View File
@@ -300,8 +300,16 @@ export const en = {
"approval.hideDetails": "Hide",
"approval.deny": "Deny",
"approval.allowOnce": "Allow once",
"approval.allowSession": "Allow for session",
"approval.allowPersistent": "Always allow (save)",
"approval.allowSession": "Allow tool for session",
"approval.allowPersistent": "Always allow tool (save)",
"approval.allowCommandSession": "Allow command for session",
"approval.allowCommandPersistent": "Always allow command (save)",
"approval.allowCommandPrefixSession": "Allow command prefix for session",
"approval.allowCommandPrefixPersistent": "Always allow command prefix (save)",
"approval.allowRuleSession": "Allow for session",
"approval.allowRulePersistent": "Always allow (save)",
"approval.allowEditsSession": "Allow edits for session",
"approval.allowFilePersistent": "Always allow this file (save)",
// ask card
"ask.title": "Need your decision",
@@ -356,6 +364,9 @@ export const en = {
"history.yesterday": "Yesterday",
"history.older": "Older",
"history.openSession": "Open session",
"history.failedOpenProject": "Cannot open session, \"{name}\" does not exist: {path}",
"history.failedOpenSession": "Cannot open session",
"history.failedOpenGlobal": "Cannot open global session",
"history.selectSession": "Select a session to preview.",
"history.trashSessionActions": "Trash session actions",
"history.historySessionActions": "History session actions",
@@ -457,7 +468,7 @@ export const en = {
"settings.tab.appearance": "Appearance",
"settings.tab.updates": "Updates",
"settings.tabSub.general": "Preferences & startup",
"settings.tabSub.models": "Defaults & subagents",
"settings.tabSub.models": "Defaults & runtime",
"settings.tabSub.providers": "Connections & auth",
"settings.tabSub.capabilities": "Servers, tools & skills",
"settings.tabSub.mcp": "Servers & tools",
@@ -509,6 +520,15 @@ export const en = {
"settings.autoPlan": "Automatic plan mode",
"settings.autoPlan.off": "Off",
"settings.autoPlan.on": "On",
"settings.agentRuntime": "Agent runtime",
"settings.agentRuntimeHint": "Saved as user defaults. A project's ./reasonix.toml can override them; use 0 for no limit.",
"settings.executorMaxSteps": "Executor step limit",
"settings.executorMaxStepsHint": "Caps the main agent's tool-call rounds per turn. Unlimited fits larger code tasks.",
"settings.plannerMaxSteps": "Planner step limit",
"settings.plannerMaxStepsHint": "Caps the dedicated planner's read-only file and code search rounds before execution.",
"settings.plannerMaxStepsDisabledHint": "Only applies when a dedicated planner model is selected on the Models page.",
"settings.stepLimit.unlimited": "Unlimited",
"settings.stepLimit.custom": "Custom",
"settings.keySet": "key set",
"settings.noKey": "no key",
"settings.cantDeleteDefault": "Can't delete the default provider",
@@ -605,7 +625,7 @@ export const en = {
"settings.fontFamilyPingFang": "PingFang",
"settings.fontFamilyNoto": "Noto Sans",
"settings.pageDesc.general": "Language, close behavior, and planning defaults.",
"settings.pageDesc.models": "Default model, planner model, and provider overview.",
"settings.pageDesc.models": "Default model, planner model, runtime limits, and provider overview.",
"settings.pageDesc.providers": "Manage model providers, API endpoints, keys, and available models.",
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
@@ -710,7 +730,6 @@ export const en = {
// todo bar
"todo.title": "To-dos",
"todo.dismiss": "Dismiss the task list",
"todo.stale": "Progress may be stale",
// slash menu tags
"slash.project": "project",
@@ -722,6 +741,11 @@ export const en = {
"msg.thinkingRunning": "thinking…",
"msg.thinkingDone": "done",
"msg.copy": "Copy",
"msg.attachments": "Attachments",
"msg.fileAttachment": "File",
"msg.imageAttachment": "Image",
"msg.workspaceReference": "Workspace reference",
"msg.folderReference": "Folder · Workspace reference",
"turnActions.summary": "Summary",
"turnActions.rewind": "Rewind",
"transcript.showEarlierHistory": "Show {n} earlier turns",
+29 -5
View File
@@ -301,8 +301,16 @@ export const zh: Record<DictKey, string> = {
"approval.hideDetails": "收起",
"approval.deny": "拒绝",
"approval.allowOnce": "允许一次",
"approval.allowSession": "本会话内允许",
"approval.allowPersistent": "总是允许(保存)",
"approval.allowSession": "本会话允许此工具",
"approval.allowPersistent": "总是允许此工具(保存)",
"approval.allowCommandSession": "本会话允许此命令",
"approval.allowCommandPersistent": "总是允许此命令(保存)",
"approval.allowCommandPrefixSession": "本会话允许命令前缀",
"approval.allowCommandPrefixPersistent": "总是允许命令前缀(保存)",
"approval.allowRuleSession": "本会话允许",
"approval.allowRulePersistent": "总是允许(保存)",
"approval.allowEditsSession": "本会话允许编辑",
"approval.allowFilePersistent": "总是允许此文件(保存)",
// 提问卡片
"ask.title": "需要你决定",
@@ -357,6 +365,9 @@ export const zh: Record<DictKey, string> = {
"history.yesterday": "昨天",
"history.older": "更早",
"history.openSession": "打开会话",
"history.failedOpenProject": "无法打开会话,{name}不存在:{path}",
"history.failedOpenSession": "无法打开会话",
"history.failedOpenGlobal": "无法打开全局会话",
"history.selectSession": "选择一个会话以预览。",
"history.trashSessionActions": "回收站会话操作",
"history.historySessionActions": "历史会话操作",
@@ -459,7 +470,7 @@ export const zh: Record<DictKey, string> = {
"settings.tab.appearance": "外观",
"settings.tab.updates": "更新",
"settings.tabSub.general": "偏好与启动",
"settings.tabSub.models": "默认与子代理",
"settings.tabSub.models": "默认与运行",
"settings.tabSub.providers": "连接与授权",
"settings.tabSub.capabilities": "服务器、工具与技能",
"settings.tabSub.mcp": "服务器与工具",
@@ -511,6 +522,15 @@ export const zh: Record<DictKey, string> = {
"settings.autoPlan": "自动计划模式",
"settings.autoPlan.off": "关闭",
"settings.autoPlan.on": "开启",
"settings.agentRuntime": "Agent 运行",
"settings.agentRuntimeHint": "保存为用户级默认值。项目的 ./reasonix.toml 可以覆盖;设为 0 表示不限。",
"settings.executorMaxSteps": "执行轮数上限",
"settings.executorMaxStepsHint": "限制主 agent 每轮回复最多调用多少轮工具。复杂代码任务适合不限。",
"settings.plannerMaxSteps": "规划轮数上限",
"settings.plannerMaxStepsHint": "限制独立规划器在执行前读取文件和搜索代码的只读轮数。",
"settings.plannerMaxStepsDisabledHint": "仅在“模型”页选择独立规划模型后生效。",
"settings.stepLimit.unlimited": "不限",
"settings.stepLimit.custom": "自定义",
"settings.keySet": "已设密钥",
"settings.noKey": "无密钥",
"settings.cantDeleteDefault": "无法删除默认模型服务",
@@ -607,7 +627,7 @@ export const zh: Record<DictKey, string> = {
"settings.fontFamilyPingFang": "苹方",
"settings.fontFamilyNoto": "思源黑体",
"settings.pageDesc.general": "语言、关闭行为和计划默认设置。",
"settings.pageDesc.models": "默认模型、规划模型与接入概览。",
"settings.pageDesc.models": "默认模型、规划模型、运行上限与接入概览。",
"settings.pageDesc.providers": "管理模型供应商、API 地址、密钥与可用模型。",
"settings.pageDesc.mcp": "管理 MCP 服务器连接和工具发现。",
"settings.pageDesc.skills": "浏览、启用和管理智能体技能与来源。",
@@ -712,7 +732,6 @@ export const zh: Record<DictKey, string> = {
// 待办栏
"todo.title": "待办",
"todo.dismiss": "关闭待办列表",
"todo.stale": "进度可能未同步",
// 斜杠菜单标签
"slash.project": "项目",
@@ -724,6 +743,11 @@ export const zh: Record<DictKey, string> = {
"msg.thinkingRunning": "思考中…",
"msg.thinkingDone": "已完成",
"msg.copy": "复制",
"msg.attachments": "附件",
"msg.fileAttachment": "文件",
"msg.imageAttachment": "图片",
"msg.workspaceReference": "工作区引用",
"msg.folderReference": "文件夹 · 工作区引用",
"turnActions.summary": "总结",
"turnActions.rewind": "回溯",
"transcript.showEarlierHistory": "展开前 {n} 轮对话",
+4 -1
View File
@@ -4,6 +4,7 @@ import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { installGlobalCrashHandlers } from "./lib/crash";
import { LocaleProvider } from "./lib/i18n";
import { ToastProvider } from "./lib/toast";
import { initFontFamily } from "./lib/fontFamily";
import { initTextSize } from "./lib/textSize";
import { initTheme } from "./lib/theme";
@@ -53,7 +54,9 @@ createRoot(root).render(
<StrictMode>
<ErrorBoundary>
<LocaleProvider>
<App />
<ToastProvider>
<App />
</ToastProvider>
</LocaleProvider>
</ErrorBoundary>
</StrictMode>,
+663 -34
View File
@@ -88,6 +88,7 @@
--z-modal: 1200;
--z-overlay-backdrop: 1300;
--z-popover: 1301;
--z-toast: 1302;
--z-tooltip: 1302;
--z-onboarding: 9999;
--z-crash-overlay: 99999;
@@ -644,8 +645,9 @@ a[href] {
position: absolute;
top: 0;
bottom: 0;
left: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: var(--border-soft);
transition: background 0.12s, box-shadow 0.12s;
}
@@ -744,7 +746,7 @@ a[href] {
font-weight: 600;
}
.topbar__model {
font-family: var(--mono);
font-family: inherit;
font-size: 11px;
color: var(--fg-dim);
overflow: hidden;
@@ -1454,6 +1456,70 @@ a[href] {
font-weight: 450;
line-height: 1.65;
}
.msg-attachments {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
min-width: min(280px, 100%);
}
.msg__text + .msg-attachments {
padding-top: 2px;
}
.msg-attachment {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 8px;
max-width: 420px;
min-height: 38px;
padding: 6px 8px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-soft) 78%, transparent);
}
.msg-attachment__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
color: var(--fg-dim);
background: var(--bg-elev);
}
.msg-attachment__icon--image {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 13%, var(--bg-elev));
}
.msg-attachment__icon--folder {
color: var(--warn);
background: color-mix(in srgb, var(--warn) 13%, var(--bg-elev));
}
.msg-attachment__main {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.msg-attachment__name,
.msg-attachment__meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-attachment__name {
color: var(--fg);
font-size: 13px;
font-weight: 560;
line-height: 1.25;
}
.msg-attachment__meta {
color: var(--fg-faint);
font-size: 11px;
line-height: 1.25;
}
.msg--assistant {
color: var(--fg);
display: flex;
@@ -2159,7 +2225,7 @@ a[href] {
opacity: 0.72;
transition: background 0.12s, box-shadow 0.12s, opacity 0.12s;
}
.composer-card:hover .composer-resize-handle::before,
.composer-resize-handle:hover::before,
.composer-resize-handle:focus-visible::before,
.composer-card--resizing .composer-resize-handle::before {
background: var(--accent);
@@ -2196,6 +2262,16 @@ a[href] {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
z-index: var(--z-inline-sticky);
}
.slashmenu__sizer {
position: relative;
width: 100%;
}
.slashmenu__row {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.slashmenu__item {
display: flex;
align-items: baseline;
@@ -2329,6 +2405,7 @@ a[href] {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-end;
width: 32px;
height: 32px;
border: none;
@@ -2364,7 +2441,7 @@ a[href] {
padding: 5px 14px 6px;
overflow: hidden;
color: var(--fg-dim);
font-family: var(--mono);
font-family: var(--sans);
font-size: 12px;
}
@@ -2741,8 +2818,9 @@ a[href] {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-family: var(--sans);
font-size: var(--font-status);
font-variant-numeric: tabular-nums;
line-height: 1.45;
color: var(--fg-faint);
/* One line, always: without this a narrow window squeezes the flex items and
@@ -2826,7 +2904,7 @@ a[href] {
border: none;
padding: 0;
font: inherit;
font-family: var(--mono);
font-family: inherit;
font-size: inherit;
color: var(--fg-dim);
}
@@ -2883,7 +2961,7 @@ a[href] {
border: none;
color: var(--fg-dim);
font: inherit;
font-family: var(--mono);
font-family: inherit;
font-size: inherit;
padding: 0;
white-space: nowrap;
@@ -2948,7 +3026,7 @@ a[href] {
border-radius: 6px;
color: var(--fg-dim);
font: inherit;
font-family: var(--mono);
font-family: inherit;
font-size: 12px;
text-align: left;
}
@@ -3291,6 +3369,15 @@ a[href] {
line-height: 1.4;
padding-block: 1px;
}
.prompt-action__label:has(.approval-rule-label) {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.prompt-action:has(.approval-rule-label) {
height: auto;
padding-block: 5px;
}
.prompt-detail-toggle {
flex: 0 0 auto;
max-width: 92px;
@@ -3348,6 +3435,21 @@ a[href] {
max-height: 150px;
overflow: auto;
}
.approval-rule-label {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.approval-rule-label code {
min-width: 0;
max-width: 24ch;
overflow-wrap: anywhere;
font-family: var(--mono);
font-size: 11px;
color: var(--fg);
}
/* ── ask tool: decision details ───────────────────────────────────────────── */
.ask-shelf__crumbs {
@@ -4012,6 +4114,42 @@ a[href] {
border-right: 0;
border-top: 0;
}
/* past:chats hover preview (PR-C2). Inherits colors from the .tooltip container
(--fg / --fg-dim / --fg-faint / --border / --bg-elev) and only adjusts layout. */
.past-chat-hover {
display: flex;
flex-direction: column;
gap: 3px;
max-width: 300px;
}
.past-chat-hover__title {
font-weight: 600;
color: var(--fg);
word-break: break-word;
}
.past-chat-hover__preview {
color: var(--fg-dim);
word-break: break-word;
max-height: 96px;
overflow: hidden;
/* fade the cut so long previews don't look amputated */
-webkit-mask-image: linear-gradient(to bottom, #000 70%, transparent 100%);
mask-image: linear-gradient(to bottom, #000 70%, transparent 100%);
}
.past-chat-hover__meta {
color: var(--fg-faint);
font-size: 11px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.past-chat-hover__path {
color: var(--fg-faint);
font-size: 11px;
word-break: break-all;
max-height: 32px;
overflow: hidden;
}
.workspace-empty {
padding: 18px 0;
color: var(--fg-faint);
@@ -4258,6 +4396,99 @@ a[href] {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 32%, var(--border-soft));
}
.workspace-git-history {
display: flex;
flex-direction: column;
gap: 12px;
}
.workspace-git-history__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.workspace-git-history__item {
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-soft);
overflow: hidden;
}
.workspace-git-history__head {
--wails-draggable: no-drag;
display: flex;
flex-direction: column;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: var(--fg);
font: inherit;
font-size: 13px;
text-align: left;
}
.workspace-git-history__head-top {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.workspace-git-history__head-bottom {
display: flex;
align-items: center;
gap: 8px;
padding-left: 22px;
margin-top: 4px;
}
.workspace-git-history__head:hover {
background: var(--sidebar-hover);
}
.workspace-git-history__message {
flex: 1;
font-weight: 560;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-git-history__author {
color: var(--fg-dim);
font-size: 12px;
}
.workspace-git-history__date {
color: var(--fg-faint);
font-size: 11.5px;
}
.workspace-git-history__hash {
font-family: var(--mono);
margin-left: 6px;
opacity: 0.8;
}
.workspace-git-history__detail {
padding: 12px;
border-top: 1px solid var(--border-soft);
background: var(--bg);
}
.workspace-git-history__files {
display: flex;
flex-direction: column;
gap: 6px;
}
.workspace-git-history__file {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-dim);
background: transparent;
border: none;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
text-align: left;
}
.workspace-git-history__file:hover {
background: var(--sidebar-hover);
color: var(--fg);
}
.workspace-tree__chev {
width: 13px;
height: 13px;
@@ -5701,10 +5932,27 @@ a[href] {
overflow: visible;
}
.cap-skill-card__more {
display: inline-flex;
align-items: center;
margin: 7px 0 0 33px;
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 650;
line-height: 1.3;
text-align: left;
}
.cap-skill-card__more:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 52%, transparent);
outline-offset: 3px;
border-radius: 5px;
}
.cap-skill-card__more:hover {
color: var(--accent-strong);
}
.cap-dot {
flex-shrink: 0;
@@ -5910,7 +6158,7 @@ a[href] {
.history-content {
min-height: 0;
display: grid;
grid-template-columns: minmax(340px, 0.38fr) minmax(0, 0.62fr);
grid-template-columns: minmax(0, 0.38fr) minmax(0, 0.62fr);
overflow: hidden;
}
.history-list {
@@ -6137,7 +6385,10 @@ a[href] {
max-width: 100%;
}
.history-content {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
.history-list {
max-height: 40vh;
}
.history-preview {
min-height: 260px;
@@ -6255,7 +6506,7 @@ a[href] {
.settings-center__navitem small {
color: var(--fg-dim);
font-size: var(--text-2xs);
font-family: var(--mono);
font-family: inherit;
}
.settings-center__content {
@@ -6423,6 +6674,27 @@ a[href] {
min-width: auto;
}
.step-limit-control {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
max-width: 640px;
min-width: 0;
}
.step-limit-control .set-seg {
min-width: 0;
overflow-x: auto;
}
.step-limit-control__custom {
width: 82px;
flex: 0 0 82px;
text-align: center;
}
.settings-page .settings-model-current {
margin-top: 14px;
}
@@ -6619,6 +6891,14 @@ a[href] {
flex-wrap: wrap;
}
.step-limit-control {
justify-content: flex-start;
}
.step-limit-control .set-seg {
flex-shrink: 0;
}
.settings-subtabs {
display: flex;
width: 100%;
@@ -6671,7 +6951,7 @@ a[href] {
font-size: var(--text-base);
font-weight: 700;
color: var(--fg);
font-family: var(--mono);
font-family: inherit;
}
.settings-model-current__meta {
display: flex;
@@ -6687,7 +6967,7 @@ a[href] {
border-radius: 999px;
background: var(--bg-elev-2);
color: var(--fg-muted);
font-family: var(--mono);
font-family: inherit;
font-size: var(--text-2xs);
line-height: 1;
padding: 5px 8px;
@@ -6743,7 +7023,7 @@ a[href] {
}
.settings-model-picker__selected span {
color: var(--fg);
font-family: var(--mono);
font-family: inherit;
font-size: var(--font-control);
font-weight: 650;
}
@@ -6869,7 +7149,6 @@ a[href] {
}
.settings-model-picker__option strong {
color: var(--fg);
font-family: var(--mono);
font-size: var(--font-control-small);
font-weight: 700;
}
@@ -7485,6 +7764,7 @@ a[href] {
font-size: var(--font-control-small);
padding: 5px 13px;
text-transform: capitalize;
white-space: nowrap;
}
.set-seg__btn--on {
background: var(--accent-soft);
@@ -7670,33 +7950,19 @@ a[href] {
background: var(--bg-elev);
}
.todobar__title {
font-family: var(--mono);
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--fg-dim);
flex-shrink: 0;
}
.todobar__count {
font-family: var(--mono);
font-family: inherit;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--fg-faint);
flex-shrink: 0;
}
.todobar__stale {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 160px;
overflow: hidden;
color: var(--fg-faint);
font-size: 11px;
white-space: nowrap;
}
.todobar__stale > svg {
flex: 0 0 auto;
opacity: 0.72;
}
.todobar__current {
margin-left: 4px;
font-size: 12px;
@@ -7863,7 +8129,7 @@ a[href] {
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev));
color: var(--accent);
font-family: var(--mono);
font-family: var(--sans);
font-size: 12px;
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
}
@@ -7990,7 +8256,34 @@ a[href] {
color: var(--fg);
background: var(--hover);
}
.composer-context__item--session {
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.composer-context__item--session .composer-context__label > svg {
color: var(--accent);
}
.slashmenu__item--special {
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--accent) 5%, var(--panel));
}
.slashmenu__item--special:hover {
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.slashmenu__item--back {
border-top: 1px solid var(--border);
color: var(--fg-dim);
}
.slashmenu__item--back:hover {
background: var(--hover);
}
.slashmenu__item--empty {
color: var(--fg-faint);
cursor: default;
}
.slashmenu__item--empty:hover {
background: transparent;
}
.composer__pasted {
display: flex;
flex-direction: column;
@@ -10323,6 +10616,277 @@ a[href] {
animation: none !important;
}
/* ── Branch indicator (changed-files view) ──────────────────────────── */
.workspace-branch-indicator {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
color: var(--fg-muted);
font-size: 12px;
}
.workspace-branch-indicator > svg {
flex-shrink: 0;
opacity: 0.6;
}
.workspace-branch-indicator > .workspace-branch-name {
display: flex;
align-items: center;
gap: 3px;
border: none;
background: transparent;
color: var(--fg);
cursor: pointer;
font-family: inherit;
font-size: 12px;
padding: 1px 4px;
border-radius: 3px;
}
.workspace-branch-indicator > .workspace-branch-name:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
}
.workspace-branch-indicator > .workspace-branch-name span {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-branch-refresh {
margin-left: auto;
border: none;
background: transparent;
color: var(--fg-muted);
cursor: pointer;
padding: 2px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
}
.workspace-branch-refresh:hover {
color: var(--fg);
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
}
.workspace-branch-refresh:disabled {
opacity: 0.4;
cursor: default;
}
.workspace-branch-refresh .spinning {
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.workspace-branch-menu {
min-width: 140px;
max-width: 260px;
max-height: 300px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.workspace-branch-menu__inner {
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--bg-elev);
}
.workspace-branch-menu__loading {
padding: 10px 12px;
color: var(--fg-muted);
font-size: 12px;
text-align: center;
}
.workspace-branch-menu__item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
color: var(--fg);
cursor: pointer;
font-family: inherit;
font-size: 12px;
text-align: left;
}
.workspace-branch-menu__item:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
}
.workspace-branch-menu__item--active {
font-weight: 600;
}
.workspace-branch-menu__item svg {
flex-shrink: 0;
opacity: 0.7;
}
.workspace-branch-menu__item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-branch-menu__item:disabled {
opacity: 0.4;
cursor: default;
}
/* ── Command palette (⌘K) ─────────────────────────────────────────── */
.drawer-backdrop:has(.palette) {
justify-content: center;
align-items: flex-start;
padding-top: 12vh;
}
.palette {
position: relative;
width: min(520px, 90vw);
max-height: 60vh;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.36);
display: flex;
flex-direction: column;
overflow: hidden;
animation: palette-in 0.1s ease;
}
@keyframes palette-in {
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.palette__inputrow {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.palette__input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--fg);
font-size: 14px;
line-height: 1.4;
}
.palette__input::placeholder {
color: var(--fg-muted);
}
.palette__esc {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
color: var(--fg-muted);
background: var(--bg-base);
font-family: inherit;
line-height: 1.3;
}
.palette__list {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.palette__empty {
padding: 24px 14px;
color: var(--fg-muted);
text-align: center;
font-size: 13px;
}
.palette__group-title {
padding: 8px 14px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg-muted);
}
.palette__item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 14px;
border: none;
background: transparent;
color: var(--fg);
cursor: pointer;
text-align: left;
font-family: inherit;
font-size: 13px;
line-height: 1.3;
}
.palette__item--on {
background: var(--accent-bg, rgba(77, 151, 255, 0.12));
}
.palette__item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.palette__title {
font-weight: 500;
}
.palette__hint {
font-size: 11px;
color: var(--fg-muted);
}
.palette__foot {
display: flex;
gap: 12px;
padding: 7px 14px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--fg-muted);
}
.palette__foot kbd {
padding: 1px 4px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--bg-base);
font-family: inherit;
font-size: 10px;
line-height: 1.3;
margin-right: 2px;
}
/* Respect the OS reduced-motion preference: squash all animations and force
instant scrolling so the UI feels immediate rather than web-like. */
@media (prefers-reduced-motion: reduce) {
@@ -10335,3 +10899,68 @@ a[href] {
scroll-behavior: auto !important;
}
}
/* ── Toast ──────────────────────────────────────────────────────────── */
.toast-container {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
.toast {
pointer-events: auto;
cursor: pointer;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 20px;
font-size: 14px;
color: var(--fg);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
animation: toast-in 0.2s ease-out;
user-select: none;
}
.toast--warn {
border-color: #eab308;
background: #fefce8;
color: #713f12;
}
.toast--error {
border-color: #ef4444;
background: #fef2f2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.toast--warn {
background: #422006;
border-color: #a16207;
color: #fde68a;
}
.toast--error {
background: #450a0a;
border-color: #dc2626;
color: #fca5a5;
}
}
.toast__icon {
flex: 0 0 auto;
line-height: 1;
font-size: 14px;
}
.toast__text {
line-height: 1.4;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
+5
View File
@@ -36,6 +36,11 @@ var assets embed.FS
// prompts to update.
var version = "dev"
// channel selects which updater pointer this build polls, injected via
// `-X main.channel=canary`. Default "stable" tracks the public release; "canary"
// tracks the opt-in pre-release line and never crosses over to stable.
var channel = "stable"
func main() {
app := NewApp()
+64 -19
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@@ -72,9 +73,10 @@ type NetworkView struct {
}
type AgentView struct {
Temperature float64 `json:"temperature"`
MaxSteps int `json:"maxSteps"`
SystemPrompt string `json:"systemPrompt"`
Temperature float64 `json:"temperature"`
MaxSteps int `json:"maxSteps"`
PlannerMaxSteps int `json:"plannerMaxSteps"`
SystemPrompt string `json:"systemPrompt"`
}
// SettingsView is the whole Settings panel payload.
@@ -132,15 +134,35 @@ func desktopModelRefsProvider(c *config.Config, ref, name string) bool {
return false
}
func builtInProviderNames() map[string]bool {
out := map[string]bool{}
for _, p := range config.Default().Providers {
out[p.Name] = true
func officialProviderHost(baseURL string) string {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
for _, name := range []string{"deepseek", "deepseek-flash", "mimo-api", "mimo-token-plan", "mimo-pro"} {
out[name] = true
return strings.ToLower(u.Hostname())
}
func officialProviderKindFromEntry(p config.ProviderEntry) string {
host := officialProviderHost(p.BaseURL)
switch config.CanonicalDesktopOfficialProviderName(p.Name) {
case "deepseek":
if host == "api.deepseek.com" {
return "deepseek"
}
case "mimo-api":
if host == "api.xiaomimimo.com" {
return "mimo-api"
}
case "mimo-token-plan":
if host == "token-plan-cn.xiaomimimo.com" {
return "mimo-token-plan"
}
}
return out
return ""
}
func isOfficialBuiltInProvider(p config.ProviderEntry) bool {
return officialProviderKindFromEntry(p) != ""
}
func providerAccessSet(names []string) map[string]bool {
@@ -183,7 +205,7 @@ func removeProviderAccess(c *config.Config, names ...string) {
func providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) ProviderView {
return ProviderView{
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL,
Models: nonNil(p.ModelList()), ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
Models: nonNil(p.ChatModelList()), ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
APIKeyEnv: p.APIKeyEnv,
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
BalanceURL: p.BalanceURL,
@@ -208,6 +230,24 @@ func officialProviderViews(added map[string]bool) []ProviderView {
return out
}
func officialProviderAddedSet(cfg *config.Config) map[string]bool {
out := map[string]bool{}
if cfg == nil {
return out
}
access := providerAccessSet(cfg.Desktop.ProviderAccess)
for i := range cfg.Providers {
p := cfg.Providers[i]
if !access[p.Name] {
continue
}
if kind := officialProviderKindFromEntry(p); kind != "" {
out[kind] = true
}
}
return out
}
// Settings returns the current configuration for the Settings panel.
func (a *App) Settings() SettingsView {
cfg, cfgPath, err := a.loadDesktopUserConfigForEdit()
@@ -223,6 +263,7 @@ func (a *App) Settings() SettingsView {
Deny: []string{},
},
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
Agent: AgentView{PlannerMaxSteps: 12},
AutoPlan: "off",
DesktopTheme: "dark",
DesktopThemeStyle: "graphite",
@@ -264,7 +305,7 @@ func (a *App) Settings() SettingsView {
Password: cfg.Network.Proxy.Password,
},
},
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, PlannerMaxSteps: cfg.Agent.PlannerMaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
DesktopLanguage: cfg.DesktopLanguage(),
DesktopTheme: cfg.DesktopTheme(),
DesktopThemeStyle: cfg.DesktopThemeStyle(),
@@ -273,12 +314,11 @@ func (a *App) Settings() SettingsView {
ProviderKinds: nonNil(provider.Kinds()),
Bypass: ctrl != nil && ctrl.Bypass(),
}
builtIns := builtInProviderNames()
added := providerAccessSet(cfg.Desktop.ProviderAccess)
v.OfficialProviders = officialProviderViews(added)
v.OfficialProviders = officialProviderViews(officialProviderAddedSet(cfg))
for i := range cfg.Providers {
p := &cfg.Providers[i]
v.Providers = append(v.Providers, providerViewFromEntry(*p, builtIns[p.Name], added[p.Name]))
v.Providers = append(v.Providers, providerViewFromEntry(*p, isOfficialBuiltInProvider(*p), added[p.Name]))
}
return v
}
@@ -740,7 +780,11 @@ func (a *App) RemoveProviderAccess(name string) error {
if name == "" {
return fmt.Errorf("remove provider access: empty provider name")
}
if builtInProviderNames()[name] {
cfg, _, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
if p, ok := cfg.Provider(name); ok && isOfficialBuiltInProvider(*p) {
return a.removeBuiltInProviderAccessAndRetargetTabs(name)
}
return a.deleteProviderAndRetargetTabs(name)
@@ -1060,12 +1104,13 @@ func (a *App) MigrateDesktopPreferences(language, theme, style string) error {
})
}
// SetAgentParams updates sampling temperature, the optional max-steps guard, and
// the base system prompt.
func (a *App) SetAgentParams(temperature float64, maxSteps int, systemPrompt string) error {
// SetAgentParams updates sampling temperature, optional step guards, and the
// base system prompt.
func (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps int, systemPrompt string) error {
return a.applyConfigChange(func(c *config.Config) error {
c.Agent.Temperature = temperature
c.Agent.MaxSteps = maxSteps
c.Agent.PlannerMaxSteps = plannerMaxSteps
c.Agent.SystemPrompt = systemPrompt
return nil
})
+43
View File
@@ -1,8 +1,10 @@
package main
import (
"reflect"
"testing"
"reasonix/internal/config"
"reasonix/internal/provider"
)
@@ -38,3 +40,44 @@ func TestWithFreshSystemPromptPrependsMissingSystemMessage(t *testing.T) {
t.Fatalf("existing user message changed: %+v", got[1])
}
}
func TestProviderViewFromEntry_FiltersNonChatModels(t *testing.T) {
p := config.ProviderEntry{
Name: "mimo-api",
Models: []string{
"mimo-v2", "mimo-v2-pro",
"mimo-v2-asr", "mimo-v2-tts",
"mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
},
}
view := providerViewFromEntry(p, true, false)
want := []string{"mimo-v2", "mimo-v2-pro"}
if !reflect.DeepEqual(view.Models, want) {
t.Errorf("ProviderView.Models = %v, want %v", view.Models, want)
}
}
func TestSetAgentParamsPersistsStepLimitsToUserConfig(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SetAgentParams(0.35, 37, 9, "custom system"); err != nil {
t.Fatalf("SetAgentParams: %v", err)
}
view := app.Settings()
if view.Agent.MaxSteps != 37 || view.Agent.PlannerMaxSteps != 9 {
t.Fatalf("Settings().Agent = %+v, want maxSteps=37 plannerMaxSteps=9", view.Agent)
}
if view.Agent.Temperature != 0.35 || view.Agent.SystemPrompt != "custom system" {
t.Fatalf("Settings().Agent did not preserve other agent params: %+v", view.Agent)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if cfg.Agent.MaxSteps != 37 || cfg.Agent.PlannerMaxSteps != 9 {
t.Fatalf("saved config agent steps = max:%d planner:%d, want 37/9", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
}
if cfg.Agent.Temperature != 0.35 || cfg.Agent.SystemPrompt != "custom system" {
t.Fatalf("saved config did not preserve other agent params: %+v", cfg.Agent)
}
}
+6 -3
View File
@@ -165,7 +165,10 @@ func TestSaveTabsPersistsModelAndEffort(t *testing.T) {
}
}
func TestSaveTabsDoesNotPersistYoloMode(t *testing.T) {
// TestSaveTabsPersistsYoloMode is the regression for #3517: yolo used to be
// dropped on save, so relaunching reverted to normal. It now round-trips through
// the real saveTabsLocked/loadTabsFile path.
func TestSaveTabsPersistsYoloMode(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
@@ -184,8 +187,8 @@ func TestSaveTabsDoesNotPersistYoloMode(t *testing.T) {
if len(got.Tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
}
if got.Tabs[0].Mode != "" {
t.Fatalf("saved yolo mode = %q, want empty", got.Tabs[0].Mode)
if got.Tabs[0].Mode != "yolo" {
t.Fatalf("saved yolo mode = %q, want yolo (#3517)", got.Tabs[0].Mode)
}
}
+9 -2
View File
@@ -300,6 +300,8 @@ func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
if abs, err := filepath.Abs(workspaceRoot); err == nil {
workspaceRoot = abs
}
saveWorkspace(workspaceRoot)
_ = addProject(workspaceRoot, "")
a.mu.Lock()
// If already open, just activate.
@@ -333,6 +335,7 @@ func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
a.mu.Unlock()
a.startTabControllerBuild(tab)
a.emitProjectTreeChanged()
return a.tabMeta(tab, true), nil
}
@@ -2299,9 +2302,13 @@ func currentTabMode(tab *WorkspaceTab) string {
return normalizeTabMode(tab.mode)
}
// persistedTabMode is the composer mode saved with a tab so it survives reload
// and app relaunch. plan and yolo are both remembered (a restored yolo tab keeps
// its status-bar indicator); "normal" is the default and isn't persisted. (#3517)
func persistedTabMode(mode string) string {
if normalizeTabMode(mode) == "plan" {
return "plan"
switch normalizeTabMode(mode) {
case "plan", "yolo":
return normalizeTabMode(mode)
}
return ""
}
+1 -5
View File
@@ -92,11 +92,7 @@ func (a *App) trayLocale() string {
}
func (a *App) showFromTray() {
ctx := a.ctx
if ctx == nil {
return
}
showFromBackground(ctx)
a.showMainWindow()
}
func (a *App) quitFromTray() {
+30 -7
View File
@@ -32,14 +32,37 @@ import (
// 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. Mirrors the v1 desktop's two-endpoint scheme.
// 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.
const (
manifestPrimary = "https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/latest.json"
manifestFallback = "https://github.com/esengine/reasonix/releases/latest/download/latest.json"
defaultDownloadPage = "https://github.com/esengine/reasonix/releases/latest"
httpTimeout = 15 * time.Second
r2Base = "https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev"
ghReleasesBase = "https://github.com/esengine/reasonix/releases"
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 + "/latest/latest.json",
ghReleasesBase + "/latest/download/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"
}
// UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
type UpdateInfo struct {
Available bool `json:"available"`
@@ -95,7 +118,7 @@ func normalizeVersion(v string) (string, bool) {
// and decodes it.
func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error) {
var lastErr error
for _, url := range []string{manifestPrimary, manifestFallback} {
for _, url := range manifestEndpoints() {
b, err := fetchBytes(ctx, c, url)
if err != nil {
lastErr = err
@@ -116,7 +139,7 @@ func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error
func evaluate(current string, m *update.Manifest) UpdateInfo {
page := m.DownloadPage
if page == "" {
page = defaultDownloadPage
page = downloadPage()
}
info := UpdateInfo{
Current: current,
+3 -3
View File
@@ -30,7 +30,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
return &UpdateInfo{
Current: version,
CanSelfUpdate: canSelfUpdate(),
DownloadURL: defaultDownloadPage,
DownloadURL: downloadPage(),
Err: err.Error(),
}, nil
}
@@ -41,7 +41,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
return &UpdateInfo{
Current: version,
CanSelfUpdate: canSelfUpdate(),
DownloadURL: defaultDownloadPage,
DownloadURL: downloadPage(),
Err: err.Error(),
}, nil
}
@@ -52,7 +52,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
// OpenDownloadPage opens the releases page in the browser — the macOS manual-update
// path and a fallback link elsewhere.
func (a *App) OpenDownloadPage() {
page := defaultDownloadPage
page := downloadPage()
if c, err := httpClient(); err == nil {
ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
defer cancel()
+31
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"compress/gzip"
"runtime"
"strings"
"testing"
"reasonix/desktop/internal/update"
@@ -69,6 +70,36 @@ func TestEvaluate(t *testing.T) {
}
}
func TestChannelSelectsDistinctPointers(t *testing.T) {
orig := channel
t.Cleanup(func() { channel = orig })
channel = "stable"
stable := manifestEndpoints()
channel = "canary"
canary := manifestEndpoints()
for _, u := range stable {
if strings.Contains(u, "canary") {
t.Errorf("stable endpoint leaks into canary: %q", u)
}
}
if !strings.Contains(stable[0], "/latest/latest.json") {
t.Errorf("stable primary = %q, want the latest/ pointer", stable[0])
}
for _, u := range canary {
if strings.Contains(u, "/latest/") {
t.Errorf("canary endpoint hits the stable latest/ pointer: %q", u)
}
}
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")
}
}
func TestCheckSHA256(t *testing.T) {
data := []byte("hello world")
// echo -n "hello world" | shasum -a 256
+10
View File
@@ -46,6 +46,16 @@ func saveWorkspace(dir string) {
_ = os.WriteFile(p, []byte(dir), 0o644)
}
// clearWorkspace removes the active workspace pointer file so that no stale
// reference remains after the current workspace is deleted.
func clearWorkspace() {
p := workspaceStatePath()
if p == "" {
return
}
_ = os.Remove(p)
}
// loadWorkspace returns the remembered working folder, or "" if none.
func loadWorkspace() string {
p := workspaceStatePath()
+145
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"sort"
@@ -31,6 +32,8 @@ func (a *App) WorkspaceChanges() WorkspaceChangesView {
return out
}
out.GitBranch = workspaceGitBranch(base)
changes := map[string]*workspaceChangeAccumulator{}
add := func(path string) *workspaceChangeAccumulator {
path = normalizeWorkspaceRelPath(base, path)
@@ -177,3 +180,145 @@ func workspaceRelPathFromGitStatus(repoRoot, base, path string) string {
}
return normalizeWorkspaceRelPath(base, path)
}
// workspaceGitBranch returns the current git branch name for the repo rooted
// at base, or an empty string when base is not inside a git repository or when
// git is unavailable.
func workspaceGitBranch(base string) string {
cmd := exec.Command("git", "-C", base, "branch", "--show-current")
proc.HideWindowDetached(cmd)
raw, err := cmd.Output()
if err != nil {
return ""
}
if branch := strings.TrimSpace(string(raw)); branch != "" {
return branch
}
headCmd := exec.Command("git", "-C", base, "rev-parse", "--short", "HEAD")
proc.HideWindowDetached(headCmd)
raw, err = headCmd.Output()
if err != nil {
return ""
}
short := strings.TrimSpace(string(raw))
if short == "" {
return ""
}
return "@" + short
}
// GitBranches returns all local git branches for the active workspace's repo.
func (a *App) GitBranches() ([]string, error) {
base, err := a.activeWorkspaceBase()
if err != nil {
return nil, err
}
cmd := exec.Command("git", "-C", base, "branch", "--format=%(refname:short)")
proc.HideWindowDetached(cmd)
raw, err := cmd.Output()
if err != nil {
return nil, err
}
branches := strings.FieldsFunc(strings.TrimSpace(string(raw)), func(r rune) bool { return r == '\n' })
return branches, nil
}
// GitCheckout switches the active workspace's git branch and returns the
// current branch name, or an error when git is unavailable.
func (a *App) GitCheckout(branch string) error {
base, err := a.activeWorkspaceBase()
if err != nil {
return err
}
cmd := exec.Command("git", "-C", base, "checkout", branch)
proc.HideWindowDetached(cmd)
out, err := cmd.CombinedOutput()
if err != nil {
if len(out) > 0 {
return fmt.Errorf("git checkout: %s", strings.TrimSpace(string(out)))
}
return err
}
return nil
}
type GitCommitView struct {
Hash string `json:"hash"`
Author string `json:"author"`
Date string `json:"date"`
Message string `json:"message"`
}
type GitCommitDetailView struct {
Diff *string `json:"diff,omitempty"`
Files []string `json:"files,omitempty"`
}
func (a *App) WorkspaceGitHistory(path string) ([]GitCommitView, error) {
base, err := a.activeWorkspaceBase()
if err != nil {
return nil, err
}
args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
if path != "" {
args = append(args, "--", path)
}
cmd := exec.Command("git", args...)
proc.HideWindowDetached(cmd)
raw, err := cmd.Output()
if err != nil {
return nil, err
}
parts := bytes.Split(raw, []byte{0})
var out []GitCommitView
// 4 parts per commit: hash, author, date, message
for i := 0; i+3 < len(parts); i += 4 {
out = append(out, GitCommitView{
Hash: string(parts[i]),
Author: string(parts[i+1]),
Date: string(parts[i+2]),
Message: string(parts[i+3]),
})
}
return out, nil
}
func (a *App) WorkspaceGitCommitDetail(hash string, path string) (GitCommitDetailView, error) {
base, err := a.activeWorkspaceBase()
if err != nil {
return GitCommitDetailView{}, err
}
if path != "" {
// Single file diff
cmd := exec.Command("git", "-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
proc.HideWindowDetached(cmd)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
}
diffStr := strings.TrimSpace(string(raw))
return GitCommitDetailView{Diff: &diffStr}, nil
}
// Project level: list of files changed
cmd := exec.Command("git", "-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-r", hash)
proc.HideWindowDetached(cmd)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
var files []string
for _, line := range lines {
if line != "" {
files = append(files, line)
}
}
return GitCommitDetailView{Files: files}, nil
}
+249
View File
@@ -551,6 +551,97 @@ func TestReadFileGB18030(t *testing.T) {
}
}
// --- RemoveWorkspace cleanup of active pointer ---
func TestRemoveWorkspaceClearsActivePointerWhenRemovingCurrentWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
if workspaceStatePath() == "" {
t.Fatal("workspaceStatePath() is empty after isolating")
}
dir := t.TempDir()
saveWorkspace(dir)
if got := loadWorkspace(); got != dir {
t.Fatalf("precondition: loadWorkspace = %q, want %q", got, dir)
}
// Simulate RemoveWorkspace's cleanup logic:
// When the removed workspace equals the active one, clearWorkspace should fire.
if loadWorkspace() == dir {
clearWorkspace()
}
if got := loadWorkspace(); got != "" {
t.Errorf("loadWorkspace = %q after clearWorkspace, want empty", got)
}
}
func TestRemoveWorkspaceFallsBackToRemainingProject(t *testing.T) {
isolateDesktopUserDirs(t)
// Set up two projects and make the first one active.
first := t.TempDir()
second := t.TempDir()
saveWorkspace(first)
// Simulate: remove the active workspace, fall back to the other.
if loadWorkspace() == first {
// In the real code, loadProjectsFile() would return remaining projects.
// Here we simulate falling back to the second project.
saveWorkspace(second)
}
if got := loadWorkspace(); got != second {
t.Errorf("loadWorkspace = %q, want fallback to %q", got, second)
}
}
func TestClearWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
if workspaceStatePath() == "" {
t.Fatal("workspaceStatePath() is empty after isolating")
}
dir := t.TempDir()
saveWorkspace(dir)
if got := loadWorkspace(); got != dir {
t.Fatalf("precondition failed: loadWorkspace = %q, want %q", got, dir)
}
clearWorkspace()
if got := loadWorkspace(); got != "" {
t.Errorf("loadWorkspace after clearWorkspace = %q, want empty", got)
}
// Also verify the file is actually removed.
if _, err := os.Stat(workspaceStatePath()); !os.IsNotExist(err) {
t.Errorf("desktop-workspace file should be removed, stat err = %v", err)
}
}
// --- OpenProjectTab updates active workspace pointer ---
func TestOpenProjectTabUpdatesActiveWorkspacePointer(t *testing.T) {
isolateDesktopUserDirs(t)
if workspaceStatePath() == "" {
t.Fatal("workspaceStatePath() is empty after isolating")
}
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if _, err := app.OpenProjectTab(projectRoot, topic.ID); err != nil {
t.Fatalf("open project tab: %v", err)
}
if got := loadWorkspace(); got != projectRoot {
t.Errorf("loadWorkspace = %q after OpenProjectTab, want %q", got, projectRoot)
}
}
func TestWindowsOpenWorkspacePathAvoidsCmdShell(t *testing.T) {
src, err := os.ReadFile("open_workspace_windows.go")
if err != nil {
@@ -612,6 +703,7 @@ func TestWorkspaceChangesGitStatus(t *testing.T) {
t.Fatal(err)
}
runGit(t, "init")
runGit(t, "checkout", "-b", "feature/test")
if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
t.Fatal(err)
}
@@ -627,6 +719,9 @@ func TestWorkspaceChangesGitStatus(t *testing.T) {
if !got.GitAvailable {
t.Fatalf("git unavailable: %s", got.GitErr)
}
if got.GitBranch != "feature/test" {
t.Fatalf("git branch = %q, want feature/test", got.GitBranch)
}
byPath := map[string]WorkspaceChangeView{}
for _, file := range got.Files {
byPath[file.Path] = file
@@ -725,6 +820,150 @@ func TestWorkspaceChangesUntrackedDirectoryListsFiles(t *testing.T) {
}
}
func TestWorkspaceChangesGitBranchDetachedHead(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
orig, _ := os.Getwd()
defer os.Chdir(orig)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
runGit(t, "init")
runGit(t, "config", "user.email", "[email protected]")
runGit(t, "config", "user.name", "Test User")
if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, "add", "tracked.txt")
runGit(t, "commit", "-m", "init")
short := gitOutput(t, "rev-parse", "--short", "HEAD")
runGit(t, "checkout", "--detach", "HEAD")
got := (&App{}).WorkspaceChanges()
if !got.GitAvailable {
t.Fatalf("git unavailable: %s", got.GitErr)
}
if got.GitBranch != "@"+short {
t.Fatalf("git branch = %q, want @%s", got.GitBranch, short)
}
}
func TestWorkspaceGitHistory(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
orig, _ := os.Getwd()
defer os.Chdir(orig)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
runGit(t, "init")
runGit(t, "config", "user.email", "[email protected]")
runGit(t, "config", "user.name", "Test User")
if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, "add", "file1.txt")
runGit(t, "commit", "-m", "init file1")
if err := os.WriteFile("file2.txt", []byte("v1\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, "add", "file2.txt")
runGit(t, "commit", "-m", "init file2")
app := &App{}
history, err := app.WorkspaceGitHistory("")
if err != nil {
t.Fatalf("WorkspaceGitHistory err = %v", err)
}
if len(history) != 2 {
t.Fatalf("expected 2 commits, got %d", len(history))
}
if history[0].Message != "init file2" {
t.Errorf("expected latest commit message 'init file2', got %q", history[0].Message)
}
if history[1].Message != "init file1" {
t.Errorf("expected older commit message 'init file1', got %q", history[1].Message)
}
// Test history for specific file
history, err = app.WorkspaceGitHistory("file1.txt")
if err != nil {
t.Fatalf("WorkspaceGitHistory err = %v", err)
}
if len(history) != 1 {
t.Fatalf("expected 1 commit for file1.txt, got %d", len(history))
}
if history[0].Message != "init file1" {
t.Errorf("expected commit message 'init file1', got %q", history[0].Message)
}
}
func TestWorkspaceGitCommitDetail(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
orig, _ := os.Getwd()
defer os.Chdir(orig)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
runGit(t, "init")
runGit(t, "config", "user.email", "[email protected]")
runGit(t, "config", "user.name", "Test User")
if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, "add", "file1.txt")
runGit(t, "commit", "-m", "init file1")
if err := os.WriteFile("file1.txt", []byte("v2\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, "add", "file1.txt")
runGit(t, "commit", "-m", "update file1")
hash := gitOutput(t, "rev-parse", "HEAD")
app := &App{}
// Test project level detail
detail, err := app.WorkspaceGitCommitDetail(hash, "")
if err != nil {
t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
}
if len(detail.Files) != 1 || detail.Files[0] != "file1.txt" {
t.Fatalf("expected files [file1.txt], got %v", detail.Files)
}
if detail.Diff != nil {
t.Fatal("expected nil diff for project level")
}
// Test file level detail
detail, err = app.WorkspaceGitCommitDetail(hash, "file1.txt")
if err != nil {
t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
}
if len(detail.Files) != 0 {
t.Fatalf("expected no files for file level, got %v", detail.Files)
}
if detail.Diff == nil || !strings.Contains(*detail.Diff, "+v2") {
t.Fatalf("expected diff to contain '+v2', got %v", detail.Diff)
}
}
func runGit(t *testing.T, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
@@ -734,6 +973,16 @@ func runGit(t *testing.T, args ...string) {
}
}
func gitOutput(t *testing.T, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// --- settings_app.go helpers ---
// These are unexported but in the same package, so we can test them.
+2 -2
View File
@@ -77,8 +77,8 @@ and DeepSeek prefix-cache–oriented design.
- **Code intelligence**: embedding semantic search is replaced by **CodeGraph**
(`codegraph_*` tools) — a tree-sitter symbol/call graph, no embedding service or
API cost. New (first-run) configs start with it off; existing configs keep it
on across upgrades. Toggle `[codegraph]` in the MCP manager or config, and set
`[codegraph].tier` to choose lazy, background, or eager startup.
on across upgrades. Toggle `[codegraph]` in the MCP manager or config; when
enabled it starts in the background so chat startup is never blocked.
- **Plan mode** + `complete_step` (evidence-backed step sign-off).
- **No web dashboard** — the v2 line is terminal + desktop (Wails), by design.
- Some granular v1 tools are intentionally consolidated (e.g. file-management ops
+72
View File
@@ -0,0 +1,72 @@
# Releasing
How Reasonix ships, who can ship what, and the canary-before-stable flow.
## Branch model: trunk + tags
- **`main-v2`** is the single development line (the v2 / 1.x trunk). Every PR merges here.
- **Production is a tag, not a branch.** A release is a tagged snapshot of `main-v2`:
`v1.4.0` (CLI), `npm-v1.4.0` (npm), `desktop-v1.4.0` (desktop).
- **`v1`** is the archived 1.0/legacy line — maintenance only.
- **Hotfix** an already-released version by branching from its tag, fixing, and tagging again.
There is no separate "production" or "develop" branch by design — the canary channel
provides the pre-release buffer instead of a long-lived branch.
## Channels
| 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) |
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
ending in `-canary.N` publish under the `canary` dist-tag.)
## Who can release what
| Action | Who | Mechanism |
|---|---|---|
| **Cut a canary** | any maintainer (write access) | `workflow_dispatch`, runs free (open `canary` environment) |
| **Ship `next` / stable** | **esengine only** | stable publish jobs gate on the `release` environment — esengine must approve before anything goes public |
So a maintainer can dispatch a canary anytime, but a stable release — even one a
maintainer starts by pushing a tag — pauses in the Actions UI until **esengine approves**
the `release` environment deployment.
> Repo settings backing this: Environments → `release` has esengine as a required
> reviewer; `canary` has none. (Optional hardening: a tag ruleset restricting
> `v*`/`npm-v*`/`desktop-v*` creation to esengine, so maintainers can't even start a
> stable release.)
## The release loop
1. **Develop** — PRs land on `main-v2` (branch auto-deletes on merge).
2. **Cut a canary** before the intended release (e.g. heading for `1.4.0`):
- Desktop: Actions → **Release desktop** → `channel: canary`, `base_version: 1.4.0`
- CLI: Actions → **Release npm** → `base_version: 1.4.0`
- Publishes `1.4.0-canary.N` to the desktop R2 `canary/` pointer (no GitHub release) and npm `@canary`.
3. **Test** — testers install `reasonix@canary` (CLI) or grab the desktop canary
build from its R2 link, and report bugs.
4. **Fix** on `main-v2` via PRs; re-cut the canary as needed (`canary.N` bumps).
5. **Ship stable** when the canary is clean — push the three tags:
```sh
git tag v1.4.0 && git push origin v1.4.0 # CLI binaries + Homebrew
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> next
git tag desktop-v1.4.0 && git push origin desktop-v1.4.0 # desktop -> R2 latest/
```
Each stable run **waits for esengine to approve the `release` environment** before publishing.
6. **Promote to default install** (optional, when 1.x should become the bare `npm i` target):
```sh
npm dist-tag add reasonix@1.4.0 latest
```
7. **Next cycle** — the canary rolls on toward `1.5.0`.
## Notes
- 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`.
- macOS canary self-update is manual (no notarization); testers download the canary
build from its R2 link (canary is not on the GitHub releases page).
+370
View File
@@ -0,0 +1,370 @@
# 会话引用功能架构文档
> GitHub Issue: https://github.com/esengine/DeepSeek-Reasonix/issues/3185
## 1. 功能需求
在当前会话中使用 `@` 引用其他会话的聊天记录作为内容发送给 AI。
### 1.1 用户场景
- 用户在当前会话中想引用之前会话的对话内容
- 用户想让 AI 参考之前的对话上下文
- 用户想合并多个会话的讨论结果
### 1.2 预期行为(P0-MVP)
```
输入 @
→ 显示原有菜单(文件/目录列表)
→ 菜单顶部新增 "past:chats" 选项
→ 选择 past:chats
→ 切换到历史会话列表
→ 选择一个会话
→ Composer 上方显示"已引用会话"
→ 发送时把该会话历史内容附加到当前 prompt/context
```
---
## 2. 现有代码结构分析
### 2.1 核心文件
| 文件 | 作用 |
|------|------|
| `desktop/frontend/src/components/Composer.tsx` | 输入框组件,包含 @ 功能 |
| `desktop/frontend/src/components/FileMenu.tsx` | @ 文件菜单组件 |
| `desktop/frontend/src/components/HistoryPanel.tsx` | 历史会话面板 |
| `desktop/frontend/src/lib/bridge.ts` | 前后端通信接口 |
| `desktop/frontend/src/lib/types.ts` | 类型定义 |
### 2.2 现有 @ 功能实现
**Composer.tsx 第 257-337 行** 实现了文件引用功能:
```typescript
// --- @ file references ---
const atRaw = useMemo(() => {
const m = /(?:^|\s)@([^\s]*)$/.exec(text);
return m ? m[1] : null;
}, [text]);
// 文件匹配结果
const atMatches = useMemo(() => {
// 过滤本地目录和搜索结果
}, [atRaw, atFrag, entries, searchEntries]);
// 菜单模式判断
const menuMode: "slash" | "slasharg" | "at" | null = ...;
// 渲染文件菜单
{menuMode === "at" && <FileMenu items={atMatches} ... />}
```
### 2.3 已有的会话 API(可复用)
```typescript
interface AppBindings {
// 会话列表
ListSessions(): Promise<SessionMeta[]>;
// 会话操作(可复用读取历史)
PreviewSession(path: string): Promise<HistoryMessage[]>;
}
```
---
## 3. P0-MVP 实施方案
### 3.1 设计思路
在现有的 `@` 菜单中添加 "past:chats" 选项,而不是创建新的 `@session:` 语法。
**菜单结构:**
```
@
├── 📁 past:chats ← 新增:选择后显示历史会话列表
├── 📁 src/
├── 📁 docs/
├── 📄 README.md
└── ...
```
### 3.2 实施路线
```
第一步:后端加搜索接口
↓
第二步:前端 bridge.ts 暴露接口
↓
第三步:在 @ 菜单中添加 "past:chats" 选项
↓
第四步:选择 past:chats 后切换到会话列表
↓
第五步:选择会话后添加到引用区域
↓
第六步:发送时附加会话上下文
```
### 3.3 最小改动文件清单
```
desktop/frontend/src/lib/types.ts — 添加 SessionReference 类型
desktop/frontend/src/lib/bridge.ts — 添加 SearchSessions API
desktop/frontend/src/components/Composer.tsx — 扩展 @ 菜单逻辑
desktop/frontend/src/components/FileMenu.tsx — 扩展菜单支持会话项
desktop/app.go — 添加 SearchSessions 方法
desktop/sessions.go — 实现会话搜索逻辑
```
### 3.4 类型定义
```typescript
// types.ts
export interface SessionReference {
path: string;
title: string;
preview?: string;
turns?: number;
createdAt?: number;
lastActivityAt?: number;
messages?: HistoryMessage[]; // P0 先不存,发送时再拉取
}
```
### 3.5 API 设计
```typescript
// bridge.ts
interface AppBindings {
// 新增:搜索会话
SearchSessions(query: string): Promise<SessionMeta[]>;
// 已有:读取会话历史(复用)
PreviewSession(path: string): Promise<HistoryMessage[]>;
}
```
### 3.6 前端逻辑修改
**Composer.tsx 修改:**
```typescript
// 1. 添加状态
const [showPastChats, setShowPastChats] = useState(false);
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
// 2. 修改 @ 菜单渲染
{menuMode === "at" && (
showPastChats ? (
// 显示会话列表
<SessionMenu
items={pastChats}
activeIndex={active}
onPick={pickSession}
onHover={setActive}
/>
) : (
// 显示文件列表(原有逻辑)
<>
<button
className="slashmenu__item slashmenu__item--special"
onMouseDown={() => {
setShowPastChats(true);
app.ListSessions().then(setPastChats);
}}
>
<MessageSquare size={13} />
<span className="slashmenu__name">past:chats</span>
<span className="slashmenu__desc">引用历史会话</span>
</button>
<FileMenu items={atMatches} ... />
</>
)
)}
// 3. 选择会话后的处理
const pickSession = (session: SessionMeta) => {
// 添加到引用区域
setSessionRefs(prev => [...prev, {
path: session.path,
title: session.title || session.preview || "Untitled",
preview: session.preview,
turns: session.turns,
createdAt: session.createdAt,
lastActivityAt: session.lastActivityAt,
}]);
// 重置状态
setShowPastChats(false);
setText(""); // 清空输入框
};
// 4. 发送时附加会话上下文
const handleSubmit = async () => {
let context = "";
if (sessionRefs.length > 0) {
context = "以下是用户引用的历史会话上下文:\n\n";
for (const ref of sessionRefs) {
const messages = await app.PreviewSession(ref.path);
const limited = limitMessages(messages, 30, 20000);
context += formatSessionContext(ref.title, limited);
}
context += "\n\n当前用户问题:\n";
}
onSubmit(context + text);
};
```
### 3.7 限制策略
```
最多引用最近 30 条消息
或最多 20k 字符
超出部分截断,并提示"已截断"
```
### 3.8 发送时的消息格式
```
以下是用户引用的历史会话上下文:
[会话:修复登录 bug]
用户:...
助手:...
用户:...
当前用户问题:
...
```
---
## 4. 架构图
```
┌─────────────────────────────────────────────────────────────┐
│ Composer.tsx │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ @ 菜单逻辑 │ │
│ │ - 原有:文件/目录列表 │ │
│ │ - 新增:past:chats 选项(在菜单顶部) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 菜单渲染 │ │
│ │ - showPastChats=false → FileMenu + past:chats 按钮 │ │
│ │ - showPastChats=true → SessionMenu (会话列表) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ bridge.ts │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 新增 API: │ │
│ │ - SearchSessions(query): Promise<SessionMeta[]> │ │
│ │ │ │
│ │ 复用 API: │ │
│ │ - ListSessions(): Promise<SessionMeta[]> │ │
│ │ - PreviewSession(path): Promise<HistoryMessage[]> │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ desktop/app.go │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 新增方法: │ │
│ │ - SearchSessions(query string) []SessionMeta │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 5. UI 设计
### 5.1 @ 菜单(showPastChats=false)
```
┌─────────────────────────────────────────────────────────────┐
│ @ ← 用户输入 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 💬 past:chats 引用历史会话 │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 📁 src/ │ │
│ │ 📁 docs/ │ │
│ │ 📄 README.md │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ [输入消息...] [发送] │
└─────────────────────────────────────────────────────────────┘
```
### 5.2 会话列表(showPastChats=true)
```
┌─────────────────────────────────────────────────────────────┐
│ @past:chats ← 用户输入 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 💬 项目架构设计讨论 - 2026-06-04 │ │
│ │ 💬 数据处理方案 - 2026-06-03 │ │
│ │ 💬 API 接口设计 - 2026-06-02 │ │
│ │ ← 返回文件列表 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ [输入消息...] [发送] │
└─────────────────────────────────────────────────────────────┘
```
### 5.3 引用区域
```
┌─────────────────────────────────────────────────────────────┐
│ 📎 引用的会话: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 📄 项目架构设计讨论 (8 轮) [×] │ │
│ └─────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ │
│ [输入消息...] [发送] │
└─────────────────────────────────────────────────────────────┘
```
---
## 6. P1 后续优化(暂不实施)
- 选择单条/多条消息
- hover 预览会话详情
- 截断/缓存优化
- 国际化翻译
- 搜索会话功能
---
## 7. 验收标准
### P0 验收
- [ ] 输入 `@` 显示菜单,包含 "past:chats" 选项
- [ ] 选择 "past:chats" 显示历史会话列表
- [ ] 选择会话后显示在引用区域
- [ ] 可以删除引用的会话
- [ ] 发送时正确附加会话上下文
- [ ] 引用内容限制在 30 条消息或 20k 字符内
- [ ] 超出部分截断并提示
### 测试用例
- [ ] 无历史会话时的行为
- [ ] 引用超大会话时的截断
- [ ] 与现有 @ 文件引用同时使用
- [ ] 在不同主题下的显示效果
+38 -16
View File
@@ -168,7 +168,8 @@ prefix cache-stable:
- The **planner** (low-frequency) runs in its own session with the same standing
memory context plus a filtered read-only research tool set, then produces a
concise plan. It can inspect files/docs before planning, but writer and
workflow tools are not exposed to it.
workflow tools are not exposed to it. `agent.planner_max_steps` bounds this
read-only exploration independently from the executor's `agent.max_steps`.
- The plan is handed off as structured text to the **executor** — a full
tool-using `Agent` in its own session — which carries it out.
- The sessions never mix, so neither model's prefix is disturbed by the other's
@@ -215,18 +216,34 @@ type Policy struct { Mode Decision; Allow, Ask, Deny []Rule }
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision
```
- **Rule syntax.** A rule is `ToolName` (matches any call to that tool) or
`ToolName(glob)` (matches when the call's *subject* matches the glob, via
`path.Match`). The subject is extracted generically from the call's JSON args
by a small set of known keys — `command` (bash), `path` / `file_path`
(file tools), `pattern` (grep/glob) — so tools need not change. A rule whose
subject the args don't expose only matches in its bare `ToolName` form.
- **Rule syntax.** A rule is `Tool` (matches any call in that tool family) or
`Tool(specifier)` (matches when the call's *subject* matches the specifier).
Bash and file mutation approvals use Claude Code-style families such as
`Bash(npm run build)`, `Bash(npm run test:*)`, and `Edit(docs/**)`. Legacy
lowercase tool IDs and `tool=literal` rules still load for compatibility. The
`:*` suffix marks a Bash command-prefix approval; generated prefix rules also
reject later commands that introduce shell operators, so `Bash(go test:*)`
does not cover `go test ./... && rm -rf tmp`.
Legacy `Bash(go test *)` prefix rules still load, but new rules are saved as
`Bash(go test:*)`. The subject is extracted generically from the call's JSON
args by a small set of
known keys — `command` (bash), `path` / `file_path` (file tools), `pattern`
(grep/glob) — so tools need not change. A rule whose subject the args don't
expose only matches in its bare `Tool` form.
- **Precedence.** `deny` > `ask` > `allow` > fallback. Fallback is `Allow` for
read-only tools and `Mode` (default `Ask`) for writers. `deny` always wins, so
a broad `allow = ["bash"]` can still be carved by `deny = ["bash(rm -rf*)"]`;
a broad `allow = ["Bash"]` can still be carved by `deny = ["Bash(rm -rf*)"]`;
conversely `ask` overrides a broad `allow` to force a prompt on a risky subset.
- **Resolving `Ask`.** The interactive front-end (the chat TUI) prompts the user
— allow once / always allow / deny — via an `Approver`. A non-interactive run
— allow once / allow this approval scope for the session / always allow this
approval scope / deny — via an `Approver`. For Bash, the default scope is the
concrete command subject, and the user may choose a conservative command-prefix
scope when available (for example `Bash(go test:*)`) so similar invocations in
the same session or saved config do not prompt again. For file-mutation tools,
a session grant covers editing for the rest of the session while a persisted
grant is path-scoped when a path is available, stored as `Edit(<path>)` so all
built-in file-mutating tools share it. A
non-interactive run
(`reasonix run`, a sub-agent, anything with no TTY / no approver) cannot prompt, so
it resolves `Ask` to **allow** — preserving autonomous behaviour. A `Deny` is a
hard block in *every* mode: the tool never executes and the model receives a
@@ -243,8 +260,10 @@ each writer/bash call. `deny` rules harden both modes.
The chat TUI accepts `/command` input. Three kinds share one dispatch:
- **Built-in actions** (`/compact`, `/new`, `/effort`, `/mcp`, `/help`) manipulate session
state locally and never reach the model.
- **Built-in actions** (`/compact`, `/new`/`/clear`, `/effort`, `/mcp`, `/help`) manipulate session
state locally and never reach the model. `/new` and its Claude Code-compatible
alias `/clear` start a fresh model context while saving the previous transcript
for resume/history; they do not delete persisted history or project memory.
- **Custom commands** are Markdown files under `.reasonix/commands/` (project) and
`~/.config/reasonix/commands/` (user); the project dir overrides the user dir on a
name clash. A file `review.md` becomes `/review`; a subdirectory namespaces it
@@ -344,7 +363,9 @@ type Chunk struct {
Resolution order: **flag > project `./reasonix.toml` > user `~/.config/reasonix/config.toml`
> built-in defaults**. Secrets come from the environment via `api_key_env` and
are never stored in config files. A `.env` in the working directory is loaded if
present.
present. Step-limit preferences usually belong in the user config; project
`reasonix.toml` should override them only when the repository needs shared
runtime bounds.
```toml
default_model = "deepseek" # provider name (→ its default model) or "provider/model"
@@ -352,8 +373,9 @@ default_model = "deepseek" # provider name (→ its default model) or "provide
[agent]
system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..."
max_steps = 25
temperature = 0.0
max_steps = 0 # executor tool-call rounds; 0 = no limit
planner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit
temperature = 0.0
# planner_model = "mimo" # 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" }
@@ -394,8 +416,8 @@ bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local ca
[permissions]
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
allow = ["bash(go test*)", "bash(git status*)"] # never prompted
deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode
allow = ["Bash(go test:*)", "Bash(git status:*)"] # never prompted
ask = [] # force a prompt even if otherwise allowed
[sandbox]
@@ -0,0 +1,198 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1900" viewBox="0 0 1600 1900" role="img" aria-labelledby="title desc">
<title id="title">Reasonix approval authorization mock states</title>
<desc id="desc">Mock UI states for exact command approval, command-prefix approval, file mutation approval, dangerous command approval, and persisted permission notices.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#101113"/>
<stop offset="1" stop-color="#171a1d"/>
</linearGradient>
<linearGradient id="card" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#202327"/>
<stop offset="1" stop-color="#1a1d20"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#000000" flood-opacity="0.36"/>
</filter>
<style>
.page-title{font:700 42px Inter,Arial,sans-serif;fill:#f5f7fa}
.page-sub{font:400 20px Inter,Arial,sans-serif;fill:#a8b0ba}
.section-title{font:700 24px Inter,Arial,sans-serif;fill:#f4f6f8}
.muted{font:400 16px Inter,Arial,sans-serif;fill:#a5adb7}
.tiny{font:500 13px Inter,Arial,sans-serif;fill:#8d96a2}
.mono{font:500 15px "SFMono-Regular",Menlo,Consolas,monospace;fill:#dbe3ea}
.mono-small{font:500 13px "SFMono-Regular",Menlo,Consolas,monospace;fill:#cdd6df}
.label{font:600 15px Inter,Arial,sans-serif;fill:#f0f3f5}
.btn-text{font:600 15px Inter,Arial,sans-serif;fill:#f6f8fa}
.btn-rule{font:500 13px "SFMono-Regular",Menlo,Consolas,monospace;fill:#ffffff}
.key{font:800 13px "SFMono-Regular",Menlo,Consolas,monospace;fill:#141516}
.notice{font:600 17px Inter,Arial,sans-serif;fill:#eaf2ee}
.notice-rule{font:600 15px "SFMono-Regular",Menlo,Consolas,monospace;fill:#ffffff}
</style>
</defs>
<rect width="1600" height="1900" fill="url(#bg)"/>
<text x="80" y="88" class="page-title">Reasonix 授权交互 Mock</text>
<text x="80" y="126" class="page-sub">展示规则可见化、命令前缀授权、文件编辑共享授权、危险命令降级、持久化 Notice 与去重覆盖提示。</text>
<!-- Mock 1 -->
<g transform="translate(80 180)" filter="url(#shadow)">
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
<text x="28" y="44" class="section-title">1. Bash:精简为前缀授权</text>
<text x="28" y="104" class="muted">Allow tool call</text>
<rect x="28" y="124" width="664" height="58" rx="10" fill="#15181b" stroke="#333942"/>
<text x="44" y="158" class="mono">bash — go test ./...</text>
<g transform="translate(28 210)">
<rect width="142" height="54" rx="9" fill="#5f4630" stroke="#ca8b45"/>
<circle cx="27" cy="27" r="13" fill="#f4a84b"/>
<text x="23" y="32" class="key">1</text>
<text x="52" y="32" class="btn-text">允许一次</text>
</g>
<g transform="translate(188 210)">
<rect width="230" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
<circle cx="27" cy="28" r="13" fill="#76d083"/>
<text x="23" y="33" class="key">2</text>
<text x="52" y="26" class="btn-text">本会话允许</text>
<text x="52" y="52" class="btn-rule">Bash(go test:*)</text>
</g>
<g transform="translate(438 210)">
<rect width="264" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
<circle cx="27" cy="28" r="13" fill="#76d083"/>
<text x="23" y="33" class="key">3</text>
<text x="52" y="26" class="btn-text">总是允许(保存)</text>
<text x="52" y="52" class="btn-rule">Bash(go test:*)</text>
</g>
<g transform="translate(28 312)">
<rect width="118" height="54" rx="9" fill="#302328" stroke="#8a4c58"/>
<circle cx="27" cy="27" r="13" fill="#ef6b7c"/>
<text x="23" y="32" class="key">4</text>
<text x="52" y="32" class="btn-text">拒绝</text>
</g>
<text x="28" y="438" class="tiny">效果:选择 2/3 后,后续 go test ./internal/control 不再重复弹审批。</text>
</g>
<!-- Mock 2 -->
<g transform="translate(840 180)" filter="url(#shadow)">
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
<text x="28" y="44" class="section-title">2. CLI:同样展示具体规则</text>
<rect x="28" y="104" width="664" height="324" rx="12" fill="#0f1215" stroke="#343a42"/>
<text x="50" y="140" class="mono-small">⏸ 需要你的许可</text>
<text x="50" y="174" class="mono-small">将调用工具 bash go test ./...</text>
<text x="50" y="216" class="mono-small">1. 本次允许</text>
<text x="50" y="244" class="mono-small">2. 本会话允许 Bash(go test:*)</text>
<text x="50" y="272" class="mono-small">3. 总是允许 Bash(go test:*)(保存到配置)</text>
<text x="50" y="300" class="mono-small">4. 拒绝</text>
<text x="50" y="342" class="mono-small">选择 [1/2/3/4](兼容 y/a/p/n)</text>
<text x="28" y="456" class="tiny">效果:终端用户不需要猜“授权范围”到底是什么。</text>
</g>
<!-- Mock 3 -->
<g transform="translate(80 720)" filter="url(#shadow)">
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
<text x="28" y="44" class="section-title">3. 文件修改:session 共享,持久化按路径</text>
<text x="28" y="104" class="muted">Allow tool call</text>
<rect x="28" y="124" width="664" height="58" rx="10" fill="#15181b" stroke="#333942"/>
<text x="44" y="158" class="mono">edit_file — src/app.go</text>
<g transform="translate(28 218)">
<rect width="142" height="54" rx="9" fill="#5f4630" stroke="#ca8b45"/>
<circle cx="27" cy="27" r="13" fill="#f4a84b"/>
<text x="23" y="32" class="key">1</text>
<text x="52" y="32" class="btn-text">允许一次</text>
</g>
<g transform="translate(188 208)">
<rect width="238" height="74" rx="9" fill="#20252b" stroke="#4a5360"/>
<circle cx="27" cy="28" r="13" fill="#aeb7c2"/>
<text x="23" y="33" class="key">2</text>
<text x="52" y="26" class="btn-text">本会话允许</text>
<text x="52" y="52" class="btn-rule">Edit</text>
</g>
<g transform="translate(448 208)">
<rect width="244" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
<circle cx="27" cy="28" r="13" fill="#76d083"/>
<text x="23" y="33" class="key">3</text>
<text x="52" y="26" class="btn-text">总是允许(保存)</text>
<text x="52" y="52" class="btn-rule">Edit(src/app.go)</text>
</g>
<g transform="translate(28 324)">
<rect width="118" height="54" rx="9" fill="#302328" stroke="#8a4c58"/>
<circle cx="27" cy="27" r="13" fill="#ef6b7c"/>
<text x="23" y="32" class="key">4</text>
<text x="52" y="32" class="btn-text">拒绝</text>
</g>
<text x="28" y="438" class="tiny">效果:本会话允许 edit/write/multi_edit 共享;保存时只允许这个路径。</text>
</g>
<!-- Mock 4 -->
<g transform="translate(840 720)" filter="url(#shadow)">
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
<text x="28" y="44" class="section-title">4. 危险 Bash:不提供前缀授权</text>
<text x="28" y="104" class="muted">Allow tool call</text>
<rect x="28" y="124" width="664" height="58" rx="10" fill="#15181b" stroke="#63363f"/>
<text x="44" y="158" class="mono">bash — rm -rf /tmp/build</text>
<g transform="translate(28 218)">
<rect width="142" height="54" rx="9" fill="#5f4630" stroke="#ca8b45"/>
<circle cx="27" cy="27" r="13" fill="#f4a84b"/>
<text x="23" y="32" class="key">1</text>
<text x="52" y="32" class="btn-text">允许一次</text>
</g>
<g transform="translate(188 208)">
<rect width="258" height="74" rx="9" fill="#20252b" stroke="#4a5360"/>
<circle cx="27" cy="28" r="13" fill="#aeb7c2"/>
<text x="23" y="33" class="key">2</text>
<text x="52" y="26" class="btn-text">本会话允许</text>
<text x="52" y="52" class="btn-rule">Bash(rm -rf /tmp/build)</text>
</g>
<g transform="translate(468 208)">
<rect width="224" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
<circle cx="27" cy="28" r="13" fill="#76d083"/>
<text x="23" y="33" class="key">3</text>
<text x="52" y="26" class="btn-text">总是允许(保存)</text>
<text x="52" y="52" class="btn-rule">Bash(rm -rf /tmp/build)</text>
</g>
<g transform="translate(28 324)">
<rect width="118" height="54" rx="9" fill="#302328" stroke="#8a4c58"/>
<circle cx="27" cy="27" r="13" fill="#ef6b7c"/>
<text x="23" y="32" class="key">4</text>
<text x="52" y="32" class="btn-text">拒绝</text>
</g>
<text x="28" y="438" class="tiny">效果:危险命令不会出现 Bash(rm -rf:*) 这种宽授权。</text>
</g>
<!-- Mock 5 -->
<g transform="translate(80 1260)" filter="url(#shadow)">
<rect width="720" height="420" rx="18" fill="url(#card)" stroke="#3b424b"/>
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
<text x="28" y="44" class="section-title">5. 保存成功 Notice</text>
<rect x="28" y="112" width="664" height="92" rx="12" fill="#15251b" stroke="#4a9860"/>
<circle cx="60" cy="158" r="18" fill="#76d083"/>
<text x="54" y="165" class="key">i</text>
<text x="88" y="150" class="notice">授权已保存到 /workspace/reasonix.toml</text>
<text x="88" y="178" class="notice-rule">Bash(go test:*)</text>
<rect x="28" y="246" width="664" height="84" rx="12" fill="#101316" stroke="#333942"/>
<text x="48" y="280" class="mono-small">[permissions]</text>
<text x="48" y="308" class="mono-small">allow = ["Bash(go test:*)"]</text>
<text x="28" y="374" class="tiny">效果:用户知道写入的是项目配置,而不是用户全局配置。</text>
</g>
<!-- Mock 6 -->
<g transform="translate(840 1260)" filter="url(#shadow)">
<rect width="720" height="420" rx="18" fill="url(#card)" stroke="#3b424b"/>
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
<text x="28" y="44" class="section-title">6. 已覆盖 / 去重 Notice</text>
<rect x="28" y="112" width="664" height="92" rx="12" fill="#17212a" stroke="#4b708f"/>
<circle cx="60" cy="158" r="18" fill="#80b7e8"/>
<text x="54" y="165" class="key">i</text>
<text x="88" y="150" class="notice">授权已由 /workspace/reasonix.toml 中的规则覆盖</text>
<text x="88" y="178" class="notice-rule">Bash(go test:*)</text>
<rect x="28" y="246" width="664" height="84" rx="12" fill="#101316" stroke="#333942"/>
<text x="48" y="280" class="mono-small">新请求:Bash(go test ./internal/control)</text>
<text x="48" y="308" class="mono-small">已有规则:Bash(go test:*) → 不重复写入</text>
<text x="28" y="374" class="tiny">效果:配置不会因为多次“总是允许”越来越乱。</text>
</g>
<text x="80" y="1774" class="page-sub">这些 mock 对应当前实现:按钮显示具体 rule;prefix scope 只在安全可推导时出现;持久化后通过 Notice 显示保存路径或覆盖规则。</text>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+168
View File
@@ -0,0 +1,168 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reasonix Approval Mock 1</title>
<style>
:root {
color-scheme: dark;
--bg: #101113;
--panel: #1b1e22;
--panel-2: #242930;
--border: #3b424b;
--muted: #a8b0ba;
--text: #f5f7fa;
--mono: "SFMono-Regular", Menlo, Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 36px;
background:
radial-gradient(circle at 20% 20%, rgba(244, 168, 75, .12), transparent 32%),
linear-gradient(135deg, #101113, #171a1d);
color: var(--text);
font-family: var(--sans);
}
.mock {
width: min(880px, 100%);
border: 1px solid var(--border);
border-radius: 18px;
background: linear-gradient(#202327, #1a1d20);
box-shadow: 0 28px 80px rgba(0,0,0,.42);
overflow: hidden;
}
.mock__head {
padding: 22px 28px;
background: var(--panel-2);
border-bottom: 1px solid var(--border);
}
.mock__title {
margin: 0;
font-size: 25px;
font-weight: 750;
letter-spacing: 0;
}
.mock__body { padding: 30px 28px 28px; }
.eyebrow {
margin: 0 0 14px;
color: var(--muted);
font-size: 15px;
}
.subject {
margin-bottom: 28px;
padding: 17px 18px;
border: 1px solid #333942;
border-radius: 10px;
background: #15181b;
font: 500 16px/1.5 var(--mono);
color: #dbe3ea;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.action {
display: inline-flex;
align-items: center;
gap: 13px;
min-height: 64px;
padding: 12px 17px;
border: 1px solid #4a5360;
border-radius: 10px;
background: #20252b;
color: #f6f8fa;
}
.action--once {
background: #5f4630;
border-color: #ca8b45;
}
.action--prefix {
background: #263328;
border-color: #62a66e;
}
.action--deny {
min-height: 54px;
background: #302328;
border-color: #8a4c58;
}
.key {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 999px;
background: #aeb7c2;
color: #141516;
font: 800 13px/1 var(--mono);
}
.action--once .key { background: #f4a84b; }
.action--prefix .key { background: #76d083; }
.action--deny .key { background: #ef6b7c; }
.copy {
display: grid;
gap: 5px;
min-width: 0;
}
.label {
font-size: 15px;
font-weight: 700;
line-height: 1.25;
}
code {
font: 600 13px/1.35 var(--mono);
color: #fff;
overflow-wrap: anywhere;
}
.note {
margin: 26px 0 0;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
</style>
</head>
<body>
<main class="mock" aria-label="Reasonix approval mock 1">
<header class="mock__head">
<h1 class="mock__title">1. Bash:精简为前缀授权</h1>
</header>
<section class="mock__body">
<p class="eyebrow">Allow tool call</p>
<div class="subject">bash - go test ./...</div>
<div class="actions">
<button class="action action--once">
<span class="key">1</span>
<span class="copy"><span class="label">允许一次</span></span>
</button>
<button class="action">
<span class="key">2</span>
<span class="copy">
<span class="label">本会话允许</span>
<code>Bash(go test:*)</code>
</span>
</button>
<button class="action action--prefix">
<span class="key">3</span>
<span class="copy">
<span class="label">总是允许(保存)</span>
<code>Bash(go test:*)</code>
</span>
</button>
<button class="action action--deny">
<span class="key">4</span>
<span class="copy"><span class="label">拒绝</span></span>
</button>
</div>
<p class="note">效果:选择 2/3 后,后续 <code>go test ./internal/control</code> 这类相似命令不再重复弹审批;选择 1 只允许本次。</p>
</section>
</main>
</body>
</html>
+59 -8
View File
@@ -10,6 +10,7 @@ import (
"unicode/utf8"
"reasonix/internal/event"
"reasonix/internal/permission"
"reasonix/internal/provider"
)
@@ -45,7 +46,7 @@ const maxResultChars = 8000
type updateSink struct {
conn notifier
sessionID string
approve func(id string, allow, session, persist bool)
approve func(id string, allow, session, persist bool, scope string)
mu sync.Mutex
turnCtx context.Context
}
@@ -57,6 +58,16 @@ func newUpdateSink(conn notifier, sessionID string) *updateSink {
// bindApprove installs the controller's Approve callback, called by the service
// once the controller exists (the sink is built first, to hand to the Factory).
func (s *updateSink) bindApprove(fn func(id string, allow, session, persist bool)) {
if fn == nil {
s.approve = nil
return
}
s.approve = func(id string, allow, session, persist bool, _ string) {
fn(id, allow, session, persist)
}
}
func (s *updateSink) bindApproveWithScope(fn func(id string, allow, session, persist bool, scope string)) {
s.approve = fn
}
@@ -211,6 +222,7 @@ func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
if a.Subject != "" {
title = a.Tool + " " + a.Subject
}
options := approvalOptions(a.Tool, a.Subject)
params := PermissionRequestParams{
SessionID: s.sessionID,
ToolCall: PermissionToolCall{
@@ -219,15 +231,11 @@ func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
Kind: toolKindFor(a.Tool),
Status: "pending",
},
Options: []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
{OptionID: string(OptAllowAlways), Name: "Allow for this session", Kind: OptAllowAlways},
{OptionID: string(OptAllowPersistent), Name: "Always allow (save to config)", Kind: OptAllowPersistent},
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
},
Options: options,
}
allow, session, persist := false, false, false
scope := permission.ApprovalScopeExact
if raw, err := s.conn.Request(ctx, "session/request_permission", params); err == nil {
var res PermissionRequestResult
if json.Unmarshal(raw, &res) == nil && res.Outcome.Outcome == "selected" {
@@ -238,10 +246,53 @@ func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
allow, session = true, true
case OptAllowPersistent:
allow, session, persist = true, true, true
case OptAllowPrefix:
allow, session = true, true
scope = permission.ApprovalScopePrefix
case OptPersistPrefix:
allow, session, persist = true, true, true
scope = permission.ApprovalScopePrefix
}
}
}
s.approve(a.ID, allow, session, persist)
s.approve(a.ID, allow, session, persist, scope)
}
func approvalOptionNames(tool, subject string) (session, persistent string) {
sessionRule := permission.SessionGrantRuleForScope(tool, subject, permission.ApprovalScopeExact)
persistentRule := permission.RememberRuleForScope(tool, subject, permission.ApprovalScopeExact)
switch {
case tool == "bash" && strings.TrimSpace(subject) != "":
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
case permission.IsFileMutationTool(tool):
if strings.TrimSpace(subject) != "" {
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
}
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
default:
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
}
}
func approvalOptions(tool, subject string) []PermissionOption {
allowSessionName, allowPersistentName := approvalOptionNames(tool, subject)
options := []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
}
if tool == "bash" && permission.BashCommandPrefix(subject) != "" {
prefixRule := permission.RememberRuleForScope(tool, subject, permission.ApprovalScopePrefix)
options = append(options,
PermissionOption{OptionID: string(OptAllowPrefix), Name: "Allow " + prefixRule + " for this session", Kind: OptAllowAlways},
PermissionOption{OptionID: string(OptPersistPrefix), Name: "Always allow " + prefixRule + " (save to config)", Kind: OptAllowPersistent},
)
} else {
options = append(options,
PermissionOption{OptionID: string(OptAllowAlways), Name: allowSessionName, Kind: OptAllowAlways},
PermissionOption{OptionID: string(OptAllowPersistent), Name: allowPersistentName, Kind: OptAllowPersistent},
)
}
options = append(options, PermissionOption{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce})
return options
}
// textBlock builds a text content block.
+54
View File
@@ -10,6 +10,7 @@ import (
"unicode/utf8"
"reasonix/internal/event"
"reasonix/internal/permission"
)
// fakeNotifier captures Notify calls and answers Request via an injectable hook,
@@ -229,6 +230,59 @@ func TestUpdateSinkApprovalAllowAlways(t *testing.T) {
}
}
func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
raw, _ := json.Marshal(params)
var p PermissionRequestParams
if err := json.Unmarshal(raw, &p); err != nil {
t.Fatalf("permission params: %v", err)
}
var hasExactSession, hasPrefix, hasPersistPrefix bool
for _, opt := range p.Options {
hasExactSession = hasExactSession || opt.OptionID == string(OptAllowAlways)
hasPrefix = hasPrefix || opt.OptionID == string(OptAllowPrefix)
hasPersistPrefix = hasPersistPrefix || opt.OptionID == string(OptPersistPrefix)
}
if hasExactSession {
t.Fatalf("options = %+v, exact bash session choice should be omitted when a prefix is available", p.Options)
}
if !hasPrefix || !hasPersistPrefix {
t.Fatalf("options = %+v, want bash prefix choices", p.Options)
}
if len(p.Options) != 4 {
t.Fatalf("options = %+v, want allow once, prefix session, prefix persistent, reject", p.Options)
}
res, _ := json.Marshal(PermissionRequestResult{
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptPersistPrefix)},
})
return res, nil
}}
sink := newUpdateSink(fn, "sess-1")
type scopedApproveCall struct {
id string
allow bool
session bool
persist bool
scope string
}
got := make(chan scopedApproveCall, 1)
sink.bindApproveWithScope(func(id string, allow, session, persist bool, scope string) {
got <- scopedApproveCall{id, allow, session, persist, scope}
})
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "10", Tool: "bash", Subject: "go test ./..."}})
select {
case c := <-got:
want := scopedApproveCall{id: "10", allow: true, session: true, persist: true, scope: permission.ApprovalScopePrefix}
if c != want {
t.Errorf("approve = %+v, want %+v", c, want)
}
case <-time.After(2 * time.Second):
t.Fatal("approve was never called")
}
}
func TestUpdateSinkApprovalDenied(t *testing.T) {
// Both a "cancelled" outcome and a transport error must deny the call.
for _, tc := range []struct {
+2
View File
@@ -358,6 +358,8 @@ const (
OptAllowOnce PermissionOptionKind = "allow_once"
OptAllowAlways PermissionOptionKind = "allow_always"
OptAllowPersistent PermissionOptionKind = "allow_persistent"
OptAllowPrefix PermissionOptionKind = "allow_prefix"
OptPersistPrefix PermissionOptionKind = "allow_prefix_persistent"
OptRejectOnce PermissionOptionKind = "reject_once"
OptRejectAlways PermissionOptionKind = "reject_always"
)
+2 -2
View File
@@ -243,7 +243,7 @@ func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, err
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
}
ctrl.EnableInteractiveApproval()
sink.bindApprove(ctrl.Approve)
sink.bindApproveWithScope(ctrl.ApproveWithScope)
now := time.Now().UTC()
sess := &acpSession{id: id, ctrl: ctrl, sink: sink, cwd: cwd, createdAt: now, updatedAt: now}
@@ -321,7 +321,7 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
return &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
}
ctrl.EnableInteractiveApproval()
sink.bindApprove(ctrl.Approve)
sink.bindApproveWithScope(ctrl.ApproveWithScope)
dir := ctrl.SessionDir()
if dir == "" {
+37 -10
View File
@@ -31,6 +31,7 @@ const maxToolOutputBytes = 32 * 1024
const maxFinalReadinessBlocks = 3
const maxEmptyFinalBlocks = 3
const maxStreamRecoveries = 1
const maxExecutorHandoffNudges = 1
// Renderer redraws the assistant's final-answer text as styled output. It is
// applied only after a turn's text stream completes, so the user sees raw
@@ -121,8 +122,12 @@ type Agent struct {
session *Session
sessMu sync.Mutex // guards the session pointer for external Session()/SetSession
maxSteps int
temperature float64
pricing *provider.Pricing
maxStepsKey string
// executorHandoffGuard is enabled by Coordinator for the executor agent. The
// per-turn marker check in Run keeps ordinary single-model turns unaffected.
executorHandoffGuard bool
temperature float64
pricing *provider.Pricing
// sink receives the turn's typed event stream (reasoning/text deltas, tool
// dispatch/results, usage, notices). The agent no longer formats output
@@ -309,7 +314,10 @@ func (a *Agent) CompactNow(ctx context.Context, instructions string) error {
// Options configures an Agent.
type Options struct {
MaxSteps int
MaxSteps int
// MaxStepsKey names the configuration knob shown when the MaxSteps guard is
// hit. Empty defaults to agent.max_steps.
MaxStepsKey string
Temperature float64
Pricing *provider.Pricing // optional, for per-turn cost display
@@ -364,11 +372,16 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
if nilutil.IsNil(hooks) {
hooks = nil
}
maxStepsKey := opts.MaxStepsKey
if strings.TrimSpace(maxStepsKey) == "" {
maxStepsKey = "agent.max_steps"
}
return &Agent{
prov: prov,
tools: tools,
session: session,
maxSteps: opts.MaxSteps,
maxStepsKey: maxStepsKey,
temperature: opts.Temperature,
pricing: opts.Pricing,
sink: sink,
@@ -402,7 +415,10 @@ func (a *Agent) Run(ctx context.Context, input string) error {
finalReadinessBlocks := 0
emptyFinalBlocks := 0
handoffNudges := 0
usedAnyTool := false
streamRecoveries := 0
executorHandoff := a.executorHandoffGuard && strings.Contains(input, executorHandoffMarker)
for step := 0; a.maxSteps <= 0 || step < a.maxSteps; step++ {
schemas := a.tools.Schemas()
prefixShape := a.capturePrefixShape(schemas)
@@ -484,12 +500,20 @@ func (a *Agent) Run(ctx context.Context, input string) error {
a.maybeCompact(ctx, usage)
continue
}
if executorHandoff && !usedAnyTool && handoffNudges < maxExecutorHandoffNudges {
handoffNudges++
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "executor answered without taking any action; nudging it to use its tools"})
a.session.Add(provider.Message{Role: provider.RoleUser, Content: executorHandoffRetryMessage()})
a.maybeCompact(ctx, usage)
continue
}
if readiness.applies {
event.RecordReadinessAudit(a.sink, readiness.audit(evidence.ReadinessAllowed, finalReadinessBlocks > 0))
}
return nil // model gave a final answer
}
emptyFinalBlocks = 0
usedAnyTool = true
results := a.executeBatch(ctx, calls)
for i, call := range calls {
@@ -508,7 +532,7 @@ func (a *Agent) Run(ctx context.Context, input string) error {
// Only reached when a positive maxSteps guard is configured. The work so far
// is already in the session, so the user can just send another message to pick
// up where it left off.
return fmt.Errorf("paused after %d tool-call rounds (agent.max_steps) — the work so far is saved; send another message to continue, or set max_steps higher or to 0 for no limit", a.maxSteps)
return fmt.Errorf("paused after %d tool-call rounds (%s) — the work so far is saved; send another message to continue, or set %s higher or to 0 for no limit", a.maxSteps, a.maxStepsKey, a.maxStepsKey)
}
func (a *Agent) finalReadinessFailure() string {
@@ -519,7 +543,6 @@ type finalReadinessCheck struct {
applies bool
reason string
missingProjectChecks int
missingCompleteStep bool
incompleteTodos int
}
@@ -528,7 +551,6 @@ func (c finalReadinessCheck) audit(result evidence.ReadinessAuditResult, recover
Result: result,
Recovered: recovered,
MissingProjectChecks: c.missingProjectChecks,
MissingCompleteStep: c.missingCompleteStep,
IncompleteTodos: c.incompleteTodos,
CommandMismatchMissing: c.missingProjectChecks,
}
@@ -570,10 +592,7 @@ func (a *Agent) finalReadinessCheck() finalReadinessCheck {
missing = append(missing, fmt.Sprintf("run %q from %s after the latest write", command, finalReadinessCheckSource(check)))
}
}
if hasTodoReceipt && !a.evidence.HasSuccessfulCompleteStepAfter(writer) {
out.missingCompleteStep = true
missing = append(missing, "call complete_step after the latest write")
}
if len(missing) == 0 {
return out
}
@@ -608,6 +627,13 @@ func finalReadinessRetryMessage(reason string) string {
return "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: " + reason + ". Run the required tool calls, then answer when readiness is satisfied."
}
func executorHandoffRetryMessage() string {
return `You are already in the executor phase. The planner's read-only limitations do not apply to you.
Do not answer as the planner and do not ask how to trigger the executor.
Use your available tools now to carry out the task. If a write or command is blocked by permissions or workspace boundaries, state that specific blocker and ask for the needed approval/path.`
}
func hasVisibleFinalAnswer(text string) bool {
return strings.TrimSpace(text) != ""
}
@@ -1028,6 +1054,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
cctx := withCallContext(ctx, call.ID, a.sink, a.asker)
if a.evidence != nil {
cctx = evidence.WithLedger(cctx, a.evidence)
cctx = evidence.WithSessionMessages(cctx, a.session.Snapshot())
}
if len(a.projectChecks) > 0 {
cctx = instruction.WithChecks(cctx, a.projectChecks)
+25 -13
View File
@@ -23,20 +23,12 @@ Given a task, produce a concise, ordered plan for the executor model to carry ou
Use the read-only tools available to you when the task needs context from the
workspace, user rules, or docs; keep that research targeted and stop once you
have enough evidence. Do not write full implementations or attempt side effects.
Outline the steps, which files to touch, and the key decisions. Keep it short and
Do not ask the user how to trigger the executor and do not say you are waiting
for the executor. Output executor-ready instructions: what to do, which files or
commands are relevant, expected blockers, and key decisions. Keep it short and
actionable.`
const DefaultPlannerMaxSteps = 6
// PlannerMaxSteps bounds planner-side read-only exploration so two-model mode
// gains context access without letting planning turns become long-running agent
// sessions. A lower explicit agent.max_steps remains respected.
func PlannerMaxSteps(configured int) int {
if configured > 0 && configured < DefaultPlannerMaxSteps {
return configured
}
return DefaultPlannerMaxSteps
}
const executorHandoffMarker = "Reasonix executor handoff"
// PlannerPromptWithContext appends cache-stable standing context, such as loaded
// REASONIX.md / AGENTS.md memory, to the planner's smaller system prompt.
@@ -80,6 +72,9 @@ func NewCoordinator(planner provider.Provider, plannerSession *Session, plannerP
plannerOptions.Pricing = plannerPricing
plannerAgent = New(planner, plannerTools, plannerSession, plannerOptions, plannerSink(sink))
}
if executor != nil {
executor.executorHandoffGuard = true
}
return &Coordinator{
planner: planner,
plannerSess: plannerSession,
@@ -178,5 +173,22 @@ func plannerSink(sink event.Sink) event.Sink {
}
func formatHandoff(task, plan string) string {
return fmt.Sprintf("Task: %s\n\nA planner proposed this approach:\n%s\n\nCarry it out, adapting as needed.", task, plan)
return fmt.Sprintf(`# %s
You are the executor now. Use your available tools to execute the task.
Original task:
%s
Planner output:
%s
Executor instructions:
- Treat the planner output as context, not as your role or capability set.
- Ignore any planner statement such as "I cannot write", "I only have read-only tools", or "hand this to the executor"; those limitations apply to the planner, not to you.
- Do not ask the user how to trigger the executor. You are already in the executor phase.
- If the task requires changes, call the appropriate tools (for example write/edit/bash) instead of only restating the plan.
- If a target path is outside the writable workspace or otherwise blocked, explain that specific blocker and ask for the needed path/approval.
Carry out the task, adapting the plan as needed.`, executorHandoffMarker, task, plan)
}
+151 -2
View File
@@ -74,7 +74,7 @@ func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
if got := lastUser(planner.lastReq); !strings.Contains(got, "fix the bug") {
t.Errorf("planner saw user %q, want it to contain the task", got)
}
if got := lastUser(exec.lastReq); !strings.Contains(got, "read main.go") || !strings.Contains(got, "fix the bug") {
if got := lastUser(exec.requests[0]); !strings.Contains(got, "read main.go") || !strings.Contains(got, "fix the bug") || !strings.Contains(got, "You are the executor now") {
t.Errorf("executor saw user %q, want task + plan", got)
}
// planner session must accumulate (system, user, assistant-plan) so its
@@ -170,7 +170,7 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
t.Fatalf("planner tools = %v, must not include %s", tools, forbidden)
}
}
if got := lastUser(exec.lastReq); !strings.Contains(got, "follow the loaded rule") || !strings.Contains(got, "fix the bug") {
if got := lastUser(exec.requests[0]); !strings.Contains(got, "follow the loaded rule") || !strings.Contains(got, "fix the bug") {
t.Errorf("executor saw user %q, want task + planner plan", got)
}
if got := plannerSess.Messages[0].Content; !strings.Contains(got, "Rule: keep changes narrow.") {
@@ -178,6 +178,155 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
}
}
func TestCoordinatorPlannerMaxStepsUsesPlannerConfigKey(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"REASONIX.md"}`}},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "keep reading"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{
MaxSteps: 2,
MaxStepsKey: "agent.planner_max_steps",
}, executor, 0, event.Discard, nil)
err := coord.Run(context.Background(), "plan a change")
if err == nil {
t.Fatal("Run should pause when the planner reaches its configured step limit")
}
msg := err.Error()
if !strings.Contains(msg, "planner: paused after 2 tool-call rounds (agent.planner_max_steps)") {
t.Fatalf("pause error = %q, want planner_max_steps message", msg)
}
if strings.Contains(msg, "agent.max_steps") {
t.Fatalf("planner pause should not point at agent.max_steps: %q", msg)
}
if got := len(planner.requests); got != 2 {
t.Fatalf("planner requests = %d, want exactly the configured 2 rounds", got)
}
if len(exec.requests) != 0 {
t.Fatal("executor should not run when planner pauses before producing a plan")
}
}
func TestCoordinatorPlannerMaxStepsZeroIsUnlimited(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"a"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-2", Name: "read_file", Arguments: `{"path":"b"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "1. use both files"},
{Type: provider.ChunkDone},
},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "ok"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{
MaxSteps: 0,
MaxStepsKey: "agent.planner_max_steps",
}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "plan a change"); err != nil {
t.Fatalf("Run with planner max steps 0 should not pause: %v", err)
}
if got := len(planner.requests); got != 3 {
t.Fatalf("planner requests = %d, want all 3 scripted planner turns", got)
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "use both files") {
t.Fatalf("executor did not receive planner output: %q", got)
}
}
func TestCoordinatorNudgesExecutorThatAnswersWithoutActing(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Write the requested skill file."},
{Type: provider.ChunkDone},
}}
// The first turn is a plain final answer with no tool call and no
// planner-vocabulary — the nudge must fire on the missing action, not on words.
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "这个计划看起来没问题,应该很好实现。"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "install the skill"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 3 {
t.Fatalf("executor requests = %d, want answer-without-acting, nudge tool call, final answer", got)
}
if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
t.Fatalf("second executor request missing handoff nudge message: %q", got)
}
}
func TestCoordinatorDoesNotNudgeExecutorThatActs(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Write the requested skill file."},
{Type: provider.ChunkDone},
}}
// Executor calls a tool on its first turn, then answers — no nudge expected.
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "install the skill"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 2 {
t.Fatalf("executor requests = %d, want tool call + final answer with no nudge", got)
}
for i, req := range exec.requests {
if strings.Contains(lastUser(req), "Use your available tools now to carry out the task") {
t.Fatalf("request %d unexpectedly received a handoff nudge", i)
}
}
}
func toolSchemaNames(schemas []provider.ToolSchema) []string {
out := make([]string, 0, len(schemas))
for _, s := range schemas {
+2 -2
View File
@@ -441,8 +441,8 @@ func TestFinalReadinessAuditRecordsTerminalError(t *testing.T) {
t.Fatalf("readiness audit events = %d, want 3: %+v", len(sink.events), sink.events)
}
last := sink.events[len(sink.events)-1]
if last.Result != evidence.ReadinessErrored || !last.MissingCompleteStep {
t.Fatalf("terminal audit = %+v, want errored missing complete_step", last)
if last.Result != evidence.ReadinessErrored || last.IncompleteTodos == 0 {
t.Fatalf("terminal audit = %+v, want errored with incomplete todos", last)
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func TestFinalReadinessFailureBranches(t *testing.T) {
{"writer without checks or todo never gates", nil, readinessLedger(writer), true, ""},
{"missing project check after writer is reported", []instruction.VerifyCheck{check}, readinessLedger(checkAfter, writer), false, "go test ./..."},
{"project check run after writer satisfies", []instruction.VerifyCheck{check}, readinessLedger(writer, checkAfter), true, ""},
{"todo writer without complete_step is reported", nil, readinessLedger(writer, todo), false, "complete_step"},
{"todo writer without complete_step is reported", nil, readinessLedger(writer, todo), false, "incomplete items"},
{"complete_step without final todo update is reported", nil, readinessLedger(writer, todo, completeAfter), false, "latest successful todo_write"},
{"todo writer with complete_step and completed todo satisfies", nil, readinessLedger(writer, todo, completeAfter, doneTodo), true, ""},
}
+83 -39
View File
@@ -87,12 +87,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
if stderr == nil {
stderr = os.Stderr
}
root := opts.WorkspaceRoot
if root == "" {
if wd, err := os.Getwd(); err == nil {
root = wd
}
}
root := resolveWorkspaceRoot(opts.WorkspaceRoot)
// One-time import of v1/v0.5 legacy config — runs before Load so the freshly
// written config + ~/.env are picked up this same boot. CLI Run also calls this
// before config-only commands; this call stays as the shared frontend fallback.
@@ -204,7 +199,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
}
searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, stderr)
bashTimeout := time.Duration(cfg.BashTimeoutSeconds()) * time.Second
addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root)
addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec)
// Always construct a host, even with no plugins configured, so the controller's
// host pointer is stable for the session and `/mcp add` can hot-add into it.
pluginHost := plugin.NewHost()
@@ -245,11 +240,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
// tools come online next session — otherwise point the user at the explicit
// install command. A failed init or fetch is a notice, not fatal.
//
// CodeGraph follows the same user-selectable tier model as ordinary MCP
// servers when a tier is set. EnsureInit only creates .codegraph/ (fast,
// size-independent). With no explicit tier — an upgraded config that predates
// the setting — it keeps the historical startup: warm projects eager so
// symbol tools are ready on the first turn, cold projects in the background.
// CodeGraph is fixed to background startup. Legacy tier values are ignored so
// enabling it never blocks chat startup.
if cfg.Codegraph.Enabled {
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
switch {
@@ -274,24 +266,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
Text: "codegraph: preparing code-intelligence tools in the background — tools will appear when ready"})
}
}
if strings.TrimSpace(cfg.Codegraph.Tier) == "" {
if warm {
eagerSpecs = append(eagerSpecs, spec)
} else {
bgSpecs = append(bgSpecs, spec)
bgNotice()
}
break
}
switch cfg.Codegraph.ResolvedTier() {
case "eager":
eagerSpecs = append(eagerSpecs, spec)
case "background":
bgSpecs = append(bgSpecs, spec)
bgNotice()
default:
lazySpecs = append(lazySpecs, spec)
}
bgSpecs = append(bgSpecs, spec)
bgNotice()
case cfg.Codegraph.AutoInstall:
notify := func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg}) }
notify("codegraph: fetching code-intelligence runtime in the background (one-time) — symbol-graph tools available next session")
@@ -630,7 +606,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
plannerSess := agent.NewSession(agent.PlannerPromptWithContext(mem.Block()))
plannerTools := agent.PlannerToolRegistry(reg)
runner = agent.NewCoordinator(plannerProv, plannerSess, pe.Price, plannerTools, agent.Options{
MaxSteps: agent.PlannerMaxSteps(maxSteps),
MaxSteps: cfg.Agent.PlannerMaxSteps,
MaxStepsKey: "agent.planner_max_steps",
Gate: headlessGate,
ContextWindow: pe.ContextWindow,
SoftCompactRatio: cfg.Agent.SoftCompactRatio,
@@ -679,8 +656,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
PluginCtx: ctx,
WorkspaceRoot: root,
AutoPlan: cfg.Agent.AutoPlan,
OnRemember: func(rule string) {
rememberPermissionRule(opts.WorkspaceRoot, rule)
OnRemember: func(rule string) control.RememberResult {
return rememberPermissionRule(root, rule)
},
}
if classifier != nil {
@@ -732,16 +709,27 @@ func migrateLegacySessionSources(sink event.Sink) {
}
}
func rememberPermissionRule(workspaceRoot, rule string) {
func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult {
path := rememberPermissionConfigPath(workspaceRoot)
edit := config.LoadForEdit(path)
result := control.RememberResult{Rule: strings.TrimSpace(rule), Path: path}
if coveredBy := coveredPermissionRule(edit.Permissions.Allow, result.Rule); coveredBy != "" {
result.CoveredBy = coveredBy
return result
}
edit.Permissions.Allow = pruneCoveredPermissionRules(edit.Permissions.Allow, result.Rule)
if err := edit.AddPermissionRule("allow", rule); err != nil {
slog.Warn("persist permission rule", "rule", rule, "err", err)
return
result.Err = err
return result
}
if err := edit.SaveTo(path); err != nil {
slog.Warn("save config after permission rule", "err", err)
result.Err = err
return result
}
result.Saved = true
return result
}
func rememberPermissionConfigPath(workspaceRoot string) string {
@@ -756,6 +744,26 @@ func rememberPermissionConfigPath(workspaceRoot string) string {
return path
}
func coveredPermissionRule(rules []string, rule string) string {
for _, existing := range rules {
if permission.RuleCoversString(existing, rule) {
return strings.TrimSpace(existing)
}
}
return ""
}
func pruneCoveredPermissionRules(rules []string, rule string) []string {
out := rules[:0]
for _, existing := range rules {
if strings.TrimSpace(existing) == "" || permission.RuleCoversString(rule, existing) {
continue
}
out = append(out, existing)
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
@@ -826,6 +834,42 @@ func subagentModelKeys(name string) []string {
return keys
}
func resolveWorkspaceRoot(explicit string) string {
if explicit != "" {
return explicit
}
wd, err := os.Getwd()
if err != nil {
return ""
}
if root, ok := nearestGitRoot(wd); ok {
return root
}
return wd
}
func nearestGitRoot(start string) (string, bool) {
dir, err := filepath.Abs(start)
if err != nil {
dir = filepath.Clean(start)
}
for {
if isGitMarker(filepath.Join(dir, ".git")) {
return dir, true
}
next := filepath.Dir(dir)
if next == dir {
return "", false
}
dir = next
}
}
func isGitMarker(path string) bool {
fi, err := os.Stat(path)
return err == nil && (fi.IsDir() || fi.Mode().IsRegular())
}
// NewProvider builds a provider.Provider from a configured entry. Exported so
// custom assemblers (e.g. the ACP per-session factory) can reuse it without
// going through the full Build.
@@ -860,12 +904,12 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
// instance bound to writeRoots (preserving registry order).
// When workDir is non-empty, tools resolve relative paths against it instead of
// the process cwd, enabling concurrent multi-project sessions.
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string) {
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec) {
// If a workspace directory is set, use workspace-bound tools that resolve
// paths relative to that directory. Otherwise fall back to the process-cwd
// compile-time builtins.
if workDir != "" {
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec}
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec}
for _, t := range ws.Tools(enabled...) {
reg.Add(t)
}
@@ -887,8 +931,8 @@ func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sand
}
// Replace the unconfined defaults with confined instances (registry order is
// preserved on replace): file-writers bound to the workspace, bash to the OS
// sandbox. Only replace tools actually enabled/present.
confined := append(builtin.ConfineWriters(writeRoots), builtin.ConfineBash(bashSpec, bashTimeout), builtin.ConfineSearch(searchSpec))
// sandbox, web_fetch to the proxy. Only replace tools actually enabled/present.
confined := append(builtin.ConfineWriters(writeRoots), builtin.ConfineBash(bashSpec, bashTimeout), builtin.ConfineSearch(searchSpec), builtin.ConfineWebFetch(proxySpec))
for _, t := range confined {
if _, ok := reg.Get(t.Name()); ok {
reg.Add(t)
+157 -7
View File
@@ -18,6 +18,7 @@ import (
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/netclient"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
@@ -216,7 +217,7 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {
reg := tool.NewRegistry()
var stderr bytes.Buffer
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t))
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})
for _, name := range []string{
"todo_write",
"complete_step",
@@ -450,14 +451,14 @@ func TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {
t.Chdir(cwd)
writeFile(t, cwd, "reasonix.toml", `
[permissions]
allow = ["bash(cwd*)"]
allow = ["Bash(cwd*)"]
`)
writeFile(t, workspace, "reasonix.toml", `
[permissions]
allow = ["bash(workspace*)"]
allow = ["Bash(workspace*)"]
`)
const rule = "bash=go test ./..."
const rule = "Bash(go test ./...)"
rememberPermissionRule(workspace, rule)
cwdCfg := config.LoadForEdit(filepath.Join(cwd, "reasonix.toml"))
@@ -470,6 +471,36 @@ allow = ["bash(workspace*)"]
}
}
func TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
workspace := robustTempDir(t)
userConfig := config.UserConfigPath()
writeFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `
[permissions]
allow = ["Bash(user)"]
`)
const rule = "Edit(src/app.go)"
res := rememberPermissionRule(workspace, rule)
if !res.Saved || res.Path != filepath.Join(workspace, "reasonix.toml") {
t.Fatalf("remember result = %+v, want saved to workspace config", res)
}
userCfg := config.LoadForEdit(userConfig)
if hasPermissionRule(userCfg.Permissions.Allow, rule) {
t.Fatalf("workspace rule was written to user config: %v", userCfg.Permissions.Allow)
}
workspaceCfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml"))
if !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {
t.Fatalf("workspace rule missing from project config: %v", workspaceCfg.Permissions.Allow)
}
}
func TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {
home := robustTempDir(t)
t.Setenv("HOME", home)
@@ -482,11 +513,14 @@ func TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {
userConfig := config.UserConfigPath()
writeFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `
[permissions]
allow = ["bash(user*)"]
allow = ["Bash(user*)"]
`)
const rule = "bash=go env"
rememberPermissionRule("", rule)
const rule = "Bash(go env)"
res := rememberPermissionRule("", rule)
if !res.Saved || res.Path != userConfig {
t.Fatalf("remember result = %+v, want saved to user source config", res)
}
userCfg := config.LoadForEdit(userConfig)
if !hasPermissionRule(userCfg.Permissions.Allow, rule) {
@@ -497,6 +531,43 @@ allow = ["bash(user*)"]
}
}
func TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {
workspace := robustTempDir(t)
writeFile(t, workspace, "reasonix.toml", `
[permissions]
allow = ["Bash(go test:*)"]
`)
res := rememberPermissionRule(workspace, "Bash(go test ./...)")
if res.Saved || res.CoveredBy != "Bash(go test:*)" {
t.Fatalf("remember result = %+v, want already covered", res)
}
cfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml"))
if len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != "Bash(go test:*)" {
t.Fatalf("allow rules = %v, want only existing prefix", cfg.Permissions.Allow)
}
}
func TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {
workspace := robustTempDir(t)
writeFile(t, workspace, "reasonix.toml", `
[permissions]
allow = ["Bash(go test ./...)", "Bash(go build ./...)"]
`)
res := rememberPermissionRule(workspace, "Bash(go test:*)")
if !res.Saved || res.CoveredBy != "" {
t.Fatalf("remember result = %+v, want saved broader rule", res)
}
cfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml"))
if hasPermissionRule(cfg.Permissions.Allow, "Bash(go test ./...)") {
t.Fatalf("narrow go test rule should be pruned: %v", cfg.Permissions.Allow)
}
if !hasPermissionRule(cfg.Permissions.Allow, "Bash(go build ./...)") || !hasPermissionRule(cfg.Permissions.Allow, "Bash(go test:*)") {
t.Fatalf("allow rules = %v, want unrelated exact plus prefix", cfg.Permissions.Allow)
}
}
func hasPermissionRule(rules []string, want string) bool {
for _, rule := range rules {
if rule == want {
@@ -843,6 +914,85 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
}
}
func TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake launcher is a POSIX-sh script")
}
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.Mkdir(filepath.Join(dir, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
launcher := filepath.Join(dir, "slow-codegraph")
writeFile(t, dir, "slow-codegraph", "#!/bin/sh\nif [ \"$1\" = serve ]; then sleep 5; fi\n")
if err := os.Chmod(launcher, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[codegraph]
enabled = true
path = %q
tier = "eager"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`, launcher))
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
ctrl, err := Build(ctx, Options{})
if err != nil {
t.Fatalf("Build should not block on warm codegraph with legacy eager tier: %v", err)
}
defer ctrl.Close()
}
func TestBuildDefaultsToNearestGitRoot(t *testing.T) {
isolateConfigHome(t)
root := robustTempDir(t)
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
subdir := filepath.Join(root, "cmd", "tool")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, root, "reasonix.toml", `
default_model = "root-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
[[providers]]
name = "root-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
t.Chdir(subdir)
ctrl, err := Build(context.Background(), Options{Model: "root-model"})
if err != nil {
t.Fatalf("Build should load config from nearest git root: %v", err)
}
defer ctrl.Close()
}
func TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
+1
View File
@@ -91,6 +91,7 @@ func acpBuiltinTools(cfg *config.Config, cwd string, writeRoots []string) []tool
Bash: bashSpec,
BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
ProxySpec: cfg.NetworkProxySpec(),
}
return ws.Tools(cfg.Tools.Enabled...)
}
+271 -43
View File
@@ -24,29 +24,25 @@ import (
"reasonix/internal/agent"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/hook"
"reasonix/internal/i18n"
"reasonix/internal/memory"
"reasonix/internal/outputstyle"
"reasonix/internal/permission"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
// chatTUI is a bubbletea Model that runs a chat session in the terminal's
// normal buffer (no alt-screen). Finalized output — user bubbles, tool dispatch
// lines, usage lines, reasoning, and the rendered assistant answer — is
// committed to the native scrollback via tea.Println, so the wheel, scrollbar,
// and copy all work like any CLI. The bubbletea-managed region is only the
// bottom — input box, status line, an optional approval/plan banner, and the
// autocomplete menu — and it is kept a stable height (it changes only on
// discrete user actions, never per streamed token) so the renderer commits
// scrollback cleanly without stranding the input box's border lines. This
// mirrors how Claude Code uses Ink's <Static> to freeze finished output into
// scrollback while re-rendering just the active prompt.
// chatTUI is a bubbletea Model that normally owns the terminal with an
// alt-screen transcript viewport. Termux is the exception: enabling mouse mode
// prevents taps from raising the soft keyboard, so it stays in the normal buffer
// and commits finalized output to native scrollback via tea.Println.
type chatTUI struct {
ctrl *control.Controller
label string
@@ -54,6 +50,9 @@ type chatTUI struct {
width int
height int
// nativeScrollback keeps Termux out of alt-screen/mouse mode so taps still
// focus the textarea and raise the soft keyboard.
nativeScrollback bool
input textarea.Model
spinner spinner.Model
@@ -117,6 +116,7 @@ type chatTUI struct {
pendingCommit *[]string
renderer *mdRenderer
showReasoning bool // Ctrl+O / /verbose: show raw thinking text in the CLI
cfg *config.Config
// reasoningLineIdx is the transcript index of the live "▎ thinking…" marker
// while a reasoning block streams; it's rewritten to "▎ thought for Ns" when
// the block closes. -1 when no block is open. transcriptDirty forces a
@@ -129,7 +129,10 @@ type chatTUI struct {
// reasoningView is a bounded trailing window (≤ reasoningViewMax bytes) of the
// streaming thought, rendered live; the full text stays in reasoning for verbose.
reasoningView []byte
thinkStart time.Time
// reasoningNative is the Termux/native-scrollback path: reasoning is buffered
// without a live transcript block, then appended once as a final summary.
reasoningNative bool
thinkStart time.Time
// answerIdx is the transcript index of the streaming answer block (rewritten in
// place as completed paragraphs arrive); -1 when none is open. answerFlushed is
// how many bytes of pending have already been rendered into it, so a Text packet
@@ -431,10 +434,12 @@ func newChatTUI(ctrl *control.Controller, missing string, eventCh chan event.Eve
sp.Style = themeStyle(activeCLITheme.accent)
commitBuf := []string{}
nativeScrollback := detectTermuxTerminal()
return chatTUI{
ctrl: ctrl,
label: ctrl.Label(),
missing: missing,
nativeScrollback: nativeScrollback,
input: ti,
spinner: sp,
submittedInputCursor: -1,
@@ -448,6 +453,7 @@ func newChatTUI(ctrl *control.Controller, missing string, eventCh chan event.Eve
pending: &strings.Builder{},
pendingCommit: &commitBuf,
renderer: newMarkdownRenderer(termW),
showReasoning: nativeScrollback,
shellOutputs: make(map[string]string),
shellExpanded: make(map[string]bool),
shellTranscriptIdx: make(map[string]int),
@@ -479,6 +485,15 @@ func configureChatTextarea(ti *textarea.Model) {
ti.Focus()
}
func isTermuxTerminal() bool {
if os.Getenv("TERMUX_VERSION") != "" || os.Getenv("TERMUX_APP_PID") != "" || os.Getenv("TERMUX__PREFIX") != "" {
return true
}
return strings.Contains(os.Getenv("PREFIX"), "/com.termux/")
}
var detectTermuxTerminal = isTermuxTerminal
func (m *chatTUI) rememberSubmittedInput(input string) {
if strings.TrimSpace(input) == "" {
return
@@ -646,7 +661,7 @@ func (m chatTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// newest output) shifts the whole window. Some terminals (Warp) mishandle
// the renderer's scroll/insert-line optimization and strand stale rows, so
// force a full clear+redraw whenever the offset actually moved.
if cm.viewport.YOffset() != prevYOff {
if cm.viewport.YOffset() != prevYOff && !cm.nativeScrollback {
return cm, tea.Batch(tea.ClearScreen, cmd)
}
return cm, cmd
@@ -862,15 +877,12 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.completion = completion{}
break // fall through to regular Enter and submit the command
}
// When Enter is pressed and the completion has exactly one item
// already fully present in the input, close the menu and let Enter
// fall through to submit the command (/resume 3 → resume session 3).
if msg.String() == "enter" && len(m.completion.items) == 1 {
tok := m.input.Value()[m.completion.replaceFrom:]
if tok == m.completion.items[0].insert {
m.completion = completion{}
break // fall through to regular Enter
}
// When Enter is pressed and the selected completion is already fully
// present in the input, close the menu and submit instead of accepting
// the same item again (/resume 1 still has /resume 10 as a prefix match).
if msg.String() == "enter" && m.completionSelectedInsertPresent() {
m.completion = completion{}
break // fall through to regular Enter
}
m.acceptCompletion()
return m, nil
@@ -1005,6 +1017,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.state == tuiRunning {
line := strings.TrimSpace(m.input.Value())
if line == "" {
m.viewport.GotoBottom()
return m, nil
}
if m.queueEditCursor >= 0 && m.queueEditCursor < len(m.pendingInterject) {
@@ -1029,6 +1042,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
line := strings.TrimSpace(m.input.Value())
if line == "" {
m.viewport.GotoBottom()
return m, nil
}
if line == "exit" || line == "quit" || line == ":q" {
@@ -1290,13 +1304,58 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, finalize(m, cmds)
}
// finalize drains the committed-line queue (its content is already mirrored in
// the transcript, which the viewport renders) and batches the turn's commands.
// finalize drains the committed-line queue and batches the turn's commands. In
// the default alt-screen path the queue is already mirrored in m.transcript. In
// Termux we cannot enable mouse mode without breaking soft-keyboard focus, so
// finalized lines are also emitted into the terminal's native scrollback.
func finalize(m chatTUI, cmds []tea.Cmd) tea.Cmd {
if m.nativeScrollback && len(*m.pendingCommit) > 0 {
out := strings.TrimRight(clampWidth(strings.Join(*m.pendingCommit, "\n"), m.width), "\n")
*m.pendingCommit = (*m.pendingCommit)[:0]
var prints []tea.Cmd
for _, chunk := range chunkLines(out, m.scrollChunkHeight()) {
prints = append(prints, tea.Println(chunk))
}
cmds = append(cmds, tea.Sequence(prints...))
return tea.Batch(cmds...)
}
*m.pendingCommit = (*m.pendingCommit)[:0]
return tea.Batch(cmds...)
}
// scrollChunkHeight is the largest block (in lines) finalize prints at once in
// native-scrollback mode, leaving room for the pinned bottom frame.
func (m chatTUI) scrollChunkHeight() int {
if m.height <= 0 {
return 100
}
if n := m.height - m.bottomRows(); n > 1 {
return n
}
return 1
}
// chunkLines splits s into blocks of at most n lines each, preserving order and
// line content. A single block is returned when it already fits.
func chunkLines(s string, n int) []string {
if n < 1 {
n = 1
}
lines := strings.Split(s, "\n")
if len(lines) <= n {
return []string{s}
}
var out []string
for i := 0; i < len(lines); i += n {
end := i + n
if end > len(lines) {
end = len(lines)
}
out = append(out, strings.Join(lines[i:end], "\n"))
}
return out
}
// clampWidth hard-breaks any line wider than width so no scrollback line wraps
// in the terminal. bubbletea's inline renderer estimates how far to scroll for
// each printed block from each line's width (insertAbove: offset += width/w); an
@@ -1333,7 +1392,8 @@ func (m *chatTUI) commitSpacer() {
// bottomRows is the terminal-row height of the pinned bottom region: any open
// bottom panels (todo / approval / chooser / rewind / completion), the composer
// when visible, and the two fixed status rows. Full-screen managers such as MCP
// and skills render inside the main transcript area instead of the bottom rail.
// and skills normally render inside the main transcript area; in native
// scrollback mode they join the bottom rail because there is no main viewport.
func (m chatTUI) bottomRows() int {
rows := 0
for _, s := range []string{
@@ -1352,6 +1412,11 @@ func (m chatTUI) bottomRows() int {
if m.state == tuiRunning {
rows++ // the working spinner line above the box
}
if m.nativeScrollback {
if main := m.renderMainManager(); main != "" {
rows += strings.Count(main, "\n") + 1
}
}
if footer := m.renderMainManagerFooter(); footer != "" {
rows += strings.Count(footer, "\n") + 1
}
@@ -1474,10 +1539,10 @@ const reasoningTailLines = 12
// bounded trailing view (mirrors streamToolOutput), so the chain of thought is
// visible while the model works without re-rendering the whole thing per token.
func (m *chatTUI) streamReasoning(chunk string) {
m.reasoning.WriteString(chunk) // full text retained for verbose mode
if m.reasoningTextIdx < 0 {
return
}
m.reasoning.WriteString(chunk) // full text retained for verbose mode
m.reasoningView = append(m.reasoningView, chunk...)
if len(m.reasoningView) > reasoningViewMax {
drop := len(m.reasoningView) - reasoningViewMax
@@ -1537,8 +1602,12 @@ func (m *chatTUI) streamToolOutput(id, chunk string) {
m.toolTail = m.toolTail[:0]
m.toolPartial = ""
m.toolLineCount = 0
m.toolStreamIdx = len(m.transcript)
m.commitLine("")
if m.nativeScrollback {
m.toolStreamIdx = -1
} else {
m.toolStreamIdx = len(m.transcript)
m.commitLine("")
}
}
// Accumulate full output for shell commands so Ctrl+B can expand it.
if strings.HasPrefix(id, "shell-") {
@@ -1560,6 +1629,9 @@ func (m *chatTUI) streamToolOutput(id, chunk string) {
if m.toolPartial != "" {
vis = append(append([]string{}, m.toolTail...), m.toolPartial)
}
if m.nativeScrollback {
return
}
lines := make([]string, len(vis))
for i, ln := range vis {
lines[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
@@ -1585,6 +1657,44 @@ func (m *chatTUI) pushToolLine(line string) {
// prefix), it shows the first shellPreviewLines with a Ctrl+B hint instead.
// No-op when id isn't streaming.
func (m *chatTUI) collapseToolOutput(id string) {
if m.nativeScrollback {
if id == "" || m.toolStreamID != id {
return
}
n := m.toolLineCount
if m.toolPartial != "" {
n++
}
if n > 0 {
if full, ok := m.shellOutputs[id]; ok {
lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
total := len(lines)
if total > shellPreviewLines {
preview := make([]string, shellPreviewLines+1)
for i := 0; i < shellPreviewLines; i++ {
preview[i] = dim(clampPlain(lines[i], m.width-len([]rune(connector))))
}
preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
m.commitLine(connectorBlock(preview))
} else {
rendered := make([]string, total)
for i, ln := range lines {
rendered[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
}
m.commitLine(connectorBlock(rendered))
}
m.shellTranscriptIdx[id] = len(m.transcript) - 1
} else {
m.commitLine(connectorBlock([]string{dim(fmt.Sprintf("%d lines", n))}))
}
}
m.toolStreamIdx = -1
m.toolStreamID = ""
m.toolTail = m.toolTail[:0]
m.toolPartial = ""
m.toolLineCount = 0
return
}
if m.toolStreamIdx < 0 || id == "" || m.toolStreamID != id {
return
}
@@ -1683,6 +1793,9 @@ func (m *chatTUI) toggleShellOutput() {
m.transcript[lastIdx] = connectorBlock(rendered)
}
m.transcriptDirty = true
if m.nativeScrollback {
m.commitLine(m.transcript[lastIdx])
}
}
// toolWorkingFrames is the braille spinner cycled once per second on the
@@ -1703,6 +1816,10 @@ func (m *chatTUI) beginToolRunning(id string) {
m.toolLineCount = 0
m.toolStreamStart = time.Now()
m.toolStreamFrame = 0
if m.nativeScrollback {
m.toolStreamIdx = -1
return
}
m.toolStreamIdx = len(m.transcript)
m.commitLine(connectorBlock([]string{dim(fmt.Sprintf(i18n.M.ChatToolWorkingFmt, toolWorkingFrames[0], 0))}))
}
@@ -1710,6 +1827,9 @@ func (m *chatTUI) beginToolRunning(id string) {
// tickToolRunning re-renders the working line of a tool that's dispatched but
// hasn't produced output yet. A no-op once output streams in or no tool runs.
func (m *chatTUI) tickToolRunning() {
if m.nativeScrollback {
return
}
if m.toolStreamIdx < 0 || m.toolLineCount != 0 || m.toolPartial != "" {
return
}
@@ -1725,6 +1845,21 @@ func (m *chatTUI) tickToolRunning() {
// removed (collapsed) — kept only in verbose mode. The viewport re-wraps from
// m.transcript, so the change is flagged via transcriptDirty.
func (m *chatTUI) commitReasoning() {
if m.reasoningNative {
if strings.TrimSpace(m.reasoning.String()) != "" || !m.thinkStart.IsZero() {
secs := int(time.Since(m.thinkStart).Seconds())
m.commitSpacer()
m.commitLine(dim(fmt.Sprintf(" ▎ "+i18n.M.ChatThoughtForFmt, secs)))
if m.showReasoning && strings.TrimSpace(m.reasoning.String()) != "" {
m.commitLine(reasoningBlock(m.reasoning.String(), m.width, 0))
}
}
m.reasoning.Reset()
m.reasoningView = m.reasoningView[:0]
m.reasoningNative = false
m.thinkStart = time.Time{}
return
}
if m.reasoningLineIdx < 0 {
return
}
@@ -1751,6 +1886,9 @@ func (m *chatTUI) commitReasoning() {
// stays buffered (a half-written fence/list never renders early), and it only
// re-renders when a new paragraph actually closes.
func (m *chatTUI) streamAnswer() {
if m.nativeScrollback {
return
}
prefix := flushableMarkdownPrefix(m.pending.String())
if len(prefix) <= m.answerFlushed {
return
@@ -1828,38 +1966,57 @@ func flushableMarkdownPrefix(buf string) string {
const planApprovalTool = "exit_plan_mode"
// handleApprovalKey resolves a pending approval from a keystroke and re-arms the
// listener. 1/y/Enter allows once, 2/a allows for the rest of the session,
// 3/p writes an "always allow" rule to the config file, 4/n/Esc denies.
// listener. 1/y/Enter allows once, 2/a allows for the rest of the session, and
// n/Esc denies. Bash approvals with a safe prefix use 2/a for the prefix
// session grant and 3/p for the persisted prefix. For standard approvals, 3/p
// writes an "always allow" rule to the config file.
// Ctrl-C cancels the whole turn via the run context. For a plan approval
// (planApprovalTool), allowing also drops the local [plan] tag — the
// controller turns plan mode off on its side.
func (m chatTUI) handleApprovalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
answer := func(allow, session, persist bool) (tea.Model, tea.Cmd) {
answer := func(allow, session, persist bool, scope string) (tea.Model, tea.Cmd) {
if allow && m.pendingApproval.Tool == planApprovalTool {
m.planMode = false
}
m.ctrl.Approve(m.pendingApproval.ID, allow, session, persist)
m.ctrl.ApproveWithScope(m.pendingApproval.ID, allow, session, persist, scope)
m.pendingApproval = nil
return m, nil // the next ApprovalRequest / event arrives on eventCh
}
hasBashPrefix := m.pendingApproval.Tool == "bash" && permission.BashCommandPrefix(m.pendingApproval.Subject) != ""
switch msg.String() {
case "ctrl+c":
m.ctrl.Cancel() // cancels the run; the approver unblocks via ctx.Done()
return answer(false, false, false)
return answer(false, false, false, permission.ApprovalScopeExact)
case "enter":
return answer(true, false, false)
return answer(true, false, false, permission.ApprovalScopeExact)
case "esc":
return answer(false, false, false)
return answer(false, false, false, permission.ApprovalScopeExact)
}
switch strings.ToLower(msg.String()) {
case "y", "1":
return answer(true, false, false)
return answer(true, false, false, permission.ApprovalScopeExact)
case "a", "2":
return answer(true, true, false) // session grant
case "p", "3":
return answer(true, true, true) // persist to config
case "n", "4":
return answer(false, false, false)
if hasBashPrefix {
return answer(true, true, false, permission.ApprovalScopePrefix) // prefix session grant
}
return answer(true, true, false, permission.ApprovalScopeExact) // session grant
case "3":
if hasBashPrefix {
return answer(true, true, true, permission.ApprovalScopePrefix) // persist prefix to config
}
return answer(true, true, true, permission.ApprovalScopeExact) // persist to config
case "p":
if hasBashPrefix {
return answer(true, true, true, permission.ApprovalScopePrefix) // persist prefix to config
}
return answer(true, true, true, permission.ApprovalScopeExact) // persist to config
case "4":
if hasBashPrefix {
return answer(false, false, false, permission.ApprovalScopeExact)
}
return answer(false, false, false, permission.ApprovalScopeExact)
case "n", "5":
return answer(false, false, false, permission.ApprovalScopeExact)
}
return m, nil // ignore anything else while awaiting a decision
}
@@ -2032,6 +2189,12 @@ func (m chatTUI) View() tea.View {
parts = append(parts, menu)
rowsAboveBox += strings.Count(menu, "\n") + 1
}
if m.nativeScrollback {
if card := m.renderMainManager(); card != "" {
parts = append(parts, card)
rowsAboveBox += strings.Count(card, "\n") + 1
}
}
// Layout: the working spinner (when running), then the composer when visible,
// then the two status rows (line 1 = mode + run config + worktree identity, line 2 = live run data).
// Each row is clamped to width independently so neither wraps; padding to full
@@ -2054,6 +2217,18 @@ func (m chatTUI) View() tea.View {
}
parts = append(parts, statusBlockStyle.Width(boxW).MaxWidth(boxW).Render(statusBlock))
if m.nativeScrollback {
v := tea.NewView(strings.Join(parts, "\n"))
if !hideComposer {
if cur := m.input.Cursor(); cur != nil {
cur.X += 1
cur.Y += rowsAboveBox + 1
v.Cursor = cur
}
}
return v
}
// Full-screen frame: the transcript viewport on top (it pads to exactly its
// height), the pinned bottom region beneath. Alt-screen owns the grid, so
// resize repaints cleanly — no scrollback reflow, no ghost borders.
@@ -2238,7 +2413,14 @@ func (m chatTUI) renderApprovalBanner() string {
if subj != "" {
subj = " " + truncateSubject(subj, w)
}
text := fmt.Sprintf(i18n.M.ToolApprovalPromptFmt, name, subj, detail)
exactSessionRule := permission.SessionGrantRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject, permission.ApprovalScopeExact)
exactPersistentRule := permission.RememberRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject, permission.ApprovalScopeExact)
choices := fmt.Sprintf(i18n.M.ToolApprovalChoices, exactSessionRule, exactPersistentRule)
if m.pendingApproval.Tool == "bash" && permission.BashCommandPrefix(m.pendingApproval.Subject) != "" {
prefixRule := permission.RememberRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject, permission.ApprovalScopePrefix)
choices = fmt.Sprintf(i18n.M.BashPrefixChoices, prefixRule, prefixRule)
}
text := fmt.Sprintf(i18n.M.ToolApprovalPromptFmt, name, subj, detail, choices)
return approvalBannerStyle.Width(w).Render("⏸ " + text)
}
@@ -2783,6 +2965,14 @@ func (m *chatTUI) ingestEvent(e event.Event) {
}
switch e.Kind {
case event.Reasoning:
if m.nativeScrollback {
if !m.reasoningNative {
m.thinkStart = time.Now()
m.reasoningNative = true
}
m.streamReasoning(e.Text)
break
}
if m.reasoningLineIdx < 0 {
// Show the marker plus a live text block the moment thinking starts; the
// text streams in below it and the block collapses to "thought for Ns"
@@ -2963,7 +3153,7 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
// guidance steering what the summary keeps.
focus := strings.TrimSpace(strings.TrimPrefix(input, cmd))
return func() tea.Msg { return compactDoneMsg{err: m.ctrl.Compact(context.Background(), focus)} }
case "/new":
case "/new", "/clear":
m.echoLocalCommand(input)
if err := m.ctrl.NewSession(); err != nil {
m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashNewFailed, err))
@@ -2987,6 +3177,9 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
m.notice(i18n.M.SlashTodoCleared)
case "/verbose":
m.toggleVerboseReasoning(true)
case "/sandbox":
m.echoLocalCommand(input)
m.showSandboxStatus()
case "/effort":
return m.runEffortCommand(input)
case "/auto-plan":
@@ -3090,6 +3283,41 @@ func (m *chatTUI) commandNames() string {
return strings.Join(names, " · ")
}
// showSandboxStatus displays the current sandbox configuration and whether
// the OS sandbox backend is available. It reads from the stored config so
// the user can inspect sandbox state without leaving the TUI (closes #3316).
func (m *chatTUI) showSandboxStatus() {
if m.cfg == nil {
m.notice("sandbox: config not loaded")
return
}
bash := m.cfg.BashMode()
network := m.cfg.Sandbox.Network
available := sandbox.Available()
roots := m.cfg.WriteRoots()
var b strings.Builder
b.WriteString("sandbox\n")
b.WriteString(" phase 0 file-writer confinement\n")
if len(roots) > 0 {
fmt.Fprintf(&b, " write_roots %s\n", strings.Join(roots, ", "))
}
if m.cfg.Sandbox.WorkspaceRoot != "" {
fmt.Fprintf(&b, " workspace_root %s\n", m.cfg.Sandbox.WorkspaceRoot)
}
if len(m.cfg.Sandbox.AllowWrite) > 0 {
fmt.Fprintf(&b, " allow_write %s\n", strings.Join(m.cfg.Sandbox.AllowWrite, ", "))
}
b.WriteString(" phase 1 OS bash sandbox\n")
fmt.Fprintf(&b, " bash %s", bash)
if bash == "enforce" && !available {
b.WriteString(" (inactive: no OS sandbox on this host — bash runs unconfined)")
}
b.WriteString("\n")
fmt.Fprintf(&b, " network %v\n", network)
m.notice(b.String())
}
// runMCPSubcommand handles "/mcp" (status), "/mcp add …" (connect a server live
// and persist it), and "/mcp remove <name>" (disconnect + drop from config). Add
// connects synchronously — like /compact, an explicit command may briefly block
+114
View File
@@ -24,6 +24,14 @@ import (
type blockingTurnRunner struct{ started chan struct{} }
func TestMain(m *testing.M) {
old := detectTermuxTerminal
detectTermuxTerminal = func() bool { return false }
code := m.Run()
detectTermuxTerminal = old
os.Exit(code)
}
func (r *blockingTurnRunner) Run(ctx context.Context, _ string) error {
close(r.started)
<-ctx.Done()
@@ -98,6 +106,45 @@ func TestTranscriptMirrorsCommits(t *testing.T) {
}
}
func TestTermuxNativeScrollbackCommitsFinalAnswer(t *testing.T) {
m := newTestChatTUI()
m.nativeScrollback = true
m.pending.WriteString("first paragraph\n\nsecond paragraph")
m.streamAnswer()
if len(*m.pendingCommit) != 0 {
t.Fatalf("Termux native scrollback should not commit rewritten streaming blocks, got %v", *m.pendingCommit)
}
m.commitPending()
if got := strings.Join(*m.pendingCommit, "\n"); !strings.Contains(got, "first paragraph") || !strings.Contains(got, "second paragraph") {
t.Fatalf("final answer was not committed to native scrollback: %v", *m.pendingCommit)
}
}
func TestTermuxNativeScrollbackDefaultsToExpandedReasoning(t *testing.T) {
old := detectTermuxTerminal
detectTermuxTerminal = func() bool { return true }
t.Cleanup(func() { detectTermuxTerminal = old })
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
if !m.nativeScrollback {
t.Fatal("Termux should use native scrollback")
}
if !m.showReasoning {
t.Fatal("Termux should expand reasoning by default because live viewport reasoning is unavailable")
}
m.width = 80
m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "reasoning details"})
m.ingestEvent(event.Event{Kind: event.Text, Text: "answer"})
got := strings.Join(*m.pendingCommit, "\n")
if !strings.Contains(got, "reasoning details") {
t.Fatalf("Termux reasoning was not expanded into native scrollback: %q", got)
}
}
// TestTranscriptViewportSizing proves the viewport tracks the terminal size and
// gets the rows left over after the pinned bottom region (input box + 2 status
// rows = 5 with an empty 1-line composer), and is fed the committed transcript.
@@ -992,6 +1039,7 @@ func TestQueueIndicatorHiddenWhenIdle(t *testing.T) {
func TestViewAltScreenFillsHeight(t *testing.T) {
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
m.nativeScrollback = false
m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
v := m0.(chatTUI).View()
@@ -1006,6 +1054,24 @@ func TestViewAltScreenFillsHeight(t *testing.T) {
}
}
func TestViewTermuxUsesNativeScrollback(t *testing.T) {
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
m.nativeScrollback = true
m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
v := m0.(chatTUI).View()
if v.AltScreen {
t.Error("Termux view must stay in the normal screen so native touch scrollback works")
}
if v.MouseMode != tea.MouseModeNone {
t.Error("Termux view must not enable mouse mode because it prevents soft-keyboard focus")
}
if lines := strings.Count(v.Content, "\n") + 1; lines >= 24 {
t.Errorf("Termux view should render only the pinned bottom frame, got %d full-screen lines", lines)
}
}
// TestTranscriptTailFollow proves the viewport pins to newest output while the
// user is at the bottom, and stops yanking once the user scrolls up.
func TestTranscriptTailFollow(t *testing.T) {
@@ -1035,6 +1101,54 @@ func TestTranscriptTailFollow(t *testing.T) {
}
}
// TestEmptyEnterScrollsToBottom proves that pressing Enter with an empty composer
// scrolls the viewport to the bottom in both idle and running states, so the user
// can quickly tail-follow after scrolling up to read history.
func TestEmptyEnterScrollsToBottom(t *testing.T) {
ctrl := control.New(control.Options{})
ch := make(chan event.Event, 1)
notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
adv := func(m chatTUI, msg tea.Msg) chatTUI {
n, _ := m.Update(msg)
return n.(chatTUI)
}
// --- idle state ---
t.Run("idle", func(t *testing.T) {
cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
for i := 0; i < 12; i++ {
cur = adv(cur, notice)
}
// Scroll up to leave the bottom.
cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
if cur.viewport.AtBottom() {
t.Fatal("wheel-up should break the bottom pin")
}
// Empty enter → should snap back to bottom.
cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
if !cur.viewport.AtBottom() {
t.Error("empty enter while idle should scroll viewport to bottom")
}
})
// --- running state ---
t.Run("running", func(t *testing.T) {
cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
for i := 0; i < 12; i++ {
cur = adv(cur, notice)
}
cur.state = tuiRunning
cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
if cur.viewport.AtBottom() {
t.Fatal("wheel-up should break the bottom pin")
}
cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
if !cur.viewport.AtBottom() {
t.Error("empty enter while running should scroll viewport to bottom")
}
})
}
func TestFoldedPasteUsesPlaceholderAndExpandsOnSend(t *testing.T) {
m := newTestChatTUI()
pasted := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3,\n \"d\": 4\n}"
+4 -4
View File
@@ -157,10 +157,10 @@ func (m chatTUI) chooserActivate(row int) (tea.Model, tea.Cmd) {
switch {
case row < len(q.Options):
if q.Multi {
c.sel[c.tab][row] = !c.sel[c.tab][row]
c.custom[c.tab] = ""
c.cursor = row
return m, nil
// Space toggles; Enter confirms current selections and advances.
// (Toggling is handled in handleChooserKey; we only arrive here
// via Enter or number keys, both of which should commit.)
return m.chooserAdvance()
}
c.sel[c.tab] = map[int]bool{row: true}
c.custom[c.tab] = ""
+52 -3
View File
@@ -93,6 +93,9 @@ func Run(args []string, version string) int {
case "doctor":
configureCLIThemeFromConfigNoProbe()
return doctorCommand(rest, version)
case "review":
configureCLIThemeFromConfigNoProbe()
return reviewCommand(rest)
case "version", "--version", "-v":
fmt.Println("reasonix", version)
return 0
@@ -202,6 +205,9 @@ func runAgent(args []string) int {
showThinking := fs.Bool("show-thinking", false, "show thinking text instead of the collapsed thinking marker")
metricsPath := fs.String("metrics", "", "write a JSON token/cache/cost summary of the run to this path")
dir := fs.String("dir", "", "change to this directory first (project root); config, sandbox and file tools resolve from here")
cont := fs.Bool("continue", false, "resume the most recent saved session")
fs.BoolVar(cont, "c", false, "shorthand for --continue")
resume := fs.String("resume", "", "resume a specific session file (non-interactive; takes precedence over --continue)")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -250,6 +256,38 @@ func runAgent(args []string) int {
}
defer ctrl.Close()
// --resume: load a specific session file (non-interactive, meant for
// MCP/API callers that manage their own per-project session). Takes
// precedence over --continue.
// --continue: resume the most recent saved session.
if *resume != "" {
loaded, err := agent.LoadSession(*resume)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
ctrl.Resume(loaded, *resume)
} else if *cont {
sessions, err := agent.ListSessions(config.SessionDir())
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if len(sessions) == 0 {
fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume)
return 1
}
loaded, err := agent.LoadSession(sessions[0].Path)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
ctrl.Resume(loaded, sessions[0].Path)
}
if ctrl.SessionPath() == "" && ctrl.SessionDir() != "" {
ctrl.SetSessionPath(agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
}
runErr := ctrl.Run(ctx, prompt)
if cfg != nil {
notify.SendEvent(newNotificationSender(), cfg.Notifications, event.Event{Kind: event.TurnDone, Err: runErr})
@@ -432,6 +470,7 @@ func chatREPL(args []string) int {
if cfg, err := config.Load(); err == nil {
m.outputStyle = cfg.Agent.OutputStyle // shown as the active entry in /output-style
m.statuslineCmd = cfg.Statusline.Command // custom status-line command, "" = built-in row
m.cfg = cfg
}
// /model support: a pure builder the TUI calls to rebuild on a different
@@ -468,9 +507,13 @@ func chatREPL(args []string) int {
}
m.refreshEffortStatus()
// No alt-screen: finalized transcript lines are committed to the terminal's
// normal buffer (via tea.Println) so native scrollback, the wheel, and copy
// all work — the bubbletea-managed region is just the bottom input/status.
if m.nativeScrollback {
reserveNativeScrollbackFrame(os.Stdout, m.bottomRows())
}
// Non-Termux terminals use an alt-screen transcript viewport. Termux stays
// in the normal buffer so native touch scrollback and soft-keyboard focus
// keep working; finalized transcript lines are emitted via tea.Println.
p := tea.NewProgram(m)
final, runErr := p.Run()
// Close the active controller plus any retired ones from /model switches.
@@ -497,6 +540,12 @@ func chatREPL(args []string) int {
return 0
}
func reserveNativeScrollbackFrame(w io.Writer, rows int) {
for i := 0; i < rows; i++ {
fmt.Fprintln(w)
}
}
// setupTargets is where the wizard writes: the TOML config and the secrets file.
// Keys always go to the reasonix-owned global credentials file so they never land
// in a project's own .env; only the config location is project-local under --local.
+17 -1
View File
@@ -49,6 +49,19 @@ func TestChdirTo(t *testing.T) {
}
}
func TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {
var b bytes.Buffer
reserveNativeScrollbackFrame(&b, 3)
if got := b.String(); got != "\n\n\n" {
t.Fatalf("reserveNativeScrollbackFrame wrote %q, want only three newlines", got)
}
reserveNativeScrollbackFrame(&b, 0)
if got := b.String(); got != "\n\n\n" {
t.Fatalf("reserveNativeScrollbackFrame(0) changed output to %q", got)
}
}
func mustGetwd(t *testing.T) string {
t.Helper()
cwd, err := os.Getwd()
@@ -92,9 +105,12 @@ func TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {
t.Fatalf("help rc = %d, want 0", rc)
}
})
if !strings.Contains(out, "Usage:") {
if !strings.Contains(out, "Usage:") && !strings.Contains(out, "用法:") {
t.Fatalf("help output missing usage:\n%s", out)
}
if !strings.Contains(out, "reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] <task>") {
t.Fatalf("help output missing run resume flags:\n%s", out)
}
}
func TestRunDispatchesACPLongFlagAlias(t *testing.T) {
+1 -1
View File
@@ -60,7 +60,7 @@ func codegraphStatus() int {
}
fmt.Printf("%-13s %v\n", "enabled:", cfg.Codegraph.Enabled)
fmt.Printf("%-13s %v\n", "auto_install:", cfg.Codegraph.AutoInstall)
fmt.Printf("%-13s %s\n", "tier:", cfg.Codegraph.ResolvedTier())
fmt.Printf("%-13s %s\n", "startup:", cfg.Codegraph.ResolvedTier())
fmt.Printf("%-13s %s\n", "version:", codegraph.Version)
fmt.Printf("%-13s %s\n", "cache:", codegraph.CacheDir())
if p, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
+13
View File
@@ -62,6 +62,7 @@ func (m *chatTUI) slashItems() []compItem {
items := []compItem{
{label: "/compact", insert: "/compact ", hint: i18n.M.CmdCompact},
{label: "/new", insert: "/new ", hint: i18n.M.CmdNew},
{label: "/clear", insert: "/clear", hint: i18n.M.CmdNew},
{label: "/resume", insert: "/resume ", hint: i18n.M.CmdResume},
{label: "/rewind", insert: "/rewind", hint: i18n.M.CmdRewind},
{label: "/tree", insert: "/tree", hint: i18n.M.CmdTree},
@@ -74,6 +75,7 @@ func (m *chatTUI) slashItems() []compItem {
{label: "/paste-image", insert: "/paste-image", hint: i18n.M.CmdPasteImage},
{label: "/output-style", insert: "/output-style", hint: i18n.M.CmdOutputStyle},
{label: "/verbose", insert: "/verbose", hint: i18n.M.CmdVerbose},
{label: "/sandbox", insert: "/sandbox", hint: i18n.M.CmdSandbox},
{label: "/effort", insert: "/effort ", hint: i18n.M.CmdEffort, descend: true},
{label: "/auto-plan", insert: "/auto-plan ", hint: i18n.M.CmdAutoPlan, descend: true},
{label: "/theme", insert: "/theme ", hint: i18n.M.CmdTheme, descend: true},
@@ -474,6 +476,17 @@ func (m *chatTUI) completionBareOverlayCommand() bool {
}
}
func (m *chatTUI) completionSelectedInsertPresent() bool {
if !m.completion.active || m.completion.sel >= len(m.completion.items) {
return false
}
val := m.input.Value()
if m.completion.replaceFrom > len(val) {
return false
}
return val[m.completion.replaceFrom:] == m.completion.items[m.completion.sel].insert
}
// acceptCompletion applies the selected item to the input, then recomputes the
// menu from the new value: it re-opens one level deeper (a descended directory
// or a freshly completed command's arguments) or closes when nothing applies.
+22
View File
@@ -330,6 +330,28 @@ func TestEnterOnMCPWithTrailingSpaceSubmitsManager(t *testing.T) {
}
}
func TestEnterOnExactSlashArgSubmitsWhenPrefixAlsoMatches(t *testing.T) {
m := newTestChatTUI()
m.ctrl = control.New(control.Options{SessionDir: t.TempDir()})
m.input.SetValue("/resume 1")
m.completion = completion{
active: true,
kind: compSlashArg,
items: []compItem{{label: "1", insert: "1"}, {label: "10", insert: "10"}},
sel: 0,
replaceFrom: len("/resume "),
}
got, _ := m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
next := got.(chatTUI)
if next.completion.active {
t.Fatalf("Enter on exact selected arg should close completion: %+v", next.completion)
}
if got := next.input.Value(); got != "" {
t.Fatalf("Enter on exact selected arg should submit command, input=%q", got)
}
}
// TestSlashArgCompletionRemoveNoHost proves "/mcp remove " stays closed when no
// servers are connected (nothing to suggest), rather than showing an empty box.
func TestSlashArgCompletionRemoveNoHost(t *testing.T) {
+2
View File
@@ -57,6 +57,7 @@ func builtinHelpItems() []compItem {
return []compItem{
{label: "/compact", hint: i18n.M.CmdCompact},
{label: "/new", hint: i18n.M.CmdNew},
{label: "/clear", hint: i18n.M.CmdNew},
{label: "/rewind", hint: i18n.M.CmdRewind},
{label: "/tree", hint: i18n.M.CmdTree},
{label: "/branch", hint: i18n.M.CmdBranch},
@@ -68,6 +69,7 @@ func builtinHelpItems() []compItem {
{label: "/hooks", hint: i18n.M.CmdHooks},
{label: "/memory", hint: i18n.M.CmdMemory},
{label: "/output-style", hint: i18n.M.CmdOutputStyle},
{label: "/sandbox", hint: i18n.M.CmdSandbox},
{label: "/language", hint: i18n.M.CmdLanguage},
{label: "/auto-plan", hint: i18n.M.CmdAutoPlan},
{label: "/help", hint: i18n.M.CmdHelp},
-2
View File
@@ -364,8 +364,6 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
status := "initializing"
if m.mcpDisabled["codegraph"] || !loadedCfg.Codegraph.Enabled {
status = "disabled"
} else if loadedCfg.Codegraph.ResolvedTier() == "lazy" {
status = "deferred"
}
snap.servers = append(snap.servers, withMCPCodegraphConfig(mcpServerView{
Name: "codegraph", Status: status,
+2 -2
View File
@@ -171,7 +171,7 @@ func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) {
}
if v.BuiltIn && v.Name == "codegraph" {
cfg.Codegraph.Enabled = true
cfg.Codegraph.Tier = normalizeMCPTierForCLI(tier)
cfg.Codegraph.Tier = ""
if err := cfg.Save(); err != nil {
m.notice("mcp mode: " + err.Error())
return m, nil
@@ -179,7 +179,7 @@ func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) {
if m.mcpDisabled != nil {
delete(m.mcpDisabled, v.Name)
}
if tier != "lazy" && m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) {
if m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) {
if _, err := m.ctrl.ConnectConfiguredMCPServer(v.Name); err != nil {
recordMCPModeCodegraphFailure(m.ctrl, cfg.Codegraph, err)
m.notice("saved connection mode, but connect failed: " + err.Error())
+3 -3
View File
@@ -459,7 +459,7 @@ func TestApplyMCPModeRecordsCodegraphConnectFailure(t *testing.T) {
t.Setenv("REASONIX_CACHE_DIR", t.TempDir())
cfg := config.Default()
cfg.Codegraph.Enabled = false
cfg.Codegraph.Tier = "lazy"
cfg.Codegraph.Tier = "eager"
if err := cfg.SaveTo("reasonix.toml"); err != nil {
t.Fatalf("save config: %v", err)
}
@@ -472,11 +472,11 @@ func TestApplyMCPModeRecordsCodegraphConnectFailure(t *testing.T) {
stage: mcpStageMode,
name: "codegraph",
snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{
Name: "codegraph", Transport: "stdio", Status: "disabled", BuiltIn: true, Configured: true, Tier: "lazy",
Name: "codegraph", Transport: "stdio", Status: "disabled", BuiltIn: true, Configured: true, Tier: "background",
}}},
}
_, _ = m.applyMCPMode("background")
_, _ = m.applyMCPMode("eager")
failures := m.ctrl.Host().Failures()
if len(failures) != 1 || failures[0].Name != "codegraph" {
+2 -2
View File
@@ -87,7 +87,7 @@ func (m *chatTUI) showModels() {
if !p.Configured() {
continue
}
for _, model := range p.ModelList() {
for _, model := range p.ChatModelList() {
refs = append(refs, p.Name+"/"+model)
}
}
@@ -106,7 +106,7 @@ func modelRefs() []string {
if !p.Configured() {
continue
}
for _, model := range p.ModelList() {
for _, model := range p.ChatModelList() {
out = append(out, p.Name+"/"+model)
}
}
+154
View File
@@ -0,0 +1,154 @@
package cli
import (
"context"
"flag"
"fmt"
"os"
"strings"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
func reviewCommand(args []string) int {
fs := flag.NewFlagSet("review", flag.ContinueOnError)
base := fs.String("base", "", "base branch/commit to diff against (defaults to HEAD — reviews uncommitted working-tree changes)")
commit := fs.String("commit", "", "review a specific commit (shows changes introduced by that commit)")
model := fs.String("model", "", "provider name override (default: config default_model)")
instructions := fs.String("instructions", "", "extra review instructions appended to the prompt")
if err := fs.Parse(args); err != nil {
return 2
}
// 1. Get the diff.
diff, err := getReviewDiff(*base, *commit)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if diff == "" {
fmt.Println("No changes to review.")
return 0
}
// 2. Load config and resolve model.
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to load config:", err)
return 1
}
modelName := *model
if modelName == "" {
modelName = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelName)
if !ok {
fmt.Fprintf(os.Stderr, "error: unknown model %q — check your config\n", modelName)
return 1
}
if err := cfg.Validate(modelName); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
// 3. Create provider.
prov, err := boot.NewProviderWithProxy(entry, cfg.NetworkProxySpec())
if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to create provider:", err)
return 1
}
// 4. Get the built-in review skill.
root, _ := os.Getwd()
skillStore := skill.New(skill.Options{ProjectRoot: root, Stderr: os.Stderr})
reviewSk, ok := skillStore.Read("review")
if !ok {
fmt.Fprintln(os.Stderr, "error: built-in review skill not found")
return 1
}
if reviewSk.RunAs != skill.RunSubagent {
fmt.Fprintln(os.Stderr, "error: review skill is not a subagent skill")
return 1
}
// 5. Build tool registry scoped to the review skill's allowed tools
// (read_file, ls, glob, grep, bash).
reg := tool.NewRegistry()
for _, name := range reviewSk.AllowedTools {
if tl, ok := tool.LookupBuiltin(name); ok {
reg.Add(tl)
}
}
// 6. Prepare the review prompt.
task := buildReviewTask(diff, *instructions)
// 7. Run the review subagent.
ctx := context.Background()
result, err := agent.RunSubAgent(ctx, prov, reg, reviewSk.Body, task, agent.Options{
MaxSteps: 12,
Temperature: cfg.Agent.Temperature,
Pricing: entry.Price,
ContextWindow: entry.ContextWindow,
}, event.Discard)
if err != nil {
fmt.Fprintln(os.Stderr, "error: review failed:", err)
return 1
}
fmt.Print(result)
return 0
}
// getReviewDiff runs the appropriate git diff command and returns its output.
// - commit="abc": shows diff of abc^..abc
// - base="main": shows diff of main...HEAD
// - neither: shows diff of uncommitted working-tree changes
func getReviewDiff(base, commit string) (string, error) {
cwd, _ := os.Getwd()
ctx := context.Background()
switch {
case commit != "":
return runGit(ctx, cwd, "diff", commit+"^.."+commit)
case base != "":
return runGit(ctx, cwd, "diff", base+"...HEAD")
default:
// Working tree changes: staged + unstaged.
out, err := runGit(ctx, cwd, "diff", "HEAD")
if err != nil {
return "", err
}
if out == "" {
// No working-tree changes; check for staged-only.
out, err = runGit(ctx, cwd, "diff", "--cached")
}
return out, err
}
}
func buildReviewTask(diff string, extra string) string {
var b strings.Builder
b.WriteString("Review the following changes. ")
if extra != "" {
b.WriteString(extra)
b.WriteString(" ")
}
b.WriteString("The diff is:\n\n```diff\n")
// Truncate huge diffs to protect the review subagent's context budget.
const maxLen = 16000
if len(diff) > maxLen {
b.WriteString(diff[:maxLen])
b.WriteString("\n```\n\n(diff truncated at ")
fmt.Fprint(&b, maxLen)
b.WriteString(" chars — focus on the changes shown)")
} else {
b.WriteString(diff)
b.WriteString("\n```")
}
return b.String()
}
+37
View File
@@ -0,0 +1,37 @@
package cli
import (
"strings"
"testing"
)
func TestBuildReviewTask(t *testing.T) {
// Small diff.
diff := "diff --git a/foo.go b/foo.go\n+added line"
got := buildReviewTask(diff, "")
if !strings.Contains(got, "Review the following changes.") {
t.Error("missing review prompt prefix")
}
if !strings.Contains(got, diff) {
t.Errorf("diff content missing:\n%s", got)
}
// With extra instructions.
got = buildReviewTask(diff, "focus on error handling")
if !strings.Contains(got, "focus on error handling") {
t.Error("extra instructions missing")
}
if !strings.Contains(got, "The diff is:") {
t.Error("missing diff separator")
}
// Truncation.
hugeDiff := strings.Repeat("x", 20000)
got = buildReviewTask(hugeDiff, "")
if !strings.Contains(got, "truncated at 16000") {
t.Error("large diff should be truncated")
}
if len(got) > 16500 {
t.Errorf("truncated output too long: %d", len(got))
}
}
-4
View File
@@ -25,7 +25,6 @@ type RunMetrics struct {
ReadinessRecoveries int `json:"readiness_recoveries"`
ReadinessErrors int `json:"readiness_errors"`
ReadinessMissingProjectChecks int `json:"readiness_missing_project_checks"`
ReadinessMissingCompleteSteps int `json:"readiness_missing_complete_steps"`
ReadinessIncompleteTodos int `json:"readiness_incomplete_todos"`
ReadinessCommandMismatches int `json:"readiness_command_mismatches"`
}
@@ -76,9 +75,6 @@ func (s *metricsSink) RecordReadinessAudit(a evidence.ReadinessAudit) {
s.m.ReadinessRecoveries++
}
s.m.ReadinessMissingProjectChecks += a.MissingProjectChecks
if a.MissingCompleteStep {
s.m.ReadinessMissingCompleteSteps++
}
s.m.ReadinessIncompleteTodos += a.IncompleteTodos
s.m.ReadinessCommandMismatches += a.CommandMismatchMissing
}
+1 -8
View File
@@ -16,7 +16,6 @@ func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
s.RecordReadinessAudit(evidence.ReadinessAudit{
Result: evidence.ReadinessBlocked,
MissingProjectChecks: 2,
MissingCompleteStep: true,
IncompleteTodos: 3,
CommandMismatchMissing: 2,
})
@@ -25,8 +24,7 @@ func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
Recovered: true,
})
s.RecordReadinessAudit(evidence.ReadinessAudit{
Result: evidence.ReadinessErrored,
MissingCompleteStep: true,
Result: evidence.ReadinessErrored,
})
if s.m.ReadinessChecks != 3 {
@@ -47,9 +45,6 @@ func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
if s.m.ReadinessMissingProjectChecks != 2 {
t.Fatalf("missing project checks = %d, want 2", s.m.ReadinessMissingProjectChecks)
}
if s.m.ReadinessMissingCompleteSteps != 2 {
t.Fatalf("missing complete_step = %d, want 2", s.m.ReadinessMissingCompleteSteps)
}
if s.m.ReadinessIncompleteTodos != 3 {
t.Fatalf("incomplete todos = %d, want 3", s.m.ReadinessIncompleteTodos)
}
@@ -72,7 +67,6 @@ func TestWriteMetricsIncludesReadinessFields(t *testing.T) {
ReadinessRecoveries: 1,
ReadinessErrors: 0,
ReadinessMissingProjectChecks: 0,
ReadinessMissingCompleteSteps: 0,
ReadinessIncompleteTodos: 0,
ReadinessCommandMismatches: 0,
}); err != nil {
@@ -94,7 +88,6 @@ func TestWriteMetricsIncludesReadinessFields(t *testing.T) {
"readiness_recoveries",
"readiness_errors",
"readiness_missing_project_checks",
"readiness_missing_complete_steps",
"readiness_incomplete_todos",
"readiness_command_mismatches",
} {
+14
View File
@@ -0,0 +1,14 @@
package codegraph
// releaseAssetSHA256 holds the upstream SHA256SUMS values for Version. These
// are baked into the reasonix binary so downloaded CodeGraph archives are
// verified against trusted metadata instead of trusting whichever mirror served
// the bytes.
var releaseAssetSHA256 = map[string]string{
"codegraph-darwin-arm64.tar.gz": "83a3b90bc334ab2a34240e29e9fab7ff273ab6381794aa6f3cba428397c916b5",
"codegraph-darwin-x64.tar.gz": "74d2331161a317fa6164285a61ec480ce7893be46c1677a4b5a2932e35586b9d",
"codegraph-linux-arm64.tar.gz": "7b4225f90ca5285cccfec099323129348c2753bcbc9910281f9b61db88fa5f37",
"codegraph-linux-x64.tar.gz": "61805e3c9b4052db53c71241b800859095fea4f2cbd2a1844a6c2b9594b9f84a",
"codegraph-win32-arm64.zip": "c728ada3d42701213dde26d8e94ded3ed1c7d4b568124210649ce8f9f938a31a",
"codegraph-win32-x64.zip": "a5571d3ee54cc1caac76bf09e0f7cb350fc4dd6788a5437217eac33b71fa7a15",
}
+21 -13
View File
@@ -40,17 +40,18 @@ You have codegraph tools for symbol-level code intelligence. For architecture qu
- codegraph_files — project file tree with symbol counts
Use grep/read_file for content search (comments, strings, config values) and when codegraph is not available.`
// BundleDirName is the directory, beside the reasonix executable, that the release
// archive unpacks the CodeGraph bundle into. Its launcher lives at
// <BundleDirName>/bin/codegraph, with the bundled node runtime and lib/ beside it;
// the launcher resolves those relative to itself, so the bundle is relocatable.
// BundleDirName is the optional directory, beside the reasonix executable, where
// an operator can place an unpacked CodeGraph bundle for offline use. Its
// launcher lives at <BundleDirName>/bin/codegraph, with the bundled node runtime
// and lib/ beside it; the launcher resolves those relative to itself, so the
// bundle is relocatable.
const BundleDirName = "codegraph"
// Resolve returns the absolute path to the CodeGraph launcher. Search order:
// 1. override — an explicit [codegraph].path from config (~ and ${VAR} expanded);
// 2. the per-version cache populated by Install (the normal case);
// 2. the per-version cache populated by Install;
// 3. a system-installed `codegraph` on PATH;
// 4. a bundle placed beside the reasonix executable (fallback for manual setups).
// 4. a bundle placed beside the executable (manual/offline fallback).
//
// ok is false when none resolves — the caller then triggers Install (or skips the
// feature), so the codegraph_* tools come online once the cache is populated.
@@ -76,6 +77,19 @@ func Resolve(override string) (string, bool) {
// The executable path is symlink-resolved first so a launcher installed via a
// symlink (e.g. a package manager's bin shim) still points at the real bundle.
func bundled() (string, bool) {
base, ok := bundledBaseDir()
if !ok {
return "", false
}
for _, rel := range launcherNames() {
if p := filepath.Join(base, rel); isExec(p) {
return p, true
}
}
return "", false
}
func bundledBaseDir() (string, bool) {
exe, err := os.Executable()
if err != nil {
return "", false
@@ -83,13 +97,7 @@ func bundled() (string, bool) {
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
base := filepath.Join(filepath.Dir(exe), BundleDirName)
for _, rel := range launcherNames() {
if p := filepath.Join(base, rel); isExec(p) {
return p, true
}
}
return "", false
return filepath.Join(filepath.Dir(exe), BundleDirName), true
}
// launcherNames are the bundle-relative launcher paths to try, per OS. The unix
+66 -15
View File
@@ -7,7 +7,6 @@ import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
@@ -24,6 +23,10 @@ const (
Version = "v0.9.7"
cgRepo = "colbymchenry/codegraph"
officialMirrorBase = "https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/codegraph"
officialMainlandMirrorBase = ""
perSourceDownloadTimeout = 15 * time.Second
renameAttempts = 5
renameBackoff = 200 * time.Millisecond
)
@@ -79,7 +82,8 @@ func assetName() string {
}
// Install downloads and unpacks the CodeGraph bundle into CacheDir on first use,
// verifying it against the release's SHA256SUMS, then returns the launcher path.
// verifying it against the checksum baked into the reasonix binary, then returns
// the launcher path.
// It is idempotent: a present cache is returned untouched. log, if non-nil,
// receives a couple of progress lines. The extraction is staged in a temp dir and
// atomically renamed into place, so a cancelled or failed run leaves no partial
@@ -104,22 +108,10 @@ func InstallWithClient(ctx context.Context, client *http.Client, log func(string
asset := assetName()
logf(log, "codegraph: downloading %s (%s, one-time)…", asset, Version)
base := fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version)
sums, err := httpGet(ctx, client, base+"/SHA256SUMS")
if err != nil {
return "", fmt.Errorf("codegraph: fetch checksums: %w", err)
}
want, err := sha256For(string(sums), asset)
data, err := downloadAsset(ctx, client, asset, log)
if err != nil {
return "", err
}
data, err := httpGet(ctx, client, base+"/"+asset)
if err != nil {
return "", fmt.Errorf("codegraph: download %s: %w", asset, err)
}
if got := sha256.Sum256(data); hex.EncodeToString(got[:]) != want {
return "", fmt.Errorf("codegraph: checksum mismatch for %s", asset)
}
parent := filepath.Dir(dir)
if err := os.MkdirAll(parent, 0o755); err != nil {
@@ -162,6 +154,65 @@ func InstallWithClient(ctx context.Context, client *http.Client, log func(string
return p, nil
}
func downloadAsset(ctx context.Context, client *http.Client, asset string, log func(string)) ([]byte, error) {
want := expectedAssetSHA256(asset)
if want == "" {
return nil, fmt.Errorf("codegraph: no embedded checksum for %s (%s)", asset, Version)
}
return downloadAssetFromBases(ctx, client, asset, want, downloadBases(), log)
}
func downloadAssetFromBases(ctx context.Context, client *http.Client, asset, want string, bases []string, log func(string)) ([]byte, error) {
var errs []string
for _, base := range bases {
url := strings.TrimRight(base, "/") + "/" + asset
getCtx := ctx
cancel := func() {}
if perSourceDownloadTimeout > 0 {
getCtx, cancel = context.WithTimeout(ctx, perSourceDownloadTimeout)
}
data, err := httpGet(getCtx, client, url)
cancel()
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", base, err))
continue
}
got := fmt.Sprintf("%x", sha256.Sum256(data))
if got != want {
errs = append(errs, fmt.Sprintf("%s: checksum mismatch for %s", base, asset))
continue
}
logf(log, "codegraph: downloaded from %s", base)
return data, nil
}
return nil, fmt.Errorf("codegraph: download %s failed (%s)", asset, strings.Join(errs, "; "))
}
func expectedAssetSHA256(asset string) string {
return releaseAssetSHA256[asset]
}
func downloadBases() []string {
bases := []string{officialMirrorBase + "/" + Version}
if strings.TrimSpace(officialMainlandMirrorBase) != "" {
bases = append(bases, strings.TrimRight(officialMainlandMirrorBase, "/")+"/"+Version)
}
bases = append(bases, fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version))
return dedupeStrings(bases)
}
func dedupeStrings(values []string) []string {
var out []string
seen := make(map[string]bool, len(values))
for _, v := range values {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// promote moves the freshly extracted bundle (root) into its versioned home
// (dir). On Windows os.Rename onto an existing directory fails with "Access is
// denied", so a stale/partial dest from an earlier interrupted install (cached()
+75
View File
@@ -5,7 +5,9 @@ import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -169,6 +171,79 @@ func TestInstallReturnsCachedWithoutNetwork(t *testing.T) {
}
}
func TestDownloadAssetUsesEmbeddedChecksumAndMirrorFallback(t *testing.T) {
asset := assetName()
body := []byte("verified codegraph payload")
sum := sha256.Sum256(body)
prev, hadPrev := releaseAssetSHA256[asset]
releaseAssetSHA256[asset] = fmt.Sprintf("%x", sum)
t.Cleanup(func() {
if hadPrev {
releaseAssetSHA256[asset] = prev
} else {
delete(releaseAssetSHA256, asset)
}
})
bad := httptest.NewServer(http.NotFoundHandler())
defer bad.Close()
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/"+asset) {
t.Fatalf("download path = %q, want asset suffix", r.URL.Path)
}
w.Write(body)
}))
defer good.Close()
got, err := downloadAssetFromBases(context.Background(), http.DefaultClient, asset, fmt.Sprintf("%x", sum), []string{bad.URL, good.URL}, nil)
if err != nil {
t.Fatalf("downloadAsset: %v", err)
}
if string(got) != string(body) {
t.Fatalf("downloadAsset body = %q, want %q", got, body)
}
}
func TestDownloadAssetRejectsMirrorChecksumMismatch(t *testing.T) {
asset := assetName()
prev, hadPrev := releaseAssetSHA256[asset]
releaseAssetSHA256[asset] = strings.Repeat("0", 64)
t.Cleanup(func() {
if hadPrev {
releaseAssetSHA256[asset] = prev
} else {
delete(releaseAssetSHA256, asset)
}
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("tampered"))
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := downloadAssetFromBases(ctx, http.DefaultClient, asset, strings.Repeat("0", 64), []string{srv.URL}, nil); err == nil {
t.Fatal("downloadAsset accepted a checksum mismatch")
}
}
func TestDownloadBasesUseOfficialSourcesBeforeGitHub(t *testing.T) {
got := downloadBases()
want := []string{
officialMirrorBase + "/" + Version,
fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version),
}
if len(got) != len(want) {
t.Fatalf("downloadBases = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("downloadBases[%d] = %q, want %q (all=%#v)", i, got[i], want[i], got)
}
}
}
func TestHTTPGetDetachedCtxSurvivesParentCancel(t *testing.T) {
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+129
View File
@@ -0,0 +1,129 @@
package config
import (
"reflect"
"testing"
)
// ── IsLikelyChatModel ──────────────────────────────────────────────────────────
func TestIsLikelyChatModel_RejectsEmptyInput(t *testing.T) {
for _, model := range []string{"", " ", "\t"} {
if IsLikelyChatModel(model) {
t.Errorf("IsLikelyChatModel(%q) = true, want false", model)
}
}
}
func TestIsLikelyChatModel_AllowsKnownChatModels(t *testing.T) {
for _, model := range []string{
"mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni",
"deepseek-v4-flash", "deepseek-v4-pro",
"gpt-4o", "gpt-4o-mini",
"claude-3.5-sonnet", "qwen-max",
} {
if !IsLikelyChatModel(model) {
t.Errorf("IsLikelyChatModel(%q) = false, want true", model)
}
}
}
func TestIsLikelyChatModel_FiltersAudioModels(t *testing.T) {
// Real-world samples from #3483.
for _, model := range []string{
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
"mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
"tts-1",
} {
if IsLikelyChatModel(model) {
t.Errorf("IsLikelyChatModel(%q) = true, want false", model)
}
}
}
func TestIsLikelyChatModel_FiltersNonChatKeywords(t *testing.T) {
for _, model := range []string{
"whisper-1",
"text-embedding-3-small", "text-embedding-ada-002",
"text-moderation-stable",
"rerank-v1",
"dall-e-3",
"text-to-speech-v1", "speech-to-text-v2",
} {
if IsLikelyChatModel(model) {
t.Errorf("IsLikelyChatModel(%q) = true, want false", model)
}
}
}
func TestIsLikelyChatModel_DoesNotFilterVoiceAlone(t *testing.T) {
for _, model := range []string{
"voice-chat-model", "gpt-4o-voice",
} {
if !IsLikelyChatModel(model) {
t.Errorf("IsLikelyChatModel(%q) = false, want true", model)
}
}
}
// ── ModelList / ChatModelList ──────────────────────────────────────────────────
func TestModelList_ReturnsRawList(t *testing.T) {
p := ProviderEntry{
Models: []string{
"mimo-v2.5", "mimo-v2.5-pro",
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
},
}
got := p.ModelList()
want := []string{
"mimo-v2.5", "mimo-v2.5-pro",
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("ModelList() = %v, want %v", got, want)
}
}
func TestChatModelList_FiltersNonChatModels(t *testing.T) {
p := ProviderEntry{
Models: []string{
"mimo-v2.5", "mimo-v2.5-pro",
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
"mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
},
}
got := p.ChatModelList()
want := []string{"mimo-v2.5", "mimo-v2.5-pro"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ChatModelList() = %v, want %v", got, want)
}
}
func TestChatModelList_AllNonChat(t *testing.T) {
p := ProviderEntry{
Models: []string{"mimo-v2.5-tts", "mimo-v2.5-asr"},
}
got := p.ChatModelList()
if len(got) != 0 {
t.Errorf("ChatModelList() = %v, want empty", got)
}
}
func TestChatModelList_AllChat(t *testing.T) {
p := ProviderEntry{
Models: []string{"gpt-4o", "gpt-4o-mini"},
}
got := p.ChatModelList()
want := []string{"gpt-4o", "gpt-4o-mini"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ChatModelList() = %v, want %v", got, want)
}
}
func TestChatModelList_EmptyModels(t *testing.T) {
p := ProviderEntry{}
if got := p.ChatModelList(); got != nil {
t.Errorf("ChatModelList() = %v, want nil", got)
}
}
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more