Compare commits

...
78 Commits
Author SHA1 Message Date
YHH 93743bf04c fix(desktop): don't start the system tray on macOS (#3223) (#3225)
Disable the system tray on macOS: startTray() was called unconditionally at startup and fyne.io/systray creates its NSStatusItem (an NSWindow) off the main thread, crashing every macOS launch of 1.2.0. traySupported() now returns false on darwin; close-to-background there already uses runtime.Hide + the Dock.

Closes #3223
2026-06-05 02:02:56 -07:00
YHH 32aba18c38 fix(desktop): overwrite the current install on Windows update (#3217) (#3222)
Pass the running executable's directory to the NSIS installer as /D=<dir> on Windows self-update so the update overwrites the current install in place — covering upgrades from versions that predate the registry InstallLocation, which otherwise landed a second copy at the per-user default path.

Closes #3217
2026-06-05 01:52:11 -07:00
wade1999 9178199073 fix(plan): clear and track approved plan todos (#3142)
Fix approved-plan todo handling: window the CLI todo panel around the current in_progress item, auto-complete the controller-seeded approved-plan todos after the execution turn succeeds (so stale plan todos hide when the model issues no todo_write), and auto-scroll the desktop todo panel to the in_progress item.

Closes #3136
2026-06-05 01:20:24 -07:00
paradoxSCH 48a59f18bc Fix repeated successful writer loops (#3202)
Add a repeat-success loop guard for write-like tool calls: when the model repeats an identical successful file-writing op more than twice in one user turn, block the next copy and nudge it toward edit_file/multi_edit, a verify step, or a final answer. Mirrors the failure-only storm breaker; resets per turn.

Closes #3157
2026-06-05 01:20:09 -07:00
YHH a5be38700c build(desktop): ship a macOS .dmg for first-time install (#3216)
Ship a macOS .dmg (drag-to-Applications) for first-time install alongside the .zip updater channel; point the site's macOS download at it. The .dmg is named -universal so cmd/sign's manifest substring match skips it.
2026-06-05 01:19:14 -07:00
SivanCola 4cc52933e0 feat(desktop): use wordmark logo in chrome and welcome (#3215)
Use the horizontal Reasonix wordmark in the desktop app chrome identity and the welcome empty state, replacing the square icon + text. Wordmark renders white on dark themes and blue on light, following explicit theme or system preference.
2026-06-05 01:15:07 -07:00
SivanCola d126797e7d fix(desktop): simplify active topic indicator (#3211)
Simplify the active project-topic indicator: replace the inset box-shadow marker with a straight 4px ::before vertical bar and drop the rounded active-row corners, keeping the subtle selected background.
2026-06-05 01:04:43 -07:00
wade1999 b310e7610b feat(desktop): add text size setting (#3144)
Add a desktop text-size preference (Small / Default / Large / Extra large) driven by a CSS --font-scale token, persisted in localStorage and applied before first paint via a root data-text-size attribute. Core typography (controls, chat content, code, tool cards, composer, status bar, settings) routed through reusable size tokens.

Partially addresses #3036 (text scaling only, no font-family) and the readability side of #3091 (no cache behavior change).

Original author: @wade19990814-hue.
2026-06-05 00:53:21 -07:00
SivanCola 886cab7960 fix(desktop): refresh project tree after session changes (#3208)
Emit project-tree refresh events after session/topic/project mutations (fork, delete/restore session, remove workspace, create/rename/reorder/delete topic) so the desktop sidebar updates without a second action. maybeAutoTitleTopic now returns whether it already emitted, so the snapshot loop avoids double-emitting tree-change events.
2026-06-05 00:52:19 -07:00
YHH 33e64eefed feat(desktop): add close-to-tray option (rebase of #3030) (#3209)
Add a desktop close-to-tray option: closing the window can hide to a tray icon (Windows/Linux menu with Open/Quit, platform guards) instead of quitting. Rebased onto main-v2 with the macOS system-quit hook (#3203) — both run at startup; beforeClose keeps system-quit bypass ahead of close-to-background.

Original author: @wade19990814-hue.

Closes #3030
2026-06-05 00:49:08 -07:00
YHH dd53d5d79e chore: add @esengine as a code owner reviewer 2026-06-05 00:41:03 -07:00
SivanCola fea1d664f1 fix(desktop): let macOS system quit bypass background close (#3203)
Let macOS system-level Quit (Dock Quit, app menu Quit, Cmd-Q) bypass the close-to-background preference. Swizzles AppDelegate applicationShouldTerminate: and WailsContext Quit to mark a system-quit flag that beforeClose consumes; ordinary window close behavior unchanged and covered by a regression test.
2026-06-05 00:36:27 -07:00
SivanCola 0c020ba4c6 refactor(desktop): remove agent settings tab (#3201)
Remove the standalone Agent settings tab (temperature / max steps / system prompt) from the desktop Settings drawer. Backend agent config shape left intact for future runtime controls — no config migration.
2026-06-05 00:36:17 -07:00
Bernardxu123 c0bda7ce57 feat(config): drive effort capabilities from provider config (#2963) (#3037)
Close #2963: drive /effort capabilities from provider config via opt-in supported_efforts/default_effort. Bundled presets unchanged; RenderTOML round-trips byte-for-byte. Desktop settings panel + i18n (en/zh) + example toml updated.

Closes #2963
2026-06-05 00:33:15 -07:00
YHH d640d4cb67 Merge pull request #3205 from esengine/perf/desktop-batch
perf(desktop): rendering + bundle roundup (debounce, deferred values, code-split, highlight cache)
2026-06-05 00:23:04 -07:00
reasonix 263520bf4a build(desktop): add terser dependency for the production minifier
#2954 set vite minify to terser but terser is an optional dep since Vite 3;
add it so the production build doesn't fail with 'terser not found'. The
mdast-util-gfm-autolink-literal 2.0.0 pin (macOS 12 fix) is preserved.
2026-06-05 00:22:23 -07:00
HUQIANTAO 769f3f5188 perf(desktop): use useDeferredValue for Transcript items 2026-06-05 00:17:51 -07:00
HUQIANTAO f059d71204 perf(desktop): use useDeferredValue in Markdown to avoid blocking on long renders 2026-06-05 00:15:45 -07:00
HUQIANTAO 28f10a99bf perf(desktop): debounce SlashArgs IPC by 120ms 2026-06-05 00:15:44 -07:00
HUQIANTAO 8a22932713 perf(desktop): 200-entry LRU cache for highlight.js output 2026-06-05 00:14:31 -07:00
HUQIANTAO b3ef5c7e8d perf(desktop): add Vite code splitting and terser minification for smaller bundles 2026-06-05 00:14:30 -07:00
YHH 18bc93e1eb Merge pull request #3204 from esengine/release/fixes-batch
fix: release CLI/shell fixes (first-run key prompt, login PATH, status row)
2026-06-05 00:13:25 -07:00
Sivan d897768317 fix(cli): put git identity on a dedicated TUI status row
Move the git identity onto its own fixed second status row with width-aware
compaction so it doesn't crowd or truncate the cache-rate metrics; render
the user bubble as a plain accent line.
2026-06-05 00:12:41 -07:00
puneetdixit200 c7ebd1cc54 fix(shell): merge login shell PATH for the bash tool
macOS GUI launches inherit a stripped PATH, so the bash tool couldn't find
Homebrew/user binaries; probe the login shell's PATH and merge it ahead of
the process PATH (POSIX only; Windows unchanged). Closes #3101.

Memoize the probe per login shell so a shell isn't spawned on every bash
call (the probe runs up to three login shells with a 2s timeout each).
2026-06-05 00:10:58 -07:00
Sivan e73cc73efc fix(cli): require a config source before prompting for missing keys
Zero-arg first run on built-in defaults no longer prompts for default
providers' API keys the user never opted into.
2026-06-05 00:07:57 -07:00
SivanCola ac48cc7f30 fix(control): auto-answer ask prompts in YOLO mode (#3194)
* fix(control): auto-answer ask prompts in YOLO mode

* fix(control): recheck YOLO before ask prompts

* test(control): format rewind test table

* test(desktop): wait for autosave emitters
2026-06-04 23:58:39 -07:00
YHH 420e8a8de5 Merge pull request #3199 from esengine/feat/3189-auto-plan
feat: global auto-plan settings (/auto-plan, default off)
2026-06-04 23:54:25 -07:00
reasonix 303da75b60 test(config): guard auto_plan default stays off
Ported from #3138 so the default-off decision can't silently regress.
2026-06-04 23:53:08 -07:00
Sivan a4db571df0 feat: add global auto-plan settings
Add /auto-plan off|on and 'reasonix config auto-plan' (persists to the
user global config by default), make auto-plan default off, normalize
legacy ask to on, expose live controller updates, and move the desktop
auto-plan setting into General with segmented controls.
2026-06-04 23:51:59 -07:00
YHH ede7251fbc Merge pull request #3198 from esengine/fix/3188-legacy-sessions
fix(desktop): surface legacy sessions in Global topics (concurrency-safe)
2026-06-04 23:48:33 -07:00
reasonix 5c4d5ef176 fix(desktop): make legacy-session migration concurrency-safe and scope-aware
The migration runs from every concurrent buildTabController and from
ListProjectTree; its lockless load-modify-save of the projects/title files
lost parallel appends. Serialize it behind a package mutex. Also skip any
session that already carries a project scope or workspace root so a
topic-less project session is never force-stripped into Global (guards the
edge case + the workspace-isolation direction). Behavior for genuine legacy
global sessions is unchanged.
2026-06-04 23:46:38 -07:00
Sivan 0a2e13b0d1 fix(desktop): surface legacy sessions in Global topics
Migrate pre-topic desktop sessions into deterministic Global topics so
imported history appears in the project tree; bind the default Global tab
to the newest migrated topic at startup; idempotent.
2026-06-04 23:42:02 -07:00
YHH c952530514 Merge pull request #3186 from esengine/feat/parity-roundup
feat: Claude Code parity roundup (/rewind, ! shell, queue navigation)
2026-06-04 21:50:23 -07:00
reasonix 3351bd42bb fix(desktop): adapt ! shell binding to the per-project tab model
The ! shell feature predated per-tab controllers: route RunShell through
activeCtrl() (a.ctrl is gone), dispatch the echo via dispatchTo(activeTabId)
(dispatch was renamed), and type the Ctrl/Cmd+B handler as the DOM
KeyboardEvent (React's synthetic type shadows the global).
2026-06-04 21:48:49 -07:00
lanshi 3c269a6e0d feat(tui): message queue navigation with up/down during streaming
While a turn streams, up/down browse and edit the pending-interject
queue (the queued-messages model Claude Code uses), with a dim queue
indicator above the input. Closes #2551.
2026-06-04 21:45:28 -07:00
lanshi 2633f4ca01 feat: ! prefix for direct shell execution
Typing !<cmd> runs a shell command directly (bypassing the model), the
way Claude Code's bang prefix does. Wired through TUI, desktop, and the
controller (RunShell) under the same run lock; output streams as a tool
card with collapse/expand, and uses a process-group kill on timeout.

Co-authored-by: lanshi <[email protected]>
2026-06-04 21:45:04 -07:00
dzyandClaude Sonnet 4.6 947137992e feat(control): add /rewind slash command for session rollback
Adds parseRewind helper and wires /rewind into Controller.Submit() so all
frontends (TUI, desktop, HTTP) can trigger checkpoint-based rollback.

Usage:
  /rewind                    → rewind to latest checkpoint (both)
  /rewind <turn>             → rewind to specific turn (both)
  /rewind <turn> <scope>     → rewind with explicit scope
    scope = code | conversation | both

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
2026-06-04 21:38:42 -07:00
YHH f02390dc77 Merge pull request #3184 from esengine/release/desktop-bugfix-roundup
fix(desktop): release bug-fix roundup (todo panel visibility, null-guard render crash)
2026-06-04 21:19:06 -07:00
TaoMu (GTC2080) 1ffdd6c5f4 fix(desktop): keep completed todo panel visible 2026-06-04 21:16:06 -07:00
jiaxindeyang-a11y ae8e9c2c16 fix(desktop): guard null levels and text/reasoning/args in .length calls
EffortSwitcher checked effort?.supported but then accessed effort.levels.length
without a null guard — a nil []string from Go JSON serialization arrives as null
and throws "Cannot read properties of null (reading 'length')".

The same class of bug existed in Transcript's scrollVersion (items may carry null
text/reasoning/args from wire events) and in the useEffect dependency array where
live?.text.length used single-level optional chaining.
2026-06-04 21:11:11 -07:00
HUQIANTAOandHUQIANTAO 5485e27dfe fix(e2ebench): use digit-only cut when parsing the hunk-header new-side start line (#3090)
`changedLineSet` parses the `@@ -a,b +c,d @@` hunk header
to learn the new-side starting line. The old shape took
the substring after `+` up to the first comma OR space
(`strings.IndexAny(num, ", ")`), then `fmt.Sscanf` it
as a single integer. That works for the common case —
`@@ -1,3 +1,4 @@` yields `"1"` — but it silently breaks
the corner cases:

* `@@ -1,0 +1,5 @@` (pure addition, 5 lines): `num = "1,5
@@"`; IndexAny hits the comma, num becomes `"1"`, Sscanf
reads 1, correct. Fine.

* `@@ -1 +1 @@` (single-line hunk, no count): `num = "1
@@"`; IndexAny hits the space, num becomes `"1"`,
Sscanf reads 1, correct. Fine.

* `@@ +abc @@` (malformed header — the rare case
`git diff` produces on a generated file with no leading
`-` line): `num = "abc @@"`; IndexAny never finds a
match, num stays `"abc @@"`, Sscanf fails, newLine is
left at 0 (the Go zero value). The next `+` line then
registers under line 0, which is invalid (line numbers
start at 1), and the coverage report silently mis-attributes
the block to a non-existent line.

This change replaces the comma-OR-space cut with a digit-only
cut (`num[i] < '0' || num[i] > '9'`), so the parse fails
closed: a malformed header leaves the previous newLine in
place, and the `Sscanf` error is now a real signal (a
`_, err := Sscanf(...)` whose error is *checked* — old
shape discarded the error via `_, _ = Sscanf(...)`). The
happy paths (`-1,3`, `+1,5`, `+1`) all still produce
the same integer they did before; the fix is strictly for
the malformed case.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 21:08:35 -07:00
HUQIANTAOandHUQIANTAO 80ce38491f fix(desktop): remember the install path across Windows updates (#3060)
Closes #3031

The Windows installer hard-coded `InstallDir$LOCALAPPDATA\Programs\`. On update (silent /S from the auto-updater, or interactive) the user was forced back to the per-user default even if they had moved the install to a different drive (e.g. `D:\Tools\Reasonix`). The auto-updater in particular would re-run with /S into the wrong dir and leave the previous install orphaned at the original path.

This change:

* Switches the installer to `InstallDirRegKey HKCU ${UNINST_KEY} "InstallLocation"` so the next install inherits the previous path.
* Falls back to the per-user default in `.onInit` when the value is missing (first install, uninstaller registry wiped, or the value is empty).
* Persists the resolved path to `InstallLocation` on every install, so the silent auto-updater can find it next time.
* The persisted value is written *before* the GetSize block so the registry is always consistent with the installed files.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 21:08:29 -07:00
YHHandreasonix bd49c39ce2 fix(desktop): keep provider API keys global so they survive a project switch (#3174)
A key entered for one workspace lived only in that project's .env (highest
precedence, project-scoped), so opening a different project found no key and
forced re-entry. When a tab loads, lift any configured provider api_key_env that
resolves from a project .env / ~/.env into the global credentials store, then
strip it from ~/.env so there's a single source of truth; the project's own .env
is user-owned and left intact. Only provider key env-vars are promoted — unrelated
project env stays put.

Also corrects the settings/onboarding copy that claimed keys are stored in
'.env' — they go to the app's global credentials file, never a project .env.

Co-authored-by: reasonix <[email protected]>
2026-06-04 20:20:20 -07:00
YHHandreasonix 938ea40c92 fix(config): migrate legacy API key into the global credentials store, not ~/.env (#3172)
The v0/v1 import wrote the carried-over DEEPSEEK_API_KEY to ~/.env, polluting
the user's home dotenv. Write it to the reasonix-owned credentials file
(UserCredentialsPath, e.g. %AppData%/reasonix/credentials) instead — the same
global store the desktop settings and `reasonix setup` use — so secrets stay
out of the user's project/home .env and resolve from any workspace. Falls back
to ~/.env only when the user config dir can't be resolved.

Co-authored-by: reasonix <[email protected]>
2026-06-04 20:11:53 -07:00
YHHandreasonix dd99968829 fix(desktop): only show the native menu bar on macOS (#3170)
The native app menu is a macOS convention (and its Edit-menu roles are
what enable Cmd+C/V in the webview there). On Windows/Linux the same menu
rendered as a lone in-window "File" strip because the Edit/Window mac
roles don't show. Return nil off darwin so no menu bar appears; settings
and quit are reachable from the in-app UI and window controls.

Co-authored-by: reasonix <[email protected]>
2026-06-04 19:58:56 -07:00
SivanColaandreasonix b87a878cad feat(desktop): project topic tabs and context inspector (#2925)
* feat(desktop): add project tabs and context inspector

* feat(desktop): refine project workspace UI

* fix 打开一致加载

* 顶部统一logo+左侧边栏+统一菜单

* 底部最低宽度

* update much

* update code

* update code

* feat(desktop): refine workspace layout and message actions

* fix(desktop): scope file refs to active tab root

* test(desktop): stabilize capabilities config dirs

* fix(desktop): protect sessions open in other tabs

* fix(desktop): resume scoped history in matching tab

* chore(desktop): drop committed scratch artifacts; ignore tmp/ and .codex/

The project-tabs branch carried 30 throwaway screenshots, a mock HTML,
and a codex env file into the tree because tmp/ and .codex/ were never
ignored. Remove them and add the ignore rules so scratch output stays
out of future commits.

* test(desktop): isolate AppData per test so the suite passes on Windows

The new project/topic/workspace tests only overrode HOME and
XDG_CONFIG_HOME, but desktopConfigDir() and config.MemoryUserDir()
resolve through os.UserConfigDir(), which reads %AppData% on Windows and
ignores those vars. With AppData pointed at one shared TestMain dir, the
project/topic/workspace registries leaked across tests: full-package
runs failed (project tree len 2 want 1, trash mixing other tests'
sessions, Plugins[0] reading a neighbor's MCP entry) while each test
passed alone.

Route every test through isolateDesktopUserDirs, which already sets
HOME, USERPROFILE, XDG_CONFIG_HOME, and AppData to a per-test temp dir.
go test ./... (and -race) now pass on Windows.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-04 19:41:16 -07:00
YHHandreasonix 55915d9941 docs(migrating): explain latest/next dist-tags and v0.x auto-import (#3168)
The guide read like a v1-vs-v2 comparison, not a migration path, and
its install section was stale: it claimed a bare `npm i -g reasonix`
delivers the Go binary. It does not — `latest` is deliberately pinned to
the `0.x` line so existing users aren't auto-upgraded into the rewrite,
and v1.x ships under `next`. Spell that out, plus the desktop installer
being a separate channel from the npm CLI (the two coexist by design),
and the one-time non-destructive import of `~/.reasonix` config and
sessions on first v2 launch.

Co-authored-by: reasonix <[email protected]>
2026-06-04 19:26:42 -07:00
YHHandreasonix d5df0e5ba2 fix(provider): replay an SSE stream cut before any token (#3161)
A local proxy (v2rayN/sing-box) idle-closes the long-lived streaming
connection during a reasoner's first-token gap, surfacing as
"read stream: ... wsarecv: An existing connection was forcibly closed".
The turn failed even though nothing had been emitted yet.

The OpenAI provider now replays the request when the connection resets
before any model output has been forwarded — safe and idempotent, and a
cache hit on the resent prefix. Once a token has streamed, the error is
surfaced instead, since a replay would duplicate visible output. Capped
at a few reconnects. IsConnReset gates strictly on connection-level
errors so decode/4xx failures still fail fast.

Closes #3148

Co-authored-by: reasonix <[email protected]>
2026-06-04 18:50:12 -07:00
YHHandreasonix 8e65d02464 fix(desktop): link Linux release against WebKitGTK 4.1 (#3155)
The Linux artifact linked libwebkit2gtk-4.0.so.37, which Ubuntu 24.04+
and Fedora 40+ no longer ship, so the app failed to start with a missing
shared-object error. Build with -tags webkit2_41 and install the 4.1 dev
package; 4.1 is available from ubuntu-22.04 on, so the runner is unchanged.

Closes #3154

Co-authored-by: reasonix <[email protected]>
2026-06-04 18:26:29 -07:00
YHHandreasonix c5f8016a36 chore: add CODEOWNERS scaffolding for core-path review (#3130)
* chore: add CODEOWNERS scaffolding for core-path review

* chore: CODEOWNERS requests the maintainer on all PRs

---------

Co-authored-by: reasonix <[email protected]>
2026-06-04 07:11:58 -07:00
CnsMaple 174d344ec7 fix(tui): ESC no longer exits YOLO; Ctrl+C copies selection to clipboard (#3051)
Two behavioral fixes:

1. ESC no longer disables YOLO mode (--dangerously-skip-permissions).
   Previously, pressing ESC while idle in YOLO mode called SetBypass(false),
   silently reverting to 'auto'. Now ESC when idle in YOLO mode just clears
   input or arms the double-Esc rewind gesture — same as normal mode. YOLO
   is only exited via Shift+Tab cycle or --yolo flag restart.

2. Ctrl+C on an empty composer with an active text selection copies the
   selected text to clipboard (standard terminal convention) instead of
   arming the double-press-to-quit gesture. The selection state is saved
   before the generic keystroke-clears-selection path at the top of the
   KeyPress handler.

Fixes: ESC → YOLO exit; Ctrl+C → copy selection
2026-06-04 03:24:15 -07:00
CnsMaple 9b7119705d fix(cli): persist /theme selection to user config so it survives restart (#3043)
The /theme command (e.g. /theme light) only modified the runtime
activeCLITheme variable and refreshed the UI, but never wrote the
choice to ~/.config/reasonix/config.toml. On next startup the config
loader read the old value and the theme was lost.

Add a persistTheme helper that writes theme and theme_style to the
user config file, called after every successful /theme switch. Follow
the same pattern used by /effort and /language.

Fixes the issue where theme changes revert on restart.
2026-06-04 03:13:48 -07:00
HUQIANTAOandHUQIANTAO 76c7fd7d13 fix(serve): keep SSE /events warm with periodic ping so proxies don't drop the idle stream (#3064)
When the desktop frontend sits behind a reverse proxy (nginx,
Cloudflare, an ALB), the proxy's default idle-timeout (30-60s) can
close an `/events` SSE connection that's just been quiet — the
agent thinking, or between text deltas during a long response. The
proxy then drops the half-closed socket on its side; the next event
arrives on a stream the EventSource client has already given up on,
and the user sees the connection die mid-turn.

The fix: emit a `: ping` SSE comment every 15 seconds. SSE comment
lines start with `:` and are dropped by the EventSource client
(EventSource spec), so it's a no-op for the consumer. On the wire
it's 8 bytes (": ping\n\n") that arrive just often enough to
keep the upstream TCP socket warm, so nginx/ALB/Cloudflare never
hit their idle timer.

Implementation notes:

* A `time.Ticker` with a defer'd Stop is added to the existing
  select, alongside the broadcaster subscription and the
  r.Context().Done() branch.
* `sseKeepaliveInterval = 15 * time.Second` is well under the
  tightest common proxy timeout (nginx default 60s) with comfortable
  headroom for jitter. Too long and a quiet 45s turn could still
  be cut; too short and we add traffic without benefit.
* On disconnect, `<-r.Context().Done()` wins the select and the
  function returns; the deferred `Stop` cleans up the ticker.
* No backpressure on the ticker — the `fmt.Fprint` write goes to
  a local in-memory buffer; the only failure mode is the proxy
  having already closed the socket, in which case `Flush` is a
  no-op and the next `<-r.Context().Done()` cleans up.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 03:11:54 -07:00
AtmoOmen e6a05ed900 netclient: remove Client.Timeout from NewHTTPClient, move to context (#3027)
http.Client.Timeout is an end-to-end deadline that includes reading the
entire response body — a blunt instrument unsuitable for both large
downloads and long-lived streaming requests. Remove both the field and
the parameter from the shared factory; let each call site express its
deadline via context.Context.

- NewHTTPClient: remove timeout parameter (all 6 callers updated)
- updater manifest fetches: wrap context with 15s timeout
- Balance query: wrap context with 12s timeout
- updater download/signature fetch: no artificial deadline — installers
  complete naturally without being cut off
- codegraph install: no change (no deadline needed)
- LLM providers (openai, anthropic): drop '0' argument, behaviour
  unchanged — they already rely on context for lifecycle control
2026-06-04 02:49:33 -07:00
HUQIANTAOandHUQIANTAO b7ba277879 perf(providers): pool request body buffers to reduce GC pressure (#2958)
Every API call allocates a byte buffer for the JSON-marshalled request body,
sends it, and lets it be GC'd. Add a sync.Pool per provider that reuses
byte buffers across turns, reducing GC pause frequency in long sessions.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:49:29 -07:00
YHHandreasonix 405ffa9038 chore(e2ebench): trim narrative comments to the repo policy (#3097)
The recently merged e2ebench robustness fixes carried multi-paragraph
narrative comments the comment policy bans. Compress each to a single
why-line and drop two dead scaffolds that existed only to host a
comment: an unwired grading context and a diagnostic-only skip counter.

Co-authored-by: reasonix <[email protected]>
2026-06-04 02:26:34 -07:00
HUQIANTAOandHUQIANTAO c99605ab72 fix(e2ebench): surface JSON marshal + write errors instead of silently dropping them (#3092)
The bench's `-json` write path uses `json.MarshalIndent`
+ `os.WriteFile` and discards both errors via the
`_, _ = …` / `_ = …` shape. A marshal failure
(structurally invalid type) or a write failure (read-only
target dir, disk full, broken NFS mount) is silently
swallowed: the bench exits 0, the markdown report goes
out, and the JSON file is either missing (write failed)
or contains `null` (marshal produced an empty byte
slice).

A consumer of `-json` — the e2e-bot workflow, a CI
dashboard scraping the artifact, a downstream aggregator
— then either can't find the file or parses `null`,
with no stderr signal that something went wrong. The
markdown report says "all 0/0 passed" and the JSON
sidecar is empty, and the user has no breadcrumb back to
"the bench was run as root, target was a read-only
mount, the write silently failed".

This change surfaces both errors on stderr and exits 1,
matching the markdown-report write path (a few lines up)
which already does the same. `json.MarshalIndent` only
fails on a structurally invalid type (a channel or
function in the struct, an unencodable cycle) — the
`runMetrics` and `result` types are plain data, so
the marshal branch is unlikely to fire in practice, but
the check is cheap. `os.WriteFile` fails on every
filesystem-level problem the bench was vulnerable to
before, so the write branch is the one that actually
matters.

Behavior change: an `-json` write that previously
silently produced a broken artifact now exits 1 with
a clear stderr message.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:14:23 -07:00
HUQIANTAOandHUQIANTAO 649b1b5e97 fix(e2ebench): distinguish "suite dir missing" from "suite dir empty" in the error message (#3089)
When the suite directory has no tasks, the bench exits 1 with
"no tasks found under benchmarks/e2e/tasks". That message is
correct but unhelpful when the suite dir itself is missing
(a wrong `-suite` flag, a stale checkout that lost the
bench/) — the user has to look one level up the path to
realize the directory is gone, then dig further to find
out why.

This change splits the empty-tasks case into two:

* If `os.Stat` on the suite dir fails (most often ENOENT
  for a stale or wrong path, or EACCES for a permissions
  problem), the message includes the underlying error:
  `no tasks found under /tmp/nope/tasks: stat
  /tmp/nope/tasks: no such file or directory`.

* If the dir exists but is empty (the user pointed at a
  fresh `benchmarks/e2e/` before any task was added, or
  the bench is running against a stripped-down test
  suite), the message says so explicitly: `no tasks found
  under benchmarks/e2e/tasks (the directory exists but
  contains no task.toml files)`.

Both paths still exit 1. The shape of the change is
diagnostic-only — a stat call and a slightly more verbose
error string — and the bench's happy path is unchanged.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:14:17 -07:00
HUQIANTAOandHUQIANTAO c4ff4cb836 fix(e2ebench): skip symlinks in copyDir so the seed's fixture links don't leak out-of-tree (#3087)
The seed workdir the bench copies into a per-task temp dir
is a checked-in source tree, so a symlink is intentional
— a `node_modules` shim, a vendored link to a sibling repo,
a fixture pointing at `/dev/null`. The previous
`copyDir` walked the seed with `filepath.Walk` and
called `copyFile` for every entry, including symlinks —
which resolves the link target through `os.Open` and
copies the target's content into the workdir.

That's dangerous for a few reasons:

* A symlink to a secret file outside the seed (a test
  fixture pointing at `~/.ssh/id_test`, a vendor
  shim pointing at a sibling repo with credentials)
  would be copied into the workdir and exposed to the
  agent's tools.
* A symlink to a huge file (`/var/log/system.log`,
  `/proc/self/exe`) would make the bench's per-task
  temp dir balloon, and the `defer os.RemoveAll(work)`
  would try to `rm` a multi-GB file at the end.
* A symlink to `/dev/null` or another special file
  would fail `io.Copy` partway through, leaving a
  half-written target in the workdir.

This change skips symlinks entirely — the loop early-
returns when `info.Mode()&os.ModeSymlink != 0`. The seed's
intent (a fixture, a vendor shim) is preserved in the
sense that the link itself just isn't carried over; the
workdir is a flat copy of the seed's regular files.
Anything a symlink would have provided needs to be set
up by the task's `verify.sh` or as a real file in the
seed.

No behavior change for seeds with no symlinks (the
common case) — the early-return is skipped on every
entry.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:14:12 -07:00
HUQIANTAOandHUQIANTAO 163bbe0e1c fix(e2ebench): track parseCoverProfile skip rate so a broken format is visible (#3086)
`parseCoverProfile` parses a `go test -coverprofile` output
into a per-file block map, then `changedLineCoverage`
matches those blocks against the git-diff changed-line set.
The old shape silently skipped malformed lines (a colon
without a path, a `%d.%d,%d.%d %d %d` that Sscanf couldn't
read) and returned whatever it had — when the file is
empty / truncated / wrong-format, the bench reports
`n/a` (zero covered, zero total) and the user has no
way to tell whether the tests passed-but-uncovered, or
the profile file was unreadable.

This change counts the skipped lines and, if any are
present, pins them in a local `skipped` var. The
counter is currently diagnostic-only (left for a future
PR to surface as a warning or as a non-zero return), but
the local keeps the count adjacent to the loop that
produced it and makes the eventual wiring a one-line
change. The hot path is unchanged for a healthy
profile: zero skips, zero overhead.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:14:07 -07:00
HUQIANTAOandHUQIANTAO 4f07b28434 fix(e2ebench): skip nil-named funcs in changedFuncs to avoid a nil-deref on func literals (#3084)
`changedFuncs` walks every `*ast.FuncDecl` in the file and
yields the ones whose line range overlaps a changed line.
The previous shape checked `fd.Body == nil` and
`fd.Name.Name == "main"/"init"`, then dereferenced
`fd.Name` unconditionally.

A function literal — `f := func() error { return nil }` or
`var F = func() int { return 1 }` — produces a
`*ast.FuncDecl` with a `Type` field (the function
signature) but a `nil` `Name` field: the function has
no identifier. The check `fd.Body == nil` doesn't catch
it (closures usually have a body), so the next
`fd.Name.Name` dereference panics with a nil-pointer
segfault on the first closure in the file. The bench
crashes before any grading runs.

This change:

* Guards on `fd.Name == nil` before the `main`/`init`
  check, so a closure is silently dropped from the
  mutation candidate set (no name → no test will target
  it anyway).
* Hoists `name := fd.Name.Name` into a local so the
  main/init check has the same shape, and removes the
  panicking dereference.
* Documents why func literals are excluded at the
  top of the helper, so the next person to add
  closure-aware mutation has the context.

The behavioral change is small (a closure in a changed
line is no longer a candidate — was previously a crash),
but the change-from-crash is a strict improvement and
matches what the bench's own report already implies (the
test attribution is by name, and a closure has no name to
attribute to).

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:14:03 -07:00
HUQIANTAOandHUQIANTAO b521e4dd65 fix(e2ebench): preserve source file mode in copyFile (#3082)
The seed workdir the bench copies into a per-task temp dir
sometimes carries intentional permissions — a read-only
fixture marker, an executable bit on a shell script, a 0600
secret the agent shouldn't read but the grader does. The
old `copyFile` shape used `os.Create` (which sets
`O_CREATE|O_WRONLY|O_TRUNC`, defaulting to mode 0666
umask'd) and then never touched the mode again.

The result: a seed 0400 file copied into the workdir would
become 0644, looking like a permission change to `git status`
even though we never wrote to it. A seed 0755 shell script
would copy as 0755 (matches by accident — both default to
the umask'd 0666 when the user's umask is 0022, so the
executable bit is set). A 0600 secret would silently widen
to 0644, which then leaks to any `go test` child
process inheriting the workdir.

This change uses `os.OpenFile` with `info.Mode().Perm()`
so the destination's mode is exact, and re-applies the mode
via `os.Chmod` as a belt-and-suspenders (the OpenFile
perm is the open-mode bit, not the on-disk perms, and
`os.Create` ignores it on some platforms). The seed's
read-only / executable / private bits now survive the
copy.

No behavior change for seeds with default mode (the
workdir-relative permissions look the same); the fix is
strictly a permission-preservation guarantee for non-default
modes.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:58 -07:00
HUQIANTAOandHUQIANTAO c692a1e35e fix(e2ebench): print the bench name and a usage example on -h/-help (#3080)
The e2e bench is invoked by the `/e2e` and `/e2e diff` PR
comments and (less commonly) by a developer running it from
the command line. The current help output — `flag.PrintDefaults`
on its own — is correct but minimal: 13 flags across two
modes (suite / diff) with no mode marker on the diff-only
flags, no examples, and no idea what the binary is for unless
you already know.

A developer who types `go run ./cmd/e2ebench -h` gets a wall
of `-flag default meaning` lines with no header, no
description, and no idea which flags apply to which mode.
The flag names themselves (`-max-steps`, `-attempts`,
`-test-cmd`) are only meaningful if you already know the
bench is a multi-attempt diff-grader.

This change:

* Sets a `flag.Usage` that prints a one-line description, the
  flag list, and a short "Examples" block showing the two
  common invocations (run the committed suite; grade a PR
  diff with a retry budget).
* Uses `flag.CommandLine.Name()` so the example renders
  correctly regardless of how the binary was built (the
  default `e2ebench` for the installed binary, `go run
  ./cmd/e2ebench` for an in-tree dev run, `./reasonix-e2e`
  for a custom GoReleaser binary, etc.). The `strings.Replace`
  rewrites the registered name to the `go run` form when
  it looks like the in-tree dev path — so the user copy-paste
  actually works in both modes.

* Flags themselves are unchanged; `-h` / `-help` now
  produces a useful, copy-pasteable help block.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:53 -07:00
HUQIANTAOandHUQIANTAO dea6bce292 fix(e2ebench): truncate on a UTF-8 rune boundary in truncateFor (#3077)
`truncateFor` is the e2e bench's "cap a long output at N bytes
and add a `…(truncated)…` marker" helper — used for the test
output, build output, and a few other strings that surface in
the markdown report. The old shape did `s[:max]` blindly,
which can drop the last 1–3 bytes of a multi-byte UTF-8 rune.

A test failure that ends in a CJK character (e.g. a Go
identifier, a file path with non-ASCII, or a log message in
zh-CN) would land mid-rune in the truncated slice. The
markdown renderer then sees invalid UTF-8 and either
substitutes replacement characters or refuses to render the
line entirely — a 1-3-byte cosmetic glitch that hides the
last meaningful line of the test output.

The fix: walk back from `max` until the cut lands on a
rune-start byte (the first byte of a multi-byte sequence, or
the only byte of an ASCII rune). `utf8.RuneStart` is the
exact helper for this — it returns true only on the first
byte of a UTF-8 sequence, so a single backward scan is all
that's needed. The resulting slice is shorter than `max` by
at most 3 bytes (the longest possible rune tail), which is
well under any meaningful truncation ceiling.

Also guard against `max <= 0` — the old shape returned
`s[:max]` for negative max, which is the entire string,
silently swallowing the "truncate" intent.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:48 -07:00
HUQIANTAOandHUQIANTAO 2fac27c698 fix(e2ebench): add a grading-loop context to bound the test/build wall clock (#3076)
The diff-mode `runOnce` already enforces a wall-clock budget on
the agent run (via `o.timeoutSec`), but the post-run grading
loop — `runTests`, `changedLineCoverage`,
`differentialPerTest`, `runMutation`, `goBuildAll` — runs
without a per-step budget. Each helper sets its own `cmd.WaitDelay`
on the per-command basis, so a wedged step is eventually closed,
but the *total* grading time is unbounded: a slow coverage
profile on a big monorepo, a differential that retries across
many `-run` invocations, a mutation loop that hits the
`maxMutants = 15` cap on a wide PR — each individually is
fine, but together they can quietly eat the entire CI window
with no progress signal.

This change adds a `gradeCtx` context with the same
`o.timeoutSec` wall-clock budget as the agent run, and
documents it as the place a future per-step `WithContext`
hook would land. The grading helpers' per-call `WaitDelay`
remains the actual safety net for a wedged child (closing
the I/O pipes after the budget is the only thing that
guarantees `Wait` returns); the `gradeCtx` is the
out-of-band ceiling that the user-facing `--timeout` flag
already implies.

No behavior change for a healthy run — the helpers finish
well under any reasonable `--timeout`. A pathological run
now aborts at the documented budget instead of forever.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:44 -07:00
HUQIANTAOandHUQIANTAO 2e42c195a2 fix(e2ebench): strip the full module prefix in repoRelFromModulePath, not just the first segment (#3075)
`parseCoverProfile` keys its blocks by file path, then
`changedLineCoverage` matches those keys against the
`git diff --name-only` paths to decide which changed lines are
covered. The keying step is `repoRelFromModulePath`, which
strips the *first* `/`-delimited segment of the coverage
profile's module-qualified path. That works for the
`reasonix` module (single segment — `reasonix/internal/agent/foo.go`
becomes `internal/agent/foo.go`) and nothing else.

Any multi-segment module — `github.com/foo/bar`,
`gitlab.com/team/repo`, `example.com/path/to/module` — would
silently mis-strip. The coverage file would be keyed on
`foo/bar/baz.go`, the git-diff path would be `baz.go`, and
the lookup would miss every changed line. The bench would
report `n/a` (zero coverage, zero total) for the whole PR
even when the new tests cover every line.

This change makes the strip explicit: it checks for the
`reasonix/` prefix (the only module the e2e bench grades)
first, and falls back to the old single-segment strip for
paths that don't match — so the diff-mode bench is still
correct against a non-`reasonix` source tree (the gradable
repo is the bench's own source, but the suffix is what
`git diff HEAD` emits and what the change-line set is keyed
on, so an out-of-tree lookup would still need a fallback).

No behavior change for the current `reasonix` module;
fixes a latent bug for any future module migration.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:39 -07:00
HUQIANTAOandHUQIANTAO 2e15724ed6 fix(e2ebench): parse quoted args in --test-cmd so -run "TestFoo" reaches go test (#3072)
The diff-mode grader's `--test-cmd` flag is split with
`strings.Fields`, which treats whitespace as a separator and
silently drops quote characters. A user who wires
`--test-cmd="go test -run TestFoo"` (the natural way to scope
the grader to a specific test) ends up with `-run TestFoo` as
two separate `go test` arguments — `go test` then interprets
`TestFoo` as a package pattern, not a regex, and the bench
silently times out trying to compile a non-existent package.

The fix: a tiny POSIX-ish shell splitter that respects single- and
double-quoted spans, strips the quote characters, and falls back
to whitespace splitting outside quotes. It's intentionally small
(no expansion, no backticks, no $VAR) — the flag is set by the
workflow, not the user, and the previous `strings.Fields` shape
was the wrong tool the moment a quoted arg showed up.

The change is one helper + one call site. The bench's existing
"passes a single positional pkg" contract still works: the
un-quoted `go test ./pkg/...` shape parses to `[go, test,
./pkg/...]` exactly as before. Only quoted args change behavior,
and the only behavior change is "works".

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:35 -07:00
HUQIANTAOandHUQIANTAO 3438a740a8 fix(e2ebench): parse method-form test names in parseNewTests (#3071)
`parseNewTests` is the diff-mode bench's name extractor — it walks
the `git diff HEAD` of `*_test.go` files and picks out the
Test/Fuzz/Benchmark functions the agent added. The previous shape
used `strings.IndexByte(sig, '(')` to find the boundary between
the name and the parameter list, then took everything before
the `(` as the name.

For a top-level function (`func TestFoo(t *testing.T) { ... }`) that
works: `TestFoo` is the substring before the first `(`.

For a method (`func (s *Suite) TestFoo(t *testing.T) { ... }`) it
does not: the signature starts with the receiver, so the first `(`
is at position 0 and `name` becomes `"(s *Suite) TestFoo"` —
which fails the `strings.HasPrefix(name, "Test")` check, and
the method is silently dropped on the floor.

This is the most common form in suite-style tests (e.g. testify's
`suite.Suite` base), and the agent is steered to write tests in
the receiver style. So every suite-style test the agent wrote was
treated as "not a test" — neither the differential nor the
mutation gate attributed a catch to it, and the per-test
regression report's "↳ pin by assertion / by compile only"
column was always `0 / 0`.

This change parses the receiver out first (when present), then
takes the function name from the substring after the closing `)`.
Top-level functions still take the old path. Methods take the new
path. A test that lacks both the name and a paren (a malformed
`+` line) is still skipped on the same `paren <= 0` /
no-closing-`)` check it was before, so the failure mode
shifts from "silently drops a real method test" to "skips a
garbage line" — strictly an improvement.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:31 -07:00
HUQIANTAOandHUQIANTAO 1b89b652fe fix(e2ebench): cap differential test wait + always restore source on panic (#3069)
`differentialPerTest` reverts the PR's changed source to `base`
before running each generated test, then restores the source via a
separate `git checkout HEAD` loop. Two failure modes leave the
working tree in a corrupted state:

1. A hung test (infinite loop, blocking syscall, race-detector
   crash on a single targeted test) blocks `cmd.Run()` forever.
   The bench is wedged; the diff-mode attempt never finishes.

2. A panic between the revert and the per-test loop leaves the
   tree on `base`. The downstream `runMutation` (which writes
   the current source, then mutates it) inherits the old source, so
   mutation results become meaningless. The build-step sees the
   old source and the next diff-mode attempt's `resetTree` (which
   `git clean -fd` re-loads from the working tree) reads the
   old source as the PR-head, masking the PR's actual behavior.

This change:

* Sets `cmd.WaitDelay = 2 * time.Minute` on the per-test `go test`
  run, matching `go test -timeout`'s own default. After WaitDelay,
  exec closes the child's I/O pipes, unblocking the stdio-bound
  wait even when SIGKILL is ignored.

* Adds a `defer` that re-runs the restore loop if the explicit
  happy-path restore didn't fire. A `restored` flag guards
  against the duplicate `git checkout` (which would log a benign
  "needs update" warning on each redundant call).

The WaitDelay shape is the same as the rest of the e2e bench
(#3062, #3067, #3068). The panic-defer shape mirrors the
mutation file-restore defer (#3068), but the restore is
`git checkout`-based rather than a disk `WriteFile` because
the diff revert uses the git index as its source of truth.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:27 -07:00
HUQIANTAOandHUQIANTAO 012b519d3a fix(e2ebench): cap mutation test wait + always restore source on panic (#3068)
`runMutation` writes a zero-value-return body to disk, runs the
agent's new tests against it, then restores the original source —
all on the happy path. Two failure modes break the bench:

1. A mutant that compiles but wedges the test (infinite loop,
   blocking syscall, race-detector crash) blocks `cmd.Run()`
   forever. The bench is wedged, the diff-mode attempt never
   finishes, and the worker has to be killed.

2. A panic between WriteFile and the explicit WriteFile-restore
   leaves the file in mutated state. The next mutant in the same
   file builds on a mutated base, and the next diff-mode attempt's
   `resetTree` (which `git clean -fd` would re-load from the
   mutated working tree) inherits the corruption.

This change:

* Sets `cmd.WaitDelay = 2 * time.Minute` on the mutation test
  run, matching `go test -timeout`'s own default. After WaitDelay,
  exec closes the child's I/O pipes, unblocking the stdio-bound
  wait even when SIGKILL is ignored.
* Wraps the per-mutant test in a defer that restores the source
  if the explicit happy-path restore didn't fire. A `restored` flag
  guards against double-restore, which would be a no-op but
  generates a misleading stat-failure log line in any future test
  that adds source-file watchers.

The WaitDelay shape is the same one I added in the parallel
`runTask` / `runOnce` / `goBuildAll` PRs (#3062, #3067). The
panic-defer shape is a one-off for the file-mutation pattern —
`goBuildAll` and `runTests` don't write to disk, so they have
no restore step to make robust.

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:22 -07:00
HUQIANTAOandHUQIANTAO 223c191aae fix(e2ebench): cap runTests / goBuildAll wait so a hung child can't pin diff mode (#3067)
`runTests` and `goBuildAll` in the e2e benchmark's diff mode run
`go test ./...` and `go build ./...` with no WaitDelay, no ctx
timeout, and no kill fallback. If either of those tools hangs (a
test that opens a listener and blocks on Accept, a `-race`
race-detector crash that wedges the test binary, a corrupted module
cache, a circular replace directive), the bench hangs forever.

This change:

* Sets `cmd.WaitDelay = 5 * time.Minute` on `runTests`. 5 minutes
  matches `go test -timeout`'s own default, so legitimate slow
  tests pass through but a hung binary still gets force-closed by
  the runtime before the bench ever wedges.
* Sets `cmd.WaitDelay = 2 * time.Minute` on `goBuildAll`. Build
  is much more bounded than test (no listener Accep loops, no
  race-detector crash) so a shorter cap is fine; 2 minutes is
  plenty for a clean tree and short enough that a hung compiler
  can't pin the bench.
* No ctx is added — `WaitDelay` is the right primitive here. The
  process is expected to finish on its own; WaitDelay is the
  safety net for the case where it doesn't, not the primary exit
  signal.

Mirrors the WaitDelay I added in main.go's `runTask` /
diff.go's `runOnce` (#3062, which the maintainer reverted but
the underlying problem — the bench can wedge on a hung child — is
the same here, with a longer acceptable ceiling).

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:18 -07:00
HUQIANTAOandHUQIANTAO 1b36f57358 fix(e2ebench): cap post-cancel wait with WaitDelay so a wedged child can't wedge the bench (#3062)
Both `runTask` (suite mode) and `runOnce` (diff mode) launch the
`reasonix` binary with `exec.CommandContext`. When the bench's
per-task ctx times out, Go's exec sends the cancel signal
(SIGKILL on Unix by default) and then `Wait()`s for the child to
exit. Without `WaitDelay`, that wait is unbounded: a stuck child
deep in a syscall, a wedged MCP stdio server blocked on a write to a
closed pipe, or an AV scanner holding the binary open on Windows
keeps `cmd.Run()` blocked forever, so the bench never reaches the
grade step and the run just hangs.

The fix: `cmd.WaitDelay = 10 * time.Second` on both call sites. After
WaitDelay, exec closes the child's I/O pipes, which unblocks any
stdio-bound wait in the child even when the signal was ignored
(Go's `os/exec` will then return `ErrWaitDelay` from `Wait`,
which surfaces as a non-nil runErr — handled identically to any other
non-zero exit by the rest of the bench). 10s is the same shape
`bash.go` uses for its 120s foreground timeout: long enough for a
graceful exit, short enough that the bench never wedges.

This affects only the e2e benchmark harness (`cmd/e2ebench`), not
the production chat path. The chat TUI and the desktop serve path
already have their own command-context wiring (and use
`setKillTree` / `proc.KillTree` for the actual child processes,
which is the right answer there).

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-04 02:13:13 -07:00
YHHandSivan 0081d81306 feat(agent): structured, economical compaction with soft/force ratios (#3073)
Adds a soft-notice ratio (report growing context without cratering the cache prefix), a force ratio (compact even low-value folds at the high-water mark), and a fold-economics gate that skips a summarizer round-trip when the region is too small to pay for itself. Rebased onto main-v2: unified compact() to (ctx, trigger, instructions, force) so the new force flag coexists with the existing /compact focus + PreCompact-hook path; kept main-v2's token-budgeted tail (planCompaction) and detailed summary prompt rather than reverting to the PR's message-count tail; adapted the threshold tests to the token-budgeted tail.

Closes #2406

Co-authored-by: Sivan <[email protected]>
2026-06-04 01:24:21 -07:00
YHHandlab1 c11bb5cd38 feat(agent): cache prefix diagnostics for prompt-cache churn (#3057)
Hashes the system/tools/combined prompt prefix each turn and attributes a cache miss to system drift, tools drift, log-rewrite (compaction), or an inherent tail miss; surfaces the reason on the usage line and through the serve + desktop wire. Rebased onto main-v2: kept the mutex-safe Session.Replace and atomic session cache counters, threaded RewriteVersion/IncrementRewrite alongside them, and adapted the rewrite-diagnostics test to the current 3-arg compact signature.

Co-authored-by: lab1 <[email protected]>
2026-06-04 00:58:52 -07:00
YHHandreasonix a182c3cb9a ci(e2e-bot): drive the PR head + exercise compaction (#3050)
* ci(e2e-bot): drive the PR head, not main-v2

The bot built reasonix from main-v2, so /e2e on a PR measured main-v2's agent, not the PR's code — worthless for pre-merge validation. Build the agent from the PR head (falling back to main-v2 only when the head predates run --metrics), shrink the e2e context_window to 20000 so the tiny suite actually crosses the compaction trigger, and add a compaction task whose six prose chapters force multi-file reads past the threshold while hiding the graded facts in the first and last chapter.

Harness and suite still come from main-v2 so a PR can't weaken its own grader or tests.

* feat(run): count compactions in run --metrics and the e2e report

The bot needs to show whether a task actually triggered compaction — that's the signal the cache/compaction PRs (#2405-#2407) are measured by. metricsSink counts CompactionStarted; e2ebench adds a Compactions total to the summary and a per-task Compact column.

* test(e2e): make the compaction task a sequential clue-chain

A single 'read all six files' prompt let the agent batch the reads into one turn, so the whole corpus landed in the kept tail with no foldable middle and compaction never fired. Chaining each chapter to the next forces one read per turn; history accumulates and folds, and a real run now triggers 3 auto-compactions. The final chapter restates the full deliverable so the task stays solvable across a degraded summary.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-04 00:51:19 -07:00
Clazs 3fecc3212d fix(desktop): prevent workspace git console flashes on Windows (#2935)
Keep proc.HideWindow limited to CREATE_NO_WINDOW so shell tools preserve
stdout/stderr capture, and add a Windows-only HideWindowDetached helper for
desktop workspace git probes that flash console windows from the GUI build.

Add proc-level coverage that HideWindow still captures child stdout.

Closes #2930
2026-06-04 00:18:48 -07:00
HUQIANTAOandHUQIANTAO bfd7cde9fb perf(serve): add ETag caching to /history and /context (#2957)
The /history and /context GET endpoints have no caching headers, so every
reconnecting client re-fetches the full payload even when nothing changed.
For long sessions, /history can be tens of KB of JSON.

Add ETag support via writeJSONCached: the response body is hashed (SHA-256)
to produce a weak ETag. If the client sends If-None-Match with the current
ETag, the server returns 304 Not Modified with no body — saving bandwidth
and JSON serialization cost on both sides.

Cache-Control is set to 'private, max-age=0, must-revalidate' so clients
always revalidate but avoid transferring unchanged data. The ETag is
deterministic (content hash) so it works correctly across server restarts.

# Conflicts:
#	internal/serve/serve.go

Co-authored-by: HUQIANTAO <[email protected]>
2026-06-03 23:44:40 -07:00
YHHandreasonix c722007772 site: resolve the v1.x version from the release manifest, not a literal (#3017)
The Go-line (preview) version was hardcoded as v1.0.0 in seven places, so
every release needed a manual edit. Resolve it from R2 latest/latest.json
at build time (baked into the static HTML) and refresh it at runtime, so a
new release updates the page on its own. Preview framing unchanged.

Co-authored-by: reasonix <[email protected]>
2026-06-03 21:40:13 -07:00
195 changed files with 23217 additions and 3956 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
+26 -9
View File
@@ -50,12 +50,12 @@ jobs:
with:
python-version: '3.12'
- name: Build harness + agent from the default branch
# The PR head may predate the bot (no cmd/e2ebench, no `run --metrics`), so
# build both here and stash them + the suite outside the tree before the
# working tree is switched to the PR head.
- name: Build harness + fallback agent from the default branch
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
# its own grader or tests. The agent is rebuilt from the PR head below; this
# main-v2 build is only the fallback for heads that predate `run --metrics`.
run: |
go build -o "$RUNNER_TEMP/reasonix" ./cmd/reasonix
go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix
go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench
cp -r benchmarks/e2e "$RUNNER_TEMP/suite"
@@ -64,6 +64,23 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr checkout ${{ github.event.issue.number }}
- name: Build the agent from the PR head
# The whole point of the bot is to drive the PR's code, not main-v2's. Fall
# back to the main-v2 build only when the PR head can't yield a
# run --metrics-capable binary (build break or predates the flag).
id: agent
run: |
bin="$RUNNER_TEMP/reasonix-base"
src="main-v2 fallback (PR head lacks run --metrics)"
if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \
&& "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then
bin="$RUNNER_TEMP/reasonix-pr"
src="PR head ($(git rev-parse --short HEAD))"
fi
echo "bin=$bin" >> "$GITHUB_OUTPUT"
echo "src=$src" >> "$GITHUB_OUTPUT"
echo "agent under test: $src"
- name: Write provider config
# User config covers suite tasks (they run in temp dirs); the repo-root copy
# covers diff mode (the agent runs in the repo root, where project config wins).
@@ -78,7 +95,7 @@ jobs:
base_url = "${{ vars.REASONIX_E2E_BASE_URL || 'https://api.deepseek.com' }}"
model = "${{ vars.REASONIX_E2E_MODEL || 'deepseek-v4-flash' }}"
api_key_env = "DEEPSEEK_API_KEY"
context_window = 65536
context_window = 20000 # small so the suite actually exercises compaction + cache churn
[providers.price]
cache_hit = ${{ vars.REASONIX_E2E_PRICE_CACHE_HIT || '0.02' }}
@@ -112,9 +129,9 @@ jobs:
BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)
git fetch -q origin "$BASE_REF"
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
"$RUNNER_TEMP/e2ebench" -mode diff -bin "$RUNNER_TEMP/reasonix" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
else
"$RUNNER_TEMP/e2ebench" -bin "$RUNNER_TEMP/reasonix" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
fi
- name: Post report
@@ -122,7 +139,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TRIGGER_USER: ${{ github.event.comment.user.login }}
run: |
printf '\n> agent built from main-v2 · PR head %s · triggered by @%s\n' "$(git rev-parse --short HEAD)" "$TRIGGER_USER" >> report.md
printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md
gh pr comment ${{ github.event.issue.number }} --body-file report.md
- name: Report failure
+8 -2
View File
@@ -69,12 +69,13 @@ jobs:
with:
version: 10
# Linux: WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag).
# Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh).
# 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+.
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
# Windows: NSIS provides makensis for `wails build -nsis`.
- name: Install NSIS
@@ -83,6 +84,11 @@ jobs:
choco install nsis -y --no-progress
echo "C:\Program Files (x86)\NSIS" >> "$GITHUB_PATH"
# macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
- name: Install create-dmg
if: runner.os == 'macOS'
run: brew install create-dmg
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/[email protected]
+4
View File
@@ -54,3 +54,7 @@ node_modules/
Thumbs.db
.idea/
.vscode/
# Scratch / agent working dirs (never committed)
/tmp/
/.codex/
+10 -6
View File
@@ -103,7 +103,7 @@ default_model = "deepseek-flash" # executor; set [agent].planner_model to add
# 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" }
auto_plan = "ask" # off|ask|on; complex chat tasks start in plan mode
auto_plan = "off" # off|on; off keeps plan mode manual
# auto_plan_classifier = "deepseek-flash" # optional; only borderline tasks call it
[[providers]]
@@ -247,11 +247,15 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
run them on another configured model, or use `subagent_models` to override only
specific skills such as `review` or `security_review`.
For interactive frontends, `agent.auto_plan = "ask"` makes complex-looking tasks
enter plan mode automatically: Reasonix first drafts a read-only plan, then waits
for approval before editing or running side-effecting commands. `auto_plan_classifier`
can name a cheap provider such as `deepseek-flash`; it is only called for
borderline inputs and falls back to the heuristic if classification fails.
For interactive frontends, plan mode is manual by default. Set
`agent.auto_plan = "on"` to make complex-looking tasks enter plan mode
automatically: Reasonix first drafts a read-only plan, then waits for approval
before editing or running side-effecting commands. `auto_plan_classifier` can
name a cheap provider such as `deepseek-flash`; it is only called for borderline
inputs and falls back to the heuristic if classification fails. Use
`/auto-plan off|on` in `reasonix chat` to change the user-level setting, or
`reasonix config auto-plan off|on` from a shell/script. Pass `--local` to the
shell command only when you intentionally want a project-local override.
## Architecture
+8 -4
View File
@@ -99,7 +99,7 @@ default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可
# planner_model = "mimo-pro" # 可选的低频规划器
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
auto_plan = "ask" # off|ask|on;复杂聊天任务自动进入计划模式
auto_plan = "off" # off|on;off 表示计划模式仅手动开启
# auto_plan_classifier = "deepseek-flash" # 可选;只在边界任务上调用
[[providers]]
@@ -227,9 +227,13 @@ planner_model = "deepseek-pro" # 作为低频规划器
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
交互式前端中,`agent.auto_plan = "ask"` 会让看起来复杂的任务自动进入 plan
mode:Reasonix 先只读生成计划,待用户批准后才编辑文件或执行有副作用的命令。
`auto_plan_classifier` 可以指定便宜的 provider,例如 `deepseek-flash`;它只在边界输入上调用,分类失败会回退到启发式规则。
交互式前端中,计划模式默认手动开启。设置 `agent.auto_plan = "on"` 后,看起来复杂
的任务会自动进入 plan mode:Reasonix 先只读生成计划,待用户批准后才
编辑文件或执行有副作用的命令。`auto_plan_classifier` 可以指定便宜的 provider,例如
`deepseek-flash`;它只在边界输入上调用,分类失败会回退到启发式规则。也可以用
`reasonix chat` 里的 `/auto-plan off|on` 修改用户级设置,或在 shell/脚本里用
`reasonix config auto-plan off|on`。只有明确想写项目级覆盖时,才给 shell 命令加
`--local`。
## 架构
@@ -0,0 +1,3 @@
prompt = "Begin by reading the file story/chapter-1.md. Each chapter ends by telling you which chapter to read next; follow that trail one chapter at a time — read a chapter, find where it points you, then read that next chapter — until a chapter says the trail ends. You cannot know the next chapter without reading the current one, so do not guess or read ahead. In chapter 1 a river village is named; in the final chapter on the trail a noble House is named. When the trail ends, write a file answer.txt whose entire contents are the village name from chapter 1, a single hyphen, and the House name from the final chapter — for example Riverton-Stark — and nothing else."
max_steps = 40
timeout_sec = 900
@@ -0,0 +1,4 @@
set -e
got=$(tr -d '[:space:]' < answer.txt | tr '[:upper:]' '[:lower:]')
want="aldermoor-verrin"
[ "$got" = "$want" ] || { echo "answer.txt normalized to '$got', want '$want'"; exit 1; }
@@ -0,0 +1,220 @@
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 4 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 10 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 16 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 22 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 28 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 34 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 40 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 46 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 52 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 58 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 64 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 70 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 76 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 82 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 88 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 94 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 100 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 106 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
It was from Aldermoor, the cluster of cottages where the north and south rivers braid together, that our courier first set out.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 112 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 118 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 124 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 130 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 136 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 142 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 148 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 154 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 160 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 166 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 172 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 178 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 184 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 190 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 196 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 202 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 208 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
To pick up the trail again, the next page you must read is chapter-4.md.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 214 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 220 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
@@ -0,0 +1,220 @@
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 3 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 9 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 15 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 21 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 27 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 33 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 39 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 45 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 51 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 57 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 63 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 69 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 75 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 81 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 87 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 93 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 99 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 105 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 111 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 117 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 123 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 129 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 135 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 141 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 147 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 153 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 159 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 165 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 171 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 177 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 183 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 189 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 195 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 201 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 207 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
To pick up the trail again, the next page you must read is chapter-5.md.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 213 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 219 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
@@ -0,0 +1,220 @@
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 2 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 8 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 14 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 20 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 26 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 32 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 38 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 44 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 50 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 56 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 62 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 68 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 74 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 80 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 86 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 92 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 98 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 104 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 110 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 116 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 122 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 128 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 134 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 140 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 146 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 152 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 158 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 164 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 170 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 176 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 182 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 188 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 194 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 200 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 206 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
To pick up the trail again, the next page you must read is chapter-6.md.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 212 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 218 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
@@ -0,0 +1,220 @@
Line 1 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 7 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 13 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 19 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 25 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 31 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 37 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 43 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 49 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 55 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 61 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 67 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 73 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 79 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 85 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 91 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 97 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 103 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 109 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 115 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 121 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 127 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 133 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 139 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 145 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 151 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 157 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 163 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 169 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 175 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 181 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 187 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 193 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 199 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 205 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
To pick up the trail again, the next page you must read is chapter-2.md.
Line 211 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 217 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
@@ -0,0 +1,220 @@
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 6 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 12 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 18 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 24 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 30 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 36 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 42 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 48 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 54 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 60 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 66 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 72 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 78 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 84 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 90 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 96 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 102 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 108 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 114 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 120 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 126 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 132 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 138 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 144 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 150 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 156 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 162 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 168 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 174 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 180 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 186 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 192 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 198 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 204 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
To pick up the trail again, the next page you must read is chapter-3.md.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 216 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
@@ -0,0 +1,220 @@
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 5 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 11 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 17 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 23 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 29 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 35 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 41 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 47 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 53 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 59 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 65 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 71 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 77 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 83 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 89 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 95 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 101 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 107 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
With that, the ancestral seal of the Verrin was pressed into the warm wax and then, before the whole assembly, ceremonially broken.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 113 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 119 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 125 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 131 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 137 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 143 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 149 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 155 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 161 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 167 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 173 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 179 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 185 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 191 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 197 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 203 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 209 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
Here the trail ends; there is no further page to follow. You now hold both names you were sent to find: the river village of Aldermoor, where the courier began in the first chapter, and the House whose seal was broken above. Your task is complete the moment you write a file named answer.txt whose entire contents are that village name, a single hyphen, and that House name, and nothing else. Write it now.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 215 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
+96 -10
View File
@@ -9,6 +9,7 @@ import (
"sort"
"strings"
"time"
"unicode/utf8"
)
type diffOpts struct {
@@ -82,6 +83,7 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
cmd.Dir = o.repo
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout
runErr := cmd.Run()
// The agent's new files are untracked, so `git diff HEAD` would miss them;
@@ -150,6 +152,7 @@ func resetTree(repo string) {
func goBuildAll(repo string) (bool, string) {
cmd := exec.Command("go", "build", "./...")
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
@@ -305,10 +308,22 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
restored := false
defer func() {
if restored {
return
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
}()
out := make([]pinResult, 0, len(refs))
for _, r := range refs {
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test
raw, err := cmd.CombinedOutput()
out = append(out, pinResult{
testRef: r,
@@ -319,6 +334,7 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
restored = true
return out
}
@@ -386,6 +402,11 @@ func parseCoverProfile(repo, path string) map[string][]coverBlock {
// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into
// "internal/agent/foo.go" by dropping the first path element (the module root).
func repoRelFromModulePath(p string) string {
// Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path.
prefix := "reasonix/"
if strings.HasPrefix(p, prefix) {
return p[len(prefix):]
}
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[i+1:]
}
@@ -409,12 +430,17 @@ func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]boo
out[file] = map[int]bool{}
case strings.HasPrefix(ln, "@@"):
// @@ -a,b +c,d @@ — start collecting at new-side line c.
// Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
if plus := strings.Index(ln, "+"); plus >= 0 {
num := ln[plus+1:]
if sp := strings.IndexAny(num, ", "); sp >= 0 {
num = num[:sp]
end := len(num)
for i := 0; i < len(num); i++ {
if num[i] < '0' || num[i] > '9' {
end = i
break
}
}
_, _ = fmt.Sscanf(num, "%d", &newLine)
_, _ = fmt.Sscanf(num[:end], "%d", &newLine)
}
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"):
if file != "" {
@@ -464,11 +490,26 @@ func parseNewTests(diff string) []testRef {
continue
}
sig := strings.TrimPrefix(body, "func ")
paren := strings.IndexByte(sig, '(')
if paren <= 0 {
continue
// Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
var name string
if sig[0] == '(' {
close := strings.IndexByte(sig, ')')
if close < 0 {
continue
}
rest := strings.TrimSpace(sig[close+1:])
methodParen := strings.IndexByte(rest, '(')
if methodParen <= 0 {
continue
}
name = rest[:methodParen]
} else {
funcParen := strings.IndexByte(sig, '(')
if funcParen <= 0 {
continue
}
name = sig[:funcParen]
}
name := sig[:paren]
if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") {
refs = append(refs, testRef{name: name, pkg: pkg})
}
@@ -505,17 +546,57 @@ func failingTestNames(out string) []string {
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
fields := strings.Fields(testCmd)
fields := splitShellFields(testCmd)
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
func splitShellFields(s string) []string {
var out []string
var cur strings.Builder
inSingle, inDouble := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inSingle:
if c == '\'' {
inSingle = false
} else {
cur.WriteByte(c)
}
case inDouble:
if c == '"' {
inDouble = false
} else {
cur.WriteByte(c)
}
case c == '\'':
inSingle = true
case c == '"':
inDouble = true
case c == ' ' || c == '\t' || c == '\n':
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
@@ -561,10 +642,15 @@ func gitOut(repo string, args ...string) string {
func truncate(s string) string { return truncateFor(s, 12000) }
func truncateFor(s string, max int) string {
if len(s) <= max {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + "\n…(truncated)…"
// Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune.
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)…"
}
func tail(s string, n int) string {
+52 -15
View File
@@ -34,6 +34,7 @@ type runMetrics struct {
Steps int `json:"steps"`
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Compactions int `json:"compactions"`
}
type result struct {
@@ -45,6 +46,17 @@ type result struct {
}
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
@@ -76,7 +88,12 @@ func main() {
os.Exit(1)
}
if len(tasks) == 0 {
fmt.Fprintln(os.Stderr, "no tasks found under", filepath.Join(*suite, "tasks"))
dir := filepath.Join(*suite, "tasks")
if _, statErr := os.Stat(dir); statErr != nil {
fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr)
} else {
fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir)
}
os.Exit(1)
}
@@ -102,8 +119,15 @@ func main() {
fmt.Print(report)
}
if *outJSON != "" {
b, _ := json.MarshalIndent(results, "", " ")
_ = os.WriteFile(*outJSON, b, 0o644)
b, err := json.MarshalIndent(results, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "marshal json:", err)
os.Exit(1)
}
if err := os.WriteFile(*outJSON, b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write json:", err)
os.Exit(1)
}
}
}
@@ -182,6 +206,7 @@ func runTask(bin, model string, t task) result {
cmd.Dir = work
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
runErr := cmd.Run()
if m, err := readMetrics(metricsPath); err == nil {
@@ -215,7 +240,7 @@ func grade(work, taskDir string) bool {
func render(results []result) string {
var b strings.Builder
passed, ran := 0, 0
var pTok, cTok, hit, miss int
var pTok, cTok, hit, miss, compacts int
var cost float64
currency := ""
for _, r := range results {
@@ -230,6 +255,7 @@ func render(results []result) string {
cTok += r.CompletionTokens
hit += r.CacheHitTokens
miss += r.CacheMissTokens
compacts += r.Compactions
cost += r.Cost
if r.Currency != "" {
currency = r.Currency
@@ -237,28 +263,28 @@ func render(results []result) string {
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark\n\n")
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Cost:** %s%.4f\n\n",
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
passed, ran, pct(passed, ran), pct(hit, hit+miss),
comma(pTok+cTok), comma(pTok), comma(cTok), currencySym(currency), cost)
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Cost |\n")
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|-----:|\n")
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n")
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n")
for _, r := range results {
switch {
case r.Skipped:
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — |\n", r.ID)
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID)
default:
res := "❌ fail"
if r.Passed {
res = "✅ pass"
}
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %s%.4f |\n",
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n",
r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens),
pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
currencySym(r.Currency), r.Cost)
r.Compactions, currencySym(r.Currency), r.Cost)
}
}
fmt.Fprintf(&b, "\n<sub>Real provider run on the PR head. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
notes := false
for _, r := range results {
@@ -329,6 +355,10 @@ func copyDir(src, dst string) error {
if err != nil {
return err
}
// Skip symlinks so a seed link can't leak a file from outside the seed tree.
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
rel, _ := filepath.Rel(src, p)
target := filepath.Join(dst, rel)
if info.IsDir() {
@@ -347,11 +377,18 @@ func copyFile(src, dst string) error {
return err
}
defer in.Close()
out, err := os.Create(dst)
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
if _, err := io.Copy(out, in); err != nil {
return err
}
// Mirror the source mode so a seed's read-only / exec bit survives the copy.
return os.Chmod(dst, info.Mode().Perm())
}
+17 -3
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"
)
const maxMutants = 15
@@ -68,8 +69,17 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
}
cmd := exec.Command("go", "test", "-run", runRe, pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
// Restore source even on panic; a file left mutated would corrupt the next mutant.
restored := false
defer func() {
if !restored {
_ = os.WriteFile(abs, srcB, 0o644)
}
}()
caught := cmd.Run() != nil
_ = os.WriteFile(abs, srcB, 0o644) // restore before the next mutant
_ = os.WriteFile(abs, srcB, 0o644)
restored = true
if caught {
res.caught++
} else {
@@ -81,12 +91,16 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
}
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
// main/init are skipped (no meaningful return to mutate).
// main/init and nil-named decls are skipped (no meaningful return to mutate).
func changedFuncs(fset *token.FileSet, f *ast.File, lines map[int]bool) []*ast.FuncDecl {
var out []*ast.FuncDecl
for _, decl := range f.Decls {
fd, ok := decl.(*ast.FuncDecl)
if !ok || fd.Body == nil || fd.Name.Name == "main" || fd.Name.Name == "init" {
if !ok || fd.Body == nil || fd.Name == nil {
continue
}
name := fd.Name.Name
if name == "main" || name == "init" {
continue
}
start := fset.Position(fd.Pos()).Line
+5 -2
View File
@@ -90,6 +90,8 @@ tags are the CLI release). Pushing one triggers `.github/workflows/release-deskt
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
```sh
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
@@ -111,8 +113,9 @@ has a manual check. Self-update behavior by platform:
There are no Apple/Windows code-signing certificates yet, so a downloaded build
trips the OS gatekeepers on first run:
- **macOS** — Gatekeeper may report the app "is damaged" or is from an
unidentified developer. Clear the quarantine attribute, then open it:
- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into
Applications. Gatekeeper may then report the app "is damaged" or is from an
unidentified developer; clear the quarantine attribute and open it:
```sh
xattr -dr com.apple.quarantine /Applications/Reasonix.app
```
+1181 -522
View File
File diff suppressed because it is too large. Load diff
+53 -9
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -46,27 +47,63 @@ func waitForFile(t *testing.T, path, want string) {
t.Fatalf("session file %q never contained %q", path, want)
}
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
tab.saveMu.Unlock()
if idle {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("autosave loop did not become idle")
}
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
t.Helper()
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "test_tab",
Ctrl: ctrl,
Scope: "global",
WorkspaceRoot: "",
Ready: true,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
a := &App{
tabs: map[string]*WorkspaceTab{"test_tab": tab},
activeTabID: "test_tab",
}
tab.sink.app = a
return a, tab
}
// TestTurnDonePersistsSession proves a completed turn is written to disk without
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a := &App{ctrl: controllerWithContent(t, path)}
a.sink = &eventSink{app: a}
_ = path
a, tab := appWithTab(t, path)
a.sink.Emit(event.Event{Kind: event.TurnDone})
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
_ = a
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
// per-token event storm doesn't thrash the disk.
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a := &App{ctrl: controllerWithContent(t, path)}
a.sink = &eventSink{app: a}
a, tab := appWithTab(t, path)
_ = a
a.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -78,12 +115,19 @@ func TestNonTurnDoneDoesNotPersist(t *testing.T) {
// single-flight loop neither panics nor drops the final write.
func TestScheduleSnapshotCoalesces(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a := &App{ctrl: controllerWithContent(t, path)}
a.sink = &eventSink{app: a}
a, tab := appWithTab(t, path)
_ = a
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
go a.scheduleSnapshot()
wg.Add(1)
go func() {
defer wg.Done()
tab.sink.Emit(event.Event{Kind: event.TurnDone})
}()
}
wg.Wait()
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, tab)
}
+488 -116
View File
@@ -6,14 +6,53 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/plugin"
"reasonix/internal/provider"
)
// setTestCtrl creates a minimal workspace tab (if needed) and sets its
// controller, so tests don't depend on the old App.ctrl field.
func (a *App) setTestCtrl(ctrl *control.Controller, model string) {
if len(a.tabs) == 0 {
tab := &WorkspaceTab{
ID: "test",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabs = map[string]*WorkspaceTab{"test": tab}
a.activeTabID = "test"
}
tab := a.tabs["test"]
tab.Ctrl = ctrl
tab.model = model
}
func isolateDesktopUserDirs(t *testing.T) string {
t.Helper()
home := t.TempDir()
xdg := filepath.Join(home, ".config")
appData := filepath.Join(home, "AppData")
for _, dir := range []string{xdg, appData} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("AppData", appData)
return home
}
func TestCommandsIncludesEffortNotThinking(t *testing.T) {
app := NewApp()
cmds := app.Commands()
@@ -26,8 +65,7 @@ func TestCommandsIncludesEffortNotThinking(t *testing.T) {
}
func TestEffortDefaultsBeforeStartup(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
got := NewApp().Effort()
if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") {
@@ -35,61 +73,44 @@ func TestEffortDefaultsBeforeStartup(t *testing.T) {
}
}
func TestSettingsFallbackKeepsNestedDefaultsOnLoadFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte("default_model = [\n"), 0o644); err != nil {
func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
isolateDesktopUserDirs(t)
consumeSystemQuitRequested()
t.Cleanup(func() { consumeSystemQuitRequested() })
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
t.Fatal(err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
got := NewApp().Settings()
if got.Providers == nil || got.ProviderKinds == nil {
t.Fatalf("Settings fallback providers/kinds should be non-nil: %+v", got)
markSystemQuitRequested()
if prevent := NewApp().beforeClose(context.Background()); prevent {
t.Fatal("system quit should bypass background close-to-tray behavior")
}
if got.Permissions.Mode != "ask" || got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil {
t.Fatalf("Settings fallback permissions should be safe defaults: %+v", got.Permissions)
}
if got.Sandbox.Bash != "enforce" || got.Sandbox.AllowWrite == nil {
t.Fatalf("Settings fallback sandbox should be safe defaults: %+v", got.Sandbox)
if consumeSystemQuitRequested() {
t.Fatal("system quit marker should be consumed by beforeClose")
}
}
func TestRebuildKeepsOldControllerOnBuildFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
t.Chdir(dir)
func TestEmitReadyInvokesReadyHook(t *testing.T) {
app := NewApp()
app.ctx = context.Background()
app.model = "deepseek-flash/deepseek-v4-flash"
old := control.New(control.Options{Label: "old-controller"})
app.ctrl = old
defer func() {
if app.ctrl != nil {
app.ctrl.Close()
}
}()
var calls int32
app.readyHook = func() {
atomic.AddInt32(&calls, 1)
}
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte("default_model = [\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := app.rebuild(); err == nil {
t.Fatal("rebuild should fail for malformed config")
}
if app.ctrl != old {
t.Fatalf("rebuild failure replaced controller: got %p want old %p", app.ctrl, old)
}
if app.startupErr == "" {
t.Fatal("rebuild failure should surface startupErr")
app.emitReady(nil)
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("ready hook calls = %d, want 1", got)
}
}
func TestSetEffortPersistsAndAutoClears(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SetEffort("max"); err != nil {
@@ -113,28 +134,133 @@ func TestSetEffortPersistsAndAutoClears(t *testing.T) {
}
}
func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
language = "zh"
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopLanguage("en"); err != nil {
t.Fatalf("set desktop language: %v", err)
}
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
t.Fatalf("set desktop close behavior: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
got := NewApp().Settings()
if got.DesktopLanguage != "en" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" {
t.Fatalf("desktop settings = lang:%q theme:%q style:%q close:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior)
}
}
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
default_model = "legacy-provider/legacy-model"
[desktop]
language = "zh"
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got := app.Settings()
if got.ConfigPath != config.UserConfigPath() {
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
}
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" {
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
}
if err := app.SetDesktopLanguage("en"); err != nil {
t.Fatalf("SetDesktopLanguage: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" {
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q theme:%q style:%q close:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior())
}
}
func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopLanguage("en"); err != nil {
t.Fatalf("set desktop language: %v", err)
}
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
t.Fatalf("migrate desktop preferences: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if got.DesktopLanguage() != "en" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
t.Fatalf("desktop prefs after migration = lang:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopTheme(), got.DesktopThemeStyle())
}
}
func TestSetEffortRebuildsController(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
app := NewApp()
app.ctx = context.Background()
app.model = "deepseek-flash/deepseek-v4-flash"
app.readyHook = func() {}
old := control.New(control.Options{Label: "old-controller"})
app.ctrl = old
app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
defer func() {
if app.ctrl != nil {
app.ctrl.Close()
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.SetEffort("max"); err != nil {
t.Fatalf("SetEffort(max): %v", err)
}
if app.ctrl == nil {
if c := app.activeCtrl(); c == nil {
t.Fatal("SetEffort should leave a rebuilt controller")
}
if app.ctrl == old {
if c := app.activeCtrl(); c == old {
t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
}
if got := app.Effort().Current; got != "max" {
@@ -143,13 +269,12 @@ func TestSetEffortRebuildsController(t *testing.T) {
}
func TestSetEffortRejectsRunningTurn(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
app := NewApp()
app.ctrl = control.New(control.Options{Runner: runner})
app.ctrl.Submit("work")
app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
app.activeCtrl().Submit("work")
<-runner.started
err := app.SetEffort("max")
@@ -158,7 +283,7 @@ func TestSetEffortRejectsRunningTurn(t *testing.T) {
}
close(runner.release)
waitNotRunning(t, app.ctrl)
waitNotRunning(t, app.activeCtrl())
}
func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
@@ -191,9 +316,51 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
}
}
func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
t.Fatal(err)
}
projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := NewApp()
tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.activeTabID = tab.ID
listed := app.ListDir("")
if !hasDirEntry(listed, "frontend") {
t.Fatalf("ListDir should list active project root, got %+v", listed)
}
if hasDirEntry(listed, "launch-only.txt") {
t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
}
found := app.SearchFileRefs("runtime.js")
if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
}
preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
if preview.Err != "" || preview.Body != "right workspace" {
t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
}
}
func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -205,8 +372,12 @@ func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
}
app := NewApp()
app.ctrl = control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.DeleteSession(filepath.Base(path)); err != errActiveSession {
t.Fatalf("DeleteSession(active basename) error = %v, want errActiveSession", err)
@@ -216,9 +387,175 @@ func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
}
}
func TestDeleteSessionRejectsInactiveOpenTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
activePath := filepath.Join(dir, "active.jsonl")
inactivePath := filepath.Join(dir, "inactive.jsonl")
otherPath := filepath.Join(dir, "other.jsonl")
for _, path := range []string{activePath, inactivePath, otherPath} {
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session %s: %v", path, err)
}
}
activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
defer activeCtrl.Close()
defer inactiveCtrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{
"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
},
tabOrder: []string{"active", "inactive"},
activeTabID: "active",
}
if err := app.DeleteSession(filepath.Base(inactivePath)); err != errActiveSession {
t.Fatalf("DeleteSession(inactive open basename) error = %v, want errActiveSession", err)
}
if _, err := os.Stat(inactivePath); err != nil {
t.Fatalf("inactive open session should remain: %v", err)
}
sessions := app.ListSessions()
current := map[string]bool{}
open := map[string]bool{}
for _, s := range sessions {
current[filepath.Base(s.Path)] = s.Current
open[filepath.Base(s.Path)] = s.Open
}
if !current[filepath.Base(activePath)] {
t.Fatalf("ListSessions should mark active session current, got %#v", current)
}
if current[filepath.Base(inactivePath)] {
t.Fatalf("ListSessions should not mark inactive open session current, got %#v", current)
}
if current[filepath.Base(otherPath)] {
t.Fatalf("ListSessions marked unopened session current, got %#v", current)
}
if !open[filepath.Base(activePath)] || !open[filepath.Base(inactivePath)] {
t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open)
}
if open[filepath.Base(otherPath)] {
t.Fatalf("ListSessions marked unopened session open, got %#v", open)
}
}
type appendingDesktopRunner struct {
session *agent.Session
started chan string
}
func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
r.started <- input
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
return nil
}
func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
isolateDesktopUserDirs(t)
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte("[codegraph]\nenabled = false\n"), 0o644); err != nil {
t.Fatalf("write workspace config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
path := agent.NewSessionPath(dir, "test")
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
runner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}
ctrl := control.New(control.Options{
Runner: runner,
Executor: exec,
Sink: event.Discard,
SessionDir: dir,
SessionPath: path,
Label: "test",
WorkspaceRoot: workspace,
})
app := NewApp()
app.setTestCtrl(ctrl, "deepseek/test")
app.tabs["test"].Scope = "project"
app.tabs["test"].WorkspaceRoot = workspace
app.tabs["test"].TopicID = "topic_source"
app.tabs["test"].TopicTitle = "Source topic"
defer ctrl.Close()
ctrl.Submit("first")
<-runner.started
waitNotRunning(t, ctrl)
ctrl.Submit("second")
<-runner.started
waitNotRunning(t, ctrl)
if got := len(ctrl.History()); got != 5 {
t.Fatalf("source history len before fork = %d, want 5", got)
}
meta, err := app.Fork(1)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if !meta.Active || meta.ID == "" || meta.ID == "test" {
t.Fatalf("fork meta = %+v, want a new active tab", meta)
}
if got := app.activeTabID; got != meta.ID {
t.Fatalf("active tab = %q, want fork tab %q", got, meta.ID)
}
if got := ctrl.SessionPath(); got != path {
t.Fatalf("source controller session path = %q, want %q", got, path)
}
if got := len(ctrl.History()); got != 5 {
t.Fatalf("source history len after fork = %d, want 5", got)
}
if got, want := meta.TopicTitle, "Source topic · 分叉"; got != want {
t.Fatalf("fork topic title = %q, want %q", got, want)
}
var forkPath string
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read session dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
candidate := filepath.Join(dir, entry.Name())
if candidate == path {
continue
}
m, ok, err := agent.LoadBranchMeta(candidate)
if err != nil {
t.Fatalf("load fork meta: %v", err)
}
if ok && m.TopicID == meta.TopicID {
forkPath = candidate
if m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {
t.Fatalf("fork branch meta = %+v, want parent %q turn 1 index 3", m, agent.BranchID(path))
}
if m.Scope != "project" || m.WorkspaceRoot != workspace || m.TopicTitle != "Source topic · 分叉" {
t.Fatalf("fork topic meta = %+v", m)
}
}
}
if forkPath == "" {
t.Fatalf("fork session with topic %q not found in %s", meta.TopicID, dir)
}
}
func TestCapabilitiesShowsLazyMCPAsDeferredNotDisabled(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -234,8 +571,12 @@ args = ["-y", "@playwright/mcp"]
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -250,16 +591,13 @@ args = ["-y", "@playwright/mcp"]
}
func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("AppData", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -283,8 +621,7 @@ func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
}
func TestCapabilitiesMarksDeferredRemoteMCPAuthPossible(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -301,8 +638,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -317,8 +654,7 @@ tier = "lazy"
}
func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -336,8 +672,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -352,8 +688,7 @@ tier = "lazy"
}
func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -372,8 +707,8 @@ tier = "lazy"
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
app := NewApp()
app.ctrl = control.New(control.Options{Host: host})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: host}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -388,8 +723,7 @@ tier = "lazy"
}
func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -410,8 +744,8 @@ tier = "lazy"
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
app := NewApp()
app.ctrl = control.New(control.Options{Host: host})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: host}), "")
defer app.activeCtrl().Close()
if err := app.ClearMCPServerAuthentication("figma"); err != nil {
t.Fatalf("ClearMCPServerAuthentication: %v", err)
@@ -452,8 +786,7 @@ tier = "lazy"
}
func TestUpdateMCPServerKeepsLazyMCPDeferred(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -470,8 +803,12 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.UpdateMCPServer("playwright", MCPServerInput{
Name: "playwright",
@@ -492,6 +829,18 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("env TOKEN = %q, want preserved env", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
if !ok {
t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("user plugin after migration = %+v", userPlugin)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
@@ -508,8 +857,7 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
}
func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -525,8 +873,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.UpdateMCPServer("broken", MCPServerInput{
Name: "broken",
@@ -546,8 +894,8 @@ tier = "lazy"
if got := cfg.Plugins[0].Tier; got != "background" {
t.Fatalf("updated tier = %q, want background", got)
}
if !mcpFailed(app.ctrl, "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.ctrl.Host().Failures())
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
}
view := app.Capabilities()
for _, s := range view.Servers {
@@ -565,8 +913,7 @@ tier = "lazy"
}
func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -582,8 +929,12 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.SetMCPServerTier("broken", "background"); err != nil {
t.Fatalf("SetMCPServerTier should persist tier even when immediate connect fails: %v", err)
@@ -595,8 +946,20 @@ tier = "lazy"
if got := cfg.Plugins[0].Tier; got != "background" {
t.Fatalf("saved tier = %q, want background", got)
}
if !mcpFailed(app.ctrl, "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.ctrl.Host().Failures())
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
if !ok {
t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Tier != "background" {
t.Fatalf("user plugin tier = %q, want background", userPlugin.Tier)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
}
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
}
view := app.Capabilities()
for _, s := range view.Servers {
@@ -631,8 +994,8 @@ auto_install = true
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerTier("codegraph", "background"); err != nil {
t.Fatalf("SetMCPServerTier(codegraph): %v", err)
@@ -647,8 +1010,15 @@ auto_install = true
if got := cfg.Codegraph.Tier; got != "background" {
t.Fatalf("codegraph tier = %q, want background", got)
}
if !mcpFailed(app.ctrl, "codegraph") {
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.ctrl.Host().Failures())
userCfg := config.LoadForEdit(config.UserConfigPath())
if !userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = false, want true after selecting a startup tier")
}
if got := userCfg.Codegraph.Tier; got != "background" {
t.Fatalf("user codegraph tier = %q, want background", got)
}
if !mcpFailed(app.activeCtrl(), "codegraph") {
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
}
view := app.Capabilities()
for _, s := range view.Servers {
@@ -666,8 +1036,7 @@ auto_install = true
}
func TestSetMCPServerEnabledPersistsCodegraphOff(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -679,8 +1048,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerEnabled("codegraph", false); err != nil {
t.Fatalf("SetMCPServerEnabled(codegraph,false): %v", err)
@@ -692,6 +1061,10 @@ tier = "lazy"
if cfg.Codegraph.Enabled {
t.Fatal("codegraph enabled = true, want false after disabling")
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = true, want false after disabling")
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "codegraph" {
@@ -705,8 +1078,7 @@ tier = "lazy"
}
func TestCapabilitiesKeepsFailedMCPConfiguredTierAfterRestart(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -722,9 +1094,9 @@ tier = "eager"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
recordMCPFailure(app.ctrl, config.PluginEntry{
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
recordMCPFailure(app.activeCtrl(), config.PluginEntry{
Name: "broken",
Command: "reasonix-missing-mcp-binary",
Tier: "eager",
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"encoding/json"
"reflect"
"testing"
)
func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
cases := []struct {
name string
got any
}{
{"Checkpoints", app.Checkpoints()},
{"ListSessions", app.ListSessions()},
{"ListTrashedSessions", app.ListTrashedSessions()},
{"ListWorkspaces", app.ListWorkspaces()},
{"History", app.History()},
{"Jobs", app.Jobs()},
{"Commands", app.Commands()},
{"Models", app.Models()},
{"ListDir", app.ListDir("__missing__")},
{"ListTabs", app.ListTabs()},
{"ListProjectTree", app.ListProjectTree()},
}
for _, tc := range cases {
assertNonNilSliceJSON(t, tc.name, tc.got)
}
if got := app.SlashArgs("/skill "); got.Items == nil {
t.Fatal("SlashArgs().Items is nil; frontend expects []")
}
if got := app.WorkspaceChanges(); got.Files == nil {
t.Fatal("WorkspaceChanges().Files is nil; frontend expects []")
}
if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
}
if got := app.Settings(); got.Providers == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil {
t.Fatalf("Settings() contains nil array fields: %+v", got)
}
}
func assertNonNilSliceJSON(t *testing.T, name string, got any) {
t.Helper()
v := reflect.ValueOf(got)
if v.Kind() != reflect.Slice {
t.Fatalf("%s returned %T, want slice", name, got)
}
if v.IsNil() {
t.Fatalf("%s returned nil slice; frontend expects []", name)
}
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("%s JSON marshal: %v", name, err)
}
if string(raw) == "null" {
t.Fatalf("%s JSON encoded as null; frontend expects []", name)
}
}
+24 -2
View File
@@ -4,7 +4,7 @@ Unicode true
## Reasonix per-user NSIS installer.
##
## This file is COMMITTED and customized (Wails leaves an existing project.nsi
## untouched and only regenerates wails_tools.nsh). The two customizations vs.
## untouched and only regenerates wails_tools.nsh). The customizations vs.
## Wails' default template:
##
## 1. REQUEST_EXECUTION_LEVEL "user" + InstallDir under $LOCALAPPDATA - install
@@ -13,6 +13,12 @@ Unicode true
## 2. Uninstall registry under HKCU (not HKLM). Wails' wails.writeUninstaller /
## wails.deleteUninstaller macros hard-code HKLM, which a non-admin install
## cannot write - so we inline HKCU versions below instead.
## 3. InstallDir is remembered across updates via InstallDirRegKey +
## InstallLocation (HKCU\...\Uninstall\InstallLocation). Without this, every
## release forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if
## they had moved the install to a different drive (e.g. D:\Tools\Reasonix);
## the silent auto-updater would re-run with /S into the wrong dir, leaving
## the old install orphaned.
##
## Everything else mirrors Wails' generated default. Defines below override the
## ProjectInfo values that wails_tools.nsh would otherwise populate.
@@ -66,7 +72,9 @@ ManifestDPIAware true
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" # Per-user install location (no admin rights required).
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
####
@@ -82,6 +90,13 @@ ShowInstDetails show # This will always show the installation details.
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
# Persist the resolved install path so a subsequent update picks it up
# via InstallDirRegKey above. Without this, every release would force the
# user back to %LOCALAPPDATA%\Programs\Reasonix even if they had moved
# the install to a different drive (e.g. D:\Tools\Reasonix). The auto-
# updater re-runs this installer with /S and trusts the persisted path,
# so it has to be present before the silent re-install.
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
@@ -95,6 +110,13 @@ ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value
; is missing (first install, or the user wiped the uninstaller registry).
; Fall back to the per-user default so the directory page lands on a
; usable path instead of crashing the install with "InstallDir empty".
StrCmp $INSTDIR "" 0 +2
StrCpy $INSTDIR "${REASONIX_DEFAULT_INSTALLDIR}"
FunctionEnd
Section
+78
View File
@@ -85,3 +85,81 @@ func upsertEnvFile(path, key, value string) error {
}
return os.Setenv(key, value)
}
// envFileKeys returns the set of KEY names assigned in a KEY=value file, empty
// when the file is absent.
func envFileKeys(path string) map[string]bool {
keys := map[string]bool{}
data, err := os.ReadFile(path)
if err != nil {
return keys
}
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if k, _, ok := strings.Cut(line, "="); ok {
keys[strings.TrimSpace(k)] = true
}
}
return keys
}
// promoteProviderKeysToCredentials copies any configured provider api_key_env that
// currently resolves (from a project .env, ~/.env, or the OS env) into the global
// credentials file when it isn't there yet, so a key set for one workspace follows
// the user across every project. Promoted keys are then stripped from ~/.env so the
// credentials file is the single source of truth; a project's own .env is
// user-owned and left untouched.
func promoteProviderKeysToCredentials(cfg *config.Config) {
credPath := credentialsPath()
have := envFileKeys(credPath)
for _, p := range cfg.Providers {
env := strings.TrimSpace(p.APIKeyEnv)
if env == "" || have[env] {
continue
}
val := os.Getenv(env)
if val == "" {
continue
}
if err := upsertEnvFile(credPath, env, val); err != nil {
continue
}
have[env] = true
removeHomeEnvKey(env)
}
}
// removeHomeEnvKey deletes a single KEY=value assignment from ~/.env (the legacy
// fallback the old migration wrote to), leaving every other line intact. No-op when
// ~/.env is absent or the credentials store resolves to ~/.env itself.
func removeHomeEnvKey(key string) {
home, err := os.UserHomeDir()
if err != nil {
return
}
path := filepath.Join(home, ".env")
if sameConfigPath(path, credentialsPath()) {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
var kept []string
removed := false
for _, raw := range strings.Split(string(data), "\n") {
check := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
if k, _, ok := strings.Cut(check, "="); ok && strings.TrimSpace(k) == key {
removed = true
continue
}
kept = append(kept, raw)
}
if !removed {
return
}
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600)
}
+63
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/config"
)
// TestUpsertEnvFile proves a new key is appended, an existing key is replaced in
@@ -36,3 +38,64 @@ func TestUpsertEnvFile(t *testing.T) {
t.Errorf("process env not updated: FOO=%q BAZ=%q", os.Getenv("FOO"), os.Getenv("BAZ"))
}
}
// TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv proves a provider key
// that resolves only from a project .env / ~/.env is copied into the global
// credentials store, removed from ~/.env, and that unrelated env vars are ignored.
func TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv(t *testing.T) {
home := isolateDesktopUserDirs(t)
homeEnv := filepath.Join(home, ".env")
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
t.Setenv("NPM_TOKEN", "secret")
promoteProviderKeysToCredentials(config.Default())
cred, err := os.ReadFile(config.UserCredentialsPath())
if err != nil {
t.Fatalf("credentials not written: %v", err)
}
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-test") {
t.Errorf("provider key not promoted to credentials:\n%s", cred)
}
if strings.Contains(string(cred), "NPM_TOKEN") {
t.Errorf("non-provider env var must not be promoted:\n%s", cred)
}
rest, _ := os.ReadFile(homeEnv)
if strings.Contains(string(rest), "DEEPSEEK_API_KEY") {
t.Errorf("promoted key must be stripped from ~/.env:\n%s", rest)
}
if !strings.Contains(string(rest), "NPM_TOKEN=secret") {
t.Errorf("unrelated ~/.env line must survive:\n%s", rest)
}
}
// TestPromoteProviderKeysLeavesExistingCredentialsKey proves promotion never
// overwrites a key already in the credentials store and leaves ~/.env untouched.
func TestPromoteProviderKeysLeavesExistingCredentialsKey(t *testing.T) {
home := isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.UserCredentialsPath(), []byte("DEEPSEEK_API_KEY=sk-global\n"), 0o600); err != nil {
t.Fatal(err)
}
homeEnv := filepath.Join(home, ".env")
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DEEPSEEK_API_KEY", "sk-global")
promoteProviderKeysToCredentials(config.Default())
cred, _ := os.ReadFile(config.UserCredentialsPath())
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-global") {
t.Errorf("existing credentials key was changed:\n%s", cred)
}
if data, err := os.Stat(homeEnv); err != nil || data.Size() == 0 {
t.Errorf("~/.env should be untouched when key already global, err=%v", err)
}
}
+2
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -24,6 +25,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"terser": "^5.48.0",
"typescript": "^5.6.3",
"vite": "^6.0.7"
},
+60 -5
View File
@@ -47,13 +47,16 @@ importers:
version: 18.3.7(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0([email protected])
version: 4.7.0([email protected]([email protected]))
terser:
specifier: ^5.48.0
version: 5.48.0
typescript:
specifier: ^5.6.3
version: 5.9.3
vite:
specifier: ^6.0.7
version: 6.4.2
version: 6.4.2([email protected])
packages:
@@ -306,6 +309,9 @@ packages:
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -499,6 +505,11 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
[email protected]:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -512,6 +523,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
[email protected]:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
[email protected]:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
@@ -533,6 +547,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
[email protected]:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
[email protected]:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
@@ -921,6 +938,13 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
[email protected]:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -933,6 +957,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
[email protected]:
resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
@@ -1240,6 +1269,11 @@ snapshots:
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]':
@@ -1384,7 +1418,7 @@ snapshots:
'@ungap/[email protected]': {}
'@vitejs/[email protected]([email protected])':
'@vitejs/[email protected]([email protected]([email protected]))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/[email protected])
@@ -1392,10 +1426,12 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.4.2
vite: 6.4.2([email protected])
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -1408,6 +1444,8 @@ snapshots:
node-releases: 2.0.46
update-browserslist-db: 1.2.3([email protected])
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -1422,6 +1460,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2141,6 +2181,13 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -2156,6 +2203,13 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
[email protected]:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
commander: 2.20.3
source-map-support: 0.5.21
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
@@ -2231,7 +2285,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected]:
[email protected]([email protected]):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0([email protected])
@@ -2241,6 +2295,7 @@ snapshots:
tinyglobby: 0.2.16
optionalDependencies:
fsevents: 2.3.3
terser: 5.48.0
[email protected]: {}
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = path.join(root, "src", "lib", "todoVisibility.ts");
const source = readFileSync(sourcePath, "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const { shouldShowTodoPanel } = await import(moduleUrl);
const completedTodos = [
{ content: "Inspect the report", status: "completed" },
{ content: "Ship the fix", status: "completed" },
];
assert.equal(
shouldShowTodoPanel("todo-final", null, completedTodos),
true,
"the final all-completed todo_write must remain visible",
);
assert.equal(
shouldShowTodoPanel("todo-active", null, [{ content: "Run tests", status: "in_progress" }]),
true,
"an active todo_write remains visible",
);
assert.equal(
shouldShowTodoPanel("todo-final", "todo-final", completedTodos),
false,
"a user dismissal still hides that exact todo list",
);
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
const iterations = 200_000;
const started = performance.now();
for (let i = 0; i < iterations; i += 1) {
if (!shouldShowTodoPanel("todo-perf", null, completedTodos)) {
throw new Error("unexpected hidden todo panel during performance loop");
}
}
const elapsed = performance.now() - started;
const perCallUs = (elapsed * 1000) / iterations;
assert.ok(elapsed < 500, `todo visibility check is too slow: ${elapsed.toFixed(2)} ms`);
console.log(
`todo visibility checks: ${iterations} calls in ${elapsed.toFixed(2)} ms (${perCallUs.toFixed(3)} us/call)`,
);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1692.66 392.25">
<defs>
<style>
.cls-1 {
fill: #0153e5;
}
</style>
</defs>
<path class="cls-1" d="M767.26,303.68c-12.37,1.71-24.96.46-38.13.63l-1.23-16.27c-12.87,14.9-29.84,21.97-49.07,19.87-24.58-2.69-44.41-19.38-53.2-42.6-7.32-19.32-6.99-40.86.81-60.12,9.5-23.46,30.72-40.21,55.73-42.02,18.41-1.33,34.33,4.84,45.96,18.54l1.13-15.37,37.63-.22.36,137.56ZM729.95,235.53c0-19.82-16.06-35.88-35.88-35.88s-35.88,16.06-35.88,35.88,16.06,35.88,35.88,35.88,35.88-16.06,35.88-35.88Z"/>
<path class="cls-1" d="M573.23,258.02c11.08,4.99,20.6,11.87,31.28,18.31-14.54,22.26-39.3,32.78-65.14,31.91-42.29-1.42-73.12-35.5-70.7-77.31,2.1-36.29,28.66-64.56,65.17-67.92,26.31-2.42,52.01,8.19,66.02,30.99,9.52,15.5,11.55,33.39,9.76,50.92l-101,.13c1.4,14.77,13.51,25.01,25.92,27.74,14.72,3.24,29.02-.82,38.69-14.77ZM572.56,219.47c-4.12-16.52-17.68-24.4-32.5-24.13-13.74.25-27.5,9.07-30.5,24.11l63,.02Z"/>
<path class="cls-1" d="M1058.84,235.62c0,40.74-33.02,73.76-73.76,73.76s-73.76-33.02-73.76-73.76,33.02-73.76,73.76-73.76,73.76,33.02,73.76,73.76ZM1021.17,235.57c0-19.97-16.19-36.16-36.16-36.16s-36.16,16.19-36.16,36.16,16.19,36.16,36.16,36.16,36.16-16.19,36.16-36.16Z"/>
<path class="cls-1" d="M1144.36,198.98c-16.4-.48-30.2,10.42-30.32,26.41l-.59,78.88-38.91-.18.06-137.83,38.15.35c.41,5.66-1.03,9.97,1.45,15.75,19.54-26.51,63.34-25.07,82.95-1.16,9.2,11.23,13.46,25.62,13.45,40.38l-.06,82.62-38.94.14-.69-79.28c-.13-14.92-10.82-25.64-26.54-26.1Z"/>
<path class="cls-1" d="M1430.09,303.82l-44.84.51-29.9-41.82-30.52,41.81-43.29-.52,50.71-70.03-48.12-67.06c16.29-1.81,30.31-.14,45.42-.02l26.06,38.81,27.14-39.29c14.85-.38,28.79-.61,44.5.69l-47.96,66.65,50.79,70.28Z"/>
<path class="cls-1" d="M873.65,302.86c-30.01,11.52-78.69,6.96-94.37-27.63l29.88-14.4c4.49,1.96,5.5,6.8,9.44,9.49,16.43,11.17,43.99,9.75,44.36-4.61.12-4.72-3.46-8.89-8.58-10.29l-36.51-9.95c-11.77-3.21-22.59-9.81-28.13-21.2-10.25-21.06.12-45.53,21.63-54.96,29.01-12.72,77.53-8.27,89.7,25.13l-29.06,12.62c-10.47-12.8-27.66-16.84-42.64-10.42-3.51,1.5-5.54,6.03-5.28,8.81.35,3.7,3.39,7.71,7.59,8.86l40.46,11.09c9.77,2.68,19.02,7.82,24.29,16.77,13.52,22.96,2.31,51.05-22.78,60.69Z"/>
<rect class="cls-1" x="1181.54" y="215.87" width="138.32" height="38.82" transform="translate(1014.48 1485.8) rotate(-89.96)"/>
<circle class="cls-1" cx="1250.95" cy="124.55" r="23.88"/>
<g>
<path class="cls-1" d="M369.75,185.3c2.09,1.76,4.98,2.41,7.95,1.06,1.18-.54,1.96-1.91,1.47-3.25-3.92-10.71-14.97-18.54-19.38-12.48-.54.74-.52,1.7-.11,2.29.32.47.97.57,1.82.4,2.68-.56,6.02,1.31,5.7,4.3-.33,3.12.3,5.79,2.56,7.69Z"/>
<path class="cls-1" d="M434.28,204.78c10.12-11.32,16.46-25.01,18.67-40.07,5.04-32.93-11.31-63.01-42.11-75.75-10.83-4.35-21.95-6.2-33.76-6.09l-114.5.03v219.42s56.29-.04,56.29-.04v-77.17c-1.86-.69-3.15-1.27-3.15-1.27-12.24-5.59-21.6-15.29-26.74-27.44-6.56-15.51-4.54-32.9,5.78-46.12,10.28-12.84,24.31-16.92,40.41-14.67,5.95.83,12.3-6.15,24.4-4.81.81.09,1.63.7,1.71,1.18.28,1.65-4.4,2.54-4.4,7.05,0,1.9.85,4.1,2.65,5.36,6.17,4.33,11.51,9.19,17.1,14.22,2.72,2.44,11.93,9.31,14.27,3.72,1.37-3.26,2.27-6.68,3.18-10.15.45-1.72-.5-2.74-1.89-3.56-9.5-5.63-13.08-17.35-9.37-27.74.36-1,1.29-1.49,2.08-1.52,3.19-.14,1.49,5.78,9.38,8.21,7.49,2.31,7.71,8.53,11.35,6.05,8.48-5.78,11.54-1.05,19.41-8.46.81-.76,2.2-.82,3.04-.23.52.36.97,1.29.93,2.38-.23,6.26-2.67,12.24-6.95,16.84-8.09,8.69-14.89,4.53-15.33,10.62-1.29,17.86-7.13,35.69-19.81,48.66-.41.42-.65,1.01-.58,1.41.07.42.57.83,1.12,1.01l10.68,3.63c1.51.51,2.6,1.87,2.41,3.31-.17,1.26-1.17,2.37-2.61,2.85-7.84,2.59-16.26,2.14-24.24-.27-10.56,8.89-23.54,13.48-37.68,12.93l20.53,28.34,33.49,45.73,66.85-.09-56.68-76.53c13.19-3.89,24.78-10.87,34.07-20.94Z"/>
<circle class="cls-1" cx="362.14" cy="177.19" r="1.58"/>
<path class="cls-1" d="M300.31,167.85c-3.53.43-5.65,2.96-5.31,6.74,2.39,26.35,26.7,47.92,53.94,42.72,5.8-1.11,11.08-3.41,15.36-7.74-9.27-7.98-13.79-16.12-21.41-24.13-10.91-11.47-26.43-19.54-42.58-17.59Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,119 @@
import { useEffect, useLayoutEffect, useState } from "react";
import type { CSSProperties, ReactNode, RefObject } from "react";
import { createPortal } from "react-dom";
type PopoverPosition = {
left: number;
top: number;
};
const EDGE_GAP = 8;
const DEFAULT_OFFSET = 8;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function samePosition(a: PopoverPosition | null, b: PopoverPosition): boolean {
return !!a && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5;
}
function calculatePosition(
anchor: DOMRect,
menu: DOMRect,
align: "start" | "end",
offset: number,
placement: "auto" | "bottom",
): PopoverPosition {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const preferredTop = anchor.top - menu.height - offset;
const fallbackTop = anchor.bottom + offset;
const top = placement === "bottom"
? Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP))
: preferredTop >= EDGE_GAP
? preferredTop
: Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP));
const rawLeft = align === "end" ? anchor.right - menu.width : anchor.left;
const left = clamp(rawLeft, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - menu.width - EDGE_GAP));
return { left, top: clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) };
}
export function AnchoredPopover({
open,
anchorRef,
onClose,
className,
children,
align = "start",
offset = DEFAULT_OFFSET,
placement = "auto",
style,
}: {
open: boolean;
anchorRef: RefObject<HTMLElement>;
onClose: () => void;
className: string;
children: ReactNode;
align?: "start" | "end";
offset?: number;
placement?: "auto" | "bottom";
style?: CSSProperties;
}) {
const [position, setPosition] = useState<PopoverPosition | null>(null);
useLayoutEffect(() => {
if (!open) {
setPosition(null);
return;
}
const anchor = anchorRef.current?.getBoundingClientRect();
const menu = document.querySelector<HTMLElement>("[data-anchored-popover='active']")?.getBoundingClientRect();
if (!anchor || !menu) return;
const next = calculatePosition(anchor, menu, align, offset, placement);
setPosition((current) => (samePosition(current, next) ? current : next));
});
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
const closeOnViewportChange = () => onClose();
window.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", closeOnViewportChange);
window.addEventListener("scroll", closeOnViewportChange, true);
return () => {
window.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", closeOnViewportChange);
window.removeEventListener("scroll", closeOnViewportChange, true);
};
}, [onClose, open]);
if (!open) return null;
return createPortal(
<>
<div className="anchored-popover__backdrop" onMouseDown={onClose} />
<div
data-anchored-popover="active"
className={`anchored-popover ${className}`}
style={{
...style,
left: position?.left ?? -9999,
top: position?.top ?? -9999,
visibility: position ? "visible" : "hidden",
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
>
{children}
</div>
</>,
document.body,
);
}
@@ -22,6 +22,7 @@ export function ApprovalModal({
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const isPlanApproval = approval.tool === "exit_plan_mode";
const subject = approval.subject.trim();
const subjectSummary = subject.split("\n").find((line) => line.trim())?.trim() ?? "";
const choosePlanAction = (key: string) => {
if (key === "1") setRevisionOpen((open) => !open);
@@ -123,10 +124,11 @@ export function ApprovalModal({
barRef={cardRef}
titleId="tool-approval-title"
title={t("approval.toolPending")}
actionsWrap
meta={
<>
<span className="tool__name">{approval.tool}</span>
{subject && <span className="prompt-shelf__subject"> · {subject}</span>}
{subjectSummary && <span className="prompt-shelf__subject"> · {subjectSummary}</span>}
</>
}
actions={
+14 -19
View File
@@ -150,6 +150,7 @@ export function AskCard({
barRef={shelfRef}
titleId="ask-shelf-title"
title={t("ask.title")}
actionsWrap
badges={
<>
{q.header && <PromptBadge>{q.header}</PromptBadge>}
@@ -159,6 +160,11 @@ export function AskCard({
meta={q.prompt}
actions={
<>
{active > 0 && (
<button className="prompt-action prompt-action--quiet" onClick={goBack}>
<span className="prompt-action__label">{t("ask.back")}</span>
</button>
)}
{q.options.map((o, index) => {
const on = (sel[q.id] ?? []).includes(o.label);
return (
@@ -171,12 +177,20 @@ export function AskCard({
/>
);
})}
{q.multi && (
<button className="prompt-action prompt-action--selected" onClick={() => finishOrAdvance()} disabled={!currentAnswered}>
<span className="prompt-action__label">{isLast ? t("common.submit") : t("ask.next")}</span>
</button>
)}
<PromptDetailToggle
open={detailsOpen}
label={t("ask.details")}
openLabel={t("ask.hideDetails")}
onClick={() => setDetailsOpen((open) => !open)}
/>
<button className="prompt-action prompt-action--quiet" onClick={onDismiss}>
<span className="prompt-action__label">{t("ask.justChat")}</span>
</button>
</>
}
crumbs={
@@ -190,25 +204,6 @@ export function AskCard({
</div>
)
}
quickActions={
!detailsOpen && (
<div className="ask-shelf__quick-actions">
{active > 0 && (
<button className="btn" onClick={goBack}>
{t("ask.back")}
</button>
)}
<button className="btn" onClick={onDismiss}>
{t("ask.justChat")}
</button>
{q.multi && (
<button className="btn btn--primary" onClick={() => finishOrAdvance()} disabled={!currentAnswered}>
{isLast ? t("common.submit") : t("ask.next")}
</button>
)}
</div>
)
}
>
{detailsOpen && (
<>
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
@@ -21,7 +23,6 @@ export function CapabilitiesPanel({
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [confirming, setConfirming] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [tab, setTab] = useState<CapTab>("servers");
const [skillQuery, setSkillQuery] = useState("");
@@ -31,7 +32,7 @@ export function CapabilitiesPanel({
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
const reload = useCallback(async () => {
setView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] })));
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
}, []);
useEffect(() => {
void reload();
@@ -84,7 +85,7 @@ export function CapabilitiesPanel({
}, [filteredSkills, skillQuery, t, view]);
const serverGroups = useMemo(() => {
const servers = view?.servers ?? [];
const servers = sortServersForDisplay(view?.servers ?? []);
return {
failed: servers.filter((s) => s.status === "failed"),
active: servers.filter((s) => s.status !== "failed"),
@@ -181,10 +182,9 @@ export function CapabilitiesPanel({
expanded={expandedErrors}
onToggle={toggleError}
onRetry={(name) => void mutate(() => app.RetryMCPServer(name))}
confirming={confirming}
onConfirm={setConfirming}
onCancelConfirm={() => setConfirming(null)}
onRemove={(name) => mutate(() => app.RemoveMCPServer(name)).then(() => setConfirming(null))}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))}
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
busy={busy}
/>
)}
@@ -196,17 +196,14 @@ export function CapabilitiesPanel({
servers={serverGroups.active}
expanded={expandedServers}
expandedTools={expandedServerTools}
confirming={confirming}
editing={editing}
onConfirm={setConfirming}
onCancelConfirm={() => setConfirming(null)}
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
onEdit={(name) => {
setEditing(name);
setConfirming(null);
}}
onCancelEdit={() => setEditing(null)}
onRemove={(name) => mutate(() => app.RemoveMCPServer(name)).then(() => setConfirming(null))}
onRetry={(name) => void mutate(() => app.RetryMCPServer(name))}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))}
onUpdate={(name, input) =>
@@ -258,8 +255,10 @@ export function CapabilitiesPanel({
<SkillRow
key={sk.name}
skill={sk}
busy={busy}
expanded={expandedSkills.has(sk.name)}
onToggle={() => toggleSkill(sk.name)}
onToggleEnabled={(enabled) => void mutate(() => app.SetSkillEnabled(sk.name, enabled))}
/>
))}
</div>
@@ -272,6 +271,39 @@ export function CapabilitiesPanel({
);
}
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
return {
servers: sortServersForDisplay(
asArray(view?.servers).map((server) => ({
...server,
args: asArray(server.args),
envKeys: asArray(server.envKeys),
toolList: asArray(server.toolList),
})),
),
skills: asArray(view?.skills),
skillRoots: asArray(view?.skillRoots).map((root) => ({
...root,
skillItems: asArray(root.skillItems),
})),
};
}
function sortServersForDisplay(servers: ServerView[]): ServerView[] {
return [...servers].sort((a, b) => {
const priority = serverDisplayPriority(a) - serverDisplayPriority(b);
if (priority !== 0) return priority;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}
function serverDisplayPriority(server: ServerView): number {
if (server.status === "failed" || server.authStatus === "required") return 0;
if (server.builtIn) return 1;
if (server.status !== "disabled") return 2;
return 3;
}
function skillListSummary(skills: SkillView[], filtered: SkillView[], searching: boolean, t: ReturnType<typeof useT>): string {
if (searching) {
return t("caps.skillsSummaryMatches", { matched: filtered.length, total: skills.length });
@@ -324,7 +356,6 @@ function SkillSources({
const t = useT();
const [expanded, setExpanded] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [confirmingRoot, setConfirmingRoot] = useState<string | null>(null);
const [expandedRootSkills, setExpandedRootSkills] = useState<Set<string>>(() => new Set());
const [fullRootSkills, setFullRootSkills] = useState<Set<string>>(() => new Set());
const primaryRoots = roots.filter(isPrimarySkillRoot);
@@ -335,11 +366,6 @@ function SkillSources({
const active = summaryRoots.filter((root) => root.skills > 0).length;
const missing = summaryRoots.filter((root) => root.status === "missing").length;
const empty = summaryRoots.filter((root) => root.status === "ok" && root.skills === 0).length;
useEffect(() => {
if (!confirmingRoot) return;
const stillRemovable = roots.some((root) => root.dir === confirmingRoot && root.scope === "custom" && root.configured);
if (!stillRemovable) setConfirmingRoot(null);
}, [confirmingRoot, roots]);
const toggleRootSkills = (key: string) => {
setExpandedRootSkills((prev) => {
const next = new Set(prev);
@@ -421,43 +447,29 @@ function SkillSources({
</div>
{(canShowRootSkills || canRemoveRoot) && (
<div className="cap-source-actions">
{confirmingRoot === root.dir && canRemoveRoot ? (
<>
<>
{canShowRootSkills && (
<button
className="btn btn--small"
disabled={busy}
onClick={() => {
onRemove(root.dir);
setConfirmingRoot(null);
}}
type="button"
aria-expanded={rootSkillsExpanded}
onClick={() => toggleRootSkills(key)}
>
{t("caps.skillRootConfirmRemove")}
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
</button>
<button className="btn btn--small" disabled={busy} onClick={() => setConfirmingRoot(null)}>
{t("common.cancel")}
</button>
<div className="cap-confirm-hint">{t("caps.skillRootRemoveHint")}</div>
</>
) : (
<>
{canShowRootSkills && (
<button
className="btn btn--small"
disabled={busy}
type="button"
aria-expanded={rootSkillsExpanded}
onClick={() => toggleRootSkills(key)}
>
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
</button>
)}
{canRemoveRoot && (
<button className="btn btn--small" disabled={busy} onClick={() => setConfirmingRoot(root.dir)}>
{t("caps.skillRootRemove")}
</button>
<InlineConfirmButton
label={t("caps.skillRootRemove")}
confirmLabel={t("caps.skillRootConfirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={() => onRemove(root.dir)}
/>
)}
</>
)}
</div>
)}
{rootSkillsExpanded && rootSkills.length > 0 && (
@@ -577,14 +589,12 @@ function ServerGroup({
expanded,
expandedTools,
busy,
confirming,
editing,
onConfirm,
onCancelConfirm,
onEdit,
onCancelEdit,
onRemove,
onRetry,
onConfirmClearAuth,
onToggle,
onSetTier,
onUpdate,
@@ -595,14 +605,12 @@ function ServerGroup({
expanded: Set<string>;
expandedTools: Set<string>;
busy: boolean;
confirming: string | null;
editing: string | null;
onConfirm: (name: string) => void;
onCancelConfirm: () => void;
onEdit: (name: string) => void;
onCancelEdit: () => void;
onRemove: (name: string) => void;
onRetry: (name: string) => void;
onConfirmClearAuth: (name: string) => void;
onToggle: (name: string, on: boolean) => void;
onSetTier: (name: string, tier: string) => void;
onUpdate: (name: string, input: MCPServerInput) => void;
@@ -619,14 +627,12 @@ function ServerGroup({
expanded={expanded.has(s.name)}
toolsExpanded={expandedTools.has(s.name)}
busy={busy}
confirming={confirming === s.name}
editing={editing === s.name}
onConfirm={() => onConfirm(s.name)}
onCancelConfirm={onCancelConfirm}
onEdit={() => onEdit(s.name)}
onCancelEdit={onCancelEdit}
onRemove={() => onRemove(s.name)}
onRetry={() => onRetry(s.name)}
onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
onToggle={(on) => onToggle(s.name, on)}
onSetTier={(tier) => onSetTier(s.name, tier)}
onUpdate={(input) => onUpdate(s.name, input)}
@@ -642,22 +648,20 @@ function FailedServersNotice({
servers,
expanded,
busy,
confirming,
onToggle,
onRetry,
onConfirmClearAuth,
onSetTier,
onConfirm,
onCancelConfirm,
onRemove,
}: {
servers: ServerView[];
expanded: Set<string>;
busy: boolean;
confirming: string | null;
onToggle: (name: string) => void;
onRetry: (name: string) => void;
onConfirmClearAuth: (name: string) => void;
onSetTier: (name: string, tier: string) => void;
onConfirm: (name: string) => void;
onCancelConfirm: () => void;
onRemove: (name: string) => void;
}) {
const t = useT();
return (
@@ -673,10 +677,10 @@ function FailedServersNotice({
const open = expanded.has(s.name);
const error = s.error || t("caps.failed");
const actionLabel = serverActionLabel(s, t);
const authURL = authURLForServer(s);
const canConfigure = s.configured;
const handlePrimaryAction = () => {
if (isAuthFailure(error) && authURL) {
openExternal(authURL);
if (shouldOpenAuth(s)) {
openExternal((s.authUrl || "").trim());
return;
}
onRetry(s.name);
@@ -687,36 +691,41 @@ function FailedServersNotice({
<span className="cap-dot cap-dot--failed" />
<div className="cap-failure__text">
<div className="cap-failure__name">{s.name}</div>
<div className="cap-failure__summary">{summarizeServerError(error, t)}</div>
<div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error, t)}</div>
</div>
</div>
<div className="cap-failure__actions">
{confirming === s.name ? (
<>
<button className="btn btn--small" disabled={busy} onClick={() => onRemove(s.name)}>
{t("caps.confirmRemove")}
</button>
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
{t("common.cancel")}
</button>
<div className="cap-confirm-hint">{t("caps.removeHint")}</div>
</>
) : (
<>
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
{actionLabel}
</button>
<button className="btn btn--small" onClick={() => onToggle(s.name)} aria-expanded={open}>
{open ? t("common.collapse") : t("caps.showLog")}
</button>
{!s.builtIn && (
<button className="btn btn--small" disabled={busy} onClick={() => onConfirm(s.name)}>
{t("caps.remove")}
</button>
)}
</>
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
{actionLabel}
</button>
{canClearAuth(s) && (
<InlineConfirmButton
label={t("caps.clearAuth")}
confirmLabel={t("caps.confirmClearAuth")}
cancelLabel={t("common.cancel")}
disabled={busy}
onConfirm={() => onConfirmClearAuth(s.name)}
/>
)}
<button className="btn btn--small" onClick={() => onToggle(s.name)} aria-expanded={open}>
{open ? t("common.collapse") : t("caps.showLog")}
</button>
{!s.builtIn && (
<InlineConfirmButton
label={t("caps.remove")}
confirmLabel={t("caps.confirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={() => onConfirm(s.name)}
/>
)}
</div>
{canConfigure && (
<div className="cap-failure__mode">
<AutoConnectControls tier={s.tier || "lazy"} busy={busy} onTierChange={(tier) => onSetTier(s.name, tier)} />
</div>
)}
{open && (
<div className="cap-failure__logbox">
<div className="cap-failure__logbar">
@@ -741,14 +750,12 @@ function ServerRow({
expanded,
toolsExpanded,
busy,
confirming,
editing,
onConfirm,
onCancelConfirm,
onEdit,
onCancelEdit,
onRemove,
onRetry,
onConfirmClearAuth,
onToggle,
onSetTier,
onUpdate,
@@ -759,14 +766,12 @@ function ServerRow({
expanded: boolean;
toolsExpanded: boolean;
busy: boolean;
confirming: boolean;
editing: boolean;
onConfirm: () => void;
onCancelConfirm: () => void;
onEdit: () => void;
onCancelEdit: () => void;
onRemove: () => void;
onRetry: () => void;
onConfirmClearAuth: () => void;
onToggle: (on: boolean) => void;
onSetTier: (tier: string) => void;
onUpdate: (input: MCPServerInput) => void;
@@ -776,7 +781,7 @@ function ServerRow({
const t = useT();
const actionLabel = serverActionLabel(s, t);
const tools = s.toolList ?? [];
const sub =
let sub =
s.status === "failed"
? s.error || t("caps.failed")
: s.status === "initializing"
@@ -788,7 +793,17 @@ function ServerRow({
? t("caps.disabledAutoStart")
: t("caps.disabled")
: t("caps.counts", { tools: s.tools, prompts: s.prompts, resources: s.resources });
if (s.authStatus === "possible" && s.status !== "failed") {
sub = `${sub} · ${t("caps.authPossibleShort")}`;
}
const enabled = s.status === "connected" || s.status === "deferred" || s.status === "initializing";
const handlePrimaryAction = () => {
if (shouldOpenAuth(s)) {
openExternal((s.authUrl || "").trim());
return;
}
onRetry();
};
return (
<div className={`cap-server-entry${s.status === "disabled" ? " cap-server-entry--disabled" : ""}`}>
<Tooltip label={s.error} disabled={!s.error} fill block>
@@ -807,49 +822,29 @@ function ServerRow({
<div className="cap-row__head">
<span className="cap-row__name">{s.name}</span>
<span className="cap-row__transport">{s.transport}</span>
{s.builtIn && <span className="cap-row__builtin">{t("caps.builtIn")}</span>}
</div>
<div className="cap-row__sub">{sub}</div>
</div>
<div className="cap-row__actions">
{confirming ? (
<>
<button className="btn btn--small" disabled={busy} onClick={onRemove}>
{t("caps.confirmRemove")}
</button>
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
{t("common.cancel")}
</button>
</>
{s.status === "failed" ? (
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
{actionLabel}
</button>
) : s.status === "initializing" ? (
<span className="cap-row__pending">{t("caps.initializingShort")}</span>
) : (
<>
{s.status === "failed" ? (
<button className="btn btn--small" disabled={busy} onClick={onRetry}>
{actionLabel}
</button>
) : s.status === "initializing" ? (
<span className="cap-row__pending">{t("caps.initializingShort")}</span>
) : s.builtIn ? (
s.status === "disabled" ? (
<button className="btn btn--small" disabled={busy} onClick={() => onToggle(true)}>
{t("caps.enable")}
</button>
) : (
<span className="cap-row__builtin">{t("caps.builtIn")}</span>
)
) : (
<Tooltip label={enabled ? t("caps.disable") : t("caps.enable")}>
<label className="cap-switch">
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => onToggle(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
)}
</>
<Tooltip label={enabled ? t("caps.disable") : t("caps.enable")}>
<label className="cap-switch">
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => onToggle(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
)}
</div>
</div>
@@ -859,11 +854,9 @@ function ServerRow({
s={s}
tools={tools}
busy={busy}
confirming={confirming}
onConfirm={onConfirm}
onCancelConfirm={onCancelConfirm}
onRemove={onRemove}
onConnectNow={onRetry}
onConfirmClearAuth={onConfirmClearAuth}
onSetTier={onSetTier}
toolsExpanded={toolsExpanded}
editing={editing}
@@ -881,11 +874,9 @@ function ServerDetails({
s,
tools,
busy,
confirming,
onConfirm,
onCancelConfirm,
onRemove,
onConnectNow,
onConfirmClearAuth,
onSetTier,
toolsExpanded,
editing,
@@ -897,11 +888,9 @@ function ServerDetails({
s: ServerView;
tools: ServerView["toolList"];
busy: boolean;
confirming: boolean;
onConfirm: () => void;
onCancelConfirm: () => void;
onRemove: () => void;
onConnectNow: () => void;
onConfirmClearAuth: () => void;
onSetTier: (tier: string) => void;
toolsExpanded: boolean;
editing: boolean;
@@ -912,10 +901,13 @@ function ServerDetails({
}) {
const t = useT();
const command = serverCommand(s);
const canConfigure = s.configured && !s.builtIn;
const canConnectNow = !s.builtIn && (s.status === "deferred" || s.status === "disabled");
const canConfigure = s.configured;
const canEditConfig = s.configured && !s.builtIn;
const canConnectNow = s.status === "deferred" || s.status === "disabled";
const canShowTools = (s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0;
if (editing && canConfigure) {
const showClearAuth = canClearAuth(s);
const authLabel = serverAuthLabel(s, t);
if (editing && canEditConfig) {
return (
<div className="cap-server-details">
<EditServerForm s={s} busy={busy} onCancel={onCancelEdit} onSave={onUpdate} />
@@ -933,6 +925,12 @@ function ServerDetails({
<span className="cap-detail__label">{t("caps.transport")}</span>
<span className="cap-detail__value">{s.transport}</span>
</div>
{authLabel && (
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.auth")}</span>
<span className="cap-detail__value">{authLabel}</span>
</div>
)}
{canConfigure && (
<AutoConnectControls tier={s.tier || "lazy"} busy={busy} onTierChange={onSetTier} />
)}
@@ -960,27 +958,30 @@ function ServerDetails({
{toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
</button>
)}
{canConfigure && (
<button className="btn btn--small" disabled={busy} onClick={onEdit}>
{t("caps.editConfig")}
</button>
{showClearAuth && (
<InlineConfirmButton
label={t("caps.clearAuth")}
confirmLabel={t("caps.confirmClearAuth")}
cancelLabel={t("common.cancel")}
disabled={busy}
onConfirm={onConfirmClearAuth}
/>
)}
{canConfigure &&
(confirming ? (
<>
<button className="btn btn--small" disabled={busy} onClick={onRemove}>
{t("caps.confirmRemove")}
</button>
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
{t("common.cancel")}
</button>
<div className="cap-confirm-hint">{t("caps.removeHint")}</div>
</>
) : (
<button className="btn btn--small" disabled={busy} onClick={onConfirm}>
{t("caps.remove")}
{canEditConfig && (
<>
<button className="btn btn--small" disabled={busy} onClick={onEdit}>
{t("caps.editConfig")}
</button>
))}
<InlineConfirmButton
label={t("caps.remove")}
confirmLabel={t("caps.confirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={onConfirm}
/>
</>
)}
</div>
{toolsExpanded && (
tools && tools.length > 0 ? (
@@ -1165,6 +1166,7 @@ function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
case "disabled":
return s.configured && !s.autoStart ? t("caps.disabledAutoStart") : t("caps.disabled");
case "failed":
if (s.authStatus === "required") return t("caps.authRequired");
return t("caps.failed");
default:
return s.status;
@@ -1186,21 +1188,9 @@ function summarizeServerError(error: string, t: ReturnType<typeof useT>): string
return summary.length > 180 ? `${summary.slice(0, 176).trim()}…` : summary;
}
function isAuthFailure(error: string): boolean {
const err = error.toLowerCase();
return err.includes("401") || err.includes("403") || err.includes("unauthorized") || err.includes("forbidden") || err.includes("invalid token") || err.includes("login required");
}
function authURLForServer(s: ServerView): string {
if (!isAuthFailure(s.error || "")) return "";
if (s.transport !== "http" && s.transport !== "sse") return "";
const url = (s.url || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
const err = (s.error || "").toLowerCase();
if (isAuthFailure(err)) return t("caps.reauthorize");
if (shouldOpenAuth(s)) return t("caps.reauthorize");
if (
err.includes("command not found") ||
err.includes("executable file not found") ||
@@ -1212,38 +1202,76 @@ function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
return t("caps.retry");
}
function serverAuthLabel(s: ServerView, t: ReturnType<typeof useT>): string {
if (s.authStatus === "required") return t("caps.authRequired");
if (s.authStatus === "possible") return t("caps.authPossible");
return "";
}
function shouldOpenAuth(s: ServerView): boolean {
const url = (s.authUrl || "").trim();
return s.authStatus === "required" && /^https?:\/\//i.test(url);
}
function canClearAuth(s: ServerView): boolean {
if (!s.configured || s.builtIn) return false;
return Boolean(s.authConfigured || s.authStatus === "required" || s.authStatus === "possible" || isRemoteTransport(s.transport));
}
function isRemoteTransport(transport?: string): boolean {
const value = (transport || "").trim().toLowerCase();
return value === "http" || value === "streamable-http" || value === "sse";
}
function SkillRow({
skill,
busy,
expanded,
onToggle,
onToggleEnabled,
}: {
skill: SkillView;
busy: boolean;
expanded: boolean;
onToggle: () => void;
onToggleEnabled: (enabled: boolean) => void;
}) {
const t = useT();
const summary = summarizeSkillDescription(skill.description);
const canExpand = summary !== skill.description;
return (
<button
className={`cap-skill-card${expanded ? " cap-skill-card--expanded" : ""}${canExpand ? " cap-skill-card--expandable" : ""}`}
type="button"
onClick={onToggle}
aria-expanded={expanded}
<div
className={`cap-skill-card${expanded ? " cap-skill-card--expanded" : ""}${canExpand ? " cap-skill-card--expandable" : ""}${!skill.enabled ? " cap-skill-card--disabled" : ""}`}
>
<div className="cap-skill-card__head">
<span className="cap-skill-card__icon">/</span>
<span className="cap-skill-card__main">
<span className="cap-skill-card__command">{skill.name}</span>
<span className="cap-skill-card__badges">
<span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
{skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
<div className="cap-skill-card__top">
<button className="cap-skill-card__toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
<span className="cap-skill-card__head">
<span className="cap-skill-card__icon">/</span>
<span className="cap-skill-card__main">
<span className="cap-skill-card__command">{skill.name}</span>
<span className="cap-skill-card__badges">
<span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
{skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
{!skill.enabled && <span className="cap-skill-badge cap-skill-badge--off">{t("caps.skillDisabled")}</span>}
</span>
</span>
</span>
</span>
</button>
<Tooltip label={skill.enabled ? t("caps.disableSkill") : t("caps.enableSkill")}>
<label className="cap-switch">
<input
type="checkbox"
checked={skill.enabled}
disabled={busy}
onChange={(e) => onToggleEnabled(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</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>}
</button>
</div>
);
}
+197 -81
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, Search, Square, Trash2, X } from "lucide-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 { asArray } from "../lib/array";
import { app, onFilesDropped } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import type { CommandInfo, ComposerInsertRequest, DirEntry, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
import type { CommandInfo, ComposerInsertRequest, DirEntry, EffortInfo, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
import {
formatWorkspaceReference,
parseWorkspaceReference,
@@ -14,7 +15,10 @@ import {
import { SlashMenu } from "./SlashMenu";
import { ArgMenu } from "./ArgMenu";
import { FileMenu } from "./FileMenu";
import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { Tooltip } from "./Tooltip";
import { AnchoredPopover } from "./AnchoredPopover";
interface Attachment {
path: string;
@@ -76,6 +80,27 @@ function loadComposerHeight(): number | null {
return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
}
function fmtTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
}
function fmtElapsed(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
function useTick(on: boolean): number {
const [, setN] = useState(0);
useEffect(() => {
if (!on) return;
const id = window.setInterval(() => setN((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [on]);
return Date.now();
}
function isImeKeyEvent(
e: KeyboardEvent<HTMLTextAreaElement>,
composing: boolean,
@@ -97,31 +122,56 @@ export function Composer({
running,
mode,
cwd,
modelLabel,
tabId,
effort,
onSend,
onCancel,
onCycleMode,
onSetMode,
onSwitchModel,
onSetEffort,
onPickFolder,
onRemoveWorkspace,
insertRequest,
disabled,
decisionPending = false,
ready,
turnStartAt,
turnTokens,
retry,
workspaceRefreshSignal,
}: {
running: boolean;
mode: Mode;
cwd?: string;
modelLabel: string;
tabId?: string;
effort?: EffortInfo;
onSend: (displayText: string, submitText?: string) => void;
// Returns the un-sent text when cancelling before the server replied (so it can
// be restored to the input); undefined for a normal cancel.
onCancel: () => string | undefined;
onCycleMode: () => void;
onSetMode: (mode: Mode) => void;
onSwitchModel: (name: string) => void;
onSetEffort: (level: string) => void;
onPickFolder: (path?: string) => Promise<string>;
onRemoveWorkspace: (path: string) => Promise<void>;
insertRequest?: ComposerInsertRequest | null;
disabled?: boolean;
decisionPending?: boolean;
// ready/cwd re-trigger the command fetch: Commands() returns only built-ins
// until boot.Build finishes (the controller, hence skills/custom/MCP, is nil
// before then), and the available set changes when the workspace switches.
ready?: boolean;
turnStartAt?: number;
turnTokens?: number;
retry?: { attempt: number; max: number };
workspaceRefreshSignal?: number;
}) {
const t = useT();
const { t, locale } = useI18n();
const now = useTick(running);
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
@@ -141,7 +191,6 @@ export function Composer({
const taRef = useRef<HTMLTextAreaElement>(null);
const composerCardRef = useRef<HTMLDivElement>(null);
const workspaceAnchorRef = useRef<HTMLDivElement>(null);
const workspaceMenuRef = useRef<HTMLDivElement>(null);
const wasRunning = useRef(running);
const composingRef = useRef(false);
const lastCompositionEndAt = useRef(0);
@@ -160,7 +209,7 @@ export function Composer({
// --- slash commands (whole-input "/token") ---
const [commands, setCommands] = useState<CommandInfo[]>([]);
useEffect(() => {
app.Commands().then(setCommands).catch(() => {});
app.Commands().then((next) => setCommands(asArray(next))).catch(() => {});
}, [ready, cwd]);
const slashQuery = useMemo(() => {
@@ -174,32 +223,41 @@ export function Composer({
// --- slash argument completion ("/cmd <args>") --- mirrors the CLI: once past
// the command word, the backend suggests sub-commands (/skill → list/show/…,
// /mcp → add/remove, /model → refs). Fetched from app.SlashArgs.
// /mcp → add/remove, /model → refs). Fetched from app.SlashArgs. Debounced
// by 120ms so rapid typing doesn't flood the backend with IPC calls — the
// menu only updates after the user pauses.
const [argRes, setArgRes] = useState<SlashArgsResult | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!text.startsWith("/") || !/\s/.test(text)) {
setArgRes(null);
return;
}
let live = true;
app
.SlashArgs(text)
.then((r) => {
if (!live) return;
// Drop suggestions that wouldn't change the input — the token is already
// fully typed (e.g. "/skill list" offering "list"). Otherwise the menu
// lingers on a complete command and Enter keeps "accepting" a no-op
// instead of sending. (Defense-in-depth: the backend filters these too.)
// r.items can arrive as null (an empty Go slice serializes to JSON null),
// so guard before filtering — otherwise the throw is swallowed and the
// stale menu from the previous keystroke lingers (the /skill list bug).
const useful = (r.items ?? []).filter((it) => text.slice(0, r.from) + it.insert !== text);
setArgRes(useful.length > 0 ? { items: useful, from: r.from } : null);
setActive(0);
})
.catch(() => {});
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
app
.SlashArgs(text)
.then((r) => {
if (!live) return;
// Drop suggestions that wouldn't change the input — the token is already
// fully typed (e.g. "/skill list" offering "list"). Otherwise the menu
// lingers on a complete command and Enter keeps "accepting" a no-op
// instead of sending. (Defense-in-depth: the backend filters these too.)
// r.items can arrive as null (an empty Go slice serializes to JSON null),
// so guard before filtering — otherwise the throw is swallowed and the
// stale menu from the previous keystroke lingers (the /skill list bug).
const items = asArray(r?.items);
const from = r?.from ?? 0;
const useful = items.filter((it) => text.slice(0, from) + it.insert !== text);
setArgRes(useful.length > 0 ? { items: useful, from } : null);
setActive(0);
})
.catch(() => {});
}, 120);
return () => {
live = false;
clearTimeout(debounceRef.current);
};
}, [text]);
@@ -237,7 +295,7 @@ export function Composer({
app
.ListDir(atDir)
.then((es) => {
const list = es ?? [];
const list = asArray(es);
dirCache.current[atDir] = list;
if (live) setEntries(list);
})
@@ -575,23 +633,12 @@ export function Composer({
}, [cwd]);
const loadWorkspaces = () => {
app.ListWorkspaces().then(setWorkspaces).catch(() => setWorkspaces([]));
app.ListWorkspaces().then((next) => setWorkspaces(asArray(next))).catch(() => setWorkspaces([]));
};
useEffect(() => {
if (workspaceMenuOpen) loadWorkspaces();
}, [workspaceMenuOpen, cwd]);
useEffect(() => {
if (!workspaceMenuOpen) return;
const close = (e: MouseEvent) => {
const target = e.target as Node;
if (workspaceAnchorRef.current?.contains(target) || workspaceMenuRef.current?.contains(target)) return;
setWorkspaceMenuOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [workspaceMenuOpen]);
}, [workspaceMenuOpen, cwd, workspaceRefreshSignal]);
const filteredWorkspaces = useMemo(() => {
const q = workspaceQuery.trim().toLowerCase();
@@ -607,6 +654,11 @@ export function Composer({
}
};
const removeWorkspace = async (path: string) => {
await onRemoveWorkspace(path);
setWorkspaces((prev) => prev.filter((w) => w.path !== path));
};
useEffect(() => {
const onResize = () => setComposerHeight((height) => (height === null ? null : clampComposerHeight(height)));
window.addEventListener("resize", onResize);
@@ -717,18 +769,48 @@ export function Composer({
}
// Esc interrupts the in-flight turn (matches the Stop button's hint), and
// restores the text if the server hadn't replied yet.
if (e.key === "Escape" && running) {
if (e.key === "Escape" && running && !decisionPending) {
e.preventDefault();
handleCancel();
}
};
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
const modeOptions: Array<{ id: Mode; label: string; icon: ReactNode }> = [
{ id: "normal", label: "auto", icon: <Zap size={13} /> },
{ id: "plan", label: "plan", icon: <List size={13} /> },
{ id: "yolo", label: "yolo", icon: <AlertTriangle size={13} /> },
];
const runActivity = retry
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
: running && turnStartAt
? (() => {
const elapsedMs = Math.max(0, now - turnStartAt);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
const hasWorkspace = Boolean(cwd);
const hasEffort = Boolean(effort?.supported);
const composerMetaClass = [
"composer-meta",
hasWorkspace ? "composer-meta--has-workspace" : "composer-meta--no-workspace",
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
].join(" ");
return (
<div className="composer-wrap" style={{ "--wails-drop-target": "drop" } as CSSProperties}>
{workspaceMenuOpen && cwd && (
<div className="workspace-switcher" ref={workspaceMenuRef}>
<div
className={`composer-wrap${decisionPending ? " composer-wrap--decision-pending" : ""}`}
style={{ "--wails-drop-target": "drop" } as CSSProperties}
>
<AnchoredPopover
open={workspaceMenuOpen && !!cwd}
anchorRef={workspaceAnchorRef}
onClose={() => setWorkspaceMenuOpen(false)}
className="workspace-switcher workspace-switcher--portal"
>
<label className="workspace-switcher__search">
<Search size={14} />
<input
@@ -743,9 +825,10 @@ export function Composer({
</label>
<div className="workspace-switcher__list">
{filteredWorkspaces.map((w) => (
<Tooltip key={w.path} label={w.path} fill>
<div className="workspace-switcher__row" key={w.path}>
<button
className="workspace-switcher__item"
className={`workspace-switcher__item${w.current ? " workspace-switcher__item--current" : ""}`}
title={w.path}
onClick={() => {
if (w.current) {
setWorkspaceMenuOpen(false);
@@ -758,7 +841,20 @@ export function Composer({
<span>{w.name}</span>
{w.current && <Check size={15} />}
</button>
</Tooltip>
<button
className="workspace-switcher__remove"
type="button"
aria-label={t("composer.removeProject")}
title={t("composer.removeProject")}
disabled={running}
onClick={(event) => {
event.stopPropagation();
void removeWorkspace(w.path);
}}
>
<Trash2 size={14} />
</button>
</div>
))}
{filteredWorkspaces.length === 0 && <div className="workspace-switcher__empty">{t("composer.noProjectMatches")}</div>}
</div>
@@ -768,8 +864,7 @@ export function Composer({
<span>{t("composer.addProject")}</span>
</button>
</div>
</div>
)}
</AnchoredPopover>
{menuMode === "slash" && (
<SlashMenu items={slashMatches} activeIndex={active} onPick={pickCommand} onHover={setActive} />
)}
@@ -777,6 +872,35 @@ export function Composer({
<ArgMenu items={argRes.items} activeIndex={active} onPick={pickArg} onHover={setActive} />
)}
{menuMode === "at" && <FileMenu items={atMatches} activeIndex={active} onPick={pickEntry} onHover={setActive} />}
<div className="composer-toolbar">
<div className="composer-modebar" role="toolbar" aria-label={t("composer.modeTitle")}>
{modeOptions.map((option) => (
<button
key={option.id}
type="button"
className={`composer-modebar__item composer-modebar__item--${option.id}${mode === option.id ? " composer-modebar__item--active" : ""}`}
onClick={() => onSetMode(option.id)}
aria-pressed={mode === option.id}
disabled={disabled || running}
>
{option.icon}
<span>{option.label}</span>
</button>
))}
</div>
{runActivity && (
<div className="composer-runstatus" role="status" aria-live="polite">
<span className="composer-runstatus__dot" />
<span className="composer-runstatus__text">{runActivity}</span>
<Tooltip label={t("composer.stop")}>
<button className="composer-runstatus__stop" type="button" onClick={handleCancel} disabled={decisionPending}>
<Square size={10} fill="currentColor" />
<span>{t("composer.stopShort")}</span>
</button>
</Tooltip>
</div>
)}
</div>
{(attachments.length > 0 || workspaceRefs.length > 0) && (
<div className="composer-context" aria-label={t("composer.contextItems")}>
{attachments.map((a) => (
@@ -865,12 +989,12 @@ export function Composer({
onDoubleClick={resetComposerHeight}
/>
<div
className={`composer${dragOver ? " composer--dragover" : ""}${disabled ? " composer--disabled" : ""}`}
className={`composer${dragOver ? " composer--dragover" : ""}${disabled ? " composer--disabled" : ""}${text.trimStart().startsWith("!") ? " composer--shell" : ""}`}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<span className="composer__caret">›</span>
<span className="composer__caret">{text.trimStart().startsWith("!") ? "$" : "›"}</span>
<textarea
ref={taRef}
className="composer__input"
@@ -893,13 +1017,7 @@ export function Composer({
rows={1}
disabled={disabled}
/>
{running ? (
<Tooltip label={t("composer.stop")}>
<button className="composer__btn composer__btn--stop" onClick={handleCancel}>
<Square size={14} fill="currentColor" />
</button>
</Tooltip>
) : (
{!running && (
<Tooltip label={t("composer.send")}>
<button
className="composer__btn composer__btn--send"
@@ -911,34 +1029,32 @@ export function Composer({
</Tooltip>
)}
</div>
<div className="composer-meta">
<div className={composerMetaClass}>
{cwd && (
<div className="composer-workspace-wrap" ref={workspaceAnchorRef}>
<Tooltip label={running ? t("common.busyHint") : t("status.switchFolder", { cwd })}>
<button
className={`composer__workspace${workspaceMenuOpen ? " composer__workspace--open" : ""}`}
onClick={() => {
if (!running) setWorkspaceMenuOpen((open) => !open);
}}
disabled={running}
>
<FolderGit2 size={13} />
<span>{workspaceName}</span>
<ChevronDown size={12} />
</button>
</Tooltip>
<div className="composer-meta__control composer-meta__control--workspace composer-workspace-wrap" ref={workspaceAnchorRef}>
<button
className={`composer__workspace${workspaceMenuOpen ? " composer__workspace--open" : ""}`}
onClick={() => {
if (!running) setWorkspaceMenuOpen((open) => !open);
}}
disabled={running}
>
<FolderGit2 size={13} />
<span>{workspaceName}</span>
<ChevronDown size={12} />
</button>
</div>
)}
<Tooltip label={t("composer.modeTitle")}>
<button
className={`composer__mode composer__mode--${mode}`}
onClick={onCycleMode}
>
<span className="composer__mode-dot" />
{mode === "yolo" ? t("composer.modeYolo") : mode === "plan" ? t("composer.modePlan") : t("composer.modeNormal")}
<span className="composer__mode-hint">{t("composer.modeHint")}</span>
</button>
</Tooltip>
<div className="composer-meta__params">
<div className="composer-meta__control composer-meta__control--model">
<ModelSwitcher label={modelLabel} tabId={tabId} onPick={onSwitchModel} />
</div>
{effort?.supported && (
<div className="composer-meta__control composer-meta__control--effort">
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
</div>
)}
</div>
</div>
</div>
</div>
@@ -0,0 +1,136 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
export type ContextMenuPoint = { left: number; top: number };
export type ContextMenuItem =
| {
type?: "item";
key: string;
icon?: ReactNode;
label: ReactNode;
disabled?: boolean;
danger?: boolean;
onSelect: () => void;
}
| {
type: "separator";
key: string;
};
const EDGE_GAP = 8;
function clampMenuPoint(left: number, top: number, width: number, height: number): ContextMenuPoint {
if (typeof window === "undefined") return { left, top };
return {
left: Math.min(Math.max(EDGE_GAP, left), Math.max(EDGE_GAP, window.innerWidth - width - EDGE_GAP)),
top: Math.min(Math.max(EDGE_GAP, top), Math.max(EDGE_GAP, window.innerHeight - height - EDGE_GAP)),
};
}
export function contextMenuPointFromEvent(
event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
): ContextMenuPoint {
if ("clientX" in event && event.clientX > 0 && event.clientY > 0) {
return { left: event.clientX, top: event.clientY };
}
const rect = event.currentTarget.getBoundingClientRect();
return { left: rect.left + 12, top: rect.bottom + 6 };
}
export function ContextMenu({
open,
point,
items,
onClose,
minWidth = 180,
ariaLabel = "Context menu",
}: {
open: boolean;
point: ContextMenuPoint | null;
items: ContextMenuItem[];
onClose: () => void;
minWidth?: number;
ariaLabel?: string;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<ContextMenuPoint | null>(point);
useLayoutEffect(() => {
if (!open || !point) return;
const rect = menuRef.current?.getBoundingClientRect();
if (!rect) {
setPosition(point);
return;
}
setPosition(clampMenuPoint(point.left, point.top, rect.width, rect.height));
}, [open, point, items]);
useEffect(() => {
if (!open) return;
const closeOnOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && menuRef.current?.contains(target)) return;
onClose();
};
const close = () => onClose();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
window.addEventListener("keydown", closeOnEscape);
return () => {
window.removeEventListener("pointerdown", closeOnOutsidePointerDown, true);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("keydown", closeOnEscape);
};
}, [open, onClose]);
if (!open || !point || !position) return null;
return createPortal(
<div
ref={menuRef}
className="context-menu"
role="menu"
aria-label={ariaLabel}
style={{ left: position.left, top: position.top, minWidth }}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{items.map((item) => {
if (item.type === "separator") {
return <div key={item.key} className="context-menu__separator" role="separator" />;
}
return (
<button
key={item.key}
type="button"
role="menuitem"
disabled={item.disabled}
className={`context-menu__item${item.danger ? " context-menu__item--danger" : ""}`}
onClick={(event) => {
event.stopPropagation();
if (!item.disabled) item.onSelect();
}}
>
{item.icon}
<span>{item.label}</span>
</button>
);
})}
</div>,
document.body,
);
}
@@ -0,0 +1,359 @@
// ContextPanel shows the active tab's context gauge, token usage, read files,
// and workspace changes. All visible text is routed through the i18n dictionary.
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, FileText, Search } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT, type Translator } from "../lib/i18n";
import type { DictKey } from "../locales/en";
import type { ContextInfo, ContextPanelInfo, WireUsage } from "../lib/types";
interface ContextPanelProps {
tabId?: string;
context?: ContextInfo;
usage?: WireUsage;
sessionCost?: number;
sessionCurrency?: string;
scopeLabel?: string;
refreshKey?: number;
}
type ContextDetail = "read" | "changed";
function fmtTokens(n: number): string {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
}
function fmtTime(ms?: number): string {
if (!ms) return "";
return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDuration(ms: number, t: Translator): string {
if (ms <= 0) return "-";
const totalSeconds = Math.max(1, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) return t("context.durationSeconds", { seconds });
return t("context.durationMinutesSeconds", { minutes, seconds });
}
function currencySymbol(currency?: string): string {
const value = (currency || "¥").trim();
if (/^(cny|rmb|yuan)$/i.test(value)) return "¥";
if (/^(usd|dollar)$/i.test(value)) return "$";
return value || "¥";
}
function fmtMoney(amount: number, currency?: string): string {
if (amount <= 0) return "-";
const symbol = currencySymbol(currency);
return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`;
}
interface HealthResult {
tone: "good" | "notice" | "warn";
labelKey: DictKey;
bodyKey: DictKey;
vars: Record<string, string | number>;
}
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
if (usagePct >= 85) {
return {
tone: "warn",
labelKey: "context.healthNearLimit",
bodyKey: "context.healthNearLimitBody",
vars: { pct: usagePct },
};
}
if (readCount >= 8) {
return {
tone: "notice",
labelKey: "context.healthManyFiles",
bodyKey: "context.healthManyFilesBody",
vars: { count: readCount },
};
}
if (cachePct > 0 && cachePct < 50) {
return {
tone: "notice",
labelKey: "context.healthLowCache",
bodyKey: "context.healthLowCacheBody",
vars: { pct: cachePct },
};
}
return {
tone: "good",
labelKey: "context.healthGood",
bodyKey: "context.healthGoodBody",
vars: {},
};
}
export function ContextPanel({ tabId, context, usage, sessionCost, sessionCurrency, scopeLabel, refreshKey }: ContextPanelProps) {
const t = useT();
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
const [detailView, setDetailView] = useState<ContextDetail | null>(null);
const [query, setQuery] = useState("");
const refresh = useCallback(async () => {
if (!tabId) return;
try {
setInfo(await app.ContextPanel(tabId));
} catch {
/* bridge unavailable */
}
}, [tabId]);
useEffect(() => {
const id = window.setInterval(() => void refresh(), 2000);
return () => window.clearInterval(id);
}, [refresh]);
useEffect(() => {
void refresh();
}, [refresh, refreshKey]);
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
const promptTokens = usage?.promptTokens && usage.promptTokens > 0 ? usage.promptTokens : info?.promptTokens ?? 0;
const completionTokens = usage?.completionTokens && usage.completionTokens > 0 ? usage.completionTokens : info?.completionTokens ?? 0;
const reasoningTokens = usage?.reasoningTokens && usage.reasoningTokens > 0 ? usage.reasoningTokens : info?.reasoningTokens ?? 0;
const cacheHitTokens = usage?.cacheHitTokens && usage.cacheHitTokens > 0 ? usage.cacheHitTokens : info?.cacheHitTokens ?? 0;
const cacheMissTokens = usage?.cacheMissTokens && usage.cacheMissTokens > 0 ? usage.cacheMissTokens : info?.cacheMissTokens ?? 0;
const cost = sessionCost && sessionCost > 0 ? sessionCost : info?.sessionCost ?? info?.sessionCostUsd ?? 0;
const currency = sessionCurrency || info?.sessionCurrency || usage?.currency || "¥";
const readFiles = asArray(info?.readFiles);
const changedFiles = asArray(info?.changedFiles);
const usagePct = windowTokens > 0 ? Math.round((usedTokens / windowTokens) * 100) : 0;
const cachePct = cacheHitTokens + cacheMissTokens > 0
? Math.round((cacheHitTokens / (cacheHitTokens + cacheMissTokens)) * 100)
: 0;
const otherTokens = Math.max(0, usedTokens - promptTokens - completionTokens - reasoningTokens);
const safeUsed = Math.max(usedTokens, 1);
const promptPct = Math.min(100, (promptTokens / safeUsed) * usagePct);
const completionPct = Math.min(100, promptPct + (completionTokens / safeUsed) * usagePct);
const reasoningPct = Math.min(100, completionPct + (reasoningTokens / safeUsed) * usagePct);
const otherPct = Math.min(100, reasoningPct + (otherTokens / safeUsed) * usagePct);
const donutStyle = {
background: `conic-gradient(#13a7a5 0 ${promptPct}%, #2f6df6 ${promptPct}% ${completionPct}%, #f97316 ${completionPct}% ${reasoningPct}%, var(--border) ${reasoningPct}% ${otherPct}%, var(--border-soft) ${otherPct}% 100%)`,
};
const eventTimes = [
...readFiles.map((file) => file.time),
...changedFiles.map((file) => file.latestTime ?? 0),
].filter((time) => time > 0);
const elapsed = eventTimes.length > 1 ? Math.max(...eventTimes) - Math.min(...eventTimes) : 0;
const requestCount = Math.max(readFiles.length + changedFiles.length, 0);
const readRows = readFiles.map((f, i) => ({
key: `${f.path}-${i}`,
path: f.path,
meta: `#${f.turn}`,
time: fmtTime(f.time),
detail: f.limit ? `${f.offset ?? 0}-${(f.offset ?? 0) + f.limit}${f.truncated ? " truncated" : ""}` : "",
}));
const changedRows = changedFiles.map((f, i) => ({
key: `${f.path}-${i}`,
path: f.path,
meta: f.gitStatus || asArray(f.sources).join(", ") || "changed",
time: fmtTime(f.latestTime),
detail: asArray(f.turns).length > 0 ? `T${asArray(f.turns).join(",")}` : "",
}));
const normalizedQuery = query.trim().toLowerCase();
const filterRows = (rows: typeof readRows) => {
if (!normalizedQuery) return rows;
return rows.filter((row) =>
`${row.path} ${row.meta} ${row.time} ${row.detail}`.toLowerCase().includes(normalizedQuery)
);
};
const filteredReadRows = filterRows(readRows);
const filteredChangedRows = filterRows(changedRows);
const health = contextHealth(usagePct, cachePct, readRows.length);
const detailRows = detailView === "changed" ? filteredChangedRows : filteredReadRows;
const detailTitle = detailView === "changed" ? t("context.sessionChanges") : t("context.referencedFiles");
const detailCount = detailView === "changed" ? changedRows.length : readRows.length;
const detailEmpty = detailView === "changed" ? t("context.noChanges") : t("context.noReads");
const detailPlaceholder = detailView === "changed" ? t("context.filterChanges") : t("context.filterReads");
const detailNote = detailView === "changed"
? t("context.changedNote", { count: detailCount })
: t("context.readNote", { count: detailCount });
const openDetail = (next: ContextDetail) => {
setDetailView(next);
setQuery("");
};
const closeDetail = () => {
setDetailView(null);
setQuery("");
};
return (
<div className="context-panel">
<div className="context-panel__summary-head">
<div className="context-panel__heading-main">
<span>{detailView ? detailTitle : t("context.overview")}</span>
<strong>{scopeLabel || t("context.scopeGlobal")}</strong>
</div>
{detailView && (
<button className="context-panel__back" type="button" onClick={closeDetail}>
<ArrowLeft size={13} />
{t("rightDock.overview")}
</button>
)}
</div>
{detailView && (
<label className="context-panel__search">
<Search size={14} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={detailPlaceholder} />
</label>
)}
<div className="context-panel__body">
{detailView ? (
<section className="context-panel__detail">
<div className="context-panel__detail-note">{detailNote}</div>
<FileTable
empty={detailEmpty}
rows={detailRows}
/>
</section>
) : (
<section className="context-panel__overview">
<section className="context-panel__usage">
<div className="context-panel__donut" style={donutStyle}>
<div className="context-panel__donut-core">
<strong>{fmtTokens(usedTokens)}</strong>
<span>/ {fmtTokens(windowTokens)} tokens</span>
</div>
</div>
<div className="context-panel__percent">{usagePct}%</div>
<div className="context-panel__breakdown">
<TokenLegend label={t("context.prompt")} value={promptTokens} color="prompt" />
<TokenLegend label={t("context.completion")} value={completionTokens} color="completion" />
<TokenLegend label={t("context.reasoning")} value={reasoningTokens} color="reasoning" />
<TokenLegend label={t("context.other")} value={otherTokens} color="other" />
<div className="context-panel__total">
<span>{t("context.total")}</span>
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
</div>
</div>
<div className="context-panel__stats">
<MetricCard label={t("context.cacheHit")} value={cachePct > 0 ? `${cachePct}%` : "-"} tone="accent" />
<MetricCard label={t("context.sessionCost")} value={fmtMoney(cost, currency)} />
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
</div>
</section>
<div className={`context-panel__health context-panel__health--${health.tone}`}>
<span>{t("context.health")}</span>
<strong>{t(health.labelKey, health.vars)}</strong>
<small>{t(health.bodyKey, health.vars)}</small>
</div>
<PreviewSection
title={t("context.referencedFiles")}
meta={t("context.readMeta", { count: readRows.length })}
action={t("context.viewAll")}
onAction={() => openDetail("read")}
rows={readRows.slice(0, 3)}
empty={t("context.noReads")}
/>
<PreviewSection
title={t("context.sessionChanges")}
meta={t("context.changedMeta", { count: changedRows.length })}
action={t("context.viewAll")}
onAction={() => openDetail("changed")}
rows={changedRows.slice(0, 3)}
empty={t("context.noChanges")}
/>
</section>
)}
</div>
<footer className="context-panel__scope">
<FileText size={14} />
<span>{scopeLabel || t("context.scopeGlobal")}</span>
</footer>
</div>
);
}
function PreviewSection({
title,
meta,
action,
onAction,
rows,
empty,
}: {
title: string;
meta?: string;
action: string;
onAction: () => void;
rows: Array<{ key: string; path: string; meta: string; time: string; detail: string }>;
empty: string;
}) {
return (
<section className="context-panel__preview">
<header className="context-panel__preview-head">
<h3>{title}</h3>
{meta && <span>{meta}</span>}
{rows.length > 0 && <button type="button" onClick={onAction}>{action}</button>}
</header>
<FileTable rows={rows} empty={empty} compact />
</section>
);
}
function TokenLegend({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="context-panel__legend-row">
<span className={`context-panel__legend-dot context-panel__legend-dot--${color}`} />
<span>{label}</span>
<strong>{value.toLocaleString()}</strong>
</div>
);
}
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: "accent" }) {
return (
<div className="context-panel__metric">
<span>{label}</span>
<strong className={tone === "accent" ? "context-panel__metric-accent" : ""}>{value}</strong>
</div>
);
}
function FileTable({
rows,
empty,
compact = false,
}: {
rows: Array<{ key: string; path: string; meta: string; time: string; detail: string }>;
empty: string;
compact?: boolean;
}) {
if (rows.length === 0) return <div className="context-panel__empty">{empty}</div>;
return (
<div className={`context-panel__file-list${compact ? " context-panel__file-list--compact" : ""}`}>
{rows.map((row) => (
<div className="context-panel__file-row" key={row.key}>
<span className="context-panel__file-main">
<FileText size={14} />
<span className="context-panel__file-copy">
<span>{row.path}</span>
{row.detail && <small>{row.detail}</small>}
</span>
</span>
<span className="context-panel__file-meta">
<span className="context-panel__file-turn">{row.meta}</span>
{row.time && <span>{row.time}</span>}
</span>
</div>
))}
</div>
);
}
@@ -1,7 +1,9 @@
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { useRef, useState } from "react";
import { Check, ChevronsUpDown, Gauge } from "lucide-react";
import { asArray } from "../lib/array";
import { useT } from "../lib/i18n";
import type { EffortInfo } from "../lib/types";
import { AnchoredPopover } from "./AnchoredPopover";
export function EffortSwitcher({
effort,
@@ -14,7 +16,9 @@ export function EffortSwitcher({
}) {
const t = useT();
const [open, setOpen] = useState(false);
if (!effort?.supported || effort.levels.length === 0) return null;
const triggerRef = useRef<HTMLButtonElement>(null);
const levels = asArray(effort?.levels);
if (!effort?.supported || levels.length === 0) return null;
const current = effort.current || "auto";
const pick = (level: string) => {
@@ -25,32 +29,40 @@ export function EffortSwitcher({
return (
<div className="modelsw effortsw">
<button
ref={triggerRef}
type="button"
className={`modelsw__trigger effortsw__trigger ${current !== "auto" ? "effortsw__trigger--explicit" : ""}`}
disabled={disabled}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Gauge size={13} className="modelsw__kind" />
<span className="modelsw__label">{t("status.effort", { level: current })}</span>
<ChevronsUpDown size={11} />
</button>
{open && !disabled && (
<>
<div className="modelsw__backdrop" onClick={() => setOpen(false)} />
<div className="modelsw__menu effortsw__menu" role="listbox">
{effort.levels.map((level) => (
<button
key={level}
role="option"
aria-selected={level === current}
className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`}
onClick={() => pick(level)}
>
<span className="modelsw__model">{level}</span>
{level === current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</>
)}
<AnchoredPopover
open={open && !disabled}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className="modelsw__menu modelsw__menu--portal effortsw__menu"
align="end"
>
<div role="listbox">
{levels.map((level) => (
<button
key={level}
type="button"
role="option"
aria-selected={level === current}
className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`}
onClick={() => pick(level)}
>
<span className="modelsw__model">{level}</span>
{level === current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</AnchoredPopover>
</div>
);
}
+226 -76
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Search, Trash2, Check, X } from "lucide-react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
import { t, useT } from "../lib/i18n";
import { sessionActivityTime } from "../lib/session";
import type { HistoryMessage, SessionMeta } from "../lib/types";
@@ -7,32 +8,47 @@ import type { Item } from "../lib/useController";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
import { Transcript } from "./Transcript";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
// HistoryPanel lists saved sessions newest-first. Idle clicks resume a session;
// running clicks load a read-only preview so the active stream keeps writing to
// the current controller/session.
export function HistoryPanel({
kind = "history",
sessions,
running,
onResume,
onPreview,
onDelete,
onRename,
onRestore,
onPurge,
onPurgeAll,
onClose,
}: {
kind?: "history" | "trash";
sessions: SessionMeta[];
running: boolean;
onResume: (path: string) => void;
onResume: (session: SessionMeta) => void;
onPreview: (path: string) => Promise<HistoryMessage[]>;
onDelete: (path: string) => void;
onRename: (path: string, title: string) => void;
onRestore?: (path: string) => void;
onPurge?: (path: string) => void;
onPurgeAll?: (paths: string[]) => void;
onClose: () => void;
}) {
const tr = useT();
const isTrash = kind === "trash";
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [confirming, setConfirming] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [menuSession, setMenuSession] = useState<SessionMeta | null>(null);
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
>(null);
const [preview, setPreview] = useState<{
path: string;
title: string;
@@ -44,7 +60,6 @@ export function HistoryPanel({
const startRename = (s: SessionMeta) => {
if (running) return;
setConfirming(null);
setEditing(s.path);
setDraft(s.title || s.preview || "");
};
@@ -57,11 +72,10 @@ export function HistoryPanel({
async (s: SessionMeta) => {
const seq = ++previewSeq.current;
setEditing(null);
setConfirming(null);
setPreview({
path: s.path,
title: sessionDisplayTitle(s, tr("history.emptySession")),
meta: sessionMetaLine(s, tr),
meta: sessionMetaLine(s, tr, isTrash),
messages: [],
loading: true,
});
@@ -70,7 +84,7 @@ export function HistoryPanel({
setPreview((cur) => (cur?.path === s.path ? { ...cur, messages, loading: false } : cur));
}
},
[onPreview, tr],
[isTrash, onPreview, tr],
);
const filteredSessions = useMemo(() => {
@@ -85,7 +99,7 @@ export function HistoryPanel({
// (Today / Yesterday / a date) while preserving that order.
const groups: { label: string; items: SessionMeta[] }[] = [];
for (const s of filteredSessions) {
const label = dayLabel(sessionActivityTime(s));
const label = dayLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
const last = groups[groups.length - 1];
if (last && last.label === label) last.items.push(s);
else groups.push({ label, items: [s] });
@@ -96,10 +110,16 @@ export function HistoryPanel({
if (!filteredSessions.some((s) => s.path === preview.path)) setPreview(null);
}, [filteredSessions, preview]);
useEffect(() => {
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(null);
setMenuConfirmTarget(null);
}, [isTrash]);
useEffect(() => {
if (!running) return;
setEditing(null);
setConfirming(null);
if (preview || filteredSessions.length === 0) return;
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
void loadPreview(first);
@@ -107,23 +127,173 @@ export function HistoryPanel({
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
const showPreview = preview !== null;
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
event.preventDefault();
event.stopPropagation();
setMenuConfirmTarget(null);
setBlankMenuPoint(null);
setMenuSession(s);
setMenuPoint(contextMenuPointFromEvent(event));
};
const openTrashBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!isTrash || sessions.length === 0) return;
const target = event.target as HTMLElement | null;
if (target?.closest(".hist-item,.history-search,.history-preview,button,input,textarea,select")) return;
event.preventDefault();
setMenuConfirmTarget(null);
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(contextMenuPointFromEvent(event));
};
const openTrashClearMenu = (event: ReactMouseEvent<HTMLElement>) => {
if (!isTrash || sessions.length === 0) return;
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
setMenuSession(null);
setMenuPoint(null);
setMenuConfirmTarget({ kind: "clear" });
setBlankMenuPoint({ left: rect.left, top: rect.bottom + 6 });
};
const closeHistoryMenus = () => {
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(null);
setMenuConfirmTarget(null);
};
const deleteHistorySession = (s: SessionMeta) => {
closeHistoryMenus();
onDelete(s.path);
};
const purgeTrashSession = (s: SessionMeta) => {
closeHistoryMenus();
onPurge?.(s.path);
};
const clearTrash = () => {
const paths = sessions.map((s) => s.path);
closeHistoryMenus();
onPurgeAll?.(paths);
};
const sessionMenuItems: ContextMenuItem[] = menuSession
? isTrash
? [
{
key: "restore",
icon: <RotateCcw size={13} />,
label: tr("history.restoreSession"),
onSelect: () => {
onRestore?.(menuSession.path);
closeHistoryMenus();
},
},
{ type: "separator", key: "trash-session-separator" },
{
key: "purge",
icon: <Trash2 size={13} />,
label:
menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path
? tr("history.confirmPurge")
: tr("history.purgeSession"),
danger: true,
onSelect: () => {
if (menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path) {
purgeTrashSession(menuSession);
} else {
setMenuConfirmTarget({ kind: "purge", path: menuSession.path });
}
},
},
]
: [
{
key: "rename",
icon: <Pencil size={13} />,
label: tr("history.rename"),
disabled: running,
onSelect: () => {
const target = menuSession;
closeHistoryMenus();
startRename(target);
},
},
...(menuSession.current
? []
: [
{
key: "delete",
icon: <Archive size={13} />,
label:
menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path
? tr("history.confirmMoveToTrash")
: tr("history.moveToTrash"),
disabled: running,
danger: menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path,
onSelect: () => {
if (menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path) {
deleteHistorySession(menuSession);
} else {
setMenuConfirmTarget({ kind: "delete", path: menuSession.path });
}
},
} as ContextMenuItem,
]),
]
: [];
const trashBlankMenuItems: ContextMenuItem[] =
menuConfirmTarget?.kind === "clear"
? [
{
key: "clear-trash-confirm",
icon: <Trash2 size={13} />,
label: tr("history.confirmClearTrash"),
danger: true,
onSelect: clearTrash,
},
]
: [
{
key: "clear-trash",
icon: <Trash2 size={13} />,
label: tr("history.clearTrashMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
},
];
return (
<ResizableDrawer onClose={onClose} wide={showPreview || running}>
<header className="drawer__head">
<div>
<div className="drawer__title">{tr("history.title")}</div>
{running && <div className="drawer__summary">{tr("history.readOnlyHint")}</div>}
<div className="drawer__title">{tr(isTrash ? "history.trashTitle" : "history.title")}</div>
{isTrash ? (
<div className="drawer__summary">{tr("history.trashHint")}</div>
) : (
running && <div className="drawer__summary">{tr("history.readOnlyHint")}</div>
)}
</div>
<div className="drawer__actions">
{isTrash && sessions.length > 0 && (
<button
className="chip history-clear"
type="button"
onClick={openTrashClearMenu}
>
{tr("history.clearTrash")}
</button>
)}
<Tooltip label={tr("common.close")}>
<button className="chip" onClick={onClose}>
✕
</button>
</Tooltip>
</div>
<Tooltip label={tr("common.close")}>
<button className="chip" onClick={onClose}>
✕
</button>
</Tooltip>
</header>
<div className={`drawer__body history-drawer${showPreview ? " history-drawer--preview" : ""}`}>
<div className="history-list">
<div
className={`drawer__body history-drawer${showPreview ? " history-drawer--preview" : ""}`}
onContextMenu={openTrashBlankMenu}
>
<div className={`history-list${isTrash ? " history-list--trash" : ""}`}>
{sessions.length > 0 && (
<label className="mem-search history-search">
<Search size={13} />
@@ -131,7 +301,10 @@ export function HistoryPanel({
</label>
)}
{sessions.length === 0 ? (
<div className="mem-empty">{tr("history.empty")}</div>
<div className={`mem-empty${isTrash ? " mem-empty--trash" : ""}`}>
{isTrash && <Trash2 size={22} />}
<span>{tr(isTrash ? "history.trashEmpty" : "history.empty")}</span>
</div>
) : filteredSessions.length === 0 ? (
<div className="mem-empty">{tr("history.noResults")}</div>
) : (
@@ -144,6 +317,7 @@ export function HistoryPanel({
<div
className={`hist-item${s.current ? " hist-item--current" : ""}${selected ? " hist-item--selected" : ""}`}
key={s.path}
onContextMenu={(event) => openSessionMenu(event, s)}
>
{editing === s.path ? (
<input
@@ -162,17 +336,24 @@ export function HistoryPanel({
<button
className="hist-item__main"
onClick={() => {
if (running) void loadPreview(s);
else onResume(s.path);
if (isTrash || running) void loadPreview(s);
else onResume(s);
}}
>
<div className="hist-item__preview">{sessionDisplayTitle(s, tr("history.emptySession"))}</div>
<div className="hist-item__meta">
{s.current && <span className="hist-item__badge">{tr("history.current")}</span>}
{!isTrash && s.current && <span className="hist-item__badge">{tr("history.current")}</span>}
{!isTrash && !s.current && s.open && <span className="hist-item__badge">{tr("history.open")}</span>}
<span>{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
<span>·</span>
<span>{timeLabel(sessionActivityTime(s))}</span>
{running && (
<span>{timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s))}</span>
{isTrash && s.deletedAt && (
<>
<span>·</span>
<span>{tr("history.deleted")}</span>
</>
)}
{!isTrash && running && (
<>
<span>·</span>
<span>{tr("history.preview")}</span>
@@ -182,55 +363,6 @@ export function HistoryPanel({
</button>
)}
{editing !== s.path && (
<div className="hist-item__actions">
{confirming === s.path ? (
<>
<Tooltip label={tr("history.confirmDelete")}>
<button
className="hist-act hist-act--danger"
disabled={running}
onClick={() => {
if (running) return;
onDelete(s.path);
setConfirming(null);
}}
>
<Check size={14} />
</button>
</Tooltip>
<Tooltip label={tr("common.cancel")}>
<button className="hist-act" onClick={() => setConfirming(null)}>
<X size={14} />
</button>
</Tooltip>
</>
) : (
<>
<Tooltip label={tr("history.rename")}>
<button
className="hist-act"
disabled={running}
onClick={() => startRename(s)}
>
<Pencil size={13} />
</button>
</Tooltip>
{!s.current && (
<Tooltip label={tr("common.delete")}>
<button
className="hist-act"
disabled={running}
onClick={() => setConfirming(s.path)}
>
<Trash2 size={13} />
</button>
</Tooltip>
)}
</>
)}
</div>
)}
</div>
);
})}
@@ -251,11 +383,27 @@ export function HistoryPanel({
) : previewItems.length === 0 ? (
<div className="mem-empty">{tr("history.previewEmpty")}</div>
) : (
<Transcript items={previewItems} onPrompt={() => {}} />
<Transcript items={previewItems} onPrompt={() => {}} questionNavigator={false} />
)}
</div>
</section>
)}
<ContextMenu
open={Boolean(menuSession)}
point={menuPoint}
items={sessionMenuItems}
minWidth={220}
ariaLabel={isTrash ? tr("history.trashSessionActions") : tr("history.historySessionActions")}
onClose={closeHistoryMenus}
/>
<ContextMenu
open={Boolean(blankMenuPoint)}
point={blankMenuPoint}
items={trashBlankMenuItems}
minWidth={220}
ariaLabel={tr("history.trashActions")}
onClose={closeHistoryMenus}
/>
</div>
</ResizableDrawer>
);
@@ -280,8 +428,10 @@ function sessionDisplayTitle(s: SessionMeta, fallback: string): string {
return s.title || s.preview || fallback;
}
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>): string {
return `${tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} · ${timeLabel(sessionActivityTime(s))}`;
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>, isTrash = false): string {
const time = timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
const suffix = isTrash && s.deletedAt ? ` · ${tr("history.deleted")}` : "";
return `${tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} · ${time}${suffix}`;
}
function previewMessagesToItems(messages: HistoryMessage[]): Item[] {
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
// Row-level destructive actions should confirm in place instead of opening a
// global modal. First click arms the action, second click confirms it, and the
// adjacent Cancel button or any disabled state returns the button to normal.
export function InlineConfirmButton({
label,
confirmLabel,
cancelLabel,
disabled = false,
danger = false,
onConfirm,
}: {
label: string;
confirmLabel: string;
cancelLabel: string;
disabled?: boolean;
danger?: boolean;
onConfirm: () => void | Promise<void>;
}) {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (disabled) setArmed(false);
}, [disabled]);
const run = async () => {
if (!armed) {
setArmed(true);
return;
}
setArmed(false);
await onConfirm();
};
return (
<span className="inline-confirm">
<button
className={`btn btn--small${armed && danger ? " btn--danger" : ""}`}
disabled={disabled}
type="button"
onClick={run}
>
{armed ? confirmLabel : label}
</button>
{armed && (
<button className="btn btn--small" disabled={disabled} type="button" onClick={() => setArmed(false)}>
{cancelLabel}
</button>
)}
</span>
);
}
+8 -1
View File
@@ -1,3 +1,4 @@
import { useDeferredValue } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -58,10 +59,16 @@ function normalizeMath(s: string): string {
}
export function Markdown({ text }: { text: string }) {
// useDeferredValue lets React prioritise the plain-text streaming frame over
// the expensive markdown parse+render. If a new text delta arrives while the
// markdown tree is still diffing, React can abort the in-progress render and
// start fresh with the latest text — keeping the UI responsive during the
// final markdown pass on long responses.
const deferred = useDeferredValue(text);
return (
<div className="md">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
{normalizeMath(text)}
{normalizeMath(deferred)}
</ReactMarkdown>
</div>
);
@@ -83,7 +83,7 @@ export function MemoryPanel({
const scrollToFact = (name: string) => {
const el = factRefs.current[name];
if (!el) return;
el.scrollIntoView({ block: "center", behavior: "smooth" });
el.scrollIntoView({ block: "center", behavior: "auto" });
setHighlight(name);
window.setTimeout(() => setHighlight((h) => (h === name ? null : h)), 1200);
};
+121 -12
View File
@@ -1,49 +1,158 @@
import { memo, useState } from "react";
import { ChevronRight } from "lucide-react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Markdown } from "./Markdown";
import { CopyButton } from "./CopyButton";
import { Tooltip } from "./Tooltip";
import { useT } from "../lib/i18n";
import type { Item } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
type AssistantItem = Extract<Item, { kind: "assistant" }>;
export function UserMessage({
text,
turn,
anchorId,
open,
onToggle,
onRewind,
checkpoint,
actionPending = false,
rewindDisabled = false,
}: {
text: string;
turn?: number;
anchorId?: string;
open?: boolean; // whether this message's rewind menu is the open one (lifted to Transcript)
onToggle?: () => void;
onRewind?: (turn: number, scope: string) => void;
checkpoint?: CheckpointMeta;
actionPending?: boolean;
rewindDisabled?: boolean;
}) {
const t = useT();
const [confirmScope, setConfirmScope] = useState<string | null>(null);
const canRewind = onRewind != null && turn != null;
const rewind = (scope: string) => onRewind?.(turn as number, scope);
const actionDisabledReason = (scope: string): string => {
if (rewindDisabled || actionPending) return t("rewind.disabledRunning");
if (!checkpoint) return t("rewind.disabledNoCheckpoint");
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
return t("rewind.disabledNoBoundary");
}
if (scope === "summ-upto") {
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
}
if (scope === "code" && !checkpoint.canCode) return t("rewind.disabledNoCode");
if (scope === "both") {
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
if (!checkpoint.canCode) return t("rewind.disabledNoCode");
}
return "";
};
const actionLabel = (scope: string): string => {
if (confirmScope !== scope) {
switch (scope) {
case "fork":
return t("rewind.fork");
case "summ-from":
return t("rewind.summFrom");
case "summ-upto":
return t("rewind.summUpto");
case "conversation":
return t("rewind.conversation");
case "code":
return t("rewind.code");
default:
return t("rewind.both");
}
}
switch (scope) {
case "fork":
return t("rewind.confirmFork");
case "summ-from":
return t("rewind.confirmSummFrom");
case "summ-upto":
return t("rewind.confirmSummUpto");
case "conversation":
return t("rewind.confirmConversation");
case "code":
return t("rewind.confirmCode");
default:
return t("rewind.confirmBoth");
}
};
const actionMeta = (scope: string): string => {
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
return t("rewind.filesChanged", { count: checkpoint.files.length });
}
return "";
};
const runAction = (scope: string) => {
setConfirmScope(null);
onRewind?.(turn as number, scope);
};
const selectRewind = (scope: string) => {
if (actionDisabledReason(scope)) return;
if (confirmScope !== scope) {
setConfirmScope(scope);
return;
}
runAction(scope);
};
const renderAction = (scope: string, danger = false) => {
const disabledReason = actionDisabledReason(scope);
const meta = actionMeta(scope);
return (
<button
className={[
"rewind__menu-item",
danger ? "rewind__menu-danger" : "",
confirmScope === scope ? "rewind__menu-confirm" : "",
].filter(Boolean).join(" ")}
type="button"
disabled={Boolean(disabledReason)}
title={disabledReason || undefined}
onClick={() => selectRewind(scope)}
>
<span>{actionLabel(scope)}</span>
{meta && <span className="rewind__menu-meta">{meta}</span>}
</button>
);
};
const displayText = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]");
return (
<div className="msg msg--user">
<div className="msg msg--user" id={anchorId} data-question-anchor={anchorId} data-turn={turn}>
<span className="msg__caret">›</span>
<div className="msg__text">{displayText}</div>
{canRewind && (
<div className="rewind">
<div className={`rewind${open ? " rewind--open" : ""}`}>
<Tooltip label={t("rewind.label")}>
<button className="rewind__btn" onClick={onToggle}>
⟲
<button
className="rewind__btn"
type="button"
aria-label={t("rewind.label")}
aria-expanded={Boolean(open)}
onClick={() => {
setConfirmScope(null);
onToggle?.();
}}
>
<MoreHorizontal size={15} />
</button>
</Tooltip>
{open && (
<div className="rewind__menu">
<button onClick={() => rewind("both")}>{t("rewind.both")}</button>
<button onClick={() => rewind("conversation")}>{t("rewind.conversation")}</button>
<button onClick={() => rewind("code")}>{t("rewind.code")}</button>
<button onClick={() => rewind("fork")}>{t("rewind.fork")}</button>
<button onClick={() => rewind("summ-from")}>{t("rewind.summFrom")}</button>
<button onClick={() => rewind("summ-upto")}>{t("rewind.summUpto")}</button>
{rewindDisabled && <div className="rewind__menu-hint">{t("rewind.disabledRunning")}</div>}
{!rewindDisabled && !checkpoint && <div className="rewind__menu-hint">{t("rewind.disabledNoCheckpoint")}</div>}
{renderAction("fork")}
<div className="rewind__menu-separator" />
{renderAction("summ-from")}
{renderAction("summ-upto")}
<div className="rewind__menu-separator" />
{renderAction("conversation")}
{renderAction("code")}
{renderAction("both", true)}
</div>
)}
</div>
@@ -1,22 +1,24 @@
import { useEffect, useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Brain, Check, ChevronsUpDown } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import type { ModelInfo } from "../lib/types";
import { AnchoredPopover } from "./AnchoredPopover";
// ModelSwitcher is the bottom-of-window model picker: the status line's model
// label becomes a button that opens a popover (upward) listing configured
// providers. Selecting one switches the active model; the conversation is carried
// over by the backend, so the chat continues. Mirrors the "switch model, keep the
// session" behavior of comparable coding agents.
export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name: string) => void }) {
// ModelSwitcher opens an upward popover listing configured providers. Selecting
// one switches the active model while the current conversation continues.
export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?: string; onPick: (name: string) => void }) {
const t = useT();
const [open, setOpen] = useState(false);
const [models, setModels] = useState<ModelInfo[]>([]);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) app.Models().then(setModels).catch(() => {});
}, [open]);
if (open) {
(tabId ? app.ModelsForTab(tabId) : app.Models()).then((next) => setModels(asArray(next))).catch(() => {});
}
}, [open, tabId]);
const pick = (name: string) => {
setOpen(false);
@@ -25,30 +27,40 @@ export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name:
return (
<div className="modelsw">
<button className="modelsw__trigger" onClick={() => setOpen((v) => !v)}>
<button
ref={triggerRef}
type="button"
className="modelsw__trigger"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Brain size={13} className="modelsw__kind" />
<span className="modelsw__label">{label}</span>
<ChevronsUpDown size={11} />
</button>
{open && (
<>
<div className="modelsw__backdrop" onClick={() => setOpen(false)} />
<div className="modelsw__menu" role="listbox">
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
{models.map((m) => (
<button
key={m.ref}
role="option"
aria-selected={m.current}
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
onClick={() => pick(m.ref)}
>
<span className="modelsw__model">{m.model}</span>
{m.current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</>
)}
<AnchoredPopover
open={open}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className="modelsw__menu modelsw__menu--portal"
>
<div role="listbox">
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
{models.map((m) => (
<button
key={m.ref}
type="button"
role="option"
aria-selected={m.current}
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
onClick={() => pick(m.ref)}
>
<span className="modelsw__model">{m.model}</span>
{m.current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</AnchoredPopover>
</div>
);
}
@@ -0,0 +1,811 @@
// ProjectTree is the sidebar replacement for the flat recent-sessions list.
// It shows a tree of projects (each with expandable topics) plus a Global
// section. Clicking a topic opens its tab; "+" next to a project creates a
// new topic.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
import { Archive, ChevronRight, ChevronDown, Pencil, Plus, Folder, FolderGit2, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, History, Check } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import type { ProjectNode } from "../lib/types";
import { getLocale, useT, type Translator } from "../lib/i18n";
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
import { Tooltip } from "./Tooltip";
interface ProjectTreeProps {
activeScope?: string;
activeWorkspaceRoot?: string;
activeTopicId?: string;
currentWorkspaceName?: string;
onOpenTopic: (scope: string, workspaceRoot: string, topicId: string) => Promise<void> | void;
onOpenProjectHistory: (scope: "global" | "project", workspaceRoot: string) => Promise<void> | void;
onAddProject: () => Promise<void>;
onUseCurrentProject?: () => Promise<void>;
onRenameTopic?: (topicId: string, title: string) => Promise<void> | void;
onTopicsChanged?: () => Promise<void> | void;
refreshSignal?: number;
}
function projectNodeKey(node: ProjectNode, depth: number): string {
return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`;
}
function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string): boolean {
if (node.kind !== "topic" && node.kind !== "global_topic") return false;
const scope = node.kind === "global_topic" ? "global" : "project";
return (
activeTopicId === node.topicId &&
activeScope === scope &&
(scope === "global" || activeWorkspaceRoot === node.root)
);
}
function topicMetaLine(node: ProjectNode, t: Translator): string {
const turns = node.turns ?? 0;
if (turns <= 0) return "";
const last = node.lastActivityAt ? ` · ${topicActivityLabel(node.lastActivityAt)}` : "";
return `${t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns })}${last}`;
}
function topicActivityLabel(ms: number): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
const locale = getLocale();
const rtf = new Intl.RelativeTimeFormat(locale === "zh" ? "zh-CN" : "en", { numeric: "auto" });
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
if (delta < minute) return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute");
if (delta < day) return rtf.format(-Math.round(delta / hour), "hour");
if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day");
return new Date(ms).toLocaleDateString();
}
type ProjectDropPosition = "before" | "after";
function projectRoots(nodes: ProjectNode[]): string[] {
return nodes
.filter((node) => node.kind === "project" && Boolean(node.root))
.map((node) => node.root!);
}
function reorderedProjectRoots(nodes: ProjectNode[], draggedRoot: string, targetRoot: string, position: ProjectDropPosition): string[] {
const roots = projectRoots(nodes);
if (draggedRoot === targetRoot || !roots.includes(draggedRoot) || !roots.includes(targetRoot)) return roots;
const next = roots.filter((root) => root !== draggedRoot);
const targetIndex = next.indexOf(targetRoot);
if (targetIndex < 0) return roots;
next.splice(position === "before" ? targetIndex : targetIndex + 1, 0, draggedRoot);
return next;
}
function applyProjectOrder(nodes: ProjectNode[], roots: string[]): ProjectNode[] {
const byRoot = new Map(nodes.filter((node) => node.kind === "project" && node.root).map((node) => [node.root!, node]));
const orderedProjects = roots.map((root) => byRoot.get(root)).filter((node): node is ProjectNode => Boolean(node));
const nonProjects = nodes.filter((node) => node.kind !== "project");
return [...nonProjects, ...orderedProjects];
}
function projectAccentStyle(color?: string): CSSProperties | undefined {
const value = projectColorValue(color);
if (!value) return undefined;
return { "--project-accent": value } as CSSProperties;
}
function colorMenuLabel(label: string, color?: string, active = false) {
const value = projectColorValue(color);
return (
<span className="project-tree__color-option">
<span
className="project-tree__color-swatch"
style={value ? ({ "--project-accent": value } as CSSProperties) : undefined}
aria-hidden="true"
/>
<span>{label}</span>
{active && <Check className="project-tree__color-check" size={12} />}
</span>
);
}
function revealLabelKey(platform: string): "projectTree.revealInFinder" | "projectTree.revealInExplorer" | "projectTree.revealInFileManager" {
if (platform === "darwin") return "projectTree.revealInFinder";
if (platform === "windows") return "projectTree.revealInExplorer";
return "projectTree.revealInFileManager";
}
function projectColorLabel(t: Translator, color?: string): string {
switch (color) {
case "red": return t("projectTree.colorRed");
case "orange": return t("projectTree.colorOrange");
case "amber": return t("projectTree.colorAmber");
case "green": return t("projectTree.colorGreen");
case "teal": return t("projectTree.colorTeal");
case "blue": return t("projectTree.colorBlue");
case "purple": return t("projectTree.colorPurple");
case "pink": return t("projectTree.colorPink");
default: return t("projectTree.colorDefault");
}
}
export function ProjectTree({
activeScope,
activeWorkspaceRoot,
activeTopicId,
currentWorkspaceName,
onOpenTopic,
onOpenProjectHistory,
onAddProject,
onUseCurrentProject,
onRenameTopic,
onTopicsChanged,
refreshSignal,
}: ProjectTreeProps) {
const t = useT();
const [tree, setTree] = useState<ProjectNode[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
const [creatingProject, setCreatingProject] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [editingTopic, setEditingTopic] = useState<string | null>(null);
const [topicDraft, setTopicDraft] = useState("");
const [menuTopic, setMenuTopic] = useState<string | null>(null);
const [menuProject, setMenuProject] = useState<{ key: string; root: string; path: string; scope: "global" | "project"; label: string } | null>(null);
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const [editingProject, setEditingProject] = useState<{ key: string; root: string } | null>(null);
const [projectDraft, setProjectDraft] = useState("");
const [addingProject, setAddingProject] = useState(false);
const [confirmAction, setConfirmAction] = useState<{ topicId: string; action: "trash" } | null>(null);
const [confirmRemoveProject, setConfirmRemoveProject] = useState<string | null>(null);
const [dragProjectRoot, setDragProjectRoot] = useState<string | null>(null);
const [dropProject, setDropProject] = useState<{ root: string; position: ProjectDropPosition } | null>(null);
const [platform, setPlatform] = useState("");
const creatingRef = useRef(false);
const closeMenu = useCallback(() => {
setMenuTopic(null);
setMenuProject(null);
setMenuPoint(null);
setConfirmAction(null);
setConfirmRemoveProject(null);
}, []);
const refresh = useCallback(async () => {
try {
const nodes = await app.ListProjectTree();
const list = asArray(nodes);
setTree(list);
setExpanded((prev) => {
const next = new Set(prev);
for (const node of list) {
if (node?.key && !manuallyCollapsed.has(node.key)) next.add(node.key);
}
return next;
});
} catch {
/* bridge unavailable */
}
}, [manuallyCollapsed]);
useEffect(() => {
void refresh();
}, [refresh, refreshSignal]);
useEffect(() => {
let cancelled = false;
void app.Platform().then((value) => {
if (!cancelled) setPlatform(value);
}).catch(() => {});
return () => {
cancelled = true;
};
}, []);
const toggleExpand = (key: string) => {
const willCollapse = expanded.has(key);
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
setManuallyCollapsed((prev) => {
const next = new Set(prev);
if (willCollapse) next.add(key);
else next.delete(key);
return next;
});
};
const handleAddProject = async () => {
if (addingProject) return;
setAddingProject(true);
try {
await onAddProject();
await refresh();
} finally {
setAddingProject(false);
}
};
const handleCreateTopic = async (scope: string, workspaceRoot: string, key: string) => {
if (creatingRef.current) return;
creatingRef.current = true;
setCreatingProject(key);
setMenuProject(null);
setMenuPoint(null);
setExpanded((prev) => {
const next = new Set(prev);
next.add(key);
return next;
});
setManuallyCollapsed((prev) => {
if (!prev.has(key)) return prev;
const next = new Set(prev);
next.delete(key);
return next;
});
try {
const topic = await app.CreateTopic(scope, workspaceRoot, "");
await refresh();
await onTopicsChanged?.();
await onOpenTopic(scope, workspaceRoot, topic.id);
} catch {
/* ignore */
} finally {
creatingRef.current = false;
setCreatingProject(null);
}
};
const startRenameTopic = (node: ProjectNode, label: string) => {
setMenuTopic(null);
setMenuProject(null);
setMenuPoint(null);
setConfirmAction(null);
setEditingTopic(node.topicId ?? null);
setTopicDraft(label);
};
const startRenameProject = (key: string, root: string, label: string) => {
setMenuProject(null);
setMenuTopic(null);
setMenuPoint(null);
setConfirmRemoveProject(null);
setEditingProject({ key, root });
setProjectDraft(label);
};
const commitRenameTopic = async (topicId: string) => {
const title = topicDraft.trim();
setEditingTopic(null);
if (!title) return;
try {
if (onRenameTopic) await onRenameTopic(topicId, title);
else await app.RenameTopic(topicId, title);
await refresh();
if (!onRenameTopic) await onTopicsChanged?.();
} catch {
/* ignore */
}
};
const commitRenameProject = async (root: string) => {
const title = projectDraft.trim();
setEditingProject(null);
if (!title) return;
try {
await app.RenameProject(root, title);
await refresh();
} catch {
/* ignore */
}
};
const trashTopic = async (topicId: string) => {
try {
await app.TrashTopic(topicId);
setMenuTopic(null);
setMenuPoint(null);
setConfirmAction(null);
await refresh();
await onTopicsChanged?.();
} catch {
/* ignore */
}
};
const copyProjectPath = async (path: string) => {
if (!path) return;
try {
await navigator.clipboard?.writeText(path);
} catch {
/* ignore */
}
};
const removeProject = async (path: string) => {
if (!path) return;
try {
await app.RemoveWorkspace(path);
setMenuProject(null);
setMenuPoint(null);
setConfirmRemoveProject(null);
await refresh();
} catch {
/* ignore */
}
};
const setProjectColor = async (path: string, color: string) => {
try {
await app.SetProjectColor(path, color);
setMenuProject(null);
setMenuPoint(null);
await refresh();
await onTopicsChanged?.();
} catch {
/* ignore */
}
};
const visibleTree = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return tree;
const matches = (node: ProjectNode) =>
[node.label, node.root, node.topicId].some((value) => (value ?? "").toLowerCase().includes(q));
const filterNode = (node: ProjectNode): ProjectNode | null => {
const children = asArray(node.children)
.map(filterNode)
.filter((child): child is ProjectNode => child !== null);
if (matches(node) || children.length > 0) return { ...node, children };
return null;
};
return tree
.map(filterNode)
.filter((node): node is ProjectNode => node !== null);
}, [query, tree]);
const projectDragEnabled = query.trim() === "";
const commitProjectReorder = useCallback(async (draggedRoot: string, targetRoot: string, position: ProjectDropPosition) => {
const nextRoots = reorderedProjectRoots(tree, draggedRoot, targetRoot, position);
const currentRoots = projectRoots(tree);
if (nextRoots.join("\n") === currentRoots.join("\n")) return;
setTree((current) => applyProjectOrder(current, nextRoots));
try {
await app.ReorderProjects(nextRoots);
await refresh();
await onTopicsChanged?.();
} catch {
await refresh();
}
}, [onTopicsChanged, refresh, tree]);
const clearProjectDrag = useCallback(() => {
setDragProjectRoot(null);
setDropProject(null);
}, []);
const activeAncestorKeys = useMemo(() => {
const walk = (nodes: ProjectNode[], ancestors: string[]): string[] | null => {
for (const node of nodes) {
if (!node) continue;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId)) return ancestors;
const children = asArray(node.children);
if (children.length > 0) {
const next = walk(children, [...ancestors, projectNodeKey(node, ancestors.length)]);
if (next) return next;
}
}
return null;
};
return walk(tree, []) ?? [];
}, [activeScope, activeTopicId, activeWorkspaceRoot, tree]);
useEffect(() => {
if (activeAncestorKeys.length === 0) return;
setExpanded((prev) => {
let changed = false;
const next = new Set(prev);
for (const key of activeAncestorKeys) {
if (manuallyCollapsed.has(key) || next.has(key)) continue;
next.add(key);
changed = true;
}
return changed ? next : prev;
});
}, [activeAncestorKeys, manuallyCollapsed]);
const renderNode = (node: ProjectNode | null | undefined, depth: number) => {
if (!node) return null;
const key = projectNodeKey(node, depth);
const children = asArray(node.children);
const isExpanded = query.trim() ? true : expanded.has(key);
const hasChildren = children.length > 0;
if (node.kind === "topic" || node.kind === "global_topic") {
const scope = node.kind === "global_topic" ? "global" : "project";
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId);
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
const meta = topicMetaLine(node, t);
const topicId = node.topicId ?? "";
const topicMenuOpen = menuTopic === topicId;
const openTopicMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuProject(null);
setConfirmRemoveProject(null);
setMenuPoint(contextMenuPointFromEvent(event));
setMenuTopic(topicId);
setConfirmAction(null);
};
const topicMenuItems: ContextMenuItem[] = [
{
key: "rename",
icon: <Pencil size={13} />,
label: t("projectTree.renameTopic"),
onSelect: () => startRenameTopic(node, label),
},
{
key: "trash",
icon: <Archive size={13} />,
label: confirmAction?.topicId === topicId && confirmAction.action === "trash" ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
danger: true,
onSelect: () => {
if (confirmAction?.topicId === topicId && confirmAction.action === "trash") void trashTopic(topicId);
else setConfirmAction({ topicId, action: "trash" });
},
},
];
if (editingTopic === topicId) {
return (
<div
key={key}
className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}`}
style={{ paddingLeft: 14 + depth * 16 }}
>
<input
autoFocus
className="project-tree__topic-input"
value={topicDraft}
onChange={(event) => setTopicDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRenameTopic(topicId);
if (event.key === "Escape") setEditingTopic(null);
}}
onBlur={() => void commitRenameTopic(topicId)}
/>
</div>
);
}
return (
<div
key={key}
className={`project-tree__topic${active ? " project-tree__topic--active" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}`}
style={projectAccentStyle(node.projectColor)}
onContextMenu={openTopicMenu}
>
<button
type="button"
className="project-tree__topic-main"
title={meta ? `${label} · ${meta}` : label}
style={{ paddingLeft: 14 + depth * 16 }}
onClick={() => onOpenTopic(scope, node.root ?? "", topicId)}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openTopicMenu(event);
}
}}
>
<span className="project-tree__topic-copy">
<span className="project-tree__topic-label">{label}</span>
</span>
</button>
<ContextMenu
open={topicMenuOpen}
point={menuPoint}
items={topicMenuItems}
minWidth={178}
ariaLabel={t("projectTree.topicActions")}
onClose={closeMenu}
/>
</div>
);
}
const scope = node.kind === "global_folder" ? "global" : "project";
const projectRoot = scope === "global" ? "" : node.root ?? "";
const projectPath = node.root ?? "";
const colorTargetRoot = scope === "global" ? "" : projectPath;
const projectLabel = node.label || (scope === "global" ? "Global" : "Untitled");
const projectActive = activeScope === scope && (scope === "global" || activeWorkspaceRoot === node.root);
const draggableProject = projectDragEnabled && scope === "project" && depth === 0 && Boolean(projectRoot) && editingProject?.key !== key;
const projectDropPosition = dropProject?.root === projectRoot ? dropProject.position : null;
const handleProjectDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
if (!draggableProject) return;
const target = event.target as HTMLElement | null;
if (target?.closest("button,input,textarea,select")) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", projectRoot);
setDragProjectRoot(projectRoot);
setDropProject(null);
};
const handleProjectDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!draggableProject || !dragProjectRoot || dragProjectRoot === projectRoot) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const rect = event.currentTarget.getBoundingClientRect();
const position: ProjectDropPosition = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
setDropProject((current) => {
if (current?.root === projectRoot && current.position === position) return current;
return { root: projectRoot, position };
});
};
const handleProjectDrop = (event: ReactDragEvent<HTMLDivElement>) => {
if (!draggableProject) return;
const draggedRoot = dragProjectRoot || event.dataTransfer.getData("text/plain");
const position = dropProject?.root === projectRoot ? dropProject.position : "after";
event.preventDefault();
clearProjectDrag();
if (draggedRoot && draggedRoot !== projectRoot) void commitProjectReorder(draggedRoot, projectRoot, position);
};
const openProjectMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuTopic(null);
setConfirmAction(null);
setMenuPoint(contextMenuPointFromEvent(event));
setMenuProject({ key, root: projectRoot, path: projectPath, scope, label: projectLabel });
setConfirmRemoveProject(null);
};
const projectMenuItems: ContextMenuItem[] = [
{
key: "new-session",
icon: <Plus size={13} />,
label: t("projectTree.newTopic"),
onSelect: () => {
void handleCreateTopic(scope, projectRoot, key);
},
},
...(scope === "project"
? [
{
key: "project-history",
icon: <History size={13} />,
label: t("projectTree.projectHistory"),
onSelect: () => {
closeMenu();
void onOpenProjectHistory(scope, projectRoot);
},
},
]
: []),
{
key: "rename",
icon: <Pencil size={13} />,
label: t("projectTree.renameProject"),
onSelect: () => startRenameProject(key, projectRoot, projectLabel),
},
{ type: "separator" as const, key: "color-separator" },
...PROJECT_COLOR_OPTIONS.map((option): ContextMenuItem => ({
key: `color-${option.key || "default"}`,
label: colorMenuLabel(projectColorLabel(t, option.key), option.key, (node.projectColor || "") === option.key),
onSelect: () => {
void setProjectColor(colorTargetRoot, option.key);
},
})),
{ type: "separator" as const, key: "path-separator" },
{
key: "reveal",
icon: <FolderOpen size={13} />,
label: t(revealLabelKey(platform)),
disabled: !projectPath,
onSelect: () => {
void app.RevealPath(projectPath);
closeMenu();
},
},
{
key: "copy-path",
icon: <Copy size={13} />,
label: t("projectTree.copyPath"),
disabled: !projectPath,
onSelect: () => {
void copyProjectPath(projectPath);
closeMenu();
},
},
...(scope === "project"
? [
{ type: "separator" as const, key: "remove-separator" },
{
key: "remove",
icon: <XCircle size={13} />,
label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProject") : t("projectTree.removeProject"),
danger: true,
onSelect: () => {
if (confirmRemoveProject === key) void removeProject(projectPath);
else setConfirmRemoveProject(key);
},
},
]
: []),
];
if (editingProject?.key === key) {
return (
<div key={key}>
<div
className={`project-tree__folder project-tree__folder--editing${projectActive ? " project-tree__folder--active" : ""}`}
style={{ paddingLeft: 8 + depth * 16 }}
>
<input
autoFocus
className="project-tree__folder-input"
value={projectDraft}
onChange={(event) => setProjectDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRenameProject(projectRoot);
if (event.key === "Escape") setEditingProject(null);
}}
onBlur={() => void commitRenameProject(projectRoot)}
/>
</div>
{isExpanded && hasChildren && (
<div className="project-tree__children">
{children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
}
return (
<div key={key}>
<div
className={`project-tree__folder${projectActive ? " project-tree__folder--active" : ""}${menuProject?.key === key ? " project-tree__folder--menu-open" : ""}${dragProjectRoot === projectRoot ? " project-tree__folder--dragging" : ""}${projectDropPosition ? ` project-tree__folder--drop-${projectDropPosition}` : ""}`}
style={projectAccentStyle(node.projectColor)}
draggable={draggableProject}
aria-grabbed={draggableProject ? dragProjectRoot === projectRoot : undefined}
onDragStart={handleProjectDragStart}
onDragOver={handleProjectDragOver}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropProject(null);
}}
onDrop={handleProjectDrop}
onDragEnd={clearProjectDrag}
onContextMenu={openProjectMenu}
>
<button
type="button"
className="project-tree__folder-main"
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => {
if (hasChildren) toggleExpand(key);
}}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openProjectMenu(event);
}
}}
aria-expanded={hasChildren ? isExpanded : undefined}
>
{hasChildren ? (
isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />
) : (
<span style={{ width: 12 }} />
)}
<Folder size={12} />
<span className="project-tree__folder-color" aria-hidden="true" />
<span className="project-tree__folder-label">{projectLabel}</span>
</button>
<Tooltip label={t("projectTree.newTopicTooltip")} className="project-tree__action-slot">
<button
type="button"
className={`project-tree__new-topic${creatingProject === key ? " project-tree__new-topic--active" : ""}`}
disabled={creatingProject !== null}
onClick={(e) => {
e.stopPropagation();
void handleCreateTopic(scope, projectRoot, key);
}}
>
<Plus size={12} />
</button>
</Tooltip>
<ContextMenu
open={menuProject?.key === key}
point={menuPoint}
items={projectMenuItems}
minWidth={212}
ariaLabel={t("projectTree.projectActions")}
onClose={closeMenu}
/>
</div>
{isExpanded && hasChildren && (
<div className="project-tree__children">
{children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
return (
<div className="project-tree">
<label className="project-tree__search">
<Search size={14} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("projectTree.searchPlaceholder")}
/>
</label>
<div className="project-tree__header">
<span className="project-tree__header-title">
<BriefcaseBusiness size={13} />
{t("projectTree.workspaceTitle")}
</span>
<Tooltip label={t("projectTree.addProjectTooltip")} className="project-tree__action-slot">
<button
type="button"
className="project-tree__add-project"
aria-label={t("projectTree.addProjectTooltip")}
disabled={addingProject}
onClick={() => void handleAddProject()}
>
<FolderPlus size={13} />
</button>
</Tooltip>
</div>
<div className="project-tree__list">
{visibleTree.length === 0 ? (
query.trim() ? (
<div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>
) : (
<div className="project-tree__empty-state">
<div className="project-tree__empty-icon">
<FolderPlus size={18} />
</div>
<div className="project-tree__empty-copy">
<strong>{t("projectTree.emptyNoProjects")}</strong>
<span>{t("projectTree.emptyNoProjectsDesc")}</span>
</div>
<div className="project-tree__empty-actions">
<button
type="button"
className="project-tree__empty-primary"
onClick={() => void handleAddProject()}
disabled={addingProject}
>
<FolderPlus size={14} />
<span>{t("projectTree.emptyAddProject")}</span>
</button>
{onUseCurrentProject && currentWorkspaceName && (
<button
type="button"
className="project-tree__empty-secondary"
onClick={async () => {
await onUseCurrentProject();
await refresh();
}}
>
<FolderGit2 size={14} />
<span>{t("projectTree.emptyUseCurrentDir")}</span>
</button>
)}
</div>
{currentWorkspaceName && <div className="project-tree__empty-current">{t("projectTree.emptyCurrentDir", { dir: currentWorkspaceName })}</div>}
</div>
)
) : (
visibleTree.map((node) => renderNode(node, 0))
)}
</div>
</div>
);
}
@@ -11,6 +11,7 @@ export function PromptShelf({
crumbs,
quickActions,
barRef,
actionsWrap = false,
}: {
titleId: string;
title: ReactNode;
@@ -21,9 +22,10 @@ export function PromptShelf({
crumbs?: ReactNode;
quickActions?: ReactNode;
barRef?: RefObject<HTMLDivElement>;
actionsWrap?: boolean;
}) {
return (
<div className="prompt-shelf" aria-live="polite">
<div className={`prompt-shelf${actionsWrap ? " prompt-shelf--actions-wrap" : ""}`} aria-live="polite">
<div
ref={barRef}
className="prompt-shelf__bar"
@@ -36,8 +38,8 @@ export function PromptShelf({
<PauseCircle size={16} aria-hidden="true" />
<div className="prompt-shelf__copy">
<div id={titleId} className="prompt-shelf__title">
{title}
{badges}
<span className="prompt-shelf__heading">{title}</span>
{badges && <span className="prompt-shelf__badges">{badges}</span>}
</div>
<div className="prompt-shelf__meta">{meta}</div>
</div>
+318 -64
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useI18n, useT } from "../lib/i18n";
import { normalizeLangPref, useI18n, useT, type LangPref } from "../lib/i18n";
import { useUpdater } from "../lib/useUpdater";
import {
THEME_STYLES,
@@ -9,21 +10,25 @@ import {
getResolvedTheme,
getTheme,
getThemeStyle,
normalizeThemePreference,
normalizeThemeStyleForTheme,
themeForStyle,
type Theme,
type ThemeStyle,
} from "../lib/theme";
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
import type { NetworkView, ProviderView, SettingsView } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
type SettingsTab = "models" | "providers" | "network" | "permissions" | "sandbox" | "agent" | "appearance" | "updates";
type SettingsTab = "general" | "models" | "providers" | "network" | "permissions" | "sandbox" | "appearance" | "updates";
const SETTINGS_TABS: SettingsTab[] = ["models", "providers", "network", "permissions", "sandbox", "agent", "appearance", "updates"];
const SETTINGS_TABS: SettingsTab[] = ["general", "models", "providers", "network", "permissions", "sandbox", "appearance", "updates"];
// SettingsPanel is the desktop settings surface, aligning with Claude Code's
// settings: model & providers (incl. API keys), permissions, sandbox, agent
// params, and appearance. Every change writes reasonix.toml (or .env for keys)
// settings: model & providers (incl. API keys), permissions, sandbox, and
// appearance. Every change writes reasonix.toml (or .env for keys)
// through the kernel's config edit API and rebuilds the controller live.
export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onChanged: () => void }) {
const t = useT();
@@ -32,12 +37,20 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC
const [err, setErr] = useState<string | null>(null);
const [theme, setThemeState] = useState<Theme>(getTheme());
const [themeStyle, setThemeStyleState] = useState<ThemeStyle>(() => getThemeStyle(getTheme()));
const [tab, setTab] = useState<SettingsTab>("models");
const [textSize, setTextSizeState] = useState<TextSize>(getTextSize());
const [tab, setTab] = useState<SettingsTab>("general");
const reload = async () => setS(await app.Settings().catch(() => null));
const reload = async () => setS(normalizeSettingsView(await app.Settings().catch(() => null)));
useEffect(() => {
void reload();
}, []);
useEffect(() => {
if (!s) return;
const nextTheme = normalizeThemePreference(s.desktopTheme);
const nextStyle = normalizeThemeStyleForTheme(s.desktopThemeStyle, nextTheme);
setThemeState(nextTheme);
setThemeStyleState(nextStyle);
}, [s?.desktopTheme, s?.desktopThemeStyle]);
// apply runs a mutation, re-reads settings, and refreshes the topbar/model. A
// rejected binding (validation / rebuild failure) surfaces as an inline banner.
@@ -85,27 +98,34 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC
</nav>
<main className="settings-content">
{err && <div className="banner banner--error">{err}</div>}
{tab === "general" && <GeneralSection s={s} busy={busy} apply={apply} />}
{tab === "models" && <ModelsSection s={s} busy={busy} apply={apply} onManageProviders={() => setTab("providers")} />}
{tab === "providers" && <ProvidersSection s={s} busy={busy} apply={apply} />}
{tab === "network" && <NetworkSection s={s} busy={busy} apply={apply} />}
{tab === "permissions" && <PermissionsSection s={s} busy={busy} apply={apply} />}
{tab === "sandbox" && <SandboxSection s={s} busy={busy} apply={apply} />}
{tab === "agent" && <AgentSection s={s} busy={busy} apply={apply} />}
{tab === "appearance" && (
<AppearanceSection
theme={theme}
themeStyle={themeStyle}
textSize={textSize}
onTheme={(t) => {
const nextStyle = themeForStyle(themeStyle) === getResolvedTheme(t) ? themeStyle : defaultStyleForTheme(t);
applyTheme(t, nextStyle);
applyTheme(t, nextStyle, { persist: false });
setThemeState(t);
setThemeStyleState(nextStyle);
void apply(() => app.SetDesktopAppearance(t, nextStyle));
}}
onThemeStyle={(style) => {
const nextTheme = themeForStyle(style);
applyTheme(nextTheme, style);
applyTheme(nextTheme, style, { persist: false });
setThemeState(nextTheme);
setThemeStyleState(style);
void apply(() => app.SetDesktopAppearance(nextTheme, style));
}}
onTextSize={(size) => {
applyTextSize(size);
setTextSizeState(size);
}}
/>
)}
@@ -128,6 +148,8 @@ function settingsTabLabel(id: SettingsTab, t: ReturnType<typeof useT>): string {
switch (id) {
case "models":
return t("settings.tab.models");
case "general":
return t("settings.tab.general");
case "providers":
return t("settings.tab.providers");
case "network":
@@ -136,8 +158,6 @@ function settingsTabLabel(id: SettingsTab, t: ReturnType<typeof useT>): string {
return t("settings.tab.permissions");
case "sandbox":
return t("settings.tab.sandbox");
case "agent":
return t("settings.tab.agent");
case "appearance":
return t("settings.tab.appearance");
case "updates":
@@ -149,6 +169,8 @@ function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType<typeof
switch (id) {
case "models":
return toRef(s.defaultModel, s) || t("common.none");
case "general":
return `${closeBehaviorLabel(normalizeCloseBehavior(s.closeBehavior), t)} · ${t(`settings.autoPlan.${normalizeAutoPlan(s.autoPlan)}`)}`;
case "providers":
return t("settings.providerCount", { n: s.providers.length });
case "network":
@@ -157,8 +179,6 @@ function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType<typeof
return s.permissions.mode;
case "sandbox":
return s.sandbox.bash;
case "agent":
return t("settings.agentMeta", { temp: s.agent.temperature, steps: s.agent.maxSteps || "∞" });
case "appearance":
return t("settings.appearanceMeta");
case "updates":
@@ -186,9 +206,18 @@ function toRef(model: string, s: SettingsView): string {
}
const PROXY_MODES = ["auto", "custom", "off"] as const;
// EFFORT_PRESETS is the canonical union of /effort levels the kernel
// recognises. The settings UI exposes these as toggleable checkboxes; users
// can additionally add arbitrary custom names via the "Add" input. The order
// here is what the user sees in the dropdown.
const EFFORT_PRESETS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const;
const LANGUAGE_PREFS: LangPref[] = ["", "zh", "en"];
const AUTO_PLAN_MODES = ["off", "on"] as const;
type ProxyMode = (typeof PROXY_MODES)[number];
type AutoPlanMode = (typeof AUTO_PLAN_MODES)[number];
function normalizeProxyMode(mode: string): ProxyMode {
switch (mode) {
@@ -205,6 +234,119 @@ function normalizeNetworkView(network: NetworkView): NetworkView {
return { ...network, proxyMode: normalizeProxyMode(network.proxyMode) };
}
function normalizeAutoPlan(mode: string | undefined): AutoPlanMode {
return mode === "ask" || mode === "on" ? "on" : "off";
}
function normalizeSettingsView(view: SettingsView | null | undefined): SettingsView | null {
if (!view) return null;
const permissions = view.permissions ?? { mode: "ask", allow: [], ask: [], deny: [] };
const sandbox = view.sandbox ?? { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [] };
const network = view.network ?? {
proxyMode: "auto",
proxyUrl: "",
noProxy: "",
proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
};
const agent = view.agent ?? { temperature: 0, maxSteps: 0, systemPrompt: "" };
return {
...view,
providers: asArray(view.providers).map((p) => ({ ...p, models: asArray(p.models) })),
providerKinds: asArray(view.providerKinds),
permissions: {
...permissions,
allow: asArray(permissions.allow),
ask: asArray(permissions.ask),
deny: asArray(permissions.deny),
},
sandbox: {
...sandbox,
allowWrite: asArray(sandbox.allowWrite),
},
network: {
...network,
proxy: network.proxy ?? { type: "socks5", server: "", port: 0, username: "", password: "" },
},
agent,
autoPlan: normalizeAutoPlan(view.autoPlan),
desktopLanguage: normalizeLangPref(view.desktopLanguage),
desktopTheme: normalizeThemePreference(view.desktopTheme),
desktopThemeStyle: normalizeThemeStyleForTheme(view.desktopThemeStyle, normalizeThemePreference(view.desktopTheme)),
closeBehavior: normalizeCloseBehavior(view.closeBehavior),
};
}
type CloseBehavior = "background" | "quit";
function normalizeCloseBehavior(mode: string | undefined): CloseBehavior {
return mode === "quit" ? "quit" : "background";
}
function closeBehaviorLabel(mode: CloseBehavior, t: ReturnType<typeof useT>): string {
return mode === "quit" ? t("settings.closeBehavior.quit") : t("settings.closeBehavior.background");
}
function GeneralSection({ s, busy, apply }: SectionProps) {
const { t, setPref } = useI18n();
const closeBehavior = normalizeCloseBehavior(s.closeBehavior);
const autoPlan = normalizeAutoPlan(s.autoPlan);
const languagePref = normalizeLangPref(s.desktopLanguage);
const setLanguage = (next: LangPref) => {
setPref(next);
void apply(() => app.SetDesktopLanguage(next));
};
return (
<section className="mem-section">
<div className="mem-section__title">{t("settings.tab.general")}</div>
<div className="set-row">
<label className="set-label">{t("settings.language")}</label>
<div className="set-seg">
{LANGUAGE_PREFS.map((pref) => (
<button
key={pref || "auto"}
className={`set-seg__btn${languagePref === pref ? " set-seg__btn--on" : ""}`}
disabled={busy}
onClick={() => setLanguage(pref)}
>
{pref === "" ? t("settings.langAuto") : pref === "zh" ? "中文" : "English"}
</button>
))}
</div>
</div>
<div className="set-row">
<label className="set-label">{t("settings.closeBehavior")}</label>
<div className="set-seg">
{(["background", "quit"] as const).map((mode) => (
<button
key={mode}
className={`set-seg__btn${closeBehavior === mode ? " set-seg__btn--on" : ""}`}
disabled={busy}
onClick={() => void apply(() => app.SetCloseBehavior(mode))}
>
{closeBehaviorLabel(mode, t)}
</button>
))}
</div>
</div>
<div className="set-row">
<label className="set-label">{t("settings.autoPlan")}</label>
<div className="set-seg">
{AUTO_PLAN_MODES.map((mode) => (
<button
key={mode}
className={`set-seg__btn${autoPlan === mode ? " set-seg__btn--on" : ""}`}
disabled={busy}
onClick={() => void apply(() => app.SetAutoPlan(mode))}
>
{t(`settings.autoPlan.${mode}`)}
</button>
))}
</div>
</div>
</section>
);
}
function NetworkSection({ s, busy, apply }: SectionProps) {
const t = useT();
const savedNetwork = normalizeNetworkView(s.network);
@@ -332,6 +474,9 @@ function ModelsSection({ s, busy, apply, onManageProviders }: SectionProps & { o
const defaultRef = toRef(s.defaultModel, s);
const plannerRef = toRef(s.plannerModel, s);
const [defaultProvider, defaultModel] = defaultRef.split("/");
const plannerModeDetail = plannerRef
? t("settings.plannerDualDetail", { planner: plannerRef, executor: defaultRef || t("common.none") })
: t("settings.plannerSingleDetail", { model: defaultRef || t("common.none") });
return (
<section className="mem-section">
@@ -383,8 +528,8 @@ function ModelsSection({ s, busy, apply, onManageProviders }: SectionProps & { o
</div>
<div>
<span>{t("settings.plannerStatus")}</span>
<strong>{plannerRef ? t("settings.plannerEnabled") : t("settings.plannerDisabled")}</strong>
<small>{plannerRef || t("settings.plannerNone")}</small>
<strong>{plannerRef ? t("settings.plannerDual") : t("settings.plannerSingle")}</strong>
<small>{plannerModeDetail}</small>
</div>
</div>
@@ -453,15 +598,22 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
<button className="btn btn--small" disabled={busy} onClick={() => setEditing(p.name)}>
{t("common.edit")}
</button>
<Tooltip label={defaultProvider === p.name ? t("settings.cantDeleteDefault") : t("settings.deleteProvider")}>
<button
className="btn btn--small"
disabled={busy || defaultProvider === p.name}
onClick={() => void apply(() => app.DeleteProvider(p.name))}
>
{t("common.delete")}
</button>
</Tooltip>
{defaultProvider === p.name ? (
<Tooltip label={t("settings.cantDeleteDefault")}>
<button className="btn btn--small" disabled>
{t("common.delete")}
</button>
</Tooltip>
) : (
<InlineConfirmButton
label={t("common.delete")}
confirmLabel={t("settings.confirmDeleteProvider")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={() => apply(() => app.DeleteProvider(p.name))}
/>
)}
</div>
<div className="prov-card__meta">
<span>{p.kind}</span>
@@ -509,11 +661,43 @@ function ProviderEditor({
// Empty when unset so the placeholder (and its "0 = default" hint) reads instead
// of a bare "0"; saved back as 0.
const [ctx, setCtx] = useState(initial?.contextWindow ? String(initial.contextWindow) : "");
const [supportedEfforts, setSupportedEfforts] = useState<string[]>(initial?.supportedEfforts ?? []);
const [customEffortDraft, setCustomEffortDraft] = useState("");
const [defaultEffort, setDefaultEffort] = useState(initial?.defaultEffort ?? "");
// Offer the kinds the kernel actually registered; if the stored kind is a
// legacy/unknown one, keep it as an option so editing doesn't silently change it.
const kindOptions = kind && !kinds.includes(kind) ? [kind, ...kinds] : kinds;
// Split supportedEfforts into the 5 canonical presets (for checkbox UI) and
// any user-added custom names (rendered as removable chips). The preset order
// is fixed; custom names keep insertion order.
const presetEfforts = supportedEfforts.filter((e) => EFFORT_PRESETS.includes(e));
const customEfforts = supportedEfforts.filter((e) => !EFFORT_PRESETS.includes(e));
const togglePreset = (level: string) => {
const has = presetEfforts.includes(level);
const nextPresets = has ? presetEfforts.filter((e) => e !== level) : [...presetEfforts, level];
setSupportedEfforts([...nextPresets, ...customEfforts]);
// If the removed preset was the default, fall back to "auto" (empty string).
if (has && defaultEffort === level) setDefaultEffort("");
};
const addCustomEffort = () => {
const v = customEffortDraft.trim().toLowerCase();
if (!v || supportedEfforts.includes(v)) {
setCustomEffortDraft("");
return;
}
setSupportedEfforts([...presetEfforts, ...customEfforts, v]);
setCustomEffortDraft("");
};
const removeCustomEffort = (level: string) => {
setSupportedEfforts(supportedEfforts.filter((e) => e !== level));
if (defaultEffort === level) setDefaultEffort("");
};
const save = () => {
const ms = models
.split(",")
@@ -529,6 +713,10 @@ function ProviderEditor({
keySet: initial?.keySet ?? false,
balanceUrl: balanceUrl.trim(),
contextWindow: Number(ctx) || 0,
supportedEfforts,
// Clear the stored default if no levels are selected; the backend's
// NormalizeEffort would otherwise silently ignore an unsupported value.
defaultEffort: supportedEfforts.length > 0 ? defaultEffort : "",
});
};
@@ -552,6 +740,81 @@ function ProviderEditor({
<label className="set-label">{t("settings.providerContextWindow")}</label>
<input className="mem-input" placeholder={t("settings.contextWindowPlaceholder")} value={ctx} onChange={(e) => setCtx(e.target.value)} inputMode="numeric" />
<div className="mem-hint">{t("settings.contextWindowHint")}</div>
<label className="set-label">{t("settings.supportedEfforts")}</label>
{EFFORT_PRESETS.map((level) => (
<label key={level} className="set-check">
<input
type="checkbox"
checked={presetEfforts.includes(level)}
onChange={() => togglePreset(level)}
/>
{level}
</label>
))}
<div className="set-row">
<input
className="mem-input set-grow"
placeholder={t("settings.supportedEffortsCustomPlaceholder")}
value={customEffortDraft}
onChange={(e) => setCustomEffortDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomEffort();
}
}}
/>
<button
type="button"
className="btn btn--small"
disabled={
!customEffortDraft.trim() || supportedEfforts.includes(customEffortDraft.trim().toLowerCase())
}
onClick={addCustomEffort}
>
{t("common.add")}
</button>
</div>
{customEfforts.length > 0 && (
<div className="set-rules__chips">
{customEfforts.map((level) => (
<span className="set-rule" key={level}>
{level}
<Tooltip label={t("common.delete")}>
<button
type="button"
className="set-rule__x"
disabled={busy}
onClick={() => removeCustomEffort(level)}
>
×
</button>
</Tooltip>
</span>
))}
</div>
)}
<div className="mem-hint">{t("settings.supportedEffortsHint")}</div>
<label className="set-label">{t("settings.defaultEffort")}</label>
{supportedEfforts.length > 0 ? (
<select
className="mem-select"
value={defaultEffort}
onChange={(e) => setDefaultEffort(e.target.value)}
>
<option value="">{t("settings.defaultEffortAuto")}</option>
{supportedEfforts.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
) : (
<select className="mem-select" value="" disabled>
<option value="">{t("settings.defaultEffortAuto")}</option>
</select>
)}
<div className="mem-hint">{t("settings.defaultEffortHint")}</div>
<div className="prov-card__actions">
<button className="btn btn--small" onClick={onCancel} disabled={busy}>
{t("common.cancel")}
@@ -725,50 +988,22 @@ function SandboxSection({ s, busy, apply }: SectionProps) {
);
}
function AgentSection({ s, busy, apply }: SectionProps) {
const t = useT();
const [temp, setTemp] = useState(String(s.agent.temperature));
const [steps, setSteps] = useState(String(s.agent.maxSteps));
const [prompt, setPrompt] = useState(s.agent.systemPrompt);
const dirty = temp !== String(s.agent.temperature) || steps !== String(s.agent.maxSteps) || prompt !== s.agent.systemPrompt;
return (
<section className="mem-section">
<div className="mem-section__title">{t("settings.agent")}</div>
<div className="set-row">
<label className="set-label">{t("settings.temperature")}</label>
<input className="mem-input set-narrow" value={temp} onChange={(e) => setTemp(e.target.value)} disabled={busy} inputMode="decimal" />
<label className="set-label">{t("settings.maxSteps")}</label>
<input className="mem-input set-narrow" value={steps} onChange={(e) => setSteps(e.target.value)} disabled={busy} inputMode="numeric" />
<span className="mem-hint">{t("settings.unlimited")}</span>
</div>
<div className="set-rules__label">{t("settings.systemPrompt")}</div>
<textarea className="mem-textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} disabled={busy} spellCheck={false} />
<div className="prov-card__actions">
<button
className="btn btn--primary btn--small"
disabled={busy || !dirty}
onClick={() => void apply(() => app.SetAgentParams(Number(temp) || 0, Number(steps) || 0, prompt))}
>
{t("settings.saveAgent")}
</button>
</div>
</section>
);
}
function AppearanceSection({
theme,
themeStyle,
textSize,
onTheme,
onThemeStyle,
onTextSize,
}: {
theme: Theme;
themeStyle: ThemeStyle;
textSize: TextSize;
onTheme: (t: Theme) => void;
onThemeStyle: (style: ThemeStyle) => void;
onTextSize: (size: TextSize) => void;
}) {
const { t, pref, setPref } = useI18n();
const t = useT();
const themeOptions: Theme[] = ["auto", "light", "dark"];
return (
<section className="mem-section">
@@ -803,12 +1038,18 @@ function AppearanceSection({
</div>
</div>
<div className="set-row">
<label className="set-label">{t("settings.language")}</label>
<select className="mem-select set-grow" value={pref} onChange={(e) => setPref(e.target.value as "" | "en" | "zh")}>
<option value="">{t("settings.langAuto")}</option>
<option value="zh">中文</option>
<option value="en">English</option>
</select>
<label className="set-label">{t("settings.textSize")}</label>
<div className="set-seg">
{TEXT_SIZES.map((size) => (
<button
key={size}
className={`set-seg__btn${textSize === size ? " set-seg__btn--on" : ""}`}
onClick={() => onTextSize(size)}
>
{textSizeName(size, t)}
</button>
))}
</div>
</div>
</section>
);
@@ -825,6 +1066,19 @@ function themeName(theme: Theme, t: ReturnType<typeof useT>): string {
}
}
function textSizeName(size: TextSize, t: ReturnType<typeof useT>): string {
switch (size) {
case "small":
return t("settings.textSizeSmall");
case "default":
return t("settings.textSizeDefault");
case "large":
return t("settings.textSizeLarge");
case "xlarge":
return t("settings.textSizeXLarge");
}
}
const MB = 1024 * 1024;
const mb = (n: number) => (n / MB).toFixed(1);
+59 -124
View File
@@ -1,24 +1,27 @@
import { useEffect, useState } from "react";
import { Coins, Cpu, Wallet } from "lucide-react";
import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { useState } from "react";
import { Tooltip } from "./Tooltip";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import type { BalanceInfo, ContextInfo, EffortInfo, JobView, Meta, Mode, WireUsage } from "../lib/types";
import { useI18n } from "../lib/i18n";
import type { BalanceInfo, ContextInfo, JobView, Mode, WireUsage } from "../lib/types";
// JobsChip is the status-bar background-jobs indicator: a count that opens an
// upward popover listing the running jobs (id · label · status), mirroring the
// ModelSwitcher's click-to-open pattern. It renders nothing when there are no
// jobs, so the caller guards on jobs.length first.
// ModelSwitcher's click-to-open pattern. With no jobs it still reserves a stable
// "jobs 0" slot so the IDE-style status order does not jump.
function JobsChip({ jobs }: { jobs: JobView[] }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
if (jobs.length === 0) {
return (
<span className="statusbar__item">
{t("status.jobsCount", { n: 0 })}
</span>
);
}
return (
<div className="statusbar__jobswrap">
<Tooltip label={t("status.jobsTitle")}>
<button className="statusbar__jobs" onClick={() => setOpen((v) => !v)}>
<Cpu size={11} />
{t("status.jobs", { n: jobs.length })}
<button className="statusbar__item statusbar__jobs" onClick={() => setOpen((v) => !v)}>
{t("status.jobsCount", { n: jobs.length })}
</button>
</Tooltip>
{open && (
@@ -40,162 +43,94 @@ function JobsChip({ jobs }: { jobs: JobView[] }) {
);
}
function formatRate(hit: number, denom: number): string | null {
if (denom <= 0) return null;
return ((hit / denom) * 100).toFixed(2);
}
// nowRate is the SINGLE-TURN prompt cache-hit % (latest turn) — the higher,
// steeper number on a non-compacting DeepSeek session. null when nothing yet.
function nowRate(u?: WireUsage): number | null {
function nowRate(u?: WireUsage): string | null {
if (!u) return null;
let denom = u.cacheHitTokens + u.cacheMissTokens;
if (denom === 0) denom = u.promptTokens;
if (denom <= 0) return null;
return Math.round((u.cacheHitTokens / denom) * 100);
return formatRate(u.cacheHitTokens, denom);
}
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
// drag the average down); it overtakes only when compaction craters single turns.
function avgRate(u?: WireUsage): number | null {
function avgRate(u?: WireUsage): string | null {
if (!u) return null;
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
if (denom <= 0) return null;
return Math.round((u.sessionCacheHitTokens / denom) * 100);
return formatRate(u.sessionCacheHitTokens, denom);
}
function fmtTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
function currencySymbol(currency?: string): string {
const value = (currency || "¥").trim();
if (/^(cny|rmb|yuan)$/i.test(value)) return "¥";
if (/^(usd|dollar)$/i.test(value)) return "$";
return value || "¥";
}
function fmtElapsed(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
// useTick re-renders once a second while `on`, so the elapsed clock advances.
function useTick(on: boolean): number {
const [, setN] = useState(0);
useEffect(() => {
if (!on) return;
const id = setInterval(() => setN((n) => n + 1), 1000);
return () => clearInterval(id);
}, [on]);
return Date.now();
function formatMoney(amount?: number, currency?: string): string {
const symbol = currencySymbol(currency);
if (typeof amount !== "number" || amount <= 0) return `${symbol}0.0000`;
return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`;
}
export function StatusBar({
meta,
context,
usage,
balance,
effort,
jobs,
running,
mode,
turnStartAt,
turnTokens,
cost,
retry,
onSwitchModel,
onSetEffort,
currency,
}: {
meta?: Meta;
context: ContextInfo;
usage?: WireUsage;
balance?: BalanceInfo;
effort?: EffortInfo;
jobs?: JobView[];
running: boolean;
mode: Mode;
turnStartAt: number;
turnTokens: number;
cost?: number;
retry?: { attempt: number; max: number };
onSwitchModel: (name: string) => void;
onSetEffort: (level: string) => void;
currency?: string;
}) {
const { t, locale } = useI18n();
const now = useTick(running);
const { t } = useI18n();
const pct = context.window ? Math.min(100, Math.round((context.used / context.window) * 100)) : null;
const compactPct = context.compactRatio ? Math.round(context.compactRatio * 100) : null;
const nowPct = nowRate(usage);
const avgPct = avgRate(usage);
// While a turn runs, the status line shows live activity (word · elapsed ·
// tokens) in place of the static context gauge.
let activity: string | null = null;
if (retry) {
activity = t("status.retrying", { attempt: retry.attempt, max: retry.max });
} else if (running && turnStartAt) {
const elapsedMs = Math.max(0, now - turnStartAt);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
activity = `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
}
const jobsList = jobs ?? [];
const costLabel = formatMoney(cost, currency);
return (
<div className="statusbar">
<span className={`statusbar__dot ${running ? "statusbar__dot--busy" : ""}`} />
<ModelSwitcher label={meta?.label ?? t("status.connecting")} onPick={onSwitchModel} />
{effort?.supported && (
<>
<span className="statusbar__sep">·</span>
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
</>
)}
{activity ? (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__activity">{activity}</span>
</>
) : (
pct !== null && (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__ctx">{t("status.ctx", { pct })}</span>
</>
)
)}
{nowPct !== null && (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__cache">{t("status.cache", { pct: nowPct })}</span>
</>
)}
{avgPct !== null && (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__cache">{t("status.cacheAvg", { pct: avgPct })}</span>
</>
)}
{jobs && jobs.length > 0 && (
<>
<span className="statusbar__sep">·</span>
<JobsChip jobs={jobs} />
</>
)}
{typeof cost === "number" && cost > 0 && (
<>
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.spendTitle")}>
<span className="statusbar__balance statusbar__cost">
<Coins size={11} />
{`¥${cost < 1 ? cost.toFixed(4) : cost.toFixed(2)}`}
</span>
</Tooltip>
</>
)}
{balance?.available && balance.display && (
<>
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.balanceTitle")}>
<span className="statusbar__balance">
<Wallet size={11} />
{balance.display}
</span>
</Tooltip>
</>
)}
<span className="statusbar__item statusbar__ctx">{pct !== null ? t("status.ctx", { pct }) : t("status.ctxUnknown")}</span>
<span className="statusbar__sep">·</span>
<span className="statusbar__item statusbar__compact">{compactPct !== null ? t("status.compact", { pct: compactPct }) : t("status.compactUnknown")}</span>
<span className="statusbar__sep">·</span>
<span className="statusbar__item statusbar__cache">{t("status.cache", { pct: nowPct ?? "-" })}</span>
<span className="statusbar__sep">·</span>
<span className="statusbar__item statusbar__avg">{t("status.cacheAvg", { pct: avgPct ?? "-" })}</span>
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.spendTitle")}>
<span className="statusbar__item statusbar__cost">
{t("status.cost", { amount: costLabel })}
</span>
</Tooltip>
<span className="statusbar__sep">·</span>
<JobsChip jobs={jobsList} />
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.balanceTitle")}>
<span className="statusbar__item statusbar__balance">
{t("status.balance", { amount: balance?.available && balance.display ? balance.display : "-" })}
</span>
</Tooltip>
<span className="statusbar__spacer" />
{mode === "plan" && <span className="statusbar__plan">{t("status.plan")}</span>}
</div>
+268
View File
@@ -0,0 +1,268 @@
// TabBar renders the browser-like workspace tab strip. Each tab represents one
// open project/global topic, so switching tabs switches the active conversation.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
import { FileText, Plus, X } from "lucide-react";
import type { TabMeta } from "../lib/types";
import { projectColorValue } from "../lib/projectColors";
import { useT } from "../lib/i18n";
import { Tooltip } from "./Tooltip";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
interface TabBarProps {
tabs: TabMeta[];
activeTabId?: string;
onTabChange: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onTabsClose: (tabIds: string[], nextActiveTabId?: string) => void;
onTabsReorder: (tabIds: string[]) => void;
onNewTab: () => void;
revealActiveSignal?: number;
}
type DropSide = "before" | "after";
function tabDisplayTitle(tab: TabMeta): string {
if (tab.tabType === "file" || tab.scope === "file") return tab.topicTitle?.trim() || tab.filePath?.split("/").filter(Boolean).pop() || "File";
const title = tab.topicTitle?.trim();
if (tab.scope === "global") return title || "Global";
return title || "Untitled";
}
function tabFullTitle(tab: TabMeta): string {
if (tab.tabType === "file" || tab.scope === "file") return tab.filePath || tabDisplayTitle(tab);
if (tab.scope === "global") {
const title = tabDisplayTitle(tab);
const workspaceName = tab.workspaceName?.trim() || "Global";
return title === workspaceName ? workspaceName : `${workspaceName} / ${title}`;
}
const workspaceName = tab.workspaceName?.trim() || "Project";
return `${workspaceName} / ${tabDisplayTitle(tab)}`;
}
function tabMode(tab: TabMeta): "normal" | "plan" | "yolo" {
return tab.mode === "plan" || tab.mode === "yolo" ? tab.mode : "normal";
}
function projectAccentStyle(color?: string): CSSProperties | undefined {
const value = projectColorValue(color);
if (!value) return undefined;
return { "--project-accent": value } as CSSProperties;
}
export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, revealActiveSignal = 0 }: TabBarProps) {
const t = useT();
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{ id: string; side: DropSide } | null>(null);
const [menuTabId, setMenuTabId] = useState<string | null>(null);
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const suppressClickRef = useRef(false);
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const backendActiveTabId = tabs.find((tab) => tab.active)?.id;
const activeTabIdExists = Boolean(activeTabId && tabs.some((tab) => tab.id === activeTabId));
const resolvedActiveTabId = activeTabIdExists ? activeTabId : backendActiveTabId;
const tabOrderKey = tabs.map((tab) => tab.id).join("\u0000");
useEffect(() => {
if (!resolvedActiveTabId) return;
const frame = window.requestAnimationFrame(() => {
tabRefs.current.get(resolvedActiveTabId)?.scrollIntoView({
block: "nearest",
inline: "nearest",
});
});
return () => window.cancelAnimationFrame(frame);
}, [backendActiveTabId, resolvedActiveTabId, revealActiveSignal, tabOrderKey]);
const handleClose = (tabId: string) => {
onTabClose(tabId);
};
const clearDragState = () => {
setDraggingTabId(null);
setDropTarget(null);
};
const dropSideForEvent = (event: DragEvent<HTMLButtonElement>): DropSide => {
const rect = event.currentTarget.getBoundingClientRect();
return event.clientX > rect.left + rect.width / 2 ? "after" : "before";
};
const reorderTabIds = (draggedId: string, targetId: string, side: DropSide): string[] => {
const ids = tabs.map((tab) => tab.id);
const from = ids.indexOf(draggedId);
const target = ids.indexOf(targetId);
if (from < 0 || target < 0 || draggedId === targetId) return ids;
const next = ids.filter((id) => id !== draggedId);
const targetAfterRemoval = next.indexOf(targetId);
const insertAt = side === "after" ? targetAfterRemoval + 1 : targetAfterRemoval;
next.splice(insertAt, 0, draggedId);
return next;
};
const handleDragStart = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
setDraggingTabId(tabId);
setDropTarget(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tabId);
};
const handleDragOver = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
if (!draggingTabId || draggingTabId === tabId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDropTarget({ id: tabId, side: dropSideForEvent(event) });
};
const handleDrop = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
event.preventDefault();
const draggedId = draggingTabId || event.dataTransfer.getData("text/plain");
const side = dropTarget?.id === tabId ? dropTarget.side : dropSideForEvent(event);
clearDragState();
if (!draggedId || draggedId === tabId) return;
const next = reorderTabIds(draggedId, tabId, side);
if (next.join("\u0000") !== tabs.map((tab) => tab.id).join("\u0000")) {
suppressClickRef.current = true;
onTabsReorder(next);
}
};
const handleTabClick = (tabId: string) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
onTabChange(tabId);
};
const openTabMenu = (event: ReactMouseEvent<HTMLButtonElement> | ReactKeyboardEvent<HTMLButtonElement>, tabId: string) => {
event.preventDefault();
event.stopPropagation();
setMenuTabId(tabId);
setMenuPoint(contextMenuPointFromEvent(event));
};
const closeTabMenu = () => {
setMenuTabId(null);
setMenuPoint(null);
};
const closeTabsFromMenu = (tabIds: string[], nextActiveTabId?: string) => {
closeTabMenu();
onTabsClose(tabIds, nextActiveTabId);
};
const menuTabIndex = menuTabId ? tabs.findIndex((tab) => tab.id === menuTabId) : -1;
const tabMenuItems: ContextMenuItem[] = menuTabId && menuTabIndex >= 0
? [
{
key: "close-current",
label: t("tabBar.closeTab"),
disabled: tabs.length <= 1,
onSelect: () => closeTabsFromMenu([menuTabId]),
},
{
key: "close-other",
label: t("tabBar.closeOtherTabs"),
disabled: tabs.length <= 1,
onSelect: () => closeTabsFromMenu(tabs.filter((tab) => tab.id !== menuTabId).map((tab) => tab.id), menuTabId),
},
{
key: "close-right",
label: t("tabBar.closeTabsToRight"),
disabled: menuTabIndex >= tabs.length - 1,
onSelect: () => {
const rightTabIds = tabs.slice(menuTabIndex + 1).map((tab) => tab.id);
const nextActiveTabId = resolvedActiveTabId && rightTabIds.includes(resolvedActiveTabId) ? menuTabId : undefined;
closeTabsFromMenu(rightTabIds, nextActiveTabId);
},
},
]
: [];
return (
<div className="tabbar">
<div className="tabbar__tabs">
{tabs.map((tab) => {
const displayTitle = tabDisplayTitle(tab);
const fullTitle = tabFullTitle(tab);
const mode = tabMode(tab);
const stateTitle = [
tab.running ? "Running" : "",
mode === "yolo" ? "YOLO" : mode === "plan" ? "Plan" : "",
].filter(Boolean).join(" · ");
const annotatedTitle = stateTitle ? `${stateTitle} · ${fullTitle}` : fullTitle;
return (
<button
key={tab.id}
ref={(node) => {
if (node) {
tabRefs.current.set(tab.id, node);
} else {
tabRefs.current.delete(tab.id);
}
}}
draggable
className={[
"tabbar__tab",
tab.id === resolvedActiveTabId ? "tabbar__tab--active" : "",
mode === "yolo" ? "tabbar__tab--yolo" : "",
draggingTabId === tab.id ? "tabbar__tab--dragging" : "",
dropTarget?.id === tab.id ? `tabbar__tab--drop-${dropTarget.side}` : "",
].filter(Boolean).join(" ")}
title={annotatedTitle}
aria-label={annotatedTitle}
style={projectAccentStyle(tab.projectColor)}
onClick={() => handleTabClick(tab.id)}
onContextMenu={(event) => openTabMenu(event, tab.id)}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openTabMenu(event, tab.id);
}
}}
onDragStart={(event) => handleDragStart(event, tab.id)}
onDragOver={(event) => handleDragOver(event, tab.id)}
onDrop={(event) => handleDrop(event, tab.id)}
onDragEnd={clearDragState}
>
{tab.tabType === "file" || tab.scope === "file" ? (
<FileText size={12} className="tabbar__file-icon" />
) : (
<span
className={[
"tabbar__status",
tab.running ? "tabbar__status--running" : "",
].filter(Boolean).join(" ")}
/>
)}
<span className="tabbar__tab-label">{displayTitle}</span>
{mode === "yolo" && <span className="tabbar__mode-badge tabbar__mode-badge--yolo">yolo</span>}
<span
className="tabbar__tab-close"
onClick={(e) => {
e.stopPropagation();
handleClose(tab.id);
}}
>
<X size={10} />
</span>
</button>
);
})}
</div>
<Tooltip label={t("tabBar.newSession")}>
<button className="tabbar__new" type="button" aria-label={t("tabBar.newSession")} onClick={onNewTab}>
<Plus size={13} />
</button>
</Tooltip>
<ContextMenu
open={Boolean(menuTabId)}
point={menuPoint}
items={tabMenuItems}
minWidth={170}
ariaLabel={t("tabBar.tabActions")}
onClose={closeTabMenu}
/>
</div>
);
}
+18 -4
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, RefreshCw, X } from "lucide-react";
import { useT } from "../lib/i18n";
import type { Todo } from "../lib/tools";
import { Tooltip } from "./Tooltip";
@@ -11,14 +11,21 @@ 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, onDismiss }: { todos: Todo[]; onDismiss: () => void }) {
export function TodoPanel({ todos, stale, onDismiss }: { todos: Todo[]; stale?: boolean; onDismiss: () => void }) {
const t = useT();
const [open, setOpen] = useState(true);
if (todos.length === 0) return null;
const currentRef = useRef<HTMLLIElement | null>(null);
const done = todos.filter((t) => t.status === "completed").length;
const current = todos.find((t) => t.status === "in_progress");
useEffect(() => {
if (!open) return;
currentRef.current?.scrollIntoView({ block: "nearest" });
}, [open, current?.content, current?.activeForm]);
if (todos.length === 0) return null;
return (
<div className="todobar">
<div className="todobar__head">
@@ -28,6 +35,12 @@ export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () =
<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>
)}
@@ -44,6 +57,7 @@ export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () =
{todos.map((t, i) => (
<li
key={i}
ref={t.status === "in_progress" ? currentRef : undefined}
className={`todobar__item todobar__item--${t.status}${t.level ? " todobar__item--sub" : ""}`}
>
{t.status === "completed" ? (
+43 -3
View File
@@ -1,4 +1,4 @@
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import {
Ban,
Check,
@@ -19,6 +19,7 @@ import { CodeViewer } from "./CodeViewer";
import { DiffView } from "./DiffView";
import { useT } from "../lib/i18n";
import { diffsFor, subjectOf, summarize } from "../lib/tools";
import { useShellExpand } from "../lib/shellExpand";
import type { Item } from "../lib/useController";
type ToolItem = Extract<Item, { kind: "tool" }>;
@@ -36,6 +37,9 @@ const ICONS: Record<string, LucideIcon> = {
task: ListTree,
};
/** Lines shown by default in a shell output block before the "show all" button. */
const SHELL_PREVIEW_LINES = 10;
function pretty(json: string): string {
try {
return JSON.stringify(JSON.parse(json), null, 2);
@@ -51,6 +55,14 @@ function StatusGlyph({ status }: { status: ToolItem["status"] }) {
return <Check className="ico ico--ok" size={13} />;
}
/** Returns the first n lines of text and the total line count. */
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
const lines = text.split("\n");
const total = lines.length;
if (total <= n) return { preview: text, total, hasMore: false };
return { preview: lines.slice(0, n).join("\n"), total, hasMore: true };
}
// ToolCard renders one tool call. `subcalls` are sub-agent calls nested under a
// `task` card (their ParentID points at this call); they render inline, live, so
// the sub-agent's work is visible as it happens.
@@ -72,10 +84,20 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
// 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.
const hasBody = diffs.length === 0 && (!!item.args || !!item.output);
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(item.isShell && hasBody);
const [showAll, setShowAll] = useState(false);
const expandable = hasBody;
// Register this shell card's toggle with the global ShellExpand context so
// Ctrl/Cmd+B can expand/collapse the most recent shell output.
const shellExpand = useShellExpand();
useEffect(() => {
if (!item.isShell || !shellExpand) return;
return shellExpand.register(item.id, () => setOpen((v) => !v));
}, [item.isShell, item.id, shellExpand]);
// Read-only "research" calls (read/grep/ls/glob/web_fetch) are quieted to a
// slim, borderless, dim row so a long run of them doesn't bury the few calls
// that matter — writers, bash, sub-agents, and anything that failed keep the
@@ -83,6 +105,10 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
const quiet =
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
// Shell output: split into preview + "show all" toggle.
const shellOutput = item.isShell && item.output ? item.output : null;
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
return (
<div className={`tool tool--${item.status} ${quiet ? "tool--quiet" : ""}`}>
<div
@@ -119,7 +145,21 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
</div>
)}
{hasBody && open && (
{/* Shell output: always visible (auto-open), with preview/show-all toggle */}
{shellPreview && open && (
<div className="tool__body">
<CodeViewer value={showAll ? shellOutput! : shellPreview.preview} maxHeight={showAll ? 480 : 260} />
{shellPreview.hasMore && !showAll && (
<button className="tool__showall" onClick={() => setShowAll(true)}>
{t("tool.showAllLines", { n: shellPreview.total })}
</button>
)}
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
</div>
)}
{/* Non-shell body: args + output, gated by open */}
{!shellPreview && hasBody && open && (
<div className="tool__body">
{item.args && <CodeViewer value={pretty(item.args)} language="json" maxHeight={180} />}
{item.output && (
+205 -14
View File
@@ -1,20 +1,34 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { type CSSProperties, type MouseEvent as ReactMouseEvent, useEffect, useMemo, useRef, useState } from "react";
import type { Item, LiveStream } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
import { useT } from "../lib/i18n";
import { AssistantMessage, UserMessage } from "./Message";
import { ToolCard } from "./ToolCard";
import { Welcome } from "./Welcome";
type ToolItem = Extract<Item, { kind: "tool" }>;
type QuestionAnchor = { id: string; text: string; turn: number };
const QUESTION_NAV_MIN_COUNT = 2;
function questionAnchorId(id: string): string {
return `question-anchor-${id}`;
}
function compactQuestionText(text: string): string {
const cleaned = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]").replace(/\s+/g, " ").trim();
if (cleaned.length <= 80) return cleaned;
return cleaned.slice(0, 80);
}
function scrollVersion(items: Item[]): string {
return items
.map((it) => {
switch (it.kind) {
case "assistant":
return `${it.id}:a:${it.text.length}:${it.reasoning.length}:${it.streaming ? 1 : 0}`;
return `${it.id}:a:${it.text?.length ?? 0}:${it.reasoning?.length ?? 0}:${it.streaming ? 1 : 0}`;
case "tool":
return `${it.id}:t:${it.name}:${it.status}:${it.args.length}:${it.output?.length ?? 0}:${it.error?.length ?? 0}:${it.truncated ? 1 : 0}`;
return `${it.id}:t:${it.name}:${it.status}:${it.args?.length ?? 0}:${it.output?.length ?? 0}:${it.error?.length ?? 0}:${it.truncated ? 1 : 0}`;
default:
return `${it.id}:${it.kind}`;
}
@@ -45,12 +59,20 @@ export function Transcript({
footerHeight = 0,
onPrompt,
onRewind,
checkpoints = [],
actionPending = false,
rewindDisabled = false,
questionNavigator = true,
}: {
items: Item[];
live?: LiveStream;
footerHeight?: number;
onPrompt: (text: string) => void;
onRewind?: (turn: number, scope: string) => void;
checkpoints?: CheckpointMeta[];
actionPending?: boolean;
rewindDisabled?: boolean;
questionNavigator?: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// stick tracks whether the view is pinned to the bottom; once the user scrolls
@@ -60,6 +82,18 @@ export function Transcript({
const lastClientHeight = useRef<number | null>(null);
const lastFooterHeight = useRef<number | null>(null);
const questions = useMemo<QuestionAnchor[]>(() => {
const anchors: QuestionAnchor[] = [];
let turn = 0;
for (const it of items) {
if (it.kind !== "user") continue;
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn });
turn += 1;
}
return anchors;
}, [items]);
const showQuestionNav = questionNavigator && questions.length >= QUESTION_NAV_MIN_COUNT;
const onScroll = () => {
const el = scrollRef.current;
if (el) stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
@@ -82,7 +116,7 @@ export function Transcript({
el.scrollTop = el.scrollHeight;
});
return () => cancelAnimationFrame(id);
}, [contentVersion, live?.text.length, live?.reasoning.length]);
}, [contentVersion, live?.text?.length ?? 0, live?.reasoning?.length ?? 0]);
useEffect(() => {
const el = scrollRef.current;
@@ -148,15 +182,37 @@ export function Transcript({
// Each user message's turn = its ordinal among user messages, so a rewind
// targets the matching checkpoint.
const userTurn = new Map<string, number>();
let nt = 0;
for (const it of items) {
if (it.kind === "user") userTurn.set(it.id, nt++);
}
const userTurn = useMemo(() => new Map(questions.map((question) => [question.id, question.turn])), [questions]);
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
const jumpToQuestion = (question: QuestionAnchor) => {
const el = scrollRef.current;
const node = document.getElementById(questionAnchorId(question.id));
if (!el || !node) return;
stick.current = false;
if (resizeFrame.current !== null) {
cancelAnimationFrame(resizeFrame.current);
resizeFrame.current = null;
}
const scrollerRect = el.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const top = el.scrollTop + nodeRect.top - scrollerRect.top - 12;
el.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
};
const empty = items.length === 0;
return (
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
{items.length === 0 && <Welcome onPrompt={onPrompt} />}
<div
className={`transcript${empty ? " transcript--empty" : ""}`}
ref={scrollRef}
onScroll={onScroll}
>
{empty && <Welcome onPrompt={onPrompt} />}
{!empty && showQuestionNav && (
<QuestionJumpBar questions={questions} onJump={jumpToQuestion} />
)}
{items.map((it) => {
switch (it.kind) {
@@ -167,9 +223,13 @@ export function Transcript({
key={it.id}
text={it.text}
turn={tn}
open={tn != null && openTurn === tn}
onToggle={() => setOpenTurn((cur) => (cur === tn ? null : (tn ?? null)))}
onRewind={(turn, scope) => {
anchorId={questionAnchorId(it.id)}
open={tn != null && openTurn === tn}
onToggle={() => setOpenTurn((cur) => (cur === tn ? null : (tn ?? null)))}
checkpoint={tn != null ? checkpointsByTurn.get(tn) : undefined}
actionPending={actionPending}
rewindDisabled={rewindDisabled}
onRewind={(turn, scope) => {
onRewind?.(turn, scope);
setOpenTurn(null);
}}
@@ -207,6 +267,137 @@ export function Transcript({
);
}
function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; onJump: (question: QuestionAnchor) => void }) {
const t = useT();
const [hovered, setHovered] = useState<number | null>(null);
const [active, setActive] = useState<number | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const previewTop = useRef(0);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
if (questions.length === 0) return;
setActive((cur) => {
if (cur !== null && questions.some((question) => question.turn === cur)) return cur;
return questions[questions.length - 1]?.turn ?? null;
});
}, [questions]);
useEffect(() => {
if (active === null) return;
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [active]);
const hoverIdx = hovered !== null ? questions.findIndex((question) => question.turn === hovered) : -1;
const hoveredQuestion = hovered !== null ? questions.find((question) => question.turn === hovered) : undefined;
const closestQuestionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
const el = barRef.current;
if (!el) return null;
const markers = el.querySelectorAll<HTMLElement>(".jump-item");
const barRect = el.getBoundingClientRect();
let closest = -1;
let closestDist = Infinity;
let closestY = 0;
markers.forEach((item, index) => {
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dist = Math.abs(clientY - midY);
if (dist < closestDist) {
closestDist = dist;
closest = index;
closestY = midY - barRect.top;
}
});
const question = questions[closest];
if (!question) return null;
return { question, previewY: closestY };
};
const onMove = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
setShowPreview(true);
};
const scrollTo = (question: QuestionAnchor) => {
setActive(question.turn);
onJump(question);
};
const onRailMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
e.preventDefault();
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
setShowPreview(true);
scrollTo(closest.question);
};
const onItemMouseDown = (e: ReactMouseEvent<HTMLButtonElement>, question: QuestionAnchor) => {
e.preventDefault();
scrollTo(question);
};
const dotProps = (
idx: number,
turn: number,
): { style: CSSProperties; "data-d"?: string } => {
const isActive = active === turn;
if (hoverIdx < 0) {
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
}
const d = Math.abs(idx - hoverIdx);
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
return {
style: { width, transitionDelay: `${d * 20}ms`, background },
"data-d": d <= 2 ? String(d) : undefined,
};
};
return (
<nav
className="jump-bar"
ref={barRef}
aria-label={t("questionNav.label")}
onMouseMove={onMove}
onMouseLeave={() => {
setHovered(null);
setShowPreview(false);
}}
>
<div className="jump-scroll" onMouseDown={onRailMouseDown} onClick={onRailMouseDown}>
{questions.map((question, index) => (
<button
className="jump-item"
key={question.id}
type="button"
data-turn={question.turn}
aria-label={t("questionNav.jump", { n: question.turn + 1 })}
onMouseDown={(e) => onItemMouseDown(e, question)}
onClick={(e) => {
e.stopPropagation();
if (e.detail === 0) scrollTo(question);
}}
>
<span className="jump-dot" {...dotProps(index, question.turn)} />
</button>
))}
</div>
{showPreview && hoveredQuestion && (
<div className="jump-preview" style={{ top: previewTop.current }} role="tooltip">
<span className="jump-text">{hoveredQuestion.text}</span>
</div>
)}
</nav>
);
}
type CompactionItem = Extract<Item, { kind: "compaction" }>;
// CompactionCard marks a context-compaction boundary in the transcript. While
+2 -3
View File
@@ -1,4 +1,4 @@
import logo from "../assets/logo.svg";
import logoWordmark from "../assets/logo-wordmark.svg";
import { useT } from "../lib/i18n";
// Welcome is the empty-state landing: brand, a one-liner, the input affordances
@@ -10,8 +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={logo} className="welcome__logo" alt="Reasonix" />
<div className="welcome__title">Reasonix</div>
<img src={logoWordmark} className="welcome__logo" alt="Reasonix" />
<div className="welcome__tag">{t("welcome.tagline")}</div>
<div className="welcome__hints">
+248 -136
View File
@@ -9,16 +9,14 @@ import type {
import {
ChevronDown,
ChevronRight,
Columns2,
FileText,
Folder,
FolderTree,
FolderX,
GitBranch,
Maximize2,
MessageSquarePlus,
Minus,
Minimize2,
PanelRightClose,
Plus,
RefreshCw,
Search,
X,
@@ -29,16 +27,22 @@ import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import type { DirEntry, FilePreview, WorkspaceChangeView, WorkspaceChangesView } from "../lib/types";
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
import { CodeViewer } from "./CodeViewer";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
import { Markdown } from "./Markdown";
import { Tooltip } from "./Tooltip";
import { AnchoredPopover } from "./AnchoredPopover";
const WORKSPACE_TREE_MIN_WIDTH = 220;
const WORKSPACE_TREE_DEFAULT_WIDTH = WORKSPACE_TREE_MIN_WIDTH;
const WORKSPACE_TREE_MAX_WIDTH = 420;
const WORKSPACE_PREVIEW_MIN_WIDTH = 420;
const WORKSPACE_TREE_MIN_WIDTH = 208;
const WORKSPACE_TREE_DEFAULT_WIDTH = 240;
const WORKSPACE_TREE_MAX_WIDTH = 340;
const WORKSPACE_PREVIEW_MIN_WIDTH = 360;
const WORKSPACE_PREVIEW_TARGET_WIDTH = 480;
const WORKSPACE_DUAL_PANEL_MIN_WIDTH = WORKSPACE_TREE_MIN_WIDTH + WORKSPACE_PREVIEW_MIN_WIDTH;
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 92;
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 48;
const WORKSPACE_MAX_PREVIEW_TABS = 5;
function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
const maxForPanel =
@@ -155,7 +159,10 @@ export function WorkspacePanel({
onToggleMaximized,
onPreviewModeChange,
onAddToChat,
changesRefreshKey,
onRequestPanelWidth,
refreshKey,
initialViewMode = "files",
showViewTabs = true,
}: {
open: boolean;
cwd?: string;
@@ -165,7 +172,10 @@ export function WorkspacePanel({
onToggleMaximized: () => void;
onPreviewModeChange?: (active: boolean) => void;
onAddToChat?: (text: string) => void;
changesRefreshKey?: number;
onRequestPanelWidth?: (width: number) => void;
refreshKey?: number;
initialViewMode?: "files" | "changed";
showViewTabs?: boolean;
}) {
const t = useT();
const panelRef = useRef<HTMLElement>(null);
@@ -177,16 +187,24 @@ export function WorkspacePanel({
const [openTabs, setOpenTabs] = useState<string[]>([]);
const [preview, setPreview] = useState<FilePreview | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [viewMode, setViewMode] = useState<"files" | "changed">("files");
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
const [changes, setChanges] = useState<WorkspaceChangesView | null>(null);
const [loadingChanges, setLoadingChanges] = 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);
const [treeResizing, setTreeResizing] = useState(false);
const [recentOpen, setRecentOpen] = useState(false);
const recentAnchorRef = useRef<HTMLButtonElement>(null);
const openDirsRef = useRef(openDirs);
useEffect(() => {
openDirsRef.current = openDirs;
}, [openDirs]);
const loadDir = useCallback(async (dir: string) => {
const entries = await app.ListDir(dir).catch(() => []);
@@ -211,16 +229,17 @@ export function WorkspacePanel({
const selectFile = useCallback(
(path: string) => {
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
setSelectedPath(path);
setFilter("");
setOpenTabs((tabs) => (tabs.includes(path) ? tabs : [...tabs, path]));
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
const dirs = parentDirs(path);
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
},
[entriesByDir, loadDir],
[entriesByDir, loadDir, onRequestPanelWidth],
);
useEffect(() => {
@@ -235,13 +254,26 @@ export function WorkspacePanel({
setTreeMenu(null);
setFilter("");
setTreeVisible(true);
setViewMode(initialViewMode);
void loadDir("");
}, [cwd, loadDir, open]);
}, [cwd, initialViewMode, loadDir, open]);
useEffect(() => {
if (!open) return;
setViewMode(initialViewMode);
if (initialViewMode === "changed") void loadChanges();
}, [initialViewMode, loadChanges, open]);
useEffect(() => {
if (!open) return;
void loadChanges();
}, [changesRefreshKey, cwd, loadChanges, open]);
}, [cwd, loadChanges, open]);
useEffect(() => {
if (!open || !refreshKey) return;
void loadChanges();
openDirsRef.current.forEach((dir) => void loadDir(dir));
}, [loadChanges, loadDir, open, refreshKey]);
useEffect(() => {
if (!selectionMenu && !treeMenu) return;
@@ -262,6 +294,19 @@ export function WorkspacePanel({
};
}, [selectionMenu, treeMenu]);
const refreshWorkspaceList = useCallback(() => {
setTreeBlankMenuPoint(null);
setSelectionMenu(null);
setTreeMenu(null);
if (viewMode === "changed") {
void loadChanges();
return;
}
const dirs = Array.from(openDirsRef.current);
setEntriesByDir({});
dirs.forEach((dir) => void loadDir(dir));
}, [loadChanges, loadDir, viewMode]);
const refreshSelected = useCallback(() => {
if (!selectedPath) return;
let live = true;
@@ -312,16 +357,6 @@ export function WorkspacePanel({
[entriesByDir, loadDir],
);
const openPickerTab = () => {
setSelectedPath(null);
setPreview(null);
setFilter("");
setSelectionMenu(null);
setTreeMenu(null);
setTreeVisible(true);
requestAnimationFrame(() => filterRef.current?.focus());
};
const closeTab = (path: string) => {
setOpenTabs((tabs) => {
const next = tabs.filter((tab) => tab !== path);
@@ -334,6 +369,7 @@ export function WorkspacePanel({
}
setSelectionMenu(null);
setTreeMenu(null);
setRecentOpen(false);
}
return next;
});
@@ -341,6 +377,9 @@ export function WorkspacePanel({
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
const pathParts = selectedPath?.split("/").filter(Boolean) ?? [];
const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
const currentFileDir = selectedPath ? parentPath(selectedPath) : "";
const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
const flattened = useMemo(() => {
const rows: { path: string; entry: DirEntry }[] = [];
for (const [dir, entries] of Object.entries(entriesByDir)) {
@@ -361,14 +400,25 @@ export function WorkspacePanel({
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 effectiveTreeWidth = useMemo(() => clampWorkspaceTreeWidth(treeWidth, panelWidth), [panelWidth, treeWidth]);
const previewVisible = openTabs.length > 0 || selectedPath !== null;
const selectedFileVisible = selectedPath !== null;
const compactTreeSplit =
treeVisible && selectedFileVisible && panelWidth !== undefined && panelWidth < WORKSPACE_DUAL_PANEL_MIN_WIDTH;
const actualTreeVisible = treeVisible;
const previewModeActive = open && previewVisible;
const embeddedDockMode = !showViewTabs;
const showFileTools = showViewTabs || previewVisible;
const panelStyle = useMemo(
() => ({ "--workspace-tree-width": `${effectiveTreeWidth}px` }) as CSSProperties,
[effectiveTreeWidth],
() =>
({
"--workspace-tree-width": `${effectiveTreeWidth}px`,
"--workspace-preview-min-width": compactTreeSplit ? "0px" : `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
}) as CSSProperties,
[compactTreeSplit, effectiveTreeWidth],
);
useEffect(() => {
@@ -405,7 +455,7 @@ export function WorkspacePanel({
setTreeResizing(true);
let nextWidth = effectiveTreeWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = clampWorkspaceTreeWidth(rect.right - moveEvent.clientX, rect.width);
nextWidth = clampWorkspaceTreeWidth(moveEvent.clientX - rect.left, rect.width);
setTreeWidth(nextWidth);
};
const onDone = () => {
@@ -431,7 +481,7 @@ export function WorkspacePanel({
(event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowLeft" ? 16 : -16));
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowRight" ? 16 : -16));
} else if (event.key === "Home") {
event.preventDefault();
setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
@@ -474,10 +524,21 @@ export function WorkspacePanel({
const openTreeMenu = (event: ReactMouseEvent<HTMLElement>, path: string, isDir: boolean) => {
event.preventDefault();
event.stopPropagation();
setTreeBlankMenuPoint(null);
setSelectionMenu(null);
setTreeMenu({ x: event.clientX, y: event.clientY, path, isDir });
};
const openTreeBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null;
if (target?.closest(".workspace-tree__row,.workspace-change,button,input,textarea,select")) return;
event.preventDefault();
event.stopPropagation();
setSelectionMenu(null);
setTreeMenu(null);
setTreeBlankMenuPoint(contextMenuPointFromEvent(event));
};
const startTreeDrag = (event: ReactDragEvent<HTMLElement>, path: string, isDir: boolean) => {
const ref = formatWorkspaceReference(path, isDir);
event.dataTransfer.effectAllowed = "copy";
@@ -550,32 +611,31 @@ export function WorkspacePanel({
const isOpen = openDirs.has(path);
const active = selectedPath === path;
const row = (
<Tooltip key={path} label={path} fill>
<button
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{entry.isDir ? (
isOpen ? (
<ChevronDown size={13} className="workspace-tree__chev" />
) : (
<ChevronRight size={13} className="workspace-tree__chev" />
)
<button
key={path}
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{entry.isDir ? (
isOpen ? (
<ChevronDown size={13} className="workspace-tree__chev" />
) : (
<span className="workspace-tree__chev" />
)}
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<FileText size={14} className="workspace-tree__icon" />
)}
<span className="workspace-tree__name">{entry.name}</span>
</button>
</Tooltip>
<ChevronRight size={13} className="workspace-tree__chev" />
)
) : (
<span className="workspace-tree__chev" />
)}
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<FileText size={14} className="workspace-tree__icon" />
)}
<span className="workspace-tree__name">{entry.name}</span>
</button>
);
if (!entry.isDir || !isOpen) return [row];
return [row, ...renderRows(path, depth + 1)];
@@ -583,50 +643,42 @@ export function WorkspacePanel({
};
const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
const treeBlankMenuItems: ContextMenuItem[] = [
{
key: "refresh-tree",
icon: <RefreshCw size={13} />,
label: t(viewMode === "changed" ? "workspace.refreshChanges" : "workspace.refreshTree"),
onSelect: refreshWorkspaceList,
},
];
return (
<aside
ref={panelRef}
className={`workspace-panel${treeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${compactTreeSplit ? " workspace-panel--compact-split" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
aria-label={t("workspace.title")}
style={panelStyle}
>
{previewVisible && <section className="workspace-preview">
<header className="workspace-preview__head">
<div className="workspace-tabs">
{openTabs.map((tab) => (
<Tooltip key={tab} label={tab}>
<button
className={`workspace-tab${selectedPath === tab ? " workspace-tab--active" : ""}`}
onClick={() => setSelectedPath(tab)}
>
<FileText size={14} className="workspace-tab__icon" />
<span className="workspace-tab__name">{basename(tab)}</span>
<span
className="workspace-tab__close"
role="button"
tabIndex={0}
aria-label={t("workspace.closeTab")}
onClick={(e) => {
e.stopPropagation();
closeTab(tab);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
closeTab(tab);
}
}}
>
<X size={12} />
</span>
</button>
<div className="workspace-current-file" aria-label={t("workspace.currentFile")}>
<FileText size={15} className="workspace-current-file__icon" />
<div className="workspace-current-file__text">
<Tooltip label={selectedPath ?? undefined}>
<span className="workspace-current-file__name">{currentFileName}</span>
</Tooltip>
))}
<Tooltip label={t("workspace.newTab")}>
<button className="workspace-tab workspace-tab--new" onClick={openPickerTab}>
<Plus size={14} />
{currentFileDir && <span className="workspace-current-file__path">{currentFileDir}</span>}
</div>
<Tooltip label={t("workspace.recentFiles")}>
<button
ref={recentAnchorRef}
className={`workspace-current-file__recent${recentOpen ? " workspace-current-file__recent--open" : ""}`}
type="button"
aria-label={t("workspace.recentFiles")}
aria-expanded={recentOpen}
onClick={() => setRecentOpen((open) => !open)}
>
<ChevronDown size={13} />
</button>
</Tooltip>
</div>
@@ -637,20 +689,44 @@ export function WorkspacePanel({
{maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</Tooltip>
<Tooltip label={t("workspace.minimize")}>
<button className="workspace-iconbtn" onClick={onClose}>
<Minus size={15} />
</button>
</Tooltip>
<Tooltip label={treeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
onClick={() => setTreeVisible((value) => !value)}
>
{treeVisible ? <PanelRightClose size={15} /> : <Columns2 size={15} />}
</button>
</Tooltip>
{selectedPath && (
<Tooltip label={t("workspace.closePreview")}>
<button className="workspace-iconbtn" onClick={() => closeTab(selectedPath)}>
<X size={15} />
</button>
</Tooltip>
)}
</div>
<AnchoredPopover
open={recentOpen}
anchorRef={recentAnchorRef}
onClose={() => setRecentOpen(false)}
className="workspace-recent-menu"
align="start"
offset={6}
placement="bottom"
>
<div className="workspace-recent-menu__title">{t("workspace.recentFiles")}</div>
<div className="workspace-recent-menu__list">
{recentFiles.map((path) => (
<button
key={path}
type="button"
className={`workspace-recent-menu__item${path === selectedPath ? " workspace-recent-menu__item--active" : ""}`}
onClick={() => {
setSelectedPath(path);
setRecentOpen(false);
}}
>
<FileText size={14} />
<span>
<span className="workspace-recent-menu__name">{basename(path)}</span>
<span className="workspace-recent-menu__path">{parentPath(path)}</span>
</span>
</button>
))}
</div>
</AnchoredPopover>
</header>
<div className="workspace-preview__meta">
@@ -660,6 +736,7 @@ export function WorkspacePanel({
onClick={() => {
setFilter("");
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
}}
>
@@ -678,6 +755,7 @@ export function WorkspacePanel({
onClick={() => {
if (isLast) return;
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
setFilter("");
setOpenDirs((prev) => new Set([...Array.from(prev), ...breadcrumbDirs, dir]));
void loadDir(dir);
@@ -727,7 +805,25 @@ export function WorkspacePanel({
</div>
</section>}
{treeVisible && previewVisible && (
{previewVisible && !actualTreeVisible && (
<section className="workspace-tree-rail" aria-label={t("workspace.showTree")}>
<Tooltip label={t("workspace.showTree")} side="right">
<button
className="workspace-tree-reveal workspace-iconbtn workspace-iconbtn--on"
type="button"
aria-label={t("workspace.showTree")}
onClick={() => {
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
}}
>
<FolderTree size={15} />
</button>
</Tooltip>
</section>
)}
{actualTreeVisible && previewVisible && (
<button
className="workspace-tree-resizer"
type="button"
@@ -744,48 +840,56 @@ export function WorkspacePanel({
)}
<section className="workspace-files">
<div className="workspace-files__tools">
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
onClick={hideTreeOrClosePanel}
>
<PanelRightClose size={15} />
</button>
</Tooltip>
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
<button
className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => setViewMode("files")}
>
{t("workspace.filesTab")}
</button>
<button
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => {
setViewMode("changed");
void loadChanges();
}}
>
<GitBranch size={13} />
{t("workspace.changedTab")}
</button>
{showFileTools && (
<div className={`workspace-files__tools${embeddedDockMode ? " workspace-files__tools--embedded" : ""}`}>
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
type="button"
aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
onClick={hideTreeOrClosePanel}
>
{previewVisible ? <FolderX size={15} /> : <X size={15} />}
</button>
</Tooltip>
{showViewTabs && (
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
<button
className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => setViewMode("files")}
>
{t("workspace.filesTab")}
</button>
<button
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => {
setViewMode("changed");
void loadChanges();
}}
>
<GitBranch size={13} />
{t("workspace.changedTab")}
</button>
</div>
)}
{showViewTabs && (
<Tooltip label={t("workspace.refreshChanges")}>
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
<RefreshCw size={14} />
</button>
</Tooltip>
)}
</div>
<Tooltip label={t("workspace.refreshChanges")}>
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
<RefreshCw size={14} />
</button>
</Tooltip>
</div>
)}
<div className="workspace-search">
<Search size={14} />
<input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={t("workspace.filter")} />
<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">
<div className="workspace-tree" onContextMenu={openTreeBlankMenu}>
{viewMode === "changed"
? renderChangedRows()
: flattened
@@ -842,6 +946,14 @@ export function WorkspacePanel({
/>
</FloatingMenu>
)}
<ContextMenu
open={Boolean(treeBlankMenuPoint)}
point={treeBlankMenuPoint}
items={treeBlankMenuItems}
minWidth={150}
ariaLabel={t("workspace.treeMenu")}
onClose={() => setTreeBlankMenuPoint(null)}
/>
</aside>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function asArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [];
}
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -1,6 +1,8 @@
// Last-resort crash surface: a React render error with no boundary unmounts the
// whole tree (blank window), and global errors/rejections leave no trace either.
import { t } from "./i18n";
function paint(text: string) {
let host = document.getElementById("crash-overlay");
if (!host) {
@@ -10,13 +12,13 @@ function paint(text: string) {
}
const title = document.createElement("div");
title.className = "crash-overlay__title";
title.textContent = "Reasonix hit an error — screenshot this and send it over";
title.textContent = t("crash.title");
const body = document.createElement("pre");
body.className = "crash-overlay__body";
body.textContent = text;
const copy = document.createElement("button");
copy.className = "crash-overlay__copy";
copy.textContent = "Copy";
copy.textContent = t("crash.copy");
copy.onclick = () => void navigator.clipboard?.writeText(text);
host.replaceChildren(title, body, copy);
}
+64 -2
View File
@@ -43,15 +43,77 @@ export function resolveLang(lang?: string): string {
return hljs.getLanguage(resolved) ? resolved : "";
}
// LRU cache for highlighted output. The same code block can re-render many
// times (Re-renders due to streaming updates that don't change this block,
// React's StrictMode double-invoke in dev, hover-reveals of the toolbar's
// child elements). highlight.highlight() is a real lexer walk that shows
// up in the profile for large blocks; a 200-entry LRU keyed on the
// resolved language plus a fast hash of the code keeps the steady-state
// cost at a Map.get(). The Map is a plain LRU, not a WeakMap, so a large
// (non-streaming) transcript will eventually evict; the size of 200 is
// chosen to cover the visible viewport plus a small overshoot — most
// transcripts re-render the same ~30 visible blocks.
const HL_CACHE_MAX = 200;
// djb2 hash for the cache key. We only use the hash inside the key
// (Map<number, string>), not as a security primitive; collisions are fine
// because we ALSO store the original code alongside the entry and verify
// before serving the cached value. The hash is what makes the key
// constant-time to compare for Map.get(); comparing the full source
// string would be O(n) on every render and dwarf the savings.
function hashCode(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return h;
}
interface CacheEntry {
code: string;
html: string;
}
const hlCache = new Map<number, CacheEntry>();
function cacheGet(code: string, lang: string): string | null {
const key = hashCode(lang + "\0" + code);
const e = hlCache.get(key);
if (!e) return null;
// Defend against the (rare) hash collision: the stored code must match
// the queried code exactly. We move the entry to the end of the Map to
// mark it most-recently-used.
if (e.code !== code) return null;
hlCache.delete(key);
hlCache.set(key, e);
return e.html;
}
function cachePut(code: string, lang: string, html: string): void {
const key = hashCode(lang + "\0" + code);
if (hlCache.has(key)) hlCache.delete(key); // refresh
hlCache.set(key, { code, html });
while (hlCache.size > HL_CACHE_MAX) {
// Map iteration is insertion-order; the first key is the oldest.
const oldest = hlCache.keys().next().value;
if (oldest === undefined) break;
hlCache.delete(oldest);
}
}
// highlightToHtml returns highlighted HTML (token <span>s) for the given code, or
// escaped plain text when the language is unknown. ignoreIllegals so partial /
// streaming snippets never throw.
// streaming snippets never throw. The LRU cache shaves the bulk of the work
// when a transcript re-renders the same blocks (most common: a streaming
// update changes the *next* block, not this one).
export function highlightToHtml(code: string, lang?: string): string {
const resolved = resolveLang(lang);
if (!resolved) return escapeHtml(code);
const cached = cacheGet(code, resolved);
if (cached !== null) return cached;
let html: string;
try {
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value;
html = hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value;
} catch {
return escapeHtml(code);
}
cachePut(code, resolved, html);
return html;
}
+20 -12
View File
@@ -5,9 +5,10 @@
// non-React code (lib/tools.ts) translate too; it stays fresh because the provider
// updates it on every render.
//
// Desktop UI language is intentionally frontend-only. The CLI/kernel may still
// have its own `language` config for prompts and terminal text, but switching the
// desktop setting must not rewrite config or rebuild the model controller.
// Desktop UI language is intentionally separate from the CLI/kernel `language`
// config for prompts and terminal text. The desktop preference is persisted in
// the user-level [desktop] config; localStorage is only read once for legacy
// migration from older desktop builds.
import { createContext, useCallback, useContext, useState } from "react";
import type { ReactNode } from "react";
@@ -15,6 +16,7 @@ import { en, type DictKey } from "../locales/en";
import { zh } from "../locales/zh";
export type Locale = "en" | "zh";
export type { DictKey };
// LangPref is the stored preference: "" means auto-detect from the OS.
export type LangPref = "" | "en" | "zh";
@@ -48,16 +50,23 @@ export function detectLocale(pref: LangPref): Locale {
}
function readPref(): LangPref {
const v = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
return "";
}
export function normalizeLangPref(v: unknown): LangPref {
return v === "en" || v === "zh" ? v : "";
}
function writePref(pref: LangPref): void {
export function readLegacyLangPref(): LangPref {
const v = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
return normalizeLangPref(v);
}
export function clearLegacyLangPref(): void {
try {
if (pref) localStorage.setItem(STORAGE_KEY, pref);
else localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
} catch {
/* private mode / no storage — the in-memory state still applies this session */
/* private mode / no storage */
}
}
@@ -79,7 +88,7 @@ export function getLocale(): Locale {
return currentLocale;
}
type Translator = (key: DictKey, vars?: Record<string, string | number>) => string;
export type Translator = (key: DictKey, vars?: Record<string, string | number>) => string;
interface I18nValue {
locale: Locale;
@@ -95,10 +104,9 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
const locale = detectLocale(pref);
currentLocale = locale; // keep the mirror fresh for non-React callers
// setPref updates only the live UI and the browser cache.
// setPref updates only the live UI; persistence is handled by desktop config.
const setPref = useCallback((next: LangPref) => {
writePref(next);
setPrefState(next);
setPrefState(normalizeLangPref(next));
}, []);
const tt = useCallback<Translator>((key, vars) => translate(detectLocale(pref), key, vars), [pref]);
@@ -1,6 +1,8 @@
export type LayoutSizeKey =
| "sidebarWidth"
| "workspacePanelWidth"
| "rightDockWidth"
| "rightDockTreeWidth"
| "rightDockPreviewWidth"
| "workspaceFileTreePanelWidth"
| "workspaceTreeWidth"
| "composerHeight"
@@ -15,7 +17,9 @@ const STORAGE_KEY = "reasonix.layoutPreferences.v1";
const LEGACY_SIZE_KEYS: Record<LayoutSizeKey, string[]> = {
sidebarWidth: ["reasonix.sidebar.width"],
workspacePanelWidth: ["reasonix.workspacePanel.width"],
rightDockWidth: [],
rightDockTreeWidth: [],
rightDockPreviewWidth: [],
workspaceFileTreePanelWidth: [],
workspaceTreeWidth: ["reasonix.workspaceTree.width"],
composerHeight: ["reasonix.composerHeight"],
+24
View File
@@ -0,0 +1,24 @@
export type ProjectColorKey = "" | "red" | "orange" | "amber" | "green" | "teal" | "blue" | "purple" | "pink";
export interface ProjectColorOption {
key: ProjectColorKey;
value?: string;
}
export const PROJECT_COLOR_OPTIONS: ProjectColorOption[] = [
{ key: "" },
{ key: "red", value: "#e5534b" },
{ key: "orange", value: "#d66e4b" },
{ key: "amber", value: "#d59a2f" },
{ key: "green", value: "#4f9f64" },
{ key: "teal", value: "#1f9d93" },
{ key: "blue", value: "#3d7be0" },
{ key: "purple", value: "#8b6de8" },
{ key: "pink", value: "#cf6ca5" },
];
const colorValues = new Map(PROJECT_COLOR_OPTIONS.map((option) => [option.key, option.value]));
export function projectColorValue(key?: string): string | undefined {
return colorValues.get((key || "") as ProjectColorKey);
}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
// Shell-expand coordination: ToolCards register their toggle callbacks, and
// Cmd+B (from App) calls the most recent one.
type ToggleFn = () => void;
interface ShellExpandCtx {
register: (id: string, toggle: ToggleFn) => void;
toggleLast: () => void;
}
const Ctx = createContext<ShellExpandCtx | null>(null);
export function ShellExpandProvider({ children }: { children: ReactNode }) {
const mapRef = useRef(new Map<string, ToggleFn>());
const orderRef: { current: string[] } = useRef<string[]>([]);
const register = useCallback((id: string, toggle: ToggleFn) => {
mapRef.current.set(id, toggle);
if (!orderRef.current.includes(id)) {
orderRef.current.push(id);
}
return () => {
mapRef.current.delete(id);
orderRef.current = orderRef.current.filter((x) => x !== id);
};
}, []);
const toggleLast = useCallback(() => {
const ids = orderRef.current;
if (ids.length === 0) return;
const fn = mapRef.current.get(ids[ids.length - 1]);
fn?.();
}, []);
const value = useMemo(() => ({ register, toggleLast }), [register, toggleLast]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useShellExpand() {
return useContext(Ctx);
}
+32
View File
@@ -0,0 +1,32 @@
export const TEXT_SIZES = ["small", "default", "large", "xlarge"] as const;
export type TextSize = (typeof TEXT_SIZES)[number];
export const DEFAULT_TEXT_SIZE: TextSize = "default";
const TEXT_SIZE_KEY = "reasonix-text-size";
export function isTextSize(value: unknown): value is TextSize {
return typeof value === "string" && (TEXT_SIZES as readonly string[]).includes(value);
}
export function getTextSize(): TextSize {
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(TEXT_SIZE_KEY) : null;
return isTextSize(stored) ? stored : DEFAULT_TEXT_SIZE;
}
export function applyTextSize(size: TextSize): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (size === DEFAULT_TEXT_SIZE) root.removeAttribute("data-text-size");
else root.setAttribute("data-text-size", size);
try {
localStorage.setItem(TEXT_SIZE_KEY, size);
} catch {
/* private mode / no storage - the in-DOM attribute still applies */
}
}
export function initTextSize(): void {
applyTextSize(getTextSize());
}
+86 -20
View File
@@ -1,6 +1,16 @@
// theme.ts manages the appearance override. The stylesheet follows the OS via
// prefers-color-scheme unless data-theme forces "dark" or "light". A separate
// data-theme-style attribute changes only accent tokens.
//
// When running inside the Wails shell, applyTheme also syncs the native window
// theme (title bar, traffic lights, etc.) so the OS chrome matches the webview.
import {
WindowSetDarkTheme,
WindowSetLightTheme,
WindowSetSystemDefaultTheme,
WindowSetBackgroundColour,
} from "../../wailsjs/runtime/runtime";
export type Theme = "auto" | "light" | "dark";
export type ResolvedTheme = Exclude<Theme, "auto">;
@@ -31,17 +41,22 @@ export const THEME_STYLE_THEME: Record<ThemeStyle, ResolvedTheme> = {
const DEFAULT_THEME_STYLE: Record<ResolvedTheme, ThemeStyle> = {
dark: "graphite",
light: "sandstone",
light: "glacier",
};
// New users default to the product's dark graphite look. Existing users, and any
// user who manually changes the theme, keep their stored desktop config.
const DEFAULT_THEME: Theme = "dark";
const THEME_KEY = "reasonix-theme";
const STYLE_KEY = "reasonix-theme-style";
let currentTheme: Theme = DEFAULT_THEME;
let currentThemeStyle: ThemeStyle = DEFAULT_THEME_STYLE.dark;
function normalizeTheme(value: unknown): Theme | null {
export function normalizeThemePreference(value: unknown): Theme {
if (typeof value === "object" && value !== null) {
return normalizeTheme((value as { mode?: unknown }).mode);
return normalizeThemePreference((value as { mode?: unknown }).mode);
}
if (typeof value !== "string") return null;
if (typeof value !== "string") return DEFAULT_THEME;
switch (value) {
case "auto":
return "auto";
@@ -54,7 +69,7 @@ function normalizeTheme(value: unknown): Theme | null {
case "contrast":
return "dark";
default:
return null;
return DEFAULT_THEME;
}
}
@@ -63,14 +78,7 @@ export function isThemeStyle(value: unknown): value is ThemeStyle {
}
export function getTheme(): Theme {
const v = typeof localStorage !== "undefined" ? localStorage.getItem(THEME_KEY) : null;
if (!v) return "auto";
try {
const parsed = JSON.parse(v) as unknown;
return normalizeTheme(parsed) ?? normalizeTheme(v) ?? "auto";
} catch {
return normalizeTheme(v) ?? "auto";
}
return currentTheme;
}
export function getResolvedTheme(theme: Theme = getTheme()): ResolvedTheme {
@@ -88,12 +96,15 @@ export function themeForStyle(style: ThemeStyle): ResolvedTheme {
}
export function getThemeStyle(theme: Theme = getTheme()): ThemeStyle {
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(STYLE_KEY) : null;
if (isThemeStyle(stored) && themeForStyle(stored) === getResolvedTheme(theme)) return stored;
if (theme === currentTheme && themeForStyle(currentThemeStyle) === getResolvedTheme(theme)) return currentThemeStyle;
return defaultStyleForTheme(theme);
}
export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme)): void {
export function normalizeThemeStyleForTheme(style: string | undefined, theme: Theme): ThemeStyle {
return isThemeStyle(style) && themeForStyle(style) === getResolvedTheme(theme) ? style : defaultStyleForTheme(theme);
}
export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme), options: { persist?: boolean } = {}): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.removeAttribute("data-theme-mode");
@@ -103,16 +114,71 @@ export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme
const resolved = getResolvedTheme(theme);
const nextStyle = themeForStyle(style) === resolved ? style : DEFAULT_THEME_STYLE[resolved];
currentTheme = theme;
currentThemeStyle = nextStyle;
root.setAttribute("data-theme-style", nextStyle);
// Sync the native window theme (title bar, traffic lights) to match.
if (typeof window !== "undefined" && window.runtime) {
if (theme === "auto") {
WindowSetSystemDefaultTheme();
} else if (theme === "light") {
WindowSetLightTheme();
} else if (theme === "dark") {
WindowSetDarkTheme();
}
}
void options;
}
export function readLegacyThemePreference(): { theme: Theme; style: ThemeStyle; hasValue: boolean } {
if (typeof localStorage === "undefined") return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE.dark, hasValue: false };
let rawTheme: string | null = null;
let rawStyle: string | null = null;
try {
localStorage.setItem(THEME_KEY, theme);
localStorage.setItem(STYLE_KEY, nextStyle);
rawTheme = localStorage.getItem(THEME_KEY);
rawStyle = localStorage.getItem(STYLE_KEY);
} catch {
/* private mode / no storage — the in-DOM attributes still apply */
return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE.dark, hasValue: false };
}
const hasValue = rawTheme !== null || rawStyle !== null;
let theme = DEFAULT_THEME;
if (rawTheme) {
try {
theme = normalizeThemePreference(JSON.parse(rawTheme) as unknown);
} catch {
theme = normalizeThemePreference(rawTheme);
}
}
const style = normalizeThemeStyleForTheme(rawStyle ?? undefined, theme);
return { theme, style, hasValue };
}
export function clearLegacyThemePreference(): void {
try {
localStorage.removeItem(THEME_KEY);
localStorage.removeItem(STYLE_KEY);
} catch {
/* ignore storage failures */
}
}
// initTheme runs before React mounts. It applies the saved theme to the DOM and
// sets the native window background colour to match the resolved theme, avoiding
// a white (or wrong-colour) flash while the webview paints its first frame.
export function initTheme(): void {
const theme = getTheme();
applyTheme(theme, getThemeStyle(theme));
applyTheme(theme, getThemeStyle(theme), { persist: false });
if (typeof window !== "undefined" && window.runtime) {
const resolved = getResolvedTheme(theme);
if (resolved === "light") {
// Light shell: matches :root[data-theme="light"] --bg (#f7f8fb).
WindowSetBackgroundColour(247, 248, 251, 255);
} else {
// Dark shell: matches :root --bg (#090a0c).
WindowSetBackgroundColour(9, 10, 12, 255);
}
}
}
@@ -0,0 +1,9 @@
import type { Todo } from "./tools";
export function shouldShowTodoPanel(
todoId: string | null | undefined,
dismissedTodoId: string | null,
todos: Todo[],
): boolean {
return !!todoId && todoId !== dismissedTodoId && todos.length > 0;
}
+111 -7
View File
@@ -49,6 +49,9 @@ export interface WireUsage {
// hit-rate (Σhit/Σ(hit+miss)), steadier than the single-turn cacheHitTokens.
sessionCacheHitTokens: number;
sessionCacheMissTokens: number;
cost?: number;
currency?: string;
// Deprecated compatibility alias. Prefer cost + currency.
costUsd?: number;
}
@@ -95,6 +98,90 @@ export interface WireEvent {
err?: string;
retryAttempt?: number;
retryMax?: number;
// Tab routing: set by the Go-side tabEventSink so multi-tab frontends
// route each event to the correct per-tab reducer.
tabId?: string;
sessionHitTokens?: number;
sessionMissTokens?: number;
sessionCost?: number;
sessionCurrency?: string;
// Deprecated compatibility alias. Prefer sessionCost + sessionCurrency.
sessionCostUsd?: number;
}
// Tab management types (desktop/tabs.go).
export interface TabMeta {
id: string;
tabType?: "session" | "file";
scope: string;
workspaceRoot: string;
workspaceName: string;
topicId: string;
topicTitle: string;
filePath?: string;
projectColor?: string;
label: string;
ready: boolean;
running: boolean;
mode: Mode;
startupErr?: string;
active: boolean;
cwd: string;
}
export interface ProjectNode {
key: string;
kind: "project" | "topic" | "global_folder" | "global_topic";
label: string;
root?: string;
topicId?: string;
projectColor?: string;
turns?: number;
lastActivityAt?: number;
open?: boolean;
running?: boolean;
children?: ProjectNode[];
}
export interface TopicMeta {
id: string;
title: string;
createdAt: number;
}
export interface ContextPanelInfo {
usedTokens: number;
windowTokens: number;
promptTokens: number;
completionTokens: number;
reasoningTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
sessionCost?: number;
sessionCurrency?: string;
// Deprecated compatibility alias. Prefer sessionCost + sessionCurrency.
sessionCostUsd?: number;
readFiles: ReadFileRecord[];
changedFiles: ChangedFileInfo[];
}
export interface ReadFileRecord {
path: string;
turn: number;
time: number;
offset?: number;
limit?: number;
truncated?: boolean;
}
export interface ChangedFileInfo {
path: string;
oldPath?: string;
sources: string[];
gitStatus?: string;
turns: number[];
latestPrompt?: string;
latestTime?: number;
}
// Bound-method payloads (desktop/app.go).
@@ -110,6 +197,8 @@ export interface CheckpointMeta {
prompt: string;
files: string[];
time: number; // unix ms
canCode?: boolean;
canConversation?: boolean;
}
// SessionMeta is one saved session for the history panel.
@@ -118,10 +207,16 @@ export interface SessionMeta {
preview: string;
title?: string; // user-chosen name; falls back to preview when empty
turns: number;
createdAt?: number; // unix milliseconds
lastActivityAt?: number; // unix milliseconds
createdAt: number; // unix milliseconds
lastActivityAt: number; // unix milliseconds
modTime: number; // compatibility alias for lastActivityAt
deletedAt?: number; // unix milliseconds, present for trashed sessions
current: boolean;
open: boolean;
scope?: string; // "project" | "global"; empty for legacy → treated as "global"
workspaceRoot?: string;
topicId?: string;
topicTitle?: string;
}
export interface WorkspaceView {
@@ -133,6 +228,7 @@ export interface WorkspaceView {
export interface ContextInfo {
used: number;
window: number;
compactRatio?: number;
}
export interface Meta {
@@ -144,8 +240,8 @@ export interface Meta {
bypass?: boolean; // YOLO mode on (auto-approve every tool call)
}
// Mode is the input mode cycled by Shift+Tab: normal → plan (read-only) → yolo
// (auto-approve every tool call; deny rules still apply).
// Mode is the input mode cycled by Shift+Tab: normal (shown as auto) → plan
// (read-only) → yolo (auto-approve every tool call; deny rules still apply).
export type Mode = "normal" | "plan" | "yolo";
export interface CommandInfo {
@@ -211,14 +307,14 @@ export interface ServerView {
args?: string[];
url?: string;
envKeys?: string[];
authStatus?: string;
authUrl?: string;
authConfigured?: boolean;
tools: number;
prompts: number;
resources: number;
error?: string;
toolList?: MCPToolView[];
authStatus?: "none" | "possible" | "required" | string;
authUrl?: string;
authConfigured?: boolean;
}
export interface MCPToolView {
name: string;
@@ -229,6 +325,7 @@ export interface SkillView {
description: string;
scope: string;
runAs: string;
enabled: boolean;
}
export interface SkillRootSkillView {
name: string;
@@ -327,6 +424,8 @@ export interface ProviderView {
keySet: boolean; // the env var currently resolves to a value
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
contextWindow: number;
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
}
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
@@ -385,11 +484,16 @@ export interface AgentView {
export interface SettingsView {
defaultModel: string;
plannerModel: string;
autoPlan: string;
providers: ProviderView[];
permissions: PermissionsView;
sandbox: SandboxView;
network: NetworkView;
agent: AgentView;
desktopLanguage: string; // "" | "en" | "zh"; empty = auto
desktopTheme: string; // "auto" | "dark" | "light"
desktopThemeStyle: string;
closeBehavior: string; // "background" | "quit"
configPath: string;
providerKinds: string[]; // provider implementations the kernel registered (for the kind picker)
bypass: boolean; // live YOLO state (runtime-only) — whether approvals are skipped this session
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
// useWindowStatePersistence polls the Wails runtime for window geometry and
// persists it via SaveWindowState so the next launch restores the same size and
// position. No-op in browser dev (no window.runtime).
//
// The Go shutdown hook (app.saveWindowStateSync) provides the authoritative
// final save; this hook covers moves/resizes during the session.
//
// NOTE: navigator.sendBeacon or a sync XHR would let us block beforeunload,
// but Wails bindings are async (Go IPC). We accept that the very last resize
// event may not land; the 5s poll and the Go shutdown hook make this unlikely
// to matter.
import { useEffect, useRef } from "react";
import {
WindowGetPosition,
WindowGetSize,
WindowIsMaximised,
} from "../../wailsjs/runtime/runtime";
import { app } from "./bridge";
export function useWindowStatePersistence() {
const lastState = useRef("");
useEffect(() => {
if (typeof window === "undefined" || !window.runtime) return;
let timer: ReturnType<typeof setInterval>;
const save = async () => {
try {
const size = await WindowGetSize();
const pos = await WindowGetPosition();
const maximised = await WindowIsMaximised();
const json = JSON.stringify({
width: size.w,
height: size.h,
x: pos.x,
y: pos.y,
maximised,
});
if (json !== lastState.current) {
await app.SaveWindowState({ width: size.w, height: size.h, x: pos.x, y: pos.y, maximised });
lastState.current = json;
}
} catch {
/* runtime not ready yet — poll will retry */
}
};
// Debounced save on resize (500ms after the last resize event).
let debounce: ReturnType<typeof setTimeout>;
const onResize = () => {
clearTimeout(debounce);
debounce = setTimeout(save, 500);
};
window.addEventListener("resize", onResize);
// Periodic poll every 5s for moves/maximise that don't trigger resize.
timer = setInterval(save, 5000);
// Best-effort save before the page unloads. The Go shutdown hook
// (saveWindowStateSync) is the authoritative final persist.
window.addEventListener("beforeunload", save);
return () => {
clearInterval(timer);
clearTimeout(debounce);
window.removeEventListener("resize", onResize);
window.removeEventListener("beforeunload", save);
};
}, []);
}
+264 -37
View File
@@ -27,11 +27,17 @@ export const en = {
"topbar.newSession": "New session",
"topbar.startupError": "startup error: {msg}",
// tab bar
"tabBar.closeTab": "Close tab",
"tabBar.closeOtherTabs": "Close other tabs",
"tabBar.closeTabsToRight": "Close tabs to right",
"tabBar.newSession": "New session",
"tabBar.tabActions": "Tab actions",
// sidebar
"sidebar.recent": "Recent",
"sidebar.conversations": "Chats",
"sidebar.viewAll": "All",
"sidebar.noRecent": "No recent chats",
"sidebar.allHistory": "All history",
"sidebar.trash": "Trash",
"sidebar.workspace": "Workspace",
"sidebar.changeWorkspace": "Change",
"sidebar.navigation": "Reasonix navigation",
@@ -41,20 +47,37 @@ export const en = {
"sidebar.resize": "Resize sidebar",
"drawer.resize": "Resize drawer",
// right dock
"rightDock.resize": "Resize right panel",
"rightDock.expand": "Expand right workspace",
"rightDock.collapse": "Collapse right workspace",
"rightDock.workbench": "Right workbench",
"rightDock.views": "Right workbench views",
"rightDock.overview": "Overview",
// topic bar
"topicBar.renameSession": "Rename session",
"topicBar.more": "More",
// scope labels
"scope.global": "Scope: Global",
"scope.project": "Project · {name}",
// workspace panel
"workspace.title": "Workspace",
"workspace.open": "Open workspace panel",
"workspace.close": "Close workspace panel",
"workspace.minimize": "Minimize workspace panel",
"workspace.maximize": "Maximize workspace panel",
"workspace.restore": "Restore workspace panel",
"workspace.resizePanel": "Resize workspace panel",
"workspace.hideTree": "Hide file tree",
"workspace.showTree": "Show file tree",
"workspace.resizeTree": "Resize file tree",
"workspace.newTab": "Open another file",
"workspace.closeTab": "Close tab",
"workspace.currentFile": "Current file",
"workspace.recentFiles": "Recent files",
"workspace.closePreview": "Close preview",
"workspace.filter": "Filter files…",
"workspace.filterChanges": "Filter changes…",
"workspace.noFile": "No file selected",
"workspace.pickFile": "Choose a file from the tree.",
"workspace.loading": "Loading file…",
@@ -66,7 +89,9 @@ export const en = {
"workspace.addFileContentToChat": "Add file contents",
"workspace.viewMode": "Workspace view",
"workspace.filesTab": "Files",
"workspace.changedTab": "Changed",
"workspace.changedTab": "Changes",
"workspace.treeMenu": "File tree actions",
"workspace.refreshTree": "Refresh file tree",
"workspace.refreshChanges": "Refresh changes",
"workspace.loadingChanges": "Loading changes…",
"workspace.noChanges": "No changed files",
@@ -106,6 +131,9 @@ export const en = {
"caps.skillScopeProject": "Project",
"caps.skillScopeCustom": "Custom",
"caps.skillScopeGlobal": "Global",
"caps.skillDisabled": "Disabled",
"caps.disableSkill": "Disable skill",
"caps.enableSkill": "Enable skill",
"caps.subagent": "Subagent",
"caps.addServer": "+ Add server",
"caps.add": "Add",
@@ -140,6 +168,13 @@ export const en = {
"caps.reauthorize": "Reauthorize",
"caps.checkCommand": "Check command",
"caps.builtIn": "Built-in",
"caps.auth": "Auth",
"caps.authRequired": "authorization required",
"caps.authRequiredSummary": "This MCP server requires authorization before it can connect.",
"caps.authPossible": "authorization may be required",
"caps.authPossibleShort": "auth may be required",
"caps.clearAuth": "Clear auth",
"caps.confirmClearAuth": "Confirm clear",
"caps.codegraphWarming": "codegraph: built-in code intelligence is still warming up or preparing the project index; retry shortly",
"caps.deferred": "enabled; connects when this MCP is used",
"caps.initializing": "connecting to MCP; tools will appear when ready",
@@ -194,18 +229,21 @@ export const en = {
"composer.planModeOn": "plan mode on",
"composer.planHint": "shift+tab",
"composer.planHintExit": "shift+tab to exit",
"composer.modeNormal": "normal",
"composer.modePlan": "plan mode",
"composer.modeNormal": "auto",
"composer.modePlan": "plan",
"composer.modeYolo": "YOLO",
"composer.modeHint": "shift+tab",
"composer.modeTitle": "Cycle mode (shift+tab): normal → plan → YOLO (auto-approve everything)",
"composer.modeTitle": "Cycle run mode (shift+tab): auto → plan → yolo",
"composer.enterPlanTitle": "Enter plan mode (shift+tab) — read-only; propose a plan before writing",
"composer.exitPlanTitle": "Exit plan mode (shift+tab)",
"composer.searchProjects": "Search projects",
"composer.noProjectMatches": "No matching projects",
"composer.addProject": "Add new project",
"composer.removeProject": "Remove project",
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
"composer.send": "Send (Enter)",
"composer.stop": "Stop (Esc)",
"composer.stopShort": "Stop",
"composer.pastedLabel": "[Pasted text #{id} · {lines} lines]",
"composer.pastedShowPreview": "Preview pasted text",
"composer.pastedHidePreview": "Hide pasted text preview",
@@ -218,7 +256,8 @@ export const en = {
// status bar
"status.connecting": "connecting…",
"status.ctx": "{pct}% ctx",
"status.ctx": "ctx {pct}%",
"status.compact": "compact {pct}%",
"status.plan": "PLAN",
"status.switchModel": "Switch model",
"status.noModels": "no models configured",
@@ -228,12 +267,18 @@ export const en = {
"status.switchFolder": "{cwd}\nClick to switch project folder",
"status.tokens": "tokens",
"status.retrying": "retrying ({attempt}/{max})…",
"status.cache": "cache {pct}%",
"status.cache": "turn hit {pct}%",
"status.cacheAvg": "avg {pct}%",
"status.ctxUnknown": "ctx -",
"status.compactUnknown": "compact -",
"status.cost": "run {amount}",
"status.balanceTitle": "Wallet balance",
"status.balance": "balance {amount}",
"status.spendTitle": "Spent this session",
"status.jobs": "{n} running",
"status.jobsCount": "jobs {n}",
"status.jobsTitle": "Background jobs",
"status.workspaceTitle": "Current workspace",
"status.yolo": "YOLO",
"status.yoloTitle": "YOLO — approvals are being skipped this session",
@@ -265,13 +310,28 @@ export const en = {
// history drawer
"history.title": "History",
"history.trashTitle": "Trash",
"history.empty": "No saved sessions yet.",
"history.trashEmpty": "Trash is empty.",
"history.trashHint": "Deleted sessions live here first. Restore them or delete them permanently.",
"history.namePlaceholder": "Session name…",
"history.emptySession": "(empty session)",
"history.current": "current",
"history.open": "open",
"history.deleted": "deleted",
"history.turnOne": "{n} turn",
"history.turnOther": "{n} turns",
"history.confirmDelete": "Confirm delete",
"history.confirmPurge": "Confirm permanent delete",
"history.clearTrash": "Empty trash",
"history.confirmClearTrash": "Confirm empty trash",
"history.clearTrashMenu": "Empty trash…",
"history.moveToTrash": "Move to trash",
"history.confirmMoveToTrash": "Confirm move to trash",
"history.restore": "Restore",
"history.restoreSession": "Restore session",
"history.purge": "Delete permanently",
"history.purgeSession": "Delete permanently…",
"history.rename": "Rename",
"history.preview": "preview",
"history.previewEmpty": "No visible messages in this session.",
@@ -280,6 +340,42 @@ export const en = {
"history.noResults": "No matching sessions.",
"history.today": "Today",
"history.yesterday": "Yesterday",
"history.trashSessionActions": "Trash session actions",
"history.historySessionActions": "History session actions",
"history.trashActions": "Trash actions",
// project tree
"projectTree.workspaceTitle": "Project workspace",
"projectTree.searchPlaceholder": "Search projects or sessions",
"projectTree.newTopic": "New session",
"projectTree.renameTopic": "Rename session",
"projectTree.renameProject": "Rename display name",
"projectTree.projectHistory": "View project history",
"projectTree.removeProject": "Remove from sidebar",
"projectTree.confirmRemoveProject": "Confirm remove from sidebar",
"projectTree.copyPath": "Copy path",
"projectTree.revealInFinder": "Show in Finder",
"projectTree.revealInExplorer": "Show in File Explorer",
"projectTree.revealInFileManager": "Show in file manager",
"projectTree.topicActions": "Session actions",
"projectTree.projectActions": "Project actions",
"projectTree.colorDefault": "Default",
"projectTree.colorRed": "Red",
"projectTree.colorOrange": "Orange",
"projectTree.colorAmber": "Amber",
"projectTree.colorGreen": "Green",
"projectTree.colorTeal": "Teal",
"projectTree.colorBlue": "Blue",
"projectTree.colorPurple": "Purple",
"projectTree.colorPink": "Pink",
"projectTree.emptyNoMatch": "No matching projects or sessions",
"projectTree.emptyNoProjects": "No projects yet",
"projectTree.emptyNoProjectsDesc": "Add projects to organize sessions, files, and context.",
"projectTree.emptyAddProject": "Add new project",
"projectTree.emptyUseCurrentDir": "Use current directory",
"projectTree.emptyCurrentDir": "Current directory: {dir}",
"projectTree.newTopicTooltip": "New session",
"projectTree.addProjectTooltip": "Add new project",
// memory drawer
"memory.title": "Memory",
@@ -312,32 +408,42 @@ export const en = {
"settings.title": "Settings",
"settings.loading": "Loading…",
"settings.modelsProviders": "Models & providers",
"settings.tab.general": "General",
"settings.tab.models": "Models",
"settings.tab.providers": "Providers",
"settings.tab.network": "Network",
"settings.tab.permissions": "Permissions",
"settings.tab.sandbox": "Sandbox",
"settings.tab.agent": "Agent",
"settings.tab.appearance": "Appearance",
"settings.tab.updates": "Updates",
"settings.providerCount": "{n} providers",
"settings.agentMeta": "temp {temp} · steps {steps}",
"settings.appearanceMeta": "theme · accent · language",
"settings.appearanceMeta": "theme · accent · text",
"settings.updatesMeta": "version · config",
"settings.closeBehavior": "When closing window",
"settings.closeBehavior.background": "Keep running",
"settings.closeBehavior.quit": "Quit Reasonix",
"settings.manageProviders": "Manage providers",
"settings.activeProvider": "Active provider",
"settings.plannerStatus": "Planner",
"settings.plannerEnabled": "enabled",
"settings.plannerDisabled": "disabled",
"settings.plannerStatus": "Planning mode",
"settings.plannerEnabled": "two-model",
"settings.plannerDisabled": "single-model",
"settings.plannerSingle": "single model",
"settings.plannerDual": "two models",
"settings.plannerSingleDetail": "{model} plans and executes",
"settings.plannerDualDetail": "{planner} plans · {executor} executes",
"settings.providers": "Providers",
"settings.availableModels": "Available models",
"settings.defaultModel": "Default model",
"settings.plannerModel": "Planner model",
"settings.plannerNone": "(none — single model)",
"settings.plannerModel": "Dedicated planner model",
"settings.plannerNone": "Use current model (single model)",
"settings.autoPlan": "Automatic plan mode",
"settings.autoPlan.off": "Off",
"settings.autoPlan.on": "On",
"settings.keySet": "key set",
"settings.noKey": "no key",
"settings.cantDeleteDefault": "Can't delete the default provider",
"settings.deleteProvider": "Delete provider",
"settings.confirmDeleteProvider": "Confirm delete provider",
"settings.addProvider": "+ Add provider",
"settings.providerName": "name (e.g. deepseek-flash)",
"settings.providerKind": "Kind",
@@ -350,6 +456,12 @@ export const en = {
"settings.providerContextWindow": "Context window",
"settings.contextWindowPlaceholder": "tokens (0 = provider default)",
"settings.contextWindowHint": "Max tokens to keep in context for this provider. Leave 0 to use the provider's default.",
"settings.supportedEfforts": "Supported efforts",
"settings.supportedEffortsCustomPlaceholder": "Custom level (press Enter)",
"settings.supportedEffortsHint": "Toggle the /effort levels this provider supports, or add a custom name. Empty uses the built-in default (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
"settings.defaultEffort": "Default effort",
"settings.defaultEffortAuto": "auto (use built-in default)",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the levels above. Empty falls back to the first level (or the built-in default when none are selected).",
"settings.proxyMode": "Proxy mode",
"settings.proxyMode.auto": "auto",
"settings.proxyMode.env": "env",
@@ -364,7 +476,7 @@ export const en = {
"settings.proxyUrlHint": "Optional. When set, proxy_url overrides the structured server and port fields.",
"settings.noProxy": "No proxy",
"settings.saveNetwork": "Save network settings",
"settings.setKey": "set {env} (→ .env)",
"settings.setKey": "set {env} (saved globally)",
"settings.saveKey": "Save key",
"settings.permissions": "Permissions",
"settings.writerMode": "Writer mode",
@@ -382,13 +494,7 @@ export const en = {
"settings.allowNetwork": "Allow network egress from sandboxed bash",
"settings.workspaceRoot": "Workspace root",
"settings.workspaceDefault": "(default: cwd)",
"settings.agent": "Agent",
"settings.temperature": "Temperature",
"settings.maxSteps": "Max steps",
"settings.unlimited": "0 = unlimited",
"settings.systemPrompt": "System prompt",
"settings.saveAgent": "Save agent settings",
"settings.appearance": "Appearance & language",
"settings.appearance": "Appearance",
"settings.theme": "Theme",
"settings.themeStyle": "Accent",
"settings.themeAuto": "auto",
@@ -397,6 +503,11 @@ export const en = {
"settings.themeCurrent": "Theme: {theme} / {style}",
"settings.themeChanged": "Theme changed to {theme} / {style}",
"settings.themeUnknown": "Unknown theme: {name}",
"settings.textSize": "Text size",
"settings.textSizeSmall": "Small",
"settings.textSizeDefault": "Default",
"settings.textSizeLarge": "Large",
"settings.textSizeXLarge": "Extra large",
"settings.language": "Language",
"settings.langAuto": "Auto (system)",
"settings.config": "config: {path}",
@@ -404,6 +515,7 @@ 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",
@@ -413,24 +525,47 @@ export const en = {
// assistant message
"msg.thinking": "thinking",
"msg.copy": "Copy",
"questionNav.label": "Question navigation",
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
"questionNav.hint": "Click to jump to this question",
"compaction.working": "Compacting conversation…",
"compaction.title": "Context compacted",
"compaction.messages": "{n} messages",
"compaction.showSummary": "show summary",
"compaction.hideSummary": "hide summary",
"rewind.label": "Rewind / fork / summarize…",
"rewind.both": "Code + conversation",
"rewind.conversation": "Conversation only",
"rewind.code": "Code only",
"rewind.fork": "Fork (new branch)",
"rewind.summFrom": "Summarize from here",
"rewind.summUpto": "Summarize up to here",
"rewind.label": "Message actions",
"rewind.disabledRunning": "Wait for the current action to finish",
"rewind.disabledNoCheckpoint": "This message has no restore point",
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
"rewind.disabledNoCode": "No code changes were captured after this message",
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
"rewind.filesChanged": "{count} files",
"rewind.both": "Rewind code and conversation",
"rewind.conversation": "Rewind conversation only",
"rewind.code": "Rewind code only",
"rewind.fork": "Fork conversation",
"rewind.summFrom": "Summarize after this",
"rewind.summUpto": "Summarize before this",
"rewind.confirmFork": "Confirm fork conversation",
"rewind.confirmSummFrom": "Confirm summarize after this",
"rewind.confirmSummUpto": "Confirm summarize before this",
"rewind.confirmBoth": "Confirm rewind code and conversation",
"rewind.confirmConversation": "Confirm rewind conversation",
"rewind.confirmCode": "Confirm rewind code",
"rewind.busyFork": "Forking from this message…",
"rewind.busySummFrom": "Summarizing after this message…",
"rewind.busySummUpto": "Summarizing up to this message…",
"rewind.busyConversation": "Rewinding conversation…",
"rewind.busyCode": "Rewinding code…",
"rewind.busyBoth": "Rewinding code and conversation…",
"msg.copied": "Copied",
// tool card summaries
"tool.stepOne": "{n} step",
"tool.stepOther": "{n} steps",
"tool.truncated": "output truncated",
"tool.showAllLines": "show all {n} lines",
"tool.lineOne": "{n} line",
"tool.lineOther": "{n} lines",
"tool.matchOne": "{n} match",
@@ -465,18 +600,110 @@ export const en = {
// onboarding — first-run API-key overlay
"onboarding.title": "Connect Reasonix",
"onboarding.tagline": "Paste a DeepSeek API key to start. Stored locally in .env, never sent anywhere else.",
"onboarding.tagline": "Paste a DeepSeek API key to start. Stored locally in this app's credentials, never sent anywhere else.",
"onboarding.inputLabel": "API key",
"onboarding.inputPlaceholder": "sk-…",
"onboarding.submit": "Connect & start",
"onboarding.validating": "Validating…",
"onboarding.getKey": "How do I get a key?",
"onboarding.privacy": "Stored only in this app's .env",
"onboarding.privacy": "Stored only in this app's local credentials",
"onboarding.error.empty": "Please paste a key first.",
"onboarding.error.invalid": "That key didn't work — double-check it's active and has billing set up.",
"onboarding.error.network": "Couldn't reach DeepSeek — check your network and try again.",
"onboarding.error.unknown": "Something went wrong: {msg}",
"onboarding.skip": "Skip for now",
// context panel
"context.overview": "Session overview",
"context.scopeGlobal": "Scope: Global",
"context.scopeProject": "Project · {name}",
"context.sessionChanges": "Session changes",
"context.referencedFiles": "Referenced files",
"context.noChanges": "No changed files in this session yet",
"context.noReads": "No files read in this session yet",
"context.filterChanges": "Filter session changes...",
"context.filterReads": "Filter referenced files...",
"context.changedNote": "This session is linked to {count} change records",
"context.readNote": "This session has read {count} file records",
"context.changedMeta": "{count} change records",
"context.readMeta": "{count} read records",
"context.viewAll": "View all",
"context.health": "Context health",
"context.healthGood": "Context healthy",
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
"context.healthNearLimit": "Context near limit",
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
"context.healthManyFiles": "Many source files",
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
"context.healthLowCache": "Low cache hit",
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
"context.prompt": "Prompt",
"context.completion": "Completion",
"context.reasoning": "Reasoning",
"context.other": "Other",
"context.total": "Total",
"context.cacheHit": "Cache hit",
"context.sessionCost": "Session cost",
"context.requests": "Requests",
"context.time": "Time",
"context.durationSeconds": "{seconds}s",
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
// crash page
"crash.title": "Reasonix hit an error — screenshot this and send it over",
"crash.copy": "Copy",
// mock / demo seed data (browser dev only)
"mock.sessionFixLogin": "fix the login bug in auth.go",
"mock.sessionRefactor": "refactor the payment module",
"mock.sessionReadme": "write the README and badges",
"mock.sessionPlugin": "explain the plugin host design",
"mock.trashDevStandardTitle": "00 Establish project development standards",
"mock.trashDevStandardPreview": "Organize project development standards including directory structure, branch conventions, and commit requirements.",
"mock.trashP3aTitle": "20260521 p3a P&D",
"mock.trashP3aPreview": "Review p3a P&D scope, document remaining risks and next-round suggestions.",
"mock.trashGlobalProductTitle": "Common product specs",
"mock.trashGlobalProductPreview": "Document cross-project common product rules including acceptance, canary, and error handling.",
"mock.projectJoyquantDb": "joyquant-db",
"mock.projectJoyquantSys": "joyquant-sys",
"mock.topicDevStandard": "00 Establish project dev standards",
"mock.topicDbMaint": "01 JoyQuant DB project maintenance",
"mock.topicEnv": "Project environment uv and Python runtime issues",
"mock.topicP3b": "20260523 p3b P&D",
"mock.topicP3a": "20260521 p3a P&D",
"mock.topicHotfix": "20260522 post-p3-hotfix P&D",
"mock.topicSysCoord": "01 JoyQuant-SYS project coordination",
"mock.topicSysStandard": "00 Establish project dev standards",
"mock.topicProduct": "Common product specs",
"mock.topicAi": "AI engineering best practices",
"mock.topicLab": "Exploratory experiments",
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
"mock.askQ1Header": "Approach",
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
"mock.askQ1Opt1Label": "git stash then pull",
"mock.askQ1Opt1Desc": "Use git stash to save local changes, pull latest code, then restore",
"mock.askQ1Opt2Label": "Discard local changes then pull",
"mock.askQ1Opt2Desc": "Abandon all local changes, force sync with remote",
"mock.askQ1Opt3Label": "Create branch to save changes",
"mock.askQ1Opt3Desc": "Create a branch to save local changes first, then pull main branch",
"mock.askQ2Header": "Reasonix build",
"mock.askQ2Prompt": "What do you want to do about the missing reasonix binary?",
"mock.askQ2Opt1Label": "Check docs first",
"mock.askQ2Opt1Desc": "Read README / build docs to determine the correct build command",
"mock.askQ2Opt2Label": "Check build config",
"mock.askQ2Opt2Desc": "Check desktop/wails.json and main.go to infer the entry point",
"mock.askQ2Opt3Label": "Let me try building",
"mock.askQ2Opt3Desc": "Handle git conflicts first, then attempt local build and report results",
"mock.todo1": "Review current frontend structure",
"mock.todo2": "Optimize approval and Ask decision bar layout",
"mock.todo2ActiveForm": "Adjusting decision bar layout",
"mock.todo3": "Verify three preview states",
"mock.newSession": "New session",
"mock.memoryBody": "# User memory\n\nAlways respond in English.",
"mock.changedFile1Prompt": "Update draft spec v0.3",
"mock.changedFile2Prompt": "Record project context",
"mock.changedFile1Path": "docs/dev-standard.md",
"mock.changedFile2Path": ".reasonix/project.md",
};
export type DictKey = keyof typeof en;
+264 -37
View File
@@ -28,11 +28,17 @@ export const zh: Record<DictKey, string> = {
"topbar.newSession": "新建会话",
"topbar.startupError": "启动错误:{msg}",
// 标签页栏
"tabBar.closeTab": "关闭标签页",
"tabBar.closeOtherTabs": "关闭其他标签页",
"tabBar.closeTabsToRight": "关闭右侧标签页",
"tabBar.newSession": "新建会话",
"tabBar.tabActions": "标签页操作",
// 侧边栏
"sidebar.recent": "最近",
"sidebar.conversations": "会话",
"sidebar.viewAll": "全部",
"sidebar.noRecent": "还没有最近会话",
"sidebar.allHistory": "全部历史",
"sidebar.trash": "回收站",
"sidebar.workspace": "工作区",
"sidebar.changeWorkspace": "更改",
"sidebar.navigation": "Reasonix 导航",
@@ -42,20 +48,37 @@ export const zh: Record<DictKey, string> = {
"sidebar.resize": "调整侧边栏宽度",
"drawer.resize": "调整抽屉宽度",
// 右侧停靠区
"rightDock.resize": "调整右侧面板宽度",
"rightDock.expand": "展开右侧工作区",
"rightDock.collapse": "收起右侧工作区",
"rightDock.workbench": "右侧工作台",
"rightDock.views": "右侧工作台视图",
"rightDock.overview": "概览",
// 话题栏
"topicBar.renameSession": "重命名会话",
"topicBar.more": "更多",
// 范围标签
"scope.global": "范围:全局",
"scope.project": "项目 · {name}",
// 工作区面板
"workspace.title": "工作区",
"workspace.open": "打开工作区面板",
"workspace.close": "关闭工作区面板",
"workspace.minimize": "最小化工作区面板",
"workspace.maximize": "最大化工作区面板",
"workspace.restore": "还原工作区面板",
"workspace.resizePanel": "调整工作区面板宽度",
"workspace.hideTree": "隐藏文件树",
"workspace.showTree": "显示文件树",
"workspace.resizeTree": "调整文件树宽度",
"workspace.newTab": "打开其他文件",
"workspace.closeTab": "关闭标签",
"workspace.currentFile": "当前文件",
"workspace.recentFiles": "最近文件",
"workspace.closePreview": "关闭预览",
"workspace.filter": "筛选文件…",
"workspace.filterChanges": "筛选改动…",
"workspace.noFile": "未选择文件",
"workspace.pickFile": "从文件树中选择一个文件。",
"workspace.loading": "正在加载文件…",
@@ -68,7 +91,9 @@ export const zh: Record<DictKey, string> = {
"workspace.viewMode": "工作区视图",
"workspace.filesTab": "文件",
"workspace.changedTab": "改动",
"workspace.refreshChanges": "刷新改动文件",
"workspace.treeMenu": "文件树操作",
"workspace.refreshTree": "刷新文件树",
"workspace.refreshChanges": "刷新改动",
"workspace.loadingChanges": "正在加载改动…",
"workspace.noChanges": "暂无改动文件",
"workspace.gitUnavailable": "当前工作区不可读取 Git 状态。",
@@ -107,6 +132,9 @@ export const zh: Record<DictKey, string> = {
"caps.skillScopeProject": "项目",
"caps.skillScopeCustom": "自定义",
"caps.skillScopeGlobal": "全局",
"caps.skillDisabled": "已停用",
"caps.disableSkill": "停用技能",
"caps.enableSkill": "启用技能",
"caps.subagent": "子代理",
"caps.addServer": "+ 添加服务器",
"caps.add": "添加",
@@ -141,6 +169,13 @@ export const zh: Record<DictKey, string> = {
"caps.reauthorize": "重新授权",
"caps.checkCommand": "检查命令",
"caps.builtIn": "内置",
"caps.auth": "授权",
"caps.authRequired": "需要授权",
"caps.authRequiredSummary": "该 MCP 服务器需要完成授权后才能连接。",
"caps.authPossible": "可能需要授权",
"caps.authPossibleShort": "可能需要授权",
"caps.clearAuth": "清除授权",
"caps.confirmClearAuth": "确认清除",
"caps.codegraphWarming": "codegraph:内置代码智能仍在冷启动或准备项目索引,稍后重试即可",
"caps.deferred": "已启用,用到该 MCP 时连接",
"caps.initializing": "正在连接 MCP,工具可用后会自动出现",
@@ -195,18 +230,21 @@ export const zh: Record<DictKey, string> = {
"composer.planModeOn": "计划模式已开",
"composer.planHint": "shift+tab",
"composer.planHintExit": "shift+tab 退出",
"composer.modeNormal": "普通",
"composer.modePlan": "计划模式",
"composer.modeNormal": "auto",
"composer.modePlan": "plan",
"composer.modeYolo": "YOLO",
"composer.modeHint": "shift+tab",
"composer.modeTitle": "切换模式(shift+tab):普通 → 计划 → YOLO(全部自动批准)",
"composer.modeTitle": "切换运行模式(shift+tab):auto → plan → yolo",
"composer.enterPlanTitle": "进入计划模式(shift+tab)—— 只读;先给出计划再动手",
"composer.exitPlanTitle": "退出计划模式(shift+tab)",
"composer.searchProjects": "搜索项目",
"composer.noProjectMatches": "没有匹配的项目",
"composer.addProject": "添加新项目",
"composer.removeProject": "移除项目",
"composer.removeCurrentProjectDisabled": "不能移除当前项目",
"composer.send": "发送(Enter)",
"composer.stop": "停止(Esc)",
"composer.stopShort": "停止",
"composer.pastedLabel": "[已粘贴文本 #{id} · {lines} 行]",
"composer.pastedShowPreview": "预览粘贴文本",
"composer.pastedHidePreview": "收起粘贴文本预览",
@@ -219,7 +257,8 @@ export const zh: Record<DictKey, string> = {
// 状态栏
"status.connecting": "连接中…",
"status.ctx": "{pct}% 上下文",
"status.ctx": "上下文 {pct}%",
"status.compact": "压缩 {pct}%",
"status.plan": "计划",
"status.switchModel": "切换模型",
"status.noModels": "未配置任何模型",
@@ -229,12 +268,18 @@ export const zh: Record<DictKey, string> = {
"status.switchFolder": "{cwd}\n点击切换项目目录",
"status.tokens": "tokens",
"status.retrying": "正在重试 ({attempt}/{max})…",
"status.cache": "缓存 {pct}%",
"status.cache": "本次命中 {pct}%",
"status.cacheAvg": "平均 {pct}%",
"status.ctxUnknown": "上下文 -",
"status.compactUnknown": "压缩 -",
"status.cost": "本次 {amount}",
"status.balanceTitle": "钱包余额",
"status.balance": "余额 {amount}",
"status.spendTitle": "本会话花费",
"status.jobs": "{n} 个运行中",
"status.jobsCount": "任务 {n}",
"status.jobsTitle": "后台作业",
"status.workspaceTitle": "当前工作区",
"status.yolo": "YOLO",
"status.yoloTitle": "YOLO —— 本会话正在跳过所有批准",
@@ -266,13 +311,28 @@ export const zh: Record<DictKey, string> = {
// 历史抽屉
"history.title": "历史",
"history.trashTitle": "回收站",
"history.empty": "还没有已保存的会话。",
"history.trashEmpty": "回收站为空。",
"history.trashHint": "删除的会话会先放在这里,可恢复或彻底删除。",
"history.namePlaceholder": "会话名称…",
"history.emptySession": "(空会话)",
"history.current": "当前",
"history.open": "已打开",
"history.deleted": "已删除",
"history.turnOne": "{n} 轮",
"history.turnOther": "{n} 轮",
"history.confirmDelete": "确认删除",
"history.confirmPurge": "确认彻底删除",
"history.clearTrash": "清空回收站",
"history.confirmClearTrash": "确认清空回收站",
"history.clearTrashMenu": "清空回收站…",
"history.moveToTrash": "移到回收站",
"history.confirmMoveToTrash": "确认移入回收站",
"history.restore": "恢复",
"history.restoreSession": "恢复会话",
"history.purge": "彻底删除",
"history.purgeSession": "彻底删除…",
"history.rename": "重命名",
"history.preview": "预览",
"history.previewEmpty": "这个会话没有可见消息。",
@@ -281,6 +341,42 @@ export const zh: Record<DictKey, string> = {
"history.noResults": "没有匹配的会话。",
"history.today": "今天",
"history.yesterday": "昨天",
"history.trashSessionActions": "回收站会话操作",
"history.historySessionActions": "历史会话操作",
"history.trashActions": "回收站操作",
// 项目树
"projectTree.workspaceTitle": "项目工作区",
"projectTree.searchPlaceholder": "搜索项目或会话",
"projectTree.newTopic": "新会话",
"projectTree.renameTopic": "重命名会话",
"projectTree.renameProject": "修改显示名称",
"projectTree.projectHistory": "查看项目历史",
"projectTree.removeProject": "移出侧边栏",
"projectTree.confirmRemoveProject": "确认移出侧边栏",
"projectTree.copyPath": "复制路径",
"projectTree.revealInFinder": "在 Finder 中显示",
"projectTree.revealInExplorer": "在文件资源管理器中显示",
"projectTree.revealInFileManager": "在文件管理器中显示",
"projectTree.topicActions": "会话操作",
"projectTree.projectActions": "项目操作",
"projectTree.colorDefault": "默认",
"projectTree.colorRed": "红色",
"projectTree.colorOrange": "橙色",
"projectTree.colorAmber": "琥珀",
"projectTree.colorGreen": "绿色",
"projectTree.colorTeal": "青色",
"projectTree.colorBlue": "蓝色",
"projectTree.colorPurple": "紫色",
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
"projectTree.emptyNoProjects": "还没有项目",
"projectTree.emptyNoProjectsDesc": "添加项目后,可以按项目管理会话、文件和上下文。",
"projectTree.emptyAddProject": "添加新项目",
"projectTree.emptyUseCurrentDir": "使用当前目录",
"projectTree.emptyCurrentDir": "当前目录:{dir}",
"projectTree.newTopicTooltip": "新建会话",
"projectTree.addProjectTooltip": "添加新项目",
// 记忆抽屉
"memory.title": "记忆",
@@ -313,32 +409,42 @@ export const zh: Record<DictKey, string> = {
"settings.title": "设置",
"settings.loading": "加载中…",
"settings.modelsProviders": "模型与模型服务",
"settings.tab.general": "通用",
"settings.tab.models": "模型",
"settings.tab.providers": "模型服务",
"settings.tab.network": "网络",
"settings.tab.permissions": "权限",
"settings.tab.sandbox": "沙箱",
"settings.tab.agent": "智能体",
"settings.tab.appearance": "外观",
"settings.tab.updates": "更新",
"settings.providerCount": "{n} 个服务",
"settings.agentMeta": "温度 {temp} · 步数 {steps}",
"settings.appearanceMeta": "主题 · 强调色 · 语言",
"settings.appearanceMeta": "主题 · 强调色 · 字号",
"settings.updatesMeta": "版本 · 配置",
"settings.closeBehavior": "关闭窗口时",
"settings.closeBehavior.background": "保持后台运行",
"settings.closeBehavior.quit": "退出 Reasonix",
"settings.manageProviders": "管理模型服务",
"settings.activeProvider": "当前模型服务",
"settings.plannerStatus": "规划模型",
"settings.plannerEnabled": "已启用",
"settings.plannerDisabled": "未启用",
"settings.plannerStatus": "规划方式",
"settings.plannerEnabled": "双模型",
"settings.plannerDisabled": "单模型",
"settings.plannerSingle": "单模型",
"settings.plannerDual": "双模型",
"settings.plannerSingleDetail": "{model} 负责规划与执行",
"settings.plannerDualDetail": "{planner} 规划 · {executor} 执行",
"settings.providers": "模型服务",
"settings.availableModels": "可用模型",
"settings.defaultModel": "默认模型",
"settings.plannerModel": "规划模型",
"settings.plannerNone": "(无 —— 单模型)",
"settings.plannerModel": "独立规划模型",
"settings.plannerNone": "使用当前模型(单模型)",
"settings.autoPlan": "自动计划模式",
"settings.autoPlan.off": "关闭",
"settings.autoPlan.on": "开启",
"settings.keySet": "已设密钥",
"settings.noKey": "无密钥",
"settings.cantDeleteDefault": "无法删除默认模型服务",
"settings.deleteProvider": "删除模型服务",
"settings.confirmDeleteProvider": "确认删除模型服务",
"settings.addProvider": "+ 添加模型服务",
"settings.providerName": "名称(如 deepseek-flash)",
"settings.providerKind": "类型",
@@ -351,6 +457,12 @@ export const zh: Record<DictKey, string> = {
"settings.providerContextWindow": "上下文窗口",
"settings.contextWindowPlaceholder": "token 数(0 = 模型服务默认值)",
"settings.contextWindowHint": "该模型服务在上下文中保留的最大 token 数。填 0 表示使用模型服务默认值。",
"settings.supportedEfforts": "支持的 effort 等级",
"settings.supportedEffortsCustomPlaceholder": "自定义等级(按回车)",
"settings.supportedEffortsHint": "勾选该模型服务在 /effort 命令中暴露的等级,或添加自定义名称。留空则使用内置默认(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
"settings.defaultEffort": "默认 effort",
"settings.defaultEffortAuto": "auto(使用内置默认)",
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。必须是上方等级之一;为空时回退到第一个等级(无选中时回退到内置默认)。",
"settings.proxyMode": "代理模式",
"settings.proxyMode.auto": "自动",
"settings.proxyMode.env": "环境变量",
@@ -365,7 +477,7 @@ export const zh: Record<DictKey, string> = {
"settings.proxyUrlHint": "可选。填写后 proxy_url 会覆盖上面的服务器和端口。",
"settings.noProxy": "不走代理",
"settings.saveNetwork": "保存网络设置",
"settings.setKey": "设置 {env}(→ .env)",
"settings.setKey": "设置 {env}(全局保存)",
"settings.saveKey": "保存密钥",
"settings.permissions": "权限",
"settings.writerMode": "写操作模式",
@@ -383,13 +495,7 @@ export const zh: Record<DictKey, string> = {
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
"settings.workspaceRoot": "工作区根目录",
"settings.workspaceDefault": "(默认:当前目录)",
"settings.agent": "智能体",
"settings.temperature": "采样温度",
"settings.maxSteps": "最大步数",
"settings.unlimited": "0 = 不限",
"settings.systemPrompt": "系统提示词",
"settings.saveAgent": "保存智能体设置",
"settings.appearance": "外观与语言",
"settings.appearance": "外观",
"settings.theme": "主题",
"settings.themeStyle": "强调色",
"settings.themeAuto": "自动",
@@ -398,6 +504,11 @@ export const zh: Record<DictKey, string> = {
"settings.themeCurrent": "当前主题:{theme} / {style}",
"settings.themeChanged": "已切换主题为 {theme} / {style}",
"settings.themeUnknown": "未知主题:{name}",
"settings.textSize": "字号",
"settings.textSizeSmall": "小",
"settings.textSizeDefault": "默认",
"settings.textSizeLarge": "大",
"settings.textSizeXLarge": "特大",
"settings.language": "语言",
"settings.langAuto": "自动(跟随系统)",
"settings.config": "配置文件:{path}",
@@ -405,6 +516,7 @@ export const zh: Record<DictKey, string> = {
// 待办栏
"todo.title": "待办",
"todo.dismiss": "关闭待办列表",
"todo.stale": "进度可能未同步",
// 斜杠菜单标签
"slash.project": "项目",
@@ -414,24 +526,47 @@ export const zh: Record<DictKey, string> = {
// 助手消息
"msg.thinking": "思考过程",
"msg.copy": "复制",
"questionNav.label": "问题导航",
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
"questionNav.hint": "点击跳转到这次提问",
"compaction.working": "正在压缩对话…",
"compaction.title": "上下文已压缩",
"compaction.messages": "{n} 条消息",
"compaction.showSummary": "展开摘要",
"compaction.hideSummary": "收起摘要",
"rewind.label": "回滚 / 分叉 / 摘要…",
"rewind.both": "代码 + 对话",
"rewind.conversation": "仅对话",
"rewind.code": "仅代码",
"rewind.fork": "从这里分叉",
"rewind.summFrom": "总结后续内容",
"rewind.summUpto": "总结到这里",
"rewind.label": "消息操作",
"rewind.disabledRunning": "等待当前操作结束",
"rewind.disabledNoCheckpoint": "这条消息没有可恢复点",
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
"rewind.filesChanged": "{count} 个文件",
"rewind.both": "回滚代码和对话",
"rewind.conversation": "仅回滚对话",
"rewind.code": "仅回滚代码",
"rewind.fork": "分叉会话",
"rewind.summFrom": "总结这条之后",
"rewind.summUpto": "总结这条之前",
"rewind.confirmFork": "确认分叉会话",
"rewind.confirmSummFrom": "确认总结这条之后",
"rewind.confirmSummUpto": "确认总结这条之前",
"rewind.confirmBoth": "确认回滚代码和对话",
"rewind.confirmConversation": "确认回滚对话",
"rewind.confirmCode": "确认回滚代码",
"rewind.busyFork": "正在从这条消息分叉…",
"rewind.busySummFrom": "正在总结这条消息之后的内容…",
"rewind.busySummUpto": "正在总结到这条消息…",
"rewind.busyConversation": "正在回滚对话…",
"rewind.busyCode": "正在回滚代码…",
"rewind.busyBoth": "正在回滚代码和对话…",
"msg.copied": "已复制",
// 工具卡片摘要
"tool.stepOne": "{n} 步",
"tool.stepOther": "{n} 步",
"tool.truncated": "输出已截断",
"tool.showAllLines": "显示全部 {n} 行",
"tool.lineOne": "{n} 行",
"tool.lineOther": "{n} 行",
"tool.matchOne": "{n} 处匹配",
@@ -466,16 +601,108 @@ export const zh: Record<DictKey, string> = {
// onboarding — first-run API-key overlay
"onboarding.title": "连接 Reasonix",
"onboarding.tagline": "粘贴一个 DeepSeek API key 即可开始。密钥仅存于本机的 .env,不会发往任何地方。",
"onboarding.tagline": "粘贴一个 DeepSeek API key 即可开始。密钥仅存于本应用的本地凭据文件,不会发往任何地方。",
"onboarding.inputLabel": "API 密钥",
"onboarding.inputPlaceholder": "sk-…",
"onboarding.submit": "连接并开始",
"onboarding.validating": "校验中…",
"onboarding.getKey": "如何获取 API key?",
"onboarding.privacy": "仅保存在本应用的 .env",
"onboarding.privacy": "仅保存在本应用的本地凭据文件",
"onboarding.error.empty": "请先粘贴密钥。",
"onboarding.error.invalid": "这个 key 无法使用 —— 检查一下是否已激活、是否开通了计费。",
"onboarding.error.network": "无法连接 DeepSeek —— 检查网络后再试。",
"onboarding.error.unknown": "出错啦:{msg}",
"onboarding.skip": "稍后设置",
// 上下文面板
"context.overview": "当前会话概览",
"context.scopeGlobal": "范围:全局",
"context.scopeProject": "项目 · {name}",
"context.sessionChanges": "本会话变更",
"context.referencedFiles": "依据文件",
"context.noChanges": "当前会话还没有变更文件",
"context.noReads": "当前会话还没有读取文件",
"context.filterChanges": "筛选本会话变更文件...",
"context.filterReads": "筛选依据文件...",
"context.changedNote": "当前会话关联 {count} 条变更记录",
"context.readNote": "当前会话读取过 {count} 条文件记录",
"context.changedMeta": "{count} 条变更记录",
"context.readMeta": "{count} 条读取记录",
"context.viewAll": "查看全部",
"context.health": "上下文状态",
"context.healthGood": "上下文状态正常",
"context.healthGoodBody": "用量、依据文件和本会话变更保持可追踪。",
"context.healthNearLimit": "上下文接近上限",
"context.healthNearLimitBody": "已使用 {pct}%,优先保留关键依据并考虑压缩。",
"context.healthManyFiles": "依据文件较多",
"context.healthManyFilesBody": "已读取 {count} 条文件记录,可查看依据文件确认是否仍相关。",
"context.healthLowCache": "缓存命中偏低",
"context.healthLowCacheBody": "当前缓存命中 {pct}%,后续长任务可关注上下文稳定性。",
"context.prompt": "Prompt",
"context.completion": "Completion",
"context.reasoning": "Reasoning",
"context.other": "Other",
"context.total": "Total",
"context.cacheHit": "缓存命中",
"context.sessionCost": "本次费用",
"context.requests": "请求数",
"context.time": "耗时",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
// 崩溃兜底页
"crash.title": "Reasonix 遇到错误 —— 请截图发送",
"crash.copy": "复制",
// 模拟/演示种子数据(仅浏览器开发模式)
"mock.sessionFixLogin": "fix the login bug in auth.go",
"mock.sessionRefactor": "refactor the payment module",
"mock.sessionReadme": "write the README and badges",
"mock.sessionPlugin": "explain the plugin host design",
"mock.trashDevStandardTitle": "00 制定项目开发规范",
"mock.trashDevStandardPreview": "整理项目开发规范,包含目录结构、分支约定和提交要求。",
"mock.trashP3aTitle": "20260521 p3a P&D",
"mock.trashP3aPreview": "复盘 p3a P&D 需求范围,记录遗留风险和下轮处理建议。",
"mock.trashGlobalProductTitle": "产品通用规范",
"mock.trashGlobalProductPreview": "沉淀跨项目通用产品规则,包含验收、灰度和异常处理。",
"mock.projectJoyquantDb": "joyquant-db",
"mock.projectJoyquantSys": "joyquant-sys",
"mock.topicDevStandard": "00 制定项目开发规范",
"mock.topicDbMaint": "01 JoyQuant DB项目维护",
"mock.topicEnv": "项目环境uv和Python运行问题",
"mock.topicP3b": "20260523 p3b P&D",
"mock.topicP3a": "20260521 p3a P&D",
"mock.topicHotfix": "20260522 post-p3-hotfix P&D",
"mock.topicSysCoord": "01 JoyQuant-SYS 项目总协调",
"mock.topicSysStandard": "00 制定项目开发规范",
"mock.topicProduct": "产品通用规范",
"mock.topicAi": "AI 工程化最佳实践",
"mock.topicLab": "临时探索与实验",
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
"mock.askQ1Header": "处理方向",
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
"mock.askQ1Opt1Label": "git stash 后 pull",
"mock.askQ1Opt1Desc": "用 git stash 暂存本地修改,拉取最新代码后再恢复",
"mock.askQ1Opt2Label": "丢弃本地修改后 pull",
"mock.askQ1Opt2Desc": "放弃本地所有修改,强制与远端同步",
"mock.askQ1Opt3Label": "另建分支保存改动",
"mock.askQ1Opt3Desc": "先创建分支把本地改动保存起来,再拉取主分支",
"mock.askQ2Header": "Reasonix 构建",
"mock.askQ2Prompt": "对于 reasonix 二进制缺失的问题,你想怎么做?",
"mock.askQ2Opt1Label": "先查文档",
"mock.askQ2Opt1Desc": "查看 README / 构建文档来确定正确的构建命令",
"mock.askQ2Opt2Label": "看构建配置",
"mock.askQ2Opt2Desc": "查看 desktop/wails.json 与 main.go 来推断入口",
"mock.askQ2Opt3Label": "我先帮你尝试构建",
"mock.askQ2Opt3Desc": "先处理 git 冲突,然后尝试本地构建并汇报结果",
"mock.todo1": "梳理当前前端结构",
"mock.todo2": "优化审批与 Ask 决策条布局",
"mock.todo2ActiveForm": "正在调整决策条布局",
"mock.todo3": "验证三种预览状态",
"mock.newSession": "新的会话",
"mock.memoryBody": "# 用户记忆\n\n始终用中文回复。",
"mock.changedFile1Prompt": "更新规范草案 v0.3",
"mock.changedFile2Prompt": "记录项目上下文",
"mock.changedFile1Path": "docs/dev-standard.md",
"mock.changedFile2Path": ".reasonix/project.md",
};
+22
View File
@@ -4,11 +4,33 @@ import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { installGlobalCrashHandlers } from "./lib/crash";
import { LocaleProvider } from "./lib/i18n";
import { initTextSize } from "./lib/textSize";
import { initTheme } from "./lib/theme";
import "./styles.css";
// Apply the saved appearance (auto/light/dark) before the first paint.
initTheme();
initTextSize();
// Pre-warm font fallback stacks so the first frame doesn't flicker between the
// browser default font and the app's configured typeface. Inserting a hidden span
// with CJK + emoji + math glyphs forces the OS font subsystem to resolve and
// cache the fallback chains before React mounts.
function prewarmFontFallbacks() {
const span = document.createElement("span");
span.style.cssText = "position:absolute;visibility:hidden;font-size:1px;pointer-events:none";
span.textContent = "中文日本語한국어 математика 😀🎉✓⚠∑∏∫";
document.body.appendChild(span);
// Force layout so the browser resolves font fallback chains.
void span.offsetHeight;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
span.remove();
});
});
}
prewarmFontFallbacks();
installGlobalCrashHandlers();
// Inside the Wails shell, suppress the webview's default right-click menu — its
File diff suppressed because it is too large. Load diff
+31
View File
@@ -21,6 +21,37 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
target: "es2021",
// Use terser for smaller output (esbuild is faster to build but produces
// larger bundles). Disabled for dev builds via the default.
minify: "terser",
terserOptions: {
compress: {
drop_console: true, // strip console.log in production
passes: 2, // two compression passes for better tree-shaking
},
},
rollupOptions: {
output: {
// Manual chunk splitting: keep the heavy markdown/math/code pipeline
// in a separate chunk so it can be cached independently from the
// app shell. The vendor chunk splits react+react-dom (stable, rarely
// changes) from the markdown stack (changes more often).
manualChunks: {
"vendor-react": ["react", "react-dom"],
"vendor-markdown": [
"react-markdown",
"remark-gfm",
"remark-math",
"rehype-katex",
"katex",
],
"vendor-highlight": ["highlight.js"],
},
},
},
// Raise the warning limit — the markdown vendor chunk is legitimately large
// (katex alone is ~300KB). The manual split ensures it's cached separately.
chunkSizeWarningLimit: 600,
},
server: {
// Bind IPv4 — unset host listens on ::1, and the Wails dev proxy's [::1]
+3 -3
View File
@@ -13,20 +13,20 @@ require reasonix v0.0.0
require (
aead.dev/minisign v0.3.0
fyne.io/systray v1.12.1
github.com/godbus/dbus/v5 v5.2.2
github.com/minio/selfupdate v0.6.0
github.com/wailsapp/wails/v2 v2.12.0
github.com/wailsapp/wails/v2 v2.11.0
golang.org/x/mod v0.36.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
+4 -4
View File
@@ -1,8 +1,8 @@
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
@@ -74,8 +74,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+58
View File
@@ -1,10 +1,14 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
@@ -61,3 +65,57 @@ func TestPreviewSessionMessagesLoadsWithoutResuming(t *testing.T) {
t.Fatalf("preview reasoning = %q, want saved reasoning", got[1].Reasoning)
}
}
func TestResumeSessionForTabTargetsSpecifiedTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
activePath := filepath.Join(dir, "active.jsonl")
inactivePath := filepath.Join(dir, "inactive.jsonl")
targetPath := filepath.Join(dir, "target.jsonl")
writeHistoryTestSession(t, activePath, "active prompt")
writeHistoryTestSession(t, inactivePath, "inactive prompt")
writeHistoryTestSession(t, targetPath, "target prompt")
activeExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
inactiveExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
activeCtrl := control.New(control.Options{Executor: activeExec, SessionDir: dir, SessionPath: activePath, Label: "active"})
inactiveCtrl := control.New(control.Options{Executor: inactiveExec, SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
defer activeCtrl.Close()
defer inactiveCtrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{
"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
},
tabOrder: []string{"active", "inactive"},
activeTabID: "active",
}
got, err := app.ResumeSessionForTab("inactive", targetPath)
if err != nil {
t.Fatalf("ResumeSessionForTab: %v", err)
}
if activeCtrl.SessionPath() != activePath {
t.Fatalf("active tab session path = %q, want %q", activeCtrl.SessionPath(), activePath)
}
if inactiveCtrl.SessionPath() != targetPath {
t.Fatalf("inactive tab session path = %q, want %q", inactiveCtrl.SessionPath(), targetPath)
}
if len(got) != 1 || got[0].Content != "target prompt" {
t.Fatalf("resumed history = %+v, want target prompt", got)
}
}
func writeHistoryTestSession(t *testing.T, path, prompt string) {
t.Helper()
session := agent.NewSession("")
session.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
if err := session.Save(path); err != nil {
t.Fatalf("Save %s: %v", path, err)
}
}
+37 -24
View File
@@ -39,20 +39,40 @@ var version = "dev"
func main() {
app := NewApp()
// Restore saved window size, or fall back to the default.
width, height := 1240, 720
if saved, ok := loadWindowState(); ok {
if saved.Width > 0 {
width = saved.Width
}
if saved.Height > 0 {
height = saved.Height
}
}
err := wails.Run(&options.App{
Title: "Reasonix",
Width: 1240,
Height: 720,
Width: width,
Height: height,
MinWidth: 760,
MinHeight: 480,
// Match the dark UI shell so first paint (before CSS loads) doesn't flash
// white — particularly visible on WebKitGTK. Uses the dark theme's --bg
// colour (#1a1a2e = RGB 26,26,46).
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
OnShutdown: app.shutdown,
Bind: []any{app},
// Match the dark UI shell so the initial webview background doesn't flash
// white before CSS loads — particularly visible on WebKitGTK.
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose,
OnShutdown: app.shutdown,
Bind: []any{app},
SingleInstanceLock: singleInstanceLock(app),
// Start hidden — domReady positions and shows the window after restoring
// geometry, so the user never sees the default size/position flash.
StartHidden: true,
// Native application menu (File > Settings, Edit, Window).
Menu: app.createAppMenu(),
// Native OS file drops: the webview withholds dropped files' paths from the
// HTML drop event, so the frontend (composer) reads them via runtime.OnFileDrop
@@ -63,22 +83,15 @@ func main() {
Mac: &mac.Options{
// Inset traffic-lights over a frameless-feeling header; the frontend
// leaves a drag region at the top (CSS --wails-draggable).
TitleBar: mac.TitleBarHiddenInset(),
Appearance: mac.NSAppearanceNameDarkAqua,
TitleBar: mac.TitleBarHiddenInset(),
// Follow the OS appearance so the title bar matches light/dark system
// preference instead of being locked to dark.
Appearance: mac.DefaultAppearance,
},
Windows: &windows.Options{
// Paint the OS title bar with the app's dark shell colour instead of the
// default light caption that clashed against the dark UI on light-mode
// Windows (#2793). Both modes use the shell colour so it always blends.
Theme: windows.Dark,
CustomTheme: &windows.ThemeSettings{
DarkModeTitleBar: windows.RGB(26, 26, 46),
DarkModeTitleText: windows.RGB(228, 228, 238),
DarkModeBorder: windows.RGB(26, 26, 46),
LightModeTitleBar: windows.RGB(26, 26, 46),
LightModeTitleText: windows.RGB(228, 228, 238),
LightModeBorder: windows.RGB(26, 26, 46),
},
// Follow the OS theme so the title bar matches light/dark system
// preference instead of being locked to dark.
Theme: windows.SystemDefault,
},
Linux: &linux.Options{
ProgramName: "Reasonix",
+40
View File
@@ -0,0 +1,40 @@
package main
import (
goruntime "runtime"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// createAppMenu builds the native application menu bar. macOS only: it's the
// platform convention there, and the Edit menu's standard roles are what make
// Cmd+C/V work in the webview. On Windows/Linux a menu bar renders as a stray
// in-window "File" strip (the Edit/Window mac roles don't show), so return nil.
func (a *App) createAppMenu() *menu.Menu {
if goruntime.GOOS != "darwin" {
return nil
}
m := menu.NewMenu()
m.Append(menu.AppMenu())
fileMenu := m.AddSubmenu("File")
fileMenu.AddText("Settings", keys.CmdOrCtrl(","), func(_ *menu.CallbackData) {
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "app:open-settings")
}
})
fileMenu.AddText("Show Reasonix", nil, func(_ *menu.CallbackData) {
a.showMainWindow()
})
fileMenu.AddText("Quit Reasonix", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
a.quitApp()
})
m.Append(menu.EditMenu())
m.Append(menu.WindowMenu())
return m
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"context"
"encoding/json"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/agent"
agenttest "reasonix/internal/agent/testutil"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
type desktopCountingTool struct {
name string
readOnly bool
calls *int32
}
func (t desktopCountingTool) Name() string { return t.name }
func (t desktopCountingTool) Description() string { return "test tool" }
func (t desktopCountingTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`)
}
func (t desktopCountingTool) ReadOnly() bool { return t.readOnly }
func (t desktopCountingTool) Execute(context.Context, json.RawMessage) (string, error) {
atomic.AddInt32(t.calls, 1)
return "ok", nil
}
func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
var calls int32
reg := tool.NewRegistry()
reg.Add(desktopCountingTool{name: "bash", calls: &calls})
args := `{"command":"python -c \"with open('prompt.txt', 'w') as f: f.write('hello')\""}`
prov := agenttest.NewMock("scripted-desktop",
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: args}}},
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c2", Name: "bash", Arguments: args}}},
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c3", Name: "bash", Arguments: args}}},
agenttest.Turn{Text: "done"},
)
events := make(chan event.Event, 32)
sink := event.FuncSink(func(e event.Event) { events <- e })
ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, sink)
ctrl := control.New(control.Options{Runner: ag, Executor: ag, Sink: sink})
app := NewApp()
app.setTestCtrl(ctrl, "scripted-desktop")
app.SubmitToTab("test", "update the prompt file")
var results []event.Event
deadline := time.After(5 * time.Second)
for {
select {
case e := <-events:
if e.Kind == event.ToolResult {
results = append(results, e)
}
if e.Kind == event.TurnDone {
if e.Err != nil {
t.Fatalf("turn failed: %v", e.Err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("bash executed %d times, want 2 before the repeat guard blocks", got)
}
if len(results) != 3 {
t.Fatalf("tool results = %d, want 3", len(results))
}
last := results[len(results)-1].Tool.Output
if !strings.Contains(last, "[loop guard]") || !strings.Contains(last, "edit_file") {
t.Fatalf("third repeated write should nudge the model to change tools, got %q", last)
}
return
}
case <-deadline:
t.Fatal("timed out waiting for desktop turn to finish")
}
}
}
+181 -6
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/fileutil"
)
@@ -24,9 +25,12 @@ var errActiveSession = errors.New("can't delete the session you're in — start
const sessionTitlesFile = ".titles.json"
const sessionDisplayFile = ".display.json"
const sessionTrashDir = ".trash"
const sessionTrashMetaFile = ".trash-meta.json"
func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) }
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }
// loadSessionTitles reads the basename→title map (missing/corrupt → empty).
func loadSessionTitles(dir string) map[string]string {
@@ -81,23 +85,133 @@ func setSessionTitle(dir, sessionPath, title string) error {
return saveSessionTitles(dir, m)
}
// deleteSessionFile removes a session's .jsonl and its sidecar state.
// deleteSessionFile moves a session's .jsonl and file sidecars into the local
// trash. Title/display sidecars stay in place so trash previews and restores can
// preserve the user's labels.
func deleteSessionFile(dir, sessionPath string) error {
sessionPath, key, err := validateSessionPath(dir, sessionPath)
if err != nil {
return err
}
return removeSessionArtifacts(dir, sessionPath, key)
return trashSessionArtifacts(dir, sessionPath, key)
}
func removeSessionArtifacts(dir, sessionPath, key string) error {
if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) {
type trashedSessionMeta struct {
Key string `json:"key"`
DeletedAt int64 `json:"deletedAt"`
}
func trashSessionArtifacts(dir, sessionPath, key string) error {
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
return nil
} else if err != nil {
return err
}
if err := os.Remove(sessionPath + ".meta"); err != nil && !os.IsNotExist(err) {
itemDir := filepath.Join(sessionTrashPath(dir), key)
if _, err := os.Stat(itemDir); err == nil {
return fmt.Errorf("session already exists in trash: %s", key)
} else if !os.IsNotExist(err) {
return err
}
if err := os.RemoveAll(strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"); err != nil {
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
if err := movePathIfExists(sessionPath, filepath.Join(itemDir, key)); err != nil {
return err
}
if err := movePathIfExists(sessionPath+".meta", filepath.Join(itemDir, key+".meta")); err != nil {
return err
}
ckptName := strings.TrimSuffix(key, ".jsonl") + ".ckpt"
if err := movePathIfExists(strings.TrimSuffix(sessionPath, ".jsonl")+".ckpt", filepath.Join(itemDir, ckptName)); err != nil {
return err
}
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
return err
}
return nil
}
func listTrashedSessionFiles(dir string) ([]string, error) {
root := sessionTrashPath(dir)
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
paths := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
key := e.Name()
if filepath.Ext(key) != ".jsonl" || filepath.Base(key) != key {
continue
}
path := filepath.Join(root, key, key)
validPath, _, _, err := validateTrashedSessionPath(dir, path)
if err != nil {
continue
}
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
paths = append(paths, validPath)
}
}
return paths, nil
}
func trashedSessionDeletedAt(path string) int64 {
b, err := os.ReadFile(filepath.Join(filepath.Dir(path), sessionTrashMetaFile))
if err != nil {
return 0
}
var meta trashedSessionMeta
if err := json.Unmarshal(b, &meta); err != nil {
return 0
}
return meta.DeletedAt
}
func restoreTrashedSessionFile(dir, path string) error {
trashPath, key, itemDir, err := validateTrashedSessionPath(dir, path)
if err != nil {
return err
}
target := filepath.Join(dir, key)
if _, err := os.Stat(target); err == nil {
return fmt.Errorf("session already exists: %s", key)
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := movePathIfExists(trashPath, target); err != nil {
return err
}
if err := movePathIfExists(trashPath+".meta", target+".meta"); err != nil {
return err
}
ckptName := strings.TrimSuffix(key, ".jsonl") + ".ckpt"
if err := movePathIfExists(filepath.Join(itemDir, ckptName), filepath.Join(dir, ckptName)); err != nil {
return err
}
return os.RemoveAll(itemDir)
}
func purgeTrashedSessionFile(dir, path string) error {
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
if err != nil {
return err
}
if err := os.RemoveAll(itemDir); err != nil {
return err
}
m := loadSessionTitles(dir)
@@ -116,6 +230,18 @@ func removeSessionArtifacts(dir, sessionPath, key string) error {
return nil
}
func movePathIfExists(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil
} else if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Rename(src, dst)
}
func validateSessionPath(dir, sessionPath string) (string, string, error) {
if strings.TrimSpace(sessionPath) == "" {
return "", "", fmt.Errorf("empty session path")
@@ -161,6 +287,55 @@ func validateSessionPath(dir, sessionPath string) (string, string, error) {
return absPath, filepath.Base(absPath), nil
}
func validateTrashedSessionPath(dir, sessionPath string) (string, string, string, error) {
if strings.TrimSpace(sessionPath) == "" {
return "", "", "", fmt.Errorf("empty session path")
}
root, err := filepath.Abs(sessionTrashPath(dir))
if err != nil {
return "", "", "", err
}
path := sessionPath
if !filepath.IsAbs(path) {
path = filepath.Join(root, path)
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", "", "", err
}
if filepath.Ext(absPath) != ".jsonl" {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
rel, err := filepath.Rel(root, absPath)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || parts[0] != parts[1] {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
if info, err := os.Lstat(absPath); err == nil {
if info.IsDir() {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
realRoot, dirErr := filepath.EvalSymlinks(root)
if dirErr != nil {
realRoot = root
}
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return "", "", "", err
}
rel, err := filepath.Rel(realRoot, realPath)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return "", "", "", fmt.Errorf("session path escapes trash dir: %s", sessionPath)
}
} else if !os.IsNotExist(err) {
return "", "", "", err
}
return absPath, filepath.Base(absPath), filepath.Dir(absPath), nil
}
type sessionDisplayMap map[string]map[string]string
func messageDisplayKey(content string) string {
+120 -10
View File
@@ -131,23 +131,36 @@ func TestDeleteSessionFile(t *testing.T) {
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("delete: %v", err)
}
// File should be gone.
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
trashMetaPath := trashPath + ".meta"
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
// File should be moved out of the active session list.
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Error("session file should be deleted")
t.Error("session file should be removed from active sessions")
}
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
t.Error("session meta should be deleted")
t.Error("session meta should be removed from active sessions")
}
if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
t.Error("session checkpoints should be deleted")
t.Error("session checkpoints should be removed from active sessions")
}
// Title should be gone.
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("session file should be in trash: %v", err)
}
if _, err := os.Stat(trashMetaPath); err != nil {
t.Fatalf("session meta should be in trash: %v", err)
}
if _, err := os.Stat(trashCkptDir); err != nil {
t.Fatalf("session checkpoints should be in trash: %v", err)
}
// Title/display should be retained until permanent deletion.
m := loadSessionTitles(dir)
if _, ok := m["session.jsonl"]; ok {
t.Error("title should be removed after delete")
if m["session.jsonl"] != "My Title" {
t.Errorf("title should be retained in trash, got %q", m["session.jsonl"])
}
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
t.Errorf("display sidecar should be removed after delete, got %q", got)
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "[Pasted text #1 · 5 lines]" {
t.Errorf("display sidecar should be retained in trash, got %q", got)
}
}
@@ -160,7 +173,104 @@ func TestDeleteSessionFileNoTitle(t *testing.T) {
t.Fatalf("delete: %v", err)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Error("session file should be deleted")
t.Error("session file should be removed from active sessions")
}
if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "no-title.jsonl", "no-title.jsonl")); err != nil {
t.Fatalf("session file should be in trash: %v", err)
}
}
func TestRestoreTrashedSessionFile(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
ckptDir := filepath.Join(dir, "session.ckpt")
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
t.Fatal(err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
t.Fatalf("restore: %v", err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("session file should be restored: %v", err)
}
if _, err := os.Stat(sessionPath + ".meta"); err != nil {
t.Fatalf("session meta should be restored: %v", err)
}
if _, err := os.Stat(ckptDir); err != nil {
t.Fatalf("session checkpoints should be restored: %v", err)
}
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
t.Fatalf("trash item should be removed after restore, stat err = %v", err)
}
if got := loadSessionTitles(dir)["session.jsonl"]; got != "My Title" {
t.Fatalf("title should survive restore, got %q", got)
}
}
func TestPurgeTrashedSessionFile(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.jsonl")
os.WriteFile(sessionPath, []byte("data"), 0o644)
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
t.Fatal(err)
}
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
t.Fatal(err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
t.Fatalf("purge: %v", err)
}
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
t.Fatalf("trash item should be removed after purge, stat err = %v", err)
}
if _, ok := loadSessionTitles(dir)["session.jsonl"]; ok {
t.Fatal("title should be removed after purge")
}
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
t.Fatalf("display sidecar should be removed after purge, got %q", got)
}
}
func TestListTrashedSessionFilesRejectsSymlinkEscape(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.jsonl")
os.WriteFile(outside, []byte("data"), 0o644)
itemDir := filepath.Join(dir, sessionTrashDir, "outside.jsonl")
if err := os.MkdirAll(itemDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(itemDir, "outside.jsonl")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
got, err := listTrashedSessionFiles(dir)
if err != nil {
t.Fatalf("list trash: %v", err)
}
if len(got) != 0 {
t.Fatalf("trash listing should skip symlink escape, got %v", got)
}
}
+215 -51
View File
@@ -2,8 +2,8 @@ package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"reasonix/internal/agent"
@@ -22,15 +22,17 @@ import (
// --- read ---
type ProviderView struct {
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Models []string `json:"models"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Models []string `json:"models"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
SupportedEfforts []string `json:"supportedEfforts"`
DefaultEffort string `json:"defaultEffort"`
}
type PermissionsView struct {
@@ -70,14 +72,19 @@ type AgentView struct {
// SettingsView is the whole Settings panel payload.
type SettingsView struct {
DefaultModel string `json:"defaultModel"`
PlannerModel string `json:"plannerModel"`
Providers []ProviderView `json:"providers"`
Permissions PermissionsView `json:"permissions"`
Sandbox SandboxView `json:"sandbox"`
Network NetworkView `json:"network"`
Agent AgentView `json:"agent"`
ConfigPath string `json:"configPath"`
DefaultModel string `json:"defaultModel"`
PlannerModel string `json:"plannerModel"`
AutoPlan string `json:"autoPlan"`
Providers []ProviderView `json:"providers"`
Permissions PermissionsView `json:"permissions"`
Sandbox SandboxView `json:"sandbox"`
Network NetworkView `json:"network"`
Agent AgentView `json:"agent"`
DesktopLanguage string `json:"desktopLanguage"`
DesktopTheme string `json:"desktopTheme"`
DesktopThemeStyle string `json:"desktopThemeStyle"`
CloseBehavior string `json:"closeBehavior"`
ConfigPath string `json:"configPath"`
// ProviderKinds lists the provider implementations the kernel actually
// registered (provider.Kinds()), so the editor's "kind" picker offers only
// kinds that resolve — selecting an unregistered one would fail the rebuild.
@@ -96,7 +103,7 @@ func nonNil(s []string) []string {
// Settings returns the current configuration for the Settings panel.
func (a *App) Settings() SettingsView {
cfg, err := config.Load()
cfg, cfgPath, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return SettingsView{
Providers: []ProviderView{},
@@ -107,9 +114,14 @@ func (a *App) Settings() SettingsView {
Ask: []string{},
Deny: []string{},
},
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
AutoPlan: "off",
DesktopTheme: "dark",
DesktopThemeStyle: "graphite",
CloseBehavior: "background",
}
}
ctrl := a.activeCtrl()
bash := cfg.Sandbox.Bash
if bash == "" {
bash = "enforce"
@@ -117,6 +129,7 @@ func (a *App) Settings() SettingsView {
v := SettingsView{
DefaultModel: cfg.DefaultModel,
PlannerModel: cfg.Agent.PlannerModel,
AutoPlan: desktopAutoPlanMode(cfg.Agent.AutoPlan),
Providers: []ProviderView{},
Permissions: PermissionsView{
Mode: orDefault(cfg.Permissions.Mode, "ask"),
@@ -140,20 +153,26 @@ func (a *App) Settings() SettingsView {
Password: cfg.Network.Proxy.Password,
},
},
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
ConfigPath: config.SourcePath(),
ProviderKinds: provider.Kinds(),
Bypass: a.ctrl != nil && a.ctrl.Bypass(),
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
DesktopLanguage: cfg.DesktopLanguage(),
DesktopTheme: cfg.DesktopTheme(),
DesktopThemeStyle: cfg.DesktopThemeStyle(),
CloseBehavior: cfg.DesktopCloseBehavior(),
ConfigPath: cfgPath,
ProviderKinds: nonNil(provider.Kinds()),
Bypass: ctrl != nil && ctrl.Bypass(),
}
for i := range cfg.Providers {
p := &cfg.Providers[i]
v.Providers = append(v.Providers, ProviderView{
Name: p.Name, Kind: p.Kind, BaseURL: p.BaseURL,
Models: nonNil(p.ModelList()), Default: p.DefaultModel(),
APIKeyEnv: p.APIKeyEnv,
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
BalanceURL: p.BalanceURL,
ContextWindow: p.ContextWindow,
APIKeyEnv: p.APIKeyEnv,
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
BalanceURL: p.BalanceURL,
ContextWindow: p.ContextWindow,
SupportedEfforts: nonNil(p.SupportedEfforts),
DefaultEffort: p.DefaultEffort,
})
}
return v
@@ -173,11 +192,10 @@ func orDefault(s, def string) string {
// keys are account-level, not per-project: writing them to the global config
// rather than the cwd's reasonix.toml is what lets them survive a workspace switch.
func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
path := config.UserConfigPath()
if path == "" {
return fmt.Errorf("cannot resolve user config directory")
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
cfg := config.LoadForEdit(path)
if err := mutate(cfg); err != nil {
return err
}
@@ -187,6 +205,65 @@ func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
return a.rebuild()
}
func (a *App) applyConfigOnly(mutate func(*config.Config) error) error {
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
if err := mutate(cfg); err != nil {
return err
}
return cfg.SaveTo(path)
}
func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
userPath := config.UserConfigPath()
if userPath == "" {
return nil, "", fmt.Errorf("cannot resolve user config directory")
}
if _, err := os.Stat(userPath); err == nil {
return config.LoadForEdit(userPath), userPath, nil
}
cfg := config.LoadForEdit(userPath)
legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
return cfg, userPath, nil
}
legacyCfg := config.LoadForEdit(legacyPath)
legacyCfg.ConfigVersion = config.Default().ConfigVersion
return legacyCfg, userPath, nil
}
func (a *App) activeWorkspaceRoot() string {
a.mu.RLock()
defer a.mu.RUnlock()
if tab := a.activeTabLocked(); tab != nil {
return tab.WorkspaceRoot
}
return "."
}
func projectConfigPathForRoot(root string) string {
if strings.TrimSpace(root) == "" || root == "." {
return "reasonix.toml"
}
return filepath.Join(root, "reasonix.toml")
}
func sameConfigPath(a, b string) bool {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
aAbs, aErr := filepath.Abs(a)
bAbs, bErr := filepath.Abs(b)
if aErr == nil && bErr == nil {
return filepath.Clean(aAbs) == filepath.Clean(bAbs)
}
return filepath.Clean(a) == filepath.Clean(b)
}
// rebuild tears down the controller and rebuilds it from the (just-changed)
// config, carrying the conversation forward. It keeps the active model if it
// still resolves; otherwise it falls back to the new default. Mirrors SetModel.
@@ -194,15 +271,20 @@ func (a *App) rebuild() error {
if a.ctx == nil {
return nil
}
tab := a.activeTab()
if tab == nil {
return fmt.Errorf("no active tab")
}
var carried []provider.Message
prevPath := ""
if a.ctrl != nil {
prevPath = a.ctrl.SessionPath()
_ = a.ctrl.Snapshot()
carried = a.ctrl.History()
if tab.Ctrl != nil {
prevPath = tab.Ctrl.SessionPath()
_ = tab.Ctrl.Snapshot()
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
model := a.model
if cfg, err := config.Load(); err == nil {
model := tab.model
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
if _, ok := cfg.ResolveModel(model); !ok {
model = cfg.DefaultModel
if e, ok := cfg.ResolveModel(model); ok {
@@ -210,20 +292,31 @@ func (a *App) rebuild() error {
}
}
}
ctrl, err := boot.Build(a.ctx, boot.Options{Model: model, RequireKey: false, Sink: a.sink, Stderr: io.Discard})
ctrl, err := boot.Build(a.bootContext(), boot.Options{
Model: model, RequireKey: false,
Sink: tab.sink,
WorkspaceRoot: tab.WorkspaceRoot,
EffortOverride: cloneStringPtr(tab.effort),
})
if err != nil {
a.startupErr = err.Error()
a.mu.Lock()
tab.StartupErr = err.Error()
tab.Ready = true
a.mu.Unlock()
a.emitReady(a.ctx)
return err
}
old := a.ctrl
a.ctrl = ctrl
a.model = model
a.label = ctrl.Label()
a.startupErr = ""
if old != nil {
old.Close()
}
a.mu.Lock()
tab.Ctrl = ctrl
tab.model = model
tab.Label = ctrl.Label()
tab.StartupErr = ""
tab.Ready = true
a.saveTabsLocked()
a.mu.Unlock()
a.emitReady(a.ctx)
ctrl.EnableInteractiveApproval()
applyTabModeToController(ctrl, tab.mode)
path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
if len(carried) > 0 {
carried = withFreshSystemPrompt(carried, systemPromptFrom(ctrl.History()))
@@ -264,8 +357,12 @@ func withFreshSystemPrompt(messages []provider.Message, system string) []provide
// SetDefaultModel sets the config default and switches the live model to it.
func (a *App) SetDefaultModel(ref string) error {
prev := a.model
a.model = ref
tab := a.activeTab()
if tab == nil {
return fmt.Errorf("no active tab")
}
prev := tab.model
tab.model = ref
if err := a.applyConfigChange(func(c *config.Config) error {
if _, ok := c.ResolveModel(ref); !ok {
return fmt.Errorf("unknown model %q", ref)
@@ -273,7 +370,7 @@ func (a *App) SetDefaultModel(ref string) error {
c.DefaultModel = ref
return nil
}); err != nil {
a.model = prev
tab.model = prev
return err
}
return nil
@@ -292,6 +389,20 @@ func (a *App) SetPlannerModel(ref string) error {
})
}
// SetAutoPlan updates the automatic plan-mode gate (off|on).
func (a *App) SetAutoPlan(mode string) error {
return a.applyConfigChange(func(c *config.Config) error { return c.SetAutoPlan(mode) })
}
func desktopAutoPlanMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "on", "ask":
return "on"
default:
return "off"
}
}
// SaveProvider adds or updates a provider. A single model fills `model`; several
// fill `models` (with `default`). The shared key/endpoint live on the entry.
func (a *App) SaveProvider(p ProviderView) error {
@@ -299,6 +410,8 @@ func (a *App) SaveProvider(p ProviderView) error {
e := config.ProviderEntry{
Name: p.Name, Kind: p.Kind, BaseURL: p.BaseURL,
APIKeyEnv: p.APIKeyEnv, BalanceURL: strings.TrimSpace(p.BalanceURL), ContextWindow: p.ContextWindow,
SupportedEfforts: p.SupportedEfforts,
DefaultEffort: p.DefaultEffort,
}
if len(p.Models) > 0 {
e.Model = p.Models[0] // also satisfies validateProvider's model requirement
@@ -376,6 +489,57 @@ func (a *App) SetNetwork(n NetworkView) error {
})
}
// SetCloseBehavior updates desktop-only window close behavior without rebuilding
// the active controller. It must stay out of provider-visible prompt/request data.
func (a *App) SetCloseBehavior(mode string) error {
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCloseBehavior(mode) })
}
// SetDesktopLanguage updates only the desktop UI language. It deliberately does
// not touch config.language, which the CLI/model-facing runtime uses.
func (a *App) SetDesktopLanguage(lang string) error {
if err := a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopLanguage(lang) }); err != nil {
return err
}
a.updateTrayLocale(lang)
return nil
}
// SetTrayLocale mirrors the resolved desktop UI language into the native tray
// menu. It is runtime-only; the persisted preference remains [desktop].language.
func (a *App) SetTrayLocale(locale string) error {
if locale != "zh" {
locale = "en"
}
a.updateTrayLocale(locale)
return nil
}
// SetDesktopAppearance updates only desktop theme preferences. It does not
// rebuild the active controller and must stay out of provider-visible requests.
func (a *App) SetDesktopAppearance(theme, style string) error {
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopAppearance(theme, style) })
}
// MigrateDesktopPreferences imports old browser-local desktop preferences into
// the user config once. Existing [desktop] values win so stale localStorage never
// overwrites an explicit config edit.
func (a *App) MigrateDesktopPreferences(language, theme, style string) error {
return a.applyConfigOnly(func(c *config.Config) error {
if strings.TrimSpace(c.Desktop.Language) == "" {
if err := c.SetDesktopLanguage(language); err != nil {
return err
}
}
if strings.TrimSpace(c.Desktop.Theme) == "" && strings.TrimSpace(c.Desktop.ThemeStyle) == "" {
if err := c.SetDesktopAppearance(theme, style); err != nil {
return err
}
}
return nil
})
}
// 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 {
+12
View File
@@ -0,0 +1,12 @@
package main
import "github.com/wailsapp/wails/v2/pkg/options"
func singleInstanceLock(app *App) *options.SingleInstanceLock {
return &options.SingleInstanceLock{
UniqueId: singleInstanceID,
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
app.secondInstanceLaunch()
},
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"testing"
"github.com/wailsapp/wails/v2/pkg/options"
)
func TestSingleInstanceLockRestoresExistingInstance(t *testing.T) {
app := NewApp()
lock := singleInstanceLock(app)
if lock == nil {
t.Fatal("singleInstanceLock returned nil")
}
if lock.UniqueId != singleInstanceID {
t.Fatalf("UniqueId = %q, want %q", lock.UniqueId, singleInstanceID)
}
if lock.OnSecondInstanceLaunch == nil {
t.Fatal("OnSecondInstanceLaunch should restore the existing window")
}
lock.OnSecondInstanceLaunch(options.SecondInstanceData{})
}
+42
View File
@@ -6,6 +6,8 @@ import (
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/skill"
)
func TestNormalizeSkillPathDirectoryLayout(t *testing.T) {
@@ -26,6 +28,7 @@ func TestSkillRootsViewCountsProjectSkills(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
project := t.TempDir()
root := filepath.Join(project, ".reasonix", "skills")
if err := os.MkdirAll(root, 0o755); err != nil {
@@ -63,6 +66,7 @@ func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
project := t.TempDir()
root := filepath.Join(home, "custom-skills")
if err := os.MkdirAll(root, 0o755); err != nil {
@@ -104,6 +108,44 @@ func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
t.Fatalf("custom skill root %q not found in %+v", root, roots)
}
func TestCapabilitiesIncludesDisabledSkills(t *testing.T) {
a := NewApp()
a.setTestCtrl(control.New(control.Options{
Skills: []skill.Skill{
{Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
},
AllSkills: []skill.Skill{
{Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
{Name: "review", Description: "disabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
},
}), "")
defer a.activeCtrl().Close()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
cfgPath := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfgPath, []byte("[skills]\ndisabled_skills = [\"review\"]\n"), 0o644); err != nil {
t.Fatal(err)
}
view := a.Capabilities()
states := map[string]bool{}
for _, sk := range view.Skills {
states[sk.Name] = sk.Enabled
}
if states["explore"] != true {
t.Fatalf("explore should be enabled in capabilities: %+v", view.Skills)
}
enabled, ok := states["review"]
if !ok || enabled {
t.Fatalf("review should be disabled but present in capabilities: %+v", view.Skills)
}
}
func realTestPath(path string) string {
if p, err := filepath.EvalSymlinks(path); err == nil {
path = p
+13
View File
@@ -0,0 +1,13 @@
package main
import "sync/atomic"
var systemQuitRequested atomic.Bool
func markSystemQuitRequested() {
systemQuitRequested.Store(true)
}
func consumeSystemQuitRequested() bool {
return systemQuitRequested.Swap(false)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build darwin
package main
/*
#cgo darwin LDFLAGS: -framework Cocoa
void installReasonixSystemQuitHook(void);
*/
import "C"
import "sync"
var installSystemQuitHookOnce sync.Once
func installSystemQuitHook() {
installSystemQuitHookOnce.Do(func() {
C.installReasonixSystemQuitHook()
})
}
//export ReasonixMarkSystemQuit
func ReasonixMarkSystemQuit() {
markSystemQuitRequested()
}
+44
View File
@@ -0,0 +1,44 @@
//go:build darwin
#import <Cocoa/Cocoa.h>
#import <objc/runtime.h>
extern void ReasonixMarkSystemQuit(void);
static NSApplicationTerminateReply (*originalApplicationShouldTerminate)(id, SEL, NSApplication *);
static void (*originalWailsContextQuit)(id, SEL);
static NSApplicationTerminateReply reasonixApplicationShouldTerminate(id self, SEL _cmd, NSApplication *sender) {
ReasonixMarkSystemQuit();
if (originalApplicationShouldTerminate != NULL) {
return originalApplicationShouldTerminate(self, _cmd, sender);
}
return NSTerminateNow;
}
static void reasonixWailsContextQuit(id self, SEL _cmd) {
ReasonixMarkSystemQuit();
if (originalWailsContextQuit != NULL) {
originalWailsContextQuit(self, _cmd);
}
}
void installReasonixSystemQuitHook(void) {
Class appDelegate = NSClassFromString(@"AppDelegate");
SEL selector = @selector(applicationShouldTerminate:);
Method method = appDelegate == Nil ? NULL : class_getInstanceMethod(appDelegate, selector);
if (method != NULL) {
IMP replacement = (IMP)reasonixApplicationShouldTerminate;
IMP previous = method_setImplementation(method, replacement);
originalApplicationShouldTerminate = (NSApplicationTerminateReply (*)(id, SEL, NSApplication *))previous;
}
Class wailsContext = NSClassFromString(@"WailsContext");
SEL quitSelector = @selector(Quit);
Method quitMethod = wailsContext == Nil ? NULL : class_getInstanceMethod(wailsContext, quitSelector);
if (quitMethod != NULL) {
IMP replacement = (IMP)reasonixWailsContextQuit;
IMP previous = method_setImplementation(quitMethod, replacement);
originalWailsContextQuit = (void (*)(id, SEL))previous;
}
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !darwin
package main
func installSystemQuitHook() {}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/control"
)
func testTab(id, root string) *WorkspaceTab {
return &WorkspaceTab{
ID: id,
Scope: "project",
WorkspaceRoot: root,
Ready: true,
Ctrl: control.New(control.Options{Label: id}),
model: "deepseek-flash/deepseek-v4-flash",
mode: "normal",
disabledMCP: map[string]ServerView{},
}
}
func TestSetEffortForTabIsTabLocal(t *testing.T) {
isolateDesktopUserDirs(t)
rootA := t.TempDir()
rootB := t.TempDir()
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tabA := testTab("a", rootA)
tabB := testTab("b", rootB)
tabA.sink = &tabEventSink{tabID: tabA.ID, app: app}
tabB.sink = &tabEventSink{tabID: tabB.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tabA.ID: tabA, tabB.ID: tabB}
app.tabOrder = []string{tabA.ID, tabB.ID}
app.activeTabID = tabA.ID
defer func() {
for _, tab := range app.tabs {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
}
}()
if err := app.SetEffortForTab(tabA.ID, "max"); err != nil {
t.Fatalf("SetEffortForTab: %v", err)
}
if got := app.EffortForTab(tabA.ID).Current; got != "max" {
t.Fatalf("tab A effort = %q, want max", got)
}
if got := app.EffortForTab(tabB.ID).Current; got != "auto" {
t.Fatalf("tab B effort = %q, want auto", got)
}
if tabB.effort != nil {
t.Fatalf("tab B stored effort = %q, want nil", *tabB.effort)
}
body, err := os.ReadFile(userConfigPathForTest())
if err == nil && strings.Contains(string(body), `effort`) {
t.Fatalf("tab-local effort should not write provider config:\n%s", body)
}
}
func TestEffortForTabResolvesProjectProviderConfig(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
configBody := `default_model = "project-provider/deepseek-v4-flash"
[[providers]]
name = "project-provider"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "PROJECT_API_KEY"
effort = "max"
`
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(configBody), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
tab := testTab("project", projectRoot)
tab.model = "project-provider/deepseek-v4-flash"
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.activeTabID = tab.ID
defer tab.Ctrl.Close()
got := app.EffortForTab(tab.ID)
if !got.Supported || got.Current != "max" {
t.Fatalf("EffortForTab project config = %+v, want supported max", got)
}
}
func TestSaveTabsPersistsModelAndEffort(t *testing.T) {
isolateDesktopUserDirs(t)
effort := "max"
app := NewApp()
tab := testTab("a", t.TempDir())
tab.effort = &effort
tab.model = "deepseek/deepseek-v4-pro"
tab.mode = "plan"
tab.Ctrl.SetPlanMode(true)
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
got := loadTabsFile()
if len(got.Tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
}
if got.Tabs[0].Model != tab.model {
t.Fatalf("saved model = %q, want %q", got.Tabs[0].Model, tab.model)
}
if got.Tabs[0].Effort == nil || *got.Tabs[0].Effort != effort {
t.Fatalf("saved effort = %#v, want %q", got.Tabs[0].Effort, effort)
}
if got.Tabs[0].Mode != "plan" {
t.Fatalf("saved mode = %q, want plan", got.Tabs[0].Mode)
}
}
func TestSaveTabsDoesNotPersistYoloMode(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
tab := testTab("a", t.TempDir())
tab.mode = "yolo"
tab.Ctrl.SetBypass(true)
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
got := loadTabsFile()
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)
}
}
func userConfigPathForTest() string {
if dir, err := os.UserConfigDir(); err == nil {
return dir + "/reasonix/reasonix.toml"
}
return ""
}
+2252
View File
File diff suppressed because it is too large. Load diff
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"strings"
"testing"
)
func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
t.Helper()
isolateDesktopUserDirs(t)
tabs := make(map[string]*WorkspaceTab, len(ids))
for _, id := range ids {
tabs[id] = &WorkspaceTab{
ID: id,
Scope: "global",
TopicID: "topic_" + id,
TopicTitle: id,
Ready: true,
disabledMCP: map[string]ServerView{},
}
}
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
}
func tabIDs(tabs []TabMeta) []string {
ids := make([]string, 0, len(tabs))
for _, tab := range tabs {
ids = append(ids, tab.ID)
}
return ids
}
func assertTabIDs(t *testing.T, got []TabMeta, want ...string) {
t.Helper()
gotIDs := tabIDs(got)
if len(gotIDs) != len(want) {
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
}
for i := range want {
if gotIDs[i] != want[i] {
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
}
}
}
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
if err := app.SetActiveTab("c"); err != nil {
t.Fatalf("SetActiveTab: %v", err)
}
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
if got := app.activeTabID; got != "c" {
t.Fatalf("active tab = %q, want c", got)
}
}
func TestReorderTabsPersistsSubmittedOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
if err := app.ReorderTabs([]string{"c", "a", "b"}); err != nil {
t.Fatalf("ReorderTabs: %v", err)
}
assertTabIDs(t, app.ListTabs(), "c", "a", "b")
if got := app.activeTabID; got != "a" {
t.Fatalf("active tab = %q, want a", got)
}
}
func TestCloseActiveTabChoosesNeighborByOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
if err := app.CloseTab("b"); err != nil {
t.Fatalf("CloseTab(b): %v", err)
}
assertTabIDs(t, app.ListTabs(), "a", "c")
if got := app.activeTabID; got != "c" {
t.Fatalf("active tab after closing middle = %q, want c", got)
}
if err := app.CloseTab("c"); err != nil {
t.Fatalf("CloseTab(c): %v", err)
}
assertTabIDs(t, app.ListTabs(), "a")
if got := app.activeTabID; got != "a" {
t.Fatalf("active tab after closing last = %q, want a", got)
}
}
func TestReorderTabsRejectsInvalidOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
for name, order := range map[string][]string{
"missing": {"a", "b"},
"unknown": {"a", "b", "missing"},
"duplicate": {"a", "b", "b"},
} {
t.Run(name, func(t *testing.T) {
if err := app.ReorderTabs(order); err == nil {
t.Fatalf("ReorderTabs(%v) succeeded, want error", order)
}
})
}
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
}
func TestNewUniqueTabIDLockedUsesFreshRandomID(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
app.mu.Lock()
got := app.newUniqueTabIDLocked()
app.mu.Unlock()
if _, exists := app.tabs[got]; exists {
t.Fatalf("newUniqueTabIDLocked returned existing id %q", got)
}
if !strings.HasPrefix(got, "tab_") {
t.Fatalf("tab id = %q, want tab_ prefix", got)
}
if len(got) != len("tab_")+32 {
t.Fatalf("tab id = %q, length %d, want 36", got, len(got))
}
}
func TestRestoredTabIDLockedReplacesEmptyAndDuplicateIDs(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
app.mu.Lock()
kept := app.restoredTabIDLocked("d")
duplicate := app.restoredTabIDLocked("a")
empty := app.restoredTabIDLocked(" ")
app.mu.Unlock()
if kept != "d" {
t.Fatalf("restored unique id = %q, want d", kept)
}
for name, got := range map[string]string{"duplicate": duplicate, "empty": empty} {
if _, exists := app.tabs[got]; exists {
t.Fatalf("%s restored id %q already exists", name, got)
}
if !strings.HasPrefix(got, "tab_") {
t.Fatalf("%s restored id = %q, want tab_ prefix", name, got)
}
}
}
+950
View File
@@ -0,0 +1,950 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
)
func writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitle,
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
return path
}
func writeLegacySession(t *testing.T, dir, name, prompt string, modTime time.Time) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
t.Fatalf("write legacy session: %v", err)
}
if err := os.Chtimes(path, modTime, modTime); err != nil {
t.Fatalf("chtimes legacy session: %v", err)
}
return path
}
func writeLegacyEventSession(t *testing.T, dir, name, prompt, reply string, modTime time.Time) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir legacy sessions: %v", err)
}
path := filepath.Join(dir, name)
body := `{"type":"user.message","id":1,"ts":"t","turn":0,"text":` + strconv.Quote(prompt) + `}` + "\n" +
`{"type":"model.final","id":2,"ts":"t","turn":0,"content":` + strconv.Quote(reply) + `,"toolCalls":[],"usage":{},"costUsd":0}` + "\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write legacy event session: %v", err)
}
if err := os.Chtimes(path, modTime, modTime); err != nil {
t.Fatalf("chtimes legacy event session: %v", err)
}
return path
}
func TestDeleteTopicKeepsSessionHistory(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_keep_history"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSession(t, dir, "keep.jsonl", topicID, "Keep history", projectRoot)
if err := NewApp().DeleteTopic(topicID); err != nil {
t.Fatalf("delete topic: %v", err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("delete topic should keep session history: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed, got %q", got)
}
}
func TestRenameProjectUpdatesSidebarTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := NewApp().RenameProject(projectRoot, "Client API"); err != nil {
t.Fatalf("rename project: %v", err)
}
nodes := NewApp().ListProjectTree()
if len(nodes) != 1 {
t.Fatalf("project tree len = %d, want 1", len(nodes))
}
if got := nodes[0].Label; got != "Client API" {
t.Fatalf("project label = %q, want Client API", got)
}
if err := NewApp().RenameProject(projectRoot, ""); err != nil {
t.Fatalf("clear project title: %v", err)
}
nodes = NewApp().ListProjectTree()
if got, want := nodes[0].Label, filepath.Base(projectRoot); got != want {
t.Fatalf("cleared project label = %q, want %q", got, want)
}
}
func TestListWorkspacesUsesProjectRegistryTitles(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Client API"); err != nil {
t.Fatalf("add project: %v", err)
}
workspaces := NewApp().ListWorkspaces()
if len(workspaces) != 1 {
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
}
if got := workspaces[0].Path; got != projectRoot {
t.Fatalf("workspace path = %q, want %q", got, projectRoot)
}
if got := workspaces[0].Name; got != "Client API" {
t.Fatalf("workspace name = %q, want Client API", got)
}
}
func TestListWorkspacesMigratesLegacyWorkspaceList(t *testing.T) {
isolateDesktopUserDirs(t)
legacyRoot := t.TempDir()
rememberWorkspace(legacyRoot)
workspaces := NewApp().ListWorkspaces()
if len(workspaces) != 1 {
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
}
if got := workspaces[0].Path; got != legacyRoot {
t.Fatalf("workspace path = %q, want %q", got, legacyRoot)
}
projects := loadProjectsFile().Projects
if len(projects) != 1 || projects[0].Root != legacyRoot {
t.Fatalf("legacy workspace was not migrated into projects: %+v", projects)
}
}
func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
older := writeLegacySession(t, dir, "older.jsonl", "older imported prompt", time.Now().Add(-2*time.Hour))
newer := writeLegacySession(t, dir, "newer.jsonl", "newer imported prompt", time.Now().Add(-time.Hour))
nodes := NewApp().ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
t.Fatalf("project tree = %#v, want global folder", nodes)
}
if got := len(nodes[0].Children); got != 2 {
t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
}
if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(newer); got != want {
t.Fatalf("newest topic first = %q, want %q", got, want)
}
if got, want := nodes[0].Children[1].TopicID, legacySessionTopicID(older); got != want {
t.Fatalf("older topic second = %q, want %q", got, want)
}
meta, ok, err := agent.LoadBranchMeta(newer)
if err != nil || !ok {
t.Fatalf("load migrated meta: ok=%v err=%v", ok, err)
}
if meta.Scope != "global" || meta.WorkspaceRoot != "" || meta.TopicID != legacySessionTopicID(newer) {
t.Fatalf("migrated meta = %+v", meta)
}
nodes = NewApp().ListProjectTree()
if got := len(nodes[0].Children); got != 2 {
t.Fatalf("migration should be idempotent, global topics = %d", got)
}
}
func TestV05LegacyEventSessionsImportIntoGlobalTopic(t *testing.T) {
home := isolateDesktopUserDirs(t)
legacyDir := filepath.Join(home, ".reasonix", "sessions")
destDir := config.SessionDir()
writeLegacyEventSession(t, legacyDir, "v053-chat.events.jsonl", "hello from v0.53", "hi from v0.53", time.Now().Add(-time.Hour))
imported, err := agent.MigrateLegacySessions(legacyDir, destDir)
if err != nil {
t.Fatalf("migrate legacy sessions: %v", err)
}
if imported != 1 {
t.Fatalf("imported legacy sessions = %d, want 1", imported)
}
migratedSession := filepath.Join(destDir, "v053-chat.jsonl")
if _, err := os.Stat(migratedSession); err != nil {
t.Fatalf("legacy v0.5 session was not imported to %s: %v", migratedSession, err)
}
wantTopicID := legacySessionTopicID(migratedSession)
migratedTopics := migrateLegacySessionsIntoGlobalTopics(destDir)
if len(migratedTopics) != 1 || migratedTopics[0] != wantTopicID {
t.Fatalf("migrated topics = %#v, want imported v0.5 topic %q", migratedTopics, wantTopicID)
}
nodes := NewApp().ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
t.Fatalf("project tree = %#v, want global folder", nodes)
}
if len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
t.Fatalf("global topics = %#v, want imported v0.5 topic %q", nodes[0].Children, wantTopicID)
}
meta, ok, err := agent.LoadBranchMeta(migratedSession)
if err != nil || !ok {
t.Fatalf("load imported v0.5 meta: ok=%v err=%v", ok, err)
}
if meta.Scope != "global" || meta.TopicID != wantTopicID {
t.Fatalf("imported v0.5 meta = %+v", meta)
}
}
func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
dotted := writeLegacySession(t, dir, "chat.1.jsonl", "dotted prompt", time.Now().Add(-2*time.Hour))
underscored := writeLegacySession(t, dir, "chat_1.jsonl", "underscored prompt", time.Now().Add(-time.Hour))
dottedTopic := legacySessionTopicID(dotted)
underscoredTopic := legacySessionTopicID(underscored)
if dottedTopic == underscoredTopic {
t.Fatalf("normalized legacy topic IDs collided: %q", dottedTopic)
}
nodes := NewApp().ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
t.Fatalf("project tree = %#v, want global folder", nodes)
}
if got := len(nodes[0].Children); got != 2 {
t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
}
seen := map[string]bool{}
for _, child := range nodes[0].Children {
seen[child.TopicID] = true
}
if !seen[dottedTopic] || !seen[underscoredTopic] {
t.Fatalf("global topics = %#v, want %q and %q", nodes[0].Children, dottedTopic, underscoredTopic)
}
}
func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeLegacySession(t, dir, "legacy-tab.jsonl", "resume this legacy tab", time.Now().Add(-time.Hour))
tab := &WorkspaceTab{
ID: "tab_legacy",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
Ready: false,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"tab_legacy": tab},
tabOrder: []string{"tab_legacy"},
activeTabID: "tab_legacy",
}
app.buildTabController(tab)
if tab.Ctrl != nil {
defer tab.Ctrl.Close()
}
wantTopicID := legacySessionTopicID(sessionPath)
if tab.TopicID != wantTopicID {
t.Fatalf("tab topicID = %q, want %q", tab.TopicID, wantTopicID)
}
if tab.Ctrl == nil {
t.Fatalf("tab controller was not built")
}
if tab.Ctrl.SessionPath() != sessionPath {
t.Fatalf("tab session path = %q, want %q", tab.Ctrl.SessionPath(), sessionPath)
}
f := loadTabsFile()
if len(f.Tabs) != 1 || f.Tabs[0].ID != "tab_legacy" || f.Tabs[0].TopicID != wantTopicID {
t.Fatalf("desktop tabs file = %+v, want tab id and migrated topic", f)
}
}
func TestReorderProjectsPersistsSidebarAndWorkspaceOrder(t *testing.T) {
isolateDesktopUserDirs(t)
first := t.TempDir()
second := t.TempDir()
third := t.TempDir()
if err := addProject(first, "First"); err != nil {
t.Fatalf("add first project: %v", err)
}
if err := addProject(second, "Second"); err != nil {
t.Fatalf("add second project: %v", err)
}
if err := addProject(third, "Third"); err != nil {
t.Fatalf("add third project: %v", err)
}
app := NewApp()
if err := app.ReorderProjects([]string{third, first, second}); err != nil {
t.Fatalf("ReorderProjects: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 3 {
t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
}
if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
t.Fatalf("project tree order = %v, want %v", got, []string{third, first, second})
}
workspaces := app.ListWorkspaces()
if len(workspaces) != 3 {
t.Fatalf("workspaces len = %d, want 3: %+v", len(workspaces), workspaces)
}
if got := []string{workspaces[0].Path, workspaces[1].Path, workspaces[2].Path}; got[0] != third || got[1] != first || got[2] != second {
t.Fatalf("workspace order = %v, want %v", got, []string{third, first, second})
}
}
func TestReorderProjectsRejectsInvalidOrder(t *testing.T) {
isolateDesktopUserDirs(t)
first := t.TempDir()
second := t.TempDir()
if err := addProject(first, "First"); err != nil {
t.Fatalf("add first project: %v", err)
}
if err := addProject(second, "Second"); err != nil {
t.Fatalf("add second project: %v", err)
}
app := NewApp()
for name, order := range map[string][]string{
"missing": {first},
"unknown": {first, filepath.Join(t.TempDir(), "missing")},
"duplicate": {first, first},
} {
t.Run(name, func(t *testing.T) {
if err := app.ReorderProjects(order); err == nil {
t.Fatalf("ReorderProjects(%v) succeeded, want error", order)
}
})
}
nodes := app.ListProjectTree()
if got := []string{nodes[0].Root, nodes[1].Root}; got[0] != first || got[1] != second {
t.Fatalf("project tree order changed after invalid reorder: %v", got)
}
}
func TestRemoveWorkspaceUsesSharedProjectRegistryForCurrentProject(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Current Project"); err != nil {
t.Fatalf("add project: %v", err)
}
app := NewApp()
tab := app.createTabEntryWithID("project", projectRoot, "topic_current", "tab_current")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
if err := app.RemoveWorkspace(projectRoot); err != nil {
t.Fatalf("remove current project: %v", err)
}
if got := app.ListWorkspaces(); len(got) != 0 {
t.Fatalf("workspaces after remove = %+v, want empty", got)
}
if got := app.ListProjectTree(); len(got) != 0 {
t.Fatalf("project tree after remove = %+v, want empty", got)
}
}
func TestRestoredProjectTabUsesStoredTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_stored_title"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "你是谁"); err != nil {
t.Fatalf("set topic title: %v", err)
}
app := NewApp()
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(tabs))
}
if got := tabs[0].TopicTitle; got != "你是谁" {
t.Fatalf("tab title = %q, want 你是谁", got)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
}
if got := nodes[0].Children[0].Label; got != tabs[0].TopicTitle {
t.Fatalf("tree title = %q, want same as tab title %q", got, tabs[0].TopicTitle)
}
}
func TestUntitledProjectTopicUsesSameFallbackEverywhere(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_without_title"
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{
Root: projectRoot,
Topics: []string{topicID},
}}}); err != nil {
t.Fatalf("save projects: %v", err)
}
app := NewApp()
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(tabs))
}
if got := tabs[0].TopicTitle; got != defaultTopicTitle {
t.Fatalf("tab title = %q, want %q", got, defaultTopicTitle)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
}
if got := nodes[0].Children[0].Label; got != defaultTopicTitle {
t.Fatalf("tree title = %q, want %q", got, defaultTopicTitle)
}
}
func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if got := topic.Title; got != defaultTopicTitle {
t.Fatalf("topic title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
t.Fatalf("title source = %q, want auto", got)
}
}
func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
first, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create first topic: %v", err)
}
second, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create second topic: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 2 {
t.Fatalf("project tree = %#v, want one project with two topics", nodes)
}
if got := nodes[0].Children[0].TopicID; got != second.ID {
t.Fatalf("first visible topic = %q, want newest %q", got, second.ID)
}
if got := nodes[0].Children[1].TopicID; got != first.ID {
t.Fatalf("second visible topic = %q, want older %q", got, first.ID)
}
}
func TestCreateGlobalTopicAppearsFirstInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
first, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create first global topic: %v", err)
}
second, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create second global topic: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 2 {
t.Fatalf("project tree = %#v, want Global with two topics", nodes)
}
if got := nodes[0].Children[0].TopicID; got != second.ID {
t.Fatalf("first visible global topic = %q, want newest %q", got, second.ID)
}
if got := nodes[0].Children[1].TopicID; got != first.ID {
t.Fatalf("second visible global topic = %q, want older %q", got, first.ID)
}
}
func TestSwitchWorkspaceRegistersDefaultTopicInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
if got, err := app.SwitchWorkspace(projectRoot); err != nil {
t.Fatalf("SwitchWorkspace: %v", err)
} else if got != projectRoot {
t.Fatalf("SwitchWorkspace root = %q, want %q", got, projectRoot)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 {
t.Fatalf("project tree len = %d, want 1: %+v", len(nodes), nodes)
}
if got := nodes[0].Root; got != projectRoot {
t.Fatalf("project root = %q, want %q", got, projectRoot)
}
if len(nodes[0].Children) != 1 {
t.Fatalf("project children len = %d, want 1: %+v", len(nodes[0].Children), nodes[0].Children)
}
child := nodes[0].Children[0]
if got := child.Label; got != defaultTopicTitle {
t.Fatalf("default topic label = %q, want %q", got, defaultTopicTitle)
}
if strings.TrimSpace(child.TopicID) == "" {
t.Fatalf("default topic ID should be persisted in the project tree: %+v", child)
}
tabs := app.ListTabs()
if len(tabs) != 1 || tabs[0].TopicID != child.TopicID {
t.Fatalf("opened tab should use the persisted topic, tabs=%+v child=%+v", tabs, child)
}
}
func TestRenameTopicLocksTitleManual(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
t.Fatalf("rename topic: %v", err)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
t.Fatalf("stored title = %q, want 手动标题", got)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
t.Fatalf("title source = %q, want manual", got)
}
}
func TestRenameTopicUpdatesOpenTabMeta(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "旧标题")
if err != nil {
t.Fatalf("create topic: %v", err)
}
tab, err := app.OpenProjectTab(projectRoot, topic.ID)
if err != nil {
t.Fatalf("open project tab: %v", err)
}
if tab.TopicTitle != "旧标题" {
t.Fatalf("opened tab title = %q, want 旧标题", tab.TopicTitle)
}
if err := app.RenameTopic(topic.ID, "新标题"); err != nil {
t.Fatalf("rename topic: %v", err)
}
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("tabs len = %d, want 1: %+v", len(tabs), tabs)
}
if got := tabs[0].TopicTitle; got != "新标题" {
t.Fatalf("open tab title = %q, want 新标题", got)
}
}
func TestAutoTitleTopicFromFirstUserMessage(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
if !updated {
t.Fatal("auto title should update")
}
if title != "讲讲这个代码库的架构" {
t.Fatalf("generated title = %q", title)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != title {
t.Fatalf("stored title = %q, want %q", got, title)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
t.Fatalf("title source = %q, want auto", got)
}
}
func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
t.Fatalf("rename topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
t.Fatalf("manual title should not auto-update, title=%q updated=%v", title, updated)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
t.Fatalf("stored title = %q, want 手动标题", got)
}
}
func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_trash_history"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Trash history"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSession(t, dir, "trash-me.jsonl", topicID, "Trash history", projectRoot)
if err := NewApp().TrashTopic(topicID); err != nil {
t.Fatalf("trash topic: %v", err)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("topic session should be removed from active history, stat err = %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "trash-me.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("topic session should be moved to trash: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed, got %q", got)
}
}
func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeLegacySession(t, dir, "restore-global.jsonl", "restore global history", time.Now().Add(-time.Hour))
topicID := legacySessionTopicID(sessionPath)
app := NewApp()
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("legacy session should start in Global, got %#v", nodes)
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("trash global topic: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "restore-global.jsonl", "restore-global.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("global session should be in trash: %v", err)
}
if got := app.ListProjectTree(); len(got) != 0 {
t.Fatalf("trashed global topic should leave project tree, got %#v", got)
}
if err := app.RestoreSession(trashPath); err != nil {
t.Fatalf("restore global session: %v", err)
}
if got := app.ListTrashedSessions(); len(got) != 0 {
t.Fatalf("trash should be empty after restore, got %#v", got)
}
nodes = app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("restored global session should reappear in Global, got %#v", nodes)
}
}
func TestRestoreProjectTopicSessionReindexesProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_restore_project"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Project restore"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
writeTopicSession(t, dir, "restore-project.jsonl", topicID, "Project restore", projectRoot)
app := NewApp()
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("trash project topic: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "restore-project.jsonl", "restore-project.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("project session should be in trash: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed while trashed, got %q", got)
}
if err := app.RestoreSession(trashPath); err != nil {
t.Fatalf("restore project session: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("restored project session should reappear in project tree, got %#v", nodes)
}
if got := loadTopicTitle(projectRoot, topicID); got != "Project restore" {
t.Fatalf("restored topic title = %q, want Project restore", got)
}
}
func TestRestoreSessionWithoutTopicMetadataFallsBackToGlobal(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeLegacySession(t, dir, "restore-orphan.jsonl", "restore orphan history", time.Now().Add(-time.Hour))
topicID := legacySessionTopicID(sessionPath)
app := NewApp()
if err := app.DeleteSession(sessionPath); err != nil {
t.Fatalf("delete orphan session: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "restore-orphan.jsonl", "restore-orphan.jsonl")
if err := app.RestoreSession(trashPath); err != nil {
t.Fatalf("restore orphan session: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("restored orphan session should fall back to Global, got %#v", nodes)
}
}
func TestTrashTopicMovesOpenSessionToTrash(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_open_trash"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Open trash"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := filepath.Join(dir, "open-trash.jsonl")
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Open trash",
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
openTab := &WorkspaceTab{
ID: "tab_open",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Open trash",
Ctrl: controllerWithContent(t, sessionPath),
Ready: true,
disabledMCP: map[string]ServerView{},
}
otherTab := &WorkspaceTab{
ID: "tab_other",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: "topic_keep",
TopicTitle: "Keep",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"tab_open": openTab, "tab_other": otherTab},
tabOrder: []string{"tab_open", "tab_other"},
activeTabID: "tab_open",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("trash topic: %v", err)
}
if _, ok := app.tabs["tab_open"]; ok {
t.Fatalf("open tab for trashed topic should be removed")
}
if got := app.activeTabID; got != "tab_other" {
t.Fatalf("active tab = %q, want tab_other", got)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("open topic session should be removed from active history, stat err = %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "open-trash.jsonl", "open-trash.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("open topic session should be moved to trash: %v", err)
}
trashed := app.ListTrashedSessions()
if len(trashed) != 1 || trashed[0].Path != trashPath {
t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed, got %q", got)
}
}
func TestLegacyMigrationSkipsProjectScopedSessions(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := writeLegacySession(t, dir, "scoped.jsonl", "hello", time.Now())
meta, err := agent.EnsureBranchMeta(path)
if err != nil {
t.Fatal(err)
}
meta.Scope = "project"
meta.WorkspaceRoot = filepath.Join(t.TempDir(), "proj")
meta.TopicID = ""
if err := agent.SaveBranchMeta(path, meta); err != nil {
t.Fatal(err)
}
migrateLegacySessionsIntoGlobalTopics(dir)
got, err := agent.EnsureBranchMeta(path)
if err != nil {
t.Fatal(err)
}
if got.Scope != "project" || got.WorkspaceRoot != meta.WorkspaceRoot {
t.Fatalf("project-scoped legacy session must not be forced into Global: %+v", got)
}
}
func TestLegacyMigrationConcurrentRunsHaveNoLostUpdates(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
const n = 8
want := make(map[string]bool, n)
for i := 0; i < n; i++ {
p := writeLegacySession(t, dir, fmt.Sprintf("legacy-%d.jsonl", i), "hi", time.Now())
want[legacySessionTopicID(p)] = true
}
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
migrateLegacySessionsIntoGlobalTopics(dir)
}()
}
wg.Wait()
gotSet := map[string]bool{}
for _, id := range loadProjectsFile().GlobalTopics {
gotSet[id] = true
}
for id := range want {
if !gotSet[id] {
t.Fatalf("concurrent migration lost topic %q; GlobalTopics=%v", id, loadProjectsFile().GlobalTopics)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"sync"
"fyne.io/systray"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
type desktopTray struct {
end func()
openItem *systray.MenuItem
quitItem *systray.MenuItem
once sync.Once
}
func (a *App) startTray() {
if !traySupported() {
return
}
a.mu.Lock()
if a.tray != nil {
a.mu.Unlock()
return
}
t := &desktopTray{}
a.tray = t
a.mu.Unlock()
t.end = startDesktopTray(func() {
systray.SetIcon(trayIconBytes)
systray.SetTitle("Reasonix")
systray.SetTooltip("Reasonix")
systray.SetOnTapped(func() { a.showFromTray() })
labels := trayMenuLabels(a.trayLocale())
t.openItem = systray.AddMenuItem(labels.openTitle, labels.openTooltip)
t.quitItem = systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
a.mu.Lock()
a.trayReady = true
a.mu.Unlock()
go func() {
for range t.openItem.ClickedCh {
a.showFromTray()
}
}()
go func() {
for range t.quitItem.ClickedCh {
a.quitFromTray()
}
}()
}, func() {
a.mu.Lock()
a.trayReady = false
a.mu.Unlock()
})
}
func (a *App) stopTray() {
a.mu.RLock()
t := a.tray
a.mu.RUnlock()
if t == nil || t.end == nil {
return
}
t.once.Do(t.end)
}
func (a *App) updateTrayLocale(locale string) {
a.mu.RLock()
t := a.tray
a.mu.RUnlock()
if t == nil || t.openItem == nil || t.quitItem == nil {
return
}
labels := trayMenuLabels(locale)
t.openItem.SetTitle(labels.openTitle)
t.openItem.SetTooltip(labels.openTooltip)
t.quitItem.SetTitle(labels.quitTitle)
t.quitItem.SetTooltip(labels.quitTooltip)
}
func (a *App) trayLocale() string {
cfg, _, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return ""
}
return cfg.DesktopLanguage()
}
func (a *App) showFromTray() {
ctx := a.ctx
if ctx == nil {
return
}
wruntime.Show(ctx)
wruntime.WindowShow(ctx)
wruntime.WindowUnminimise(ctx)
}
func (a *App) quitFromTray() {
a.quitApp()
}
type trayLabels struct {
openTitle string
openTooltip string
quitTitle string
quitTooltip string
}
func trayMenuLabels(locale string) trayLabels {
if locale == "zh" {
return trayLabels{
openTitle: "打开",
openTooltip: "打开 Reasonix 窗口",
quitTitle: "退出",
quitTooltip: "退出 Reasonix",
}
}
return trayLabels{
openTitle: "Open",
openTooltip: "Open the Reasonix window",
quitTitle: "Quit",
quitTooltip: "Quit Reasonix",
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package main
import _ "embed"
//go:embed build/appicon.png
var trayIconBytes []byte
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package main
import _ "embed"
// Windows systray loads icons through LoadImage(IMAGE_ICON), so it needs ICO
// bytes rather than the PNG app icon used by macOS/Linux.
//
//go:embed build/windows/icon.ico
var trayIconBytes []byte
+14
View File
@@ -0,0 +1,14 @@
//go:build windows
package main
import "testing"
func TestWindowsTrayIconUsesICOBytes(t *testing.T) {
if len(trayIconBytes) < 4 {
t.Fatalf("tray icon is too small: %d bytes", len(trayIconBytes))
}
if trayIconBytes[0] != 0x00 || trayIconBytes[1] != 0x00 || trayIconBytes[2] != 0x01 || trayIconBytes[3] != 0x00 {
t.Fatalf("Windows tray icon must be ICO bytes, got header % x", trayIconBytes[:4])
}
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package main
import "fyne.io/systray"
func startDesktopTray(onReady, onExit func()) func() {
start, end := systray.RunWithExternalLoop(onReady, onExit)
start()
return end
}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
package main
import "fyne.io/systray"
func startDesktopTray(onReady, onExit func()) func() {
go systray.Run(onReady, onExit)
return systray.Quit
}
+8
View File
@@ -0,0 +1,8 @@
//go:build darwin
package main
// systray creates the NSStatusItem (an NSWindow) on the calling goroutine, but
// Cocoa demands the main thread — which Wails owns — so the tray crashes the app
// at launch (#3223). macOS backgrounds via runtime.Hide + the Dock instead.
func traySupported() bool { return false }
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff. Show more