From 3dfc8d11a639e49dbd087f3a2c8d1fdc4040bde1 Mon Sep 17 00:00:00 2001 From: Sivan Date: Fri, 29 May 2026 19:05:04 +0800 Subject: [PATCH 01/10] fix(cli): restore TUI scroll and copy command --- docs/CLI-REFERENCE.md | 22 +-- docs/cli-ref-i18n.js | 18 ++- docs/cli-reference.html | 66 ++------- src/cli/commands/chat.tsx | 21 +-- src/cli/ui/App.tsx | 3 + src/cli/ui/copy-history.ts | 211 ++++++++++++++++++++++++++++ src/cli/ui/history-scroll-mode.ts | 3 +- src/cli/ui/mouse-mode.ts | 10 +- src/cli/ui/slash/commands.ts | 6 + src/cli/ui/slash/handlers/basic.ts | 24 ++++ src/cli/ui/slash/types.ts | 3 + src/cli/ui/stdin-reader.ts | 8 +- src/config.ts | 4 +- src/i18n/EN.ts | 22 ++- src/i18n/JA.ts | 2 +- src/i18n/de.ts | 2 +- src/i18n/zh-CN.ts | 18 ++- tests/chat-scroll-wheel.test.ts | 20 ++- tests/copy-history.test.ts | 77 ++++++++++ tests/slash.test.ts | 45 +++++- tests/stdin-reader.test.ts | 12 ++ tests/ui-slash-suggestions.test.tsx | 6 +- 22 files changed, 489 insertions(+), 114 deletions(-) create mode 100644 src/cli/ui/copy-history.ts create mode 100644 tests/copy-history.test.ts diff --git a/docs/CLI-REFERENCE.md b/docs/CLI-REFERENCE.md index 8575920ce..f9be9d7ae 100644 --- a/docs/CLI-REFERENCE.md +++ b/docs/CLI-REFERENCE.md @@ -40,7 +40,7 @@ Run `reasonix --help` (or any subcommand with `--help`) for the full flag list. | `--no-config` | Ignore `~/.reasonix/config.json` for this run | | `--no-dashboard` | Don't auto-start the embedded web dashboard | | `--no-alt-screen` | Render to scrollback instead of the alt-screen buffer (preserves chat in shell history; legacy mode, can ghost on resize) | -| `--no-mouse` | Disable DECSET 1007 (alternate-scroll); wheel reverts to native terminal scroll | +| `--no-mouse` | Disable app-managed mouse tracking; wheel reverts to native terminal scroll | --- @@ -57,7 +57,7 @@ Type `/` mid-chat to open the picker. Aliases shown in parentheses. Code-mode-on | `/retry` | Truncate and resend your last message — fresh sample | | `/compact` | Fold older turns into a summary (cache-safe). Auto-fires at 50% ctx; this is the manual trigger | | `/stop` | Abort the current model turn (typed alternative to Esc) | -| `/copy` | Open vim/tmux-style copy mode — `j`/`k` navigate, `v` select, `y` yank to clipboard. The right answer for SSH / mosh / tmux where drag-select can't extend past the viewport | +| `/copy [all\|last\|assistant\|N]` | Copy the latest assistant response by default; `all` copies the whole chat and `N` copies the last N items | ### Setup @@ -176,7 +176,7 @@ Type `/` mid-chat to open the picker. Aliases shown in parentheses. Code-mode-on | Drag | Selects text natively — no modifier needed | | Right-click | Terminal-native (e.g. paste menu on Windows Terminal) | -Reasonix sets DECSET 1007 (alternate-scroll) only — wheel events translate to ↑/↓ keypresses for the app, but native click/drag selection is left untouched. Pass `--no-mouse` to opt out entirely. +In auto/app scroll mode, Reasonix captures mouse-wheel reports so chat history can scroll inside terminals whose native scrollback does not move TUI content. Pass `--no-mouse` to opt out entirely. --- @@ -195,23 +195,9 @@ The default path is **terminal-native**. Drag to select, then use your terminal' In SSH / mosh / tmux, the alt-screen buffer prevents the terminal from extending the selection past the visible viewport — there is no scrollback above the alt-screen to drag into. Two fixes: -1. **`/copy`** — open vim/tmux-style copy mode in-app. Snapshots the current chat to a navigable buffer; `y` yanks to clipboard via OSC 52 (with a temp-file fallback for terminals that don't support it). +1. **`/copy`** — copy the latest assistant response through OSC 52. Use `/copy all` for the full chat or `/copy N` for the last N serialized chat items. Oversized content falls back to a temp file path. 2. **`--no-alt-screen`** — render to shell scrollback instead. Drag-select then works terminal-natively (the chat content is real lines in the scrollback above your cursor). Trade-off: redraw can ghost on resize. -### `/copy` — copy mode keys - -| Key | What it does | -|---|---| -| `j` / `↓` | Cursor down one line | -| `k` / `↑` | Cursor up one line | -| `PgUp` / `PgDn` | Page up / down | -| `g` / `G` | Jump to top / bottom | -| `v` | Start (or cancel) selection at the cursor | -| `y` / `Enter` | Yank selection to clipboard, exit | -| `q` / `Esc` | Quit without yanking | - -`y` with no active selection yanks just the current line. The yank goes through OSC 52 first (works through SSH, mosh, tmux with `set -g set-clipboard on`); content larger than 75 KB falls back to a temp file whose path is printed on exit. - --- ## Where this lives diff --git a/docs/cli-ref-i18n.js b/docs/cli-ref-i18n.js index b746a9839..91b234366 100644 --- a/docs/cli-ref-i18n.js +++ b/docs/cli-ref-i18n.js @@ -47,7 +47,7 @@ "ms.title": "Mouse", "ms.body": - "Reasonix sets DECSET 1007 (alternate-scroll) only — wheel events translate to ↑/↓ keypresses for the app, but native click/drag selection is left untouched. Pass --no-mouse to opt out entirely.", + "In auto/app scroll mode, Reasonix captures mouse-wheel reports so chat history can scroll inside terminals whose native scrollback does not move TUI content. Pass --no-mouse to opt out entirely.", "cp.title": "Copy / paste", "cp.body": @@ -56,12 +56,11 @@ "cp.body.drag": "In SSH / mosh / tmux, the alt-screen buffer prevents the terminal from extending the selection past the visible viewport — there is no scrollback above the alt-screen to drag into. Two fixes:", "cp.fix1": - "/copy — open vim/tmux-style copy mode in-app. Snapshots the current chat to a navigable buffer; y yanks to clipboard via OSC 52 (with a temp-file fallback for terminals that don't support it).", + "/copy — copy the latest assistant response through OSC 52. Use /copy all for the full chat or /copy N for the last N serialized chat items. Oversized content falls back to a temp file path.", "cp.fix2": "--no-alt-screen — render to shell scrollback instead. Drag-select then works terminal-natively (the chat content is real lines in the scrollback above your cursor). Trade-off: redraw can ghost on resize.", - "cp.h.copymode": "/copy — copy mode keys", - "cp.body.osc": - "y with no active selection yanks just the current line. The yank goes through OSC 52 first (works through SSH, mosh, tmux with set -g set-clipboard on); content larger than 75 KB falls back to a temp file whose path is printed on exit.", + "cp.h.copymode": "/copy", + "cp.body.osc": "/copy uses OSC 52 first; large content falls back to a temp file.", }; var zh = { @@ -105,7 +104,7 @@ "ms.title": "鼠标", "ms.body": - "Reasonix 只设置 DECSET 1007(alternate-scroll)——滚轮事件转为 ↑/↓ 按键传给应用,原生点击/拖拽选择不受影响。加 --no-mouse 可完全关闭。", + "在 auto/app 滚动模式下,Reasonix 会捕获鼠标滚轮事件,让原生 scrollback 无法滚动 TUI 内容的终端也能滚聊天。加 --no-mouse 可完全关闭。", "cp.title": "复制 / 粘贴", "cp.body": @@ -114,12 +113,11 @@ "cp.body.drag": "SSH / mosh / tmux 下,alt-screen 缓冲区会阻止终端把选区延伸到可视视口以外——alt-screen 上方根本没有 scrollback 可拖入。两种解决方式:", "cp.fix1": - "/copy — 在应用内打开 vim/tmux 风格的复制模式,把当前聊天快照到可导航的缓冲区;y 通过 OSC 52 复制到剪贴板(不支持 OSC 52 的终端会退到临时文件)。", + "/copy — 通过 OSC 52 复制最近一条 assistant 回复。用 /copy all 复制完整聊天,或 /copy N 复制最近 N 个序列化聊天项;内容过大时退到临时文件路径。", "cp.fix2": "--no-alt-screen — 改为渲染到 shell scrollback。拖拽选择恢复终端原生(聊天内容就是光标上方的真实行)。代价:窗口大小改变时可能出现重绘残影。", - "cp.h.copymode": "/copy — 复制模式快捷键", - "cp.body.osc": - "没有活动选区时按 y 只复制当前行。复制先走 OSC 52(通过 SSH、mosh、开了 set -g set-clipboard on 的 tmux 均可用);超过 75 KB 的内容退到临时文件,路径在退出时打印。", + "cp.h.copymode": "/copy", + "cp.body.osc": "/copy 优先使用 OSC 52;大内容会退到临时文件。", }; var DICT = { en: en, zh: zh }; diff --git a/docs/cli-reference.html b/docs/cli-reference.html index 6de88325b..7fbbef186 100644 --- a/docs/cli-reference.html +++ b/docs/cli-reference.html @@ -302,7 +302,7 @@

Notable runtime flags (chat / code)

--no-mouse - Disable DECSET 1007 (alternate-scroll); wheel reverts to native terminal scroll + Disable app-managed mouse tracking; wheel reverts to native terminal scroll @@ -345,8 +345,8 @@

Chat ops

Abort the current model turn (typed alternative to Esc) - /copy - Open vim/tmux-style copy mode — j/k navigate, v select, y yank to clipboard. The right answer for SSH / mosh / tmux where drag-select can't extend past the viewport + /copy [all|last|assistant|N] + Copy the latest assistant response by default; all copies the whole chat and N copies the last N items @@ -685,9 +685,9 @@

Mouse

- Reasonix sets DECSET 1007 (alternate-scroll) only — wheel events translate to - ↑/↓ keypresses for the app, but native click/drag selection is left untouched. - Pass --no-mouse to opt out entirely. + In auto/app scroll mode, Reasonix captures mouse-wheel reports so chat history can + scroll inside terminals whose native scrollback does not move TUI content. Pass + --no-mouse to opt out entirely.

@@ -732,10 +732,10 @@

When drag-select doesn't work

  1. - /copy — open vim/tmux-style copy mode in-app. - Snapshots the current chat to a navigable buffer; y yanks to - clipboard via OSC 52 (with a temp-file fallback for terminals that don't - support it). + /copy — copy the latest assistant response through + OSC 52. Use /copy all for the full chat or /copy N + for the last N serialized chat items. Oversized content falls back to a temp + file path.
  2. --no-alt-screen — render to shell scrollback @@ -744,52 +744,6 @@

    When drag-select doesn't work

    resize.
- -

/copy — copy mode keys

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyWhat it does
j / Cursor down one line
k / Cursor up one line
PgUp / PgDnPage up / down
g / GJump to top / bottom
vStart (or cancel) selection at the cursor
y / EnterYank selection to clipboard, exit
q / EscQuit without yanking
-

- y with no active selection yanks just the current line. The yank - goes through OSC 52 first (works through SSH, mosh, tmux with - set -g set-clipboard on); content larger than 75 KB falls back to - a temp file whose path is printed on exit. -

diff --git a/src/cli/commands/chat.tsx b/src/cli/commands/chat.tsx index 7bb36c3e8..278bccf05 100644 --- a/src/cli/commands/chat.tsx +++ b/src/cli/commands/chat.tsx @@ -314,11 +314,14 @@ export async function chatCommand(opts: ChatOptions): Promise { const mcpSpecs = [...requestedSpecs]; const mcpServers: McpServerSummary[] = []; const cfg = readConfig(); - const historyScrollMode = resolveHistoryScrollMode({ - configured: loadHistoryScrollMode(), - env: process.env, - platform: process.platform, - }); + const historyScrollMode = + opts.noMouse || cfg.mouseTracking === false + ? "native" + : resolveHistoryScrollMode({ + configured: loadHistoryScrollMode(), + env: process.env, + platform: process.platform, + }); const startupInfoHints: string[] = []; const hasAnyMcp = normalizeMcpConfig(cfg).length > 0 || mcpSpecs.length > 0; if (cfg.setupCompleted === true && !hasAnyMcp) { @@ -424,10 +427,10 @@ export async function chatCommand(opts: ChatOptions): Promise { // path so N cards don't accumulate N native stdout listeners. installResizeBroadcaster(); - // Wheel scrolling. Opt-out via `mouseTracking: false` for users who - // prefer native drag-select copy (Shift+drag still selects with mouse - // mode on in most terminals). exit hooks cover hard kills so the - // sequence doesn't leak into the parent shell. + // Wheel scrolling. Opt-out via `--no-mouse` / `mouseTracking: false` also + // forces native history mode so the terminal, not CardStream, owns wheel + // movement. Exit hooks cover hard kills so the sequence doesn't leak into + // the parent shell. if (!opts.noMouse && cfg.mouseTracking !== false) { enableMouseMode(historyScrollMode); process.once("exit", disableMouseMode); diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 5d5c6daec..80d72c94c 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -488,6 +488,7 @@ function AppInner({ markPhase("app_inner_start"); const log = useScrollback(); const agentStore = useAgentStore(); + const getCardsForSlash = useCallback(() => agentStore.getState().cards, [agentStore]); const hasConversation = useAgentState((s) => s.cards.some((c) => c.kind === "user" || c.kind === "streaming"), ); @@ -3083,6 +3084,7 @@ function AppInner({ footer: args.footer, oneTime: false, }), + getCards: getCardsForSlash, dispatch: agentStore.dispatch, markPlanStepDone: (stepId: string) => { const steps = planStepsRef.current; @@ -3678,6 +3680,7 @@ function AppInner({ startDashboard, stopDashboard, getDashboardUrl, + getCardsForSlash, broadcastDashboardEvent, touchedPaths, model, diff --git a/src/cli/ui/copy-history.ts b/src/cli/ui/copy-history.ts new file mode 100644 index 000000000..57a50bea4 --- /dev/null +++ b/src/cli/ui/copy-history.ts @@ -0,0 +1,211 @@ +import type { Card } from "./state/cards.js"; + +export type CopyHistoryMode = + | { kind: "latest-assistant" } + | { kind: "all" } + | { kind: "last"; count: number }; + +export interface CopyHistorySelection { + label: string; + text: string; +} + +export function parseCopyHistoryArgs(args: readonly string[]): CopyHistoryMode | { error: string } { + if (args.length === 0) return { kind: "latest-assistant" }; + if (args.length === 1) { + const raw = args[0]!.toLowerCase(); + if (raw === "last" || raw === "assistant" || raw === "reply" || raw === "response") { + return { kind: "latest-assistant" }; + } + if (raw === "all") return { kind: "all" }; + if (/^\d+$/.test(raw)) { + const count = Number.parseInt(raw, 10); + if (count > 0) return { kind: "last", count }; + } + } + return { error: "usage" }; +} + +export function selectCopyHistory( + cards: ReadonlyArray, + mode: CopyHistoryMode = { kind: "latest-assistant" }, +): CopyHistorySelection | null { + if (mode.kind === "latest-assistant") { + for (let i = cards.length - 1; i >= 0; i--) { + const card = cards[i]!; + if (card.kind !== "streaming") continue; + const text = normalize(card.text); + if (text) return { label: "last assistant response", text }; + } + return null; + } + + const entries = cards.flatMap(cardToCopyEntries).filter((entry) => entry.text.length > 0); + const selected = mode.kind === "all" ? entries : entries.slice(-mode.count); + if (selected.length === 0) return null; + return { + label: mode.kind === "all" ? "conversation" : `last ${selected.length} item(s)`, + text: selected.map((entry) => `${entry.label}:\n${entry.text}`).join("\n\n"), + }; +} + +interface CopyEntry { + label: string; + text: string; +} + +function cardToCopyEntries(card: Card): CopyEntry[] { + switch (card.kind) { + case "user": + return [{ label: "User", text: normalize(card.text) }]; + case "streaming": + return [{ label: "Assistant", text: normalize(card.text) }]; + case "tool": { + const args = card.args === undefined ? "" : formatJson(card.args); + const body = [args ? `Args:\n${args}` : "", card.output ? `Output:\n${card.output}` : ""] + .filter(Boolean) + .join("\n\n"); + return [{ label: `Tool ${card.name}`, text: normalize(body) }]; + } + case "reasoning": + return [{ label: "Reasoning", text: normalize(card.text) }]; + case "live": + return [ + { label: "Info", text: normalize(card.meta ? `${card.text}\n${card.meta}` : card.text) }, + ]; + case "warn": + return [ + { + label: "Warning", + text: normalize([card.title, card.message, card.detail].filter(Boolean).join("\n")), + }, + ]; + case "error": + return [ + { + label: "Error", + text: normalize([card.title, card.message, card.stack].filter(Boolean).join("\n")), + }, + ]; + case "plan": + return [ + { + label: "Plan", + text: normalize( + [card.title, ...card.steps.map((step) => `- [${step.status}] ${step.title}`)].join( + "\n", + ), + ), + }, + ]; + case "task": + return [ + { + label: "Task", + text: normalize( + [ + `${card.title} (${card.status})`, + ...card.steps.map((step) => `- [${step.status}] ${step.title}`), + ].join("\n"), + ), + }, + ]; + case "diff": + return [ + { + label: `Diff ${card.file}`, + text: normalize( + card.hunks + .map((hunk) => + [ + hunk.header, + ...hunk.lines.map( + (line) => + `${line.kind === "add" ? "+" : line.kind === "del" ? "-" : " "}${line.text}`, + ), + ].join("\n"), + ) + .join("\n\n"), + ), + }, + ]; + case "usage": + return [ + { + label: "Usage", + text: normalize( + `turn ${card.turn}: prompt ${card.tokens.prompt}, reasoning ${card.tokens.reason}, output ${card.tokens.output}, cost $${card.cost}`, + ), + }, + ]; + case "memory": + return [ + { + label: "Memory", + text: normalize( + card.entries.map((entry) => `- ${entry.category}: ${entry.summary}`).join("\n"), + ), + }, + ]; + case "subagent": + return [ + { + label: `Subagent ${card.name}`, + text: normalize( + [ + `${card.task} (${card.status})`, + ...card.children + .flatMap(cardToCopyEntries) + .map((entry) => `${entry.label}:\n${entry.text}`), + ].join("\n\n"), + ), + }, + ]; + case "search": + return [ + { + label: "Search", + text: normalize( + [ + `query: ${card.query}`, + ...card.hits.map((hit) => `${hit.file}:${hit.line}: ${hit.preview}`), + ].join("\n"), + ), + }, + ]; + case "ctx": + return [{ label: "Context", text: normalize(card.text) }]; + case "tip": + return [ + { + label: "Tip", + text: normalize( + [ + card.topic, + ...card.sections.flatMap((section) => [ + section.title ? `[${section.title}]` : "", + ...section.rows.map((row) => `${row.key}\t${row.text}`), + ]), + card.footer ?? "", + ].join("\n"), + ), + }, + ]; + case "compaction": + return [{ label: "Compaction", text: normalize(card.summary) }]; + default: + return []; + } +} + +function normalize(text: string): string { + return text.trim(); +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/src/cli/ui/history-scroll-mode.ts b/src/cli/ui/history-scroll-mode.ts index b7af60b3c..8df54b861 100644 --- a/src/cli/ui/history-scroll-mode.ts +++ b/src/cli/ui/history-scroll-mode.ts @@ -15,11 +15,12 @@ export function resolveHistoryScrollMode({ }: ResolveHistoryScrollModeInput = {}): ResolvedHistoryScrollMode { if (configured === "native") return "native"; if (configured === "app") return "app"; + if ((env.TERM_PROGRAM ?? "").toLowerCase() === "apple_terminal") return "native"; if (isKnownJumpProneTerminal(env)) return "app"; if (platform === "win32" && env.TERM_PROGRAM === undefined && env.MSYSTEM === undefined) { return "native"; } - return "native"; + return "app"; } function isKnownJumpProneTerminal(env: NodeJS.ProcessEnv | Record) { diff --git a/src/cli/ui/mouse-mode.ts b/src/cli/ui/mouse-mode.ts index adbee93ea..2af9cf245 100644 --- a/src/cli/ui/mouse-mode.ts +++ b/src/cli/ui/mouse-mode.ts @@ -1,8 +1,8 @@ -// Reasonix is append-only now: the terminal owns scrollback, copy, and the -// mouse wheel. On most terminals, startup emits disables for common -// mouse-capture modes so stale state from a prior crashed TUI can't keep -// eating wheel events. Apple Terminal has had native crashes in its renderer -// after receiving these private mouse-mode toggles, so its default is silent. +// In native history mode the terminal owns scrollback, copy, and the mouse +// wheel. In app history mode Reasonix captures wheel reports so CardStream can +// scroll consistently in terminals whose native scrollback does not move TUI +// content. Apple Terminal has had native crashes in its renderer after +// receiving these private mouse-mode toggles, so its default is silent. // REASONIX_MOUSE_MODE remains an escape hatch. type Mode = "alternate-scroll" | "sgr" | "off" | "apple-terminal-off"; diff --git a/src/cli/ui/slash/commands.ts b/src/cli/ui/slash/commands.ts index 15f1a12aa..929580818 100644 --- a/src/cli/ui/slash/commands.ts +++ b/src/cli/ui/slash/commands.ts @@ -63,6 +63,12 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [ group: "chat", summary: "abort the current model turn (typed alternative to Esc)", }, + { + cmd: "copy", + group: "chat", + argsHint: "[all|last|assistant|N]", + summary: "copy the latest assistant response, the whole conversation, or the last N items", + }, { cmd: "btw", group: "chat", diff --git a/src/cli/ui/slash/handlers/basic.ts b/src/cli/ui/slash/handlers/basic.ts index 53cdcafaa..c1bcba019 100644 --- a/src/cli/ui/slash/handlers/basic.ts +++ b/src/cli/ui/slash/handlers/basic.ts @@ -1,6 +1,8 @@ import { wrapToCells } from "@/cli/ui/text-width.js"; import { t, tObj } from "@/i18n/index.js"; import { VERSION } from "@/version.js"; +import { writeClipboard } from "../../clipboard.js"; +import { parseCopyHistoryArgs, selectCopyHistory } from "../../copy-history.js"; import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js"; import { SLASH_COMMANDS, SLASH_GROUP_ORDER, orderSlashCommandsByGroup } from "../commands.js"; import type { SlashHandler } from "../dispatch.js"; @@ -151,6 +153,27 @@ const keys: SlashHandler = (_args, _loop, ctx) => { return {}; }; +const copy: SlashHandler = (args, _loop, ctx) => { + if (!ctx.getCards) return { info: t("handlers.basic.copyNeedsTui") }; + const mode = parseCopyHistoryArgs(args); + if ("error" in mode) return { info: t("handlers.basic.copyUsage") }; + const selection = selectCopyHistory(ctx.getCards(), mode); + if (!selection) return { info: t("handlers.basic.copyNothing") }; + const result = writeClipboard(selection.text); + if (!result.osc52 && result.filePath) { + return { + info: t("handlers.basic.copySavedFile", { + label: selection.label, + chars: result.size, + path: result.filePath, + }), + }; + } + return { + info: t("handlers.basic.copyDone", { label: selection.label, chars: result.size }), + }; +}; + const about: SlashHandler = () => { const lines = [ t("handlers.basic.aboutHeader", { version: VERSION }), @@ -169,5 +192,6 @@ export const handlers: Record = { retry, loop, keys, + copy, about, }; diff --git a/src/cli/ui/slash/types.ts b/src/cli/ui/slash/types.ts index ff4b58d0c..deed1ef29 100644 --- a/src/cli/ui/slash/types.ts +++ b/src/cli/ui/slash/types.ts @@ -3,6 +3,7 @@ import type { EditMode } from "../../../config.js"; import type { McpServerSummary } from "../../../mcp/summary.js"; import type { JobRegistry } from "../../../tools/jobs.js"; import type { PlanStep } from "../../../tools/plan.js"; +import type { Card } from "../state/cards.js"; import type { CodeUndoOutput } from "../undo-context.js"; export type { McpServerSummary } from "../../../mcp/summary.js"; @@ -111,6 +112,8 @@ export interface SlashContext { }>; footer?: string; }) => void; + /** Current TUI scrollback cards; used by `/copy`. */ + getCards?: () => ReadonlyArray; dispatch?: (event: import("../state/events.js").AgentEvent) => void; setPlanMode?: (on: boolean, source?: PlanModeToggleSource) => void; /** Manual escape valve when the model forgot to call `mark_step_complete` — used by `/plans done `. */ diff --git a/src/cli/ui/stdin-reader.ts b/src/cli/ui/stdin-reader.ts index e4cb70e53..399909a58 100644 --- a/src/cli/ui/stdin-reader.ts +++ b/src/cli/ui/stdin-reader.ts @@ -131,8 +131,12 @@ function decodeSgrMouseBody(body: string): KeyEvent | null { if (!Number.isFinite(btn) || !Number.isFinite(col) || !Number.isFinite(row)) return null; const tail = m[4]!; if (tail === "m") return { input: "", mouseRelease: true, mouseRow: row, mouseCol: col }; - if (btn === 64) return { input: "", mouseScrollUp: true, mouseRow: row, mouseCol: col }; - if (btn === 65) return { input: "", mouseScrollDown: true, mouseRow: row, mouseCol: col }; + const wheelButton = btn & 0x43; + if (wheelButton === 64 || wheelButton === 65) { + return wheelButton === 64 + ? { input: "", mouseScrollUp: true, mouseRow: row, mouseCol: col } + : { input: "", mouseScrollDown: true, mouseRow: row, mouseCol: col }; + } if (btn === 0) return { input: "", mouseClick: true, mouseRow: row, mouseCol: col }; if (btn === 32) return { input: "", mouseDrag: true, mouseRow: row, mouseCol: col }; return null; diff --git a/src/config.ts b/src/config.ts index b73b6f100..1df81bf6b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -238,11 +238,11 @@ export interface ReasonixConfig { /** Brave Search API key. Falls back to BRAVE_SEARCH_API_KEY env var. Free 2000/mo signup at https://brave.com/search/api/ */ braveApiKey?: string; - /** TUI mouse-wheel scrolling via SGR mouse tracking. Default true. Set false to fall back to native terminal drag-select for copy (then wheel is terminal-dependent — most terminals translate wheel→arrow in alt-screen, some don't). */ + /** TUI mouse-wheel scrolling via app-managed mouse tracking when historyScrollMode resolves to "app". Default true. Set false to leave the wheel entirely to the terminal. */ mouseTracking?: boolean; /** Rows scrolled per single SGR mouse-wheel report. Default 1 — most terminals emit 2-5 reports per physical notch, so 1 already produces 2-5 rows per notch (#1419). Bump to 3-5 only if your terminal emits one report per notch and scrolling feels slow (#1494). Clamped to [1, 10]. */ mouseWheelRows?: number; - /** Chat-history scrolling: "native" leaves terminal scrollback in charge; "app" captures wheel/PgUp/PgDn/End inside the TUI; "auto" enables app mode for terminals with known jumpy native scrollback. */ + /** Chat-history scrolling: "native" leaves terminal scrollback in charge; "app" captures wheel/PgUp/PgDn/End inside the TUI; "auto" prefers app mode except terminals where native scrollback is safer. */ historyScrollMode?: HistoryScrollMode; /** Diff display mode for edit_file / write_file / multi_edit results in CLI. */ diffDisplay?: DiffDisplay; diff --git a/src/i18n/EN.ts b/src/i18n/EN.ts index fe3a50648..97d85b2d7 100644 --- a/src/i18n/EN.ts +++ b/src/i18n/EN.ts @@ -120,6 +120,10 @@ export const EN: TranslationSchema = { text: "your terminal's native menu (paste / copy on Windows Terminal etc.)", }, { key: "wheel", text: "scrolls chat history (works on web/cloud/SSH terminals too)" }, + { + key: "/copy", + text: "copy the latest assistant response; `/copy all` copies the chat", + }, { key: "↑ / ↓", text: "prompt history (or per-line cursor in a multi-line draft) — Ctrl+P / Ctrl+N alias", @@ -175,6 +179,10 @@ export const EN: TranslationSchema = { text: "Ctrl+Shift+C (Win/Linux) · Cmd+C (macOS) — or auto-copy-on-select if your terminal does it", }, { key: "paste", text: "Ctrl+V or Ctrl+Shift+V (Win/Linux) · Cmd+V (macOS)" }, + { + key: "/copy", + text: "copy latest assistant response · `/copy all` for the full chat · `/copy N` for last N items", + }, { key: "bracketed paste", text: "multi-line pastes stay one block — no auto-submit on intermediate newlines", @@ -191,7 +199,7 @@ export const EN: TranslationSchema = { }, ], footer: - "Wheel scrolls chat on most terminals (web/cloud/SSH included) — SGR mouse tracking is on by default and stays out of the way of native drag-select and right-click. Pass --no-mouse to opt out.", + "Wheel scrolls chat on most terminals (web/cloud/SSH included). If your terminal traps selection while mouse tracking is active, use /copy or pass --no-mouse.", }, tipShownOnce: "shown once", modelOverride: "override the default model", @@ -357,6 +365,11 @@ export const EN: TranslationSchema = { feedback: { description: "open a GitHub issue with diagnostic info copied to clipboard" }, about: { description: "project info — version, website, repo, license" }, keys: { description: "keyboard + mouse + copy/paste reference" }, + copy: { + description: + "copy the latest assistant response, the whole conversation (`all`), or the last N items", + argsHint: "[all|last|assistant|N]", + }, plans: { description: "list this session's active + archived plans, newest first" }, replay: { description: "load an archived plan as a read-only Time Travel snapshot (default: newest)", @@ -856,6 +869,11 @@ export const EN: TranslationSchema = { loopStarted: '▸ loop started — re-submitting "{prompt}" every {duration}. Type anything (or /loop stop) to cancel.', keysNeedsTui: "/keys needs a TUI context (postKeys wired).", + copyNeedsTui: "/copy is only available in the interactive TUI.", + copyUsage: "usage: /copy [all|last|assistant|N]", + copyNothing: "nothing to copy yet.", + copyDone: "▸ copied {label} to clipboard ({chars} chars).", + copySavedFile: "▸ {label} is too large for terminal clipboard; saved {chars} chars to {path}", aboutHeader: "Reasonix v{version} — a cache-first DeepSeek coding agent", aboutWebsiteLabel: "Website", aboutRepoLabel: "GitHub ", @@ -1937,7 +1955,7 @@ export const EN: TranslationSchema = { scrollAbovePlural: " \u2191 {scroll} / {max} rows above", scrollMore: " \u2014 {remaining} more", scrollPgUp: " \u00b7 PgUp / wheel", - scrollCopy: " \u00b7 /copy enters copy mode", + scrollCopy: " \u00b7 /copy copies reply", }, slashArgPicker: { noMatch: 'no match for "{partial}"', diff --git a/src/i18n/JA.ts b/src/i18n/JA.ts index 91c4fd830..295ca2f85 100644 --- a/src/i18n/JA.ts +++ b/src/i18n/JA.ts @@ -1985,7 +1985,7 @@ export const JA: TranslationSchema = { scrollAbovePlural: " \u2191 {scroll} / {max} 行上", scrollMore: " \u2014 さらに {remaining} 件", scrollPgUp: " \u00b7 PgUp / ホイール", - scrollCopy: " \u00b7 /copy でコピーモード", + scrollCopy: " \u00b7 /copy で返信をコピー", }, slashArgPicker: { ...EN.slashArgPicker, diff --git a/src/i18n/de.ts b/src/i18n/de.ts index fa7e57e13..7a7f7ee95 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1889,7 +1889,7 @@ export const de: TranslationSchema = { scrollAbovePlural: " ↑ {scroll} / {max} Zeilen darüber", scrollMore: " — {remaining} weitere", scrollPgUp: " · Bild↑ / Mausrad", - scrollCopy: " · /copy aktiviert Kopiermodus", + scrollCopy: " · /copy kopiert Antwort", }, slashArgPicker: { ...EN.slashArgPicker, diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index f3f03151e..889a8dd9f 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -116,6 +116,7 @@ export const zhCN: TranslationSchema = { text: "终端原生菜单(Windows Terminal 等的复制 / 粘贴)", }, { key: "滚轮", text: "滚动聊天记录(Web / 云端 / SSH 终端也能用)" }, + { key: "/copy", text: "复制最近一条 assistant 回复;`/copy all` 复制整个聊天" }, { key: "↑ / ↓", text: "输入历史(多行草稿时按行移动光标)— Ctrl+P / Ctrl+N 同义", @@ -168,6 +169,10 @@ export const zhCN: TranslationSchema = { text: "Ctrl+Shift+C(Win/Linux)· Cmd+C(macOS)— 或选中即复制(看终端设置)", }, { key: "粘贴", text: "Ctrl+V 或 Ctrl+Shift+V(Win/Linux)· Cmd+V(macOS)" }, + { + key: "/copy", + text: "复制最近回复 · `/copy all` 复制完整聊天 · `/copy N` 复制最近 N 项", + }, { key: "bracketed paste", text: "多行粘贴整体进入 — 中间换行不会触发提交", @@ -184,7 +189,7 @@ export const zhCN: TranslationSchema = { }, ], footer: - "滚轮在大多数终端(含 Web / 云端 / SSH)都能滚聊天 — 默认开启 SGR 鼠标跟踪,但不会影响终端原生拖选和右键菜单。直接拖动选中文本无需 Shift。传入 --no-mouse 可关闭。", + "滚轮在大多数终端(含 Web / 云端 / SSH)都能滚聊天。如果鼠标跟踪让终端选区受限,可用 /copy 或传入 --no-mouse。", }, tipShownOnce: "仅显示一次", modelOverride: "覆盖默认模型", @@ -335,6 +340,10 @@ export const zhCN: TranslationSchema = { argsHint: "[tokens]", }, keys: { description: "键盘 + 鼠标 + 复制粘贴参考" }, + copy: { + description: "复制最近一条 assistant 回复、完整聊天(all),或最近 N 项", + argsHint: "[all|last|assistant|N]", + }, cwd: { description: "切换工作区根目录 — 重新指向文件/Shell/记忆工具,重载项目 hooks,刷新 @ 引用遍历器", @@ -813,6 +822,11 @@ export const zhCN: TranslationSchema = { loopStarted: '▸ 循环已启动 — 每 {duration} 重新提交 "{prompt}"。输入任何内容(或 /loop stop)取消。', keysNeedsTui: "/keys 需要 TUI 上下文(postKeys 已连接)。", + copyNeedsTui: "/copy 仅在交互式 TUI 中可用。", + copyUsage: "用法:/copy [all|last|assistant|N]", + copyNothing: "还没有可复制的内容。", + copyDone: "▸ 已复制 {label} 到剪贴板({chars} 字符)。", + copySavedFile: "▸ {label} 超出终端剪贴板限制;已将 {chars} 字符保存到 {path}", aboutHeader: "Reasonix v{version} — 缓存优先的 DeepSeek 编码代理", aboutWebsiteLabel: "官网", aboutRepoLabel: "仓库", @@ -1830,7 +1844,7 @@ export const zhCN: TranslationSchema = { scrollAbovePlural: " \u2191 {scroll}/{max} 行", scrollMore: " \u2014 还有 {remaining} 行", scrollPgUp: " \u00b7 PgUp/\u6eda\u8f6e", - scrollCopy: " \u00b7 /copy \u8fdb\u5165\u590d\u5236\u6a21\u5f0f", + scrollCopy: " \u00b7 /copy \u590d\u5236\u56de\u590d", }, slashArgPicker: { noMatch: '\u6ca1\u6709\u5339\u914d "{partial}"', diff --git a/tests/chat-scroll-wheel.test.ts b/tests/chat-scroll-wheel.test.ts index 57f7846ba..bc8d5024f 100644 --- a/tests/chat-scroll-wheel.test.ts +++ b/tests/chat-scroll-wheel.test.ts @@ -94,13 +94,31 @@ describe("history scroll mode resolution", () => { ); }); - it("keeps native scrollback for unknown terminals in auto mode", () => { + it("prefers app-managed scrolling for unknown terminals in auto mode", () => { expect( resolveHistoryScrollMode({ configured: "auto", env: { TERM: "xterm-256color" }, platform: "linux", }), + ).toBe("app"); + }); + + it("keeps native scrollback for terminals where app mouse tracking is risky", () => { + expect( + resolveHistoryScrollMode({ + configured: "auto", + env: { TERM_PROGRAM: "Apple_Terminal" }, + platform: "darwin", + }), + ).toBe("native"); + + expect( + resolveHistoryScrollMode({ + configured: "auto", + env: {}, + platform: "win32", + }), ).toBe("native"); }); }); diff --git a/tests/copy-history.test.ts b/tests/copy-history.test.ts new file mode 100644 index 000000000..1bdfc6bb6 --- /dev/null +++ b/tests/copy-history.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + type CopyHistoryMode, + parseCopyHistoryArgs, + selectCopyHistory, +} from "../src/cli/ui/copy-history.js"; +import type { Card } from "../src/cli/ui/state/cards.js"; + +const ts = 1; + +function user(id: string, text: string): Card { + return { kind: "user", id, ts, text }; +} + +function assistant(id: string, text: string): Card { + return { kind: "streaming", id, ts, text, done: true }; +} + +function tool(id: string, name: string, output: string): Card { + return { kind: "tool", id, ts, name, args: { ok: true }, output, done: true, elapsedMs: 12 }; +} + +describe("copy history selection", () => { + it("defaults to the latest assistant response", () => { + const selected = selectCopyHistory([ + user("u1", "hello"), + assistant("a1", "first"), + assistant("a2", "second"), + ]); + + expect(selected).toEqual({ label: "last assistant response", text: "second" }); + }); + + it("copies the whole conversation when requested", () => { + const selected = selectCopyHistory( + [user("u1", "hello"), assistant("a1", "hi"), tool("t1", "run_command", "done")], + { kind: "all" }, + ); + + expect(selected?.text).toContain("User:\nhello"); + expect(selected?.text).toContain("Assistant:\nhi"); + expect(selected?.text).toContain("Tool run_command:"); + expect(selected?.text).toContain("Output:\ndone"); + }); + + it("copies the last N serializable entries", () => { + const selected = selectCopyHistory( + [user("u1", "one"), assistant("a1", "two"), user("u2", "three")], + { kind: "last", count: 2 }, + ); + + expect(selected?.label).toBe("last 2 item(s)"); + expect(selected?.text).not.toContain("one"); + expect(selected?.text).toContain("Assistant:\ntwo"); + expect(selected?.text).toContain("User:\nthree"); + }); + + it("returns null when there is no matching content", () => { + expect(selectCopyHistory([], { kind: "latest-assistant" })).toBeNull(); + }); +}); + +describe("parseCopyHistoryArgs", () => { + it.each([ + [[], { kind: "latest-assistant" }], + [["all"], { kind: "all" }], + [["assistant"], { kind: "latest-assistant" }], + [["3"], { kind: "last", count: 3 }], + ] as Array<[string[], CopyHistoryMode]>)("parses %j", (args, expected) => { + expect(parseCopyHistoryArgs(args)).toEqual(expected); + }); + + it("rejects unsupported forms", () => { + expect(parseCopyHistoryArgs(["0"])).toEqual({ error: "usage" }); + expect(parseCopyHistoryArgs(["all", "extra"])).toEqual({ error: "usage" }); + }); +}); diff --git a/tests/slash.test.ts b/tests/slash.test.ts index 44e820bcf..efd61b808 100644 --- a/tests/slash.test.ts +++ b/tests/slash.test.ts @@ -520,6 +520,48 @@ describe("handleSlash", () => { expect(r.info).toMatch(/\/retry/); }); + it("/copy copies the latest assistant response through OSC 52", () => { + const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + const r = handleSlash("copy", [], makeLoop(), { + getCards: () => [ + { kind: "user", id: "u1", ts: 1, text: "hello" }, + { kind: "streaming", id: "a1", ts: 2, text: "copy me", done: true }, + ], + }); + + expect(r.info).toContain("copied"); + const expectedB64 = Buffer.from("copy me", "utf8").toString("base64"); + expect(write).toHaveBeenCalledWith(`\x1b]52;c;${expectedB64}\x1b\\`); + } finally { + write.mockRestore(); + } + }); + + it("/copy all serializes the conversation", () => { + const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + const r = handleSlash("copy", ["all"], makeLoop(), { + getCards: () => [ + { kind: "user", id: "u1", ts: 1, text: "hello" }, + { kind: "streaming", id: "a1", ts: 2, text: "hi back", done: true }, + ], + }); + + expect(r.info).toContain("copied"); + const expected = "User:\nhello\n\nAssistant:\nhi back"; + const expectedB64 = Buffer.from(expected, "utf8").toString("base64"); + expect(write).toHaveBeenCalledWith(`\x1b]52;c;${expectedB64}\x1b\\`); + } finally { + write.mockRestore(); + } + }); + + it("/copy requires TUI cards", () => { + const r = handleSlash("copy", [], makeLoop()); + expect(r.info).toMatch(/interactive TUI/); + }); + describe("detectSlashArgContext", () => { it("returns null before the user commits to a slash name", () => { expect(detectSlashArgContext("/pr")).toBeNull(); @@ -684,6 +726,7 @@ describe("handleSlash", () => { "compact", "sessions", "new", + "copy", "exit", "apply", "discard", @@ -700,7 +743,7 @@ describe("handleSlash", () => { // Case-insensitive. expect(suggestSlashCommands("HE").map((s) => s.cmd)).toEqual(["help"]); // Empty prefix returns the full non-advanced release list, including code commands. - expect(suggestSlashCommands("", true)).toHaveLength(48); + expect(suggestSlashCommands("", true)).toHaveLength(49); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("logs"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("language"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("weixin"); diff --git a/tests/stdin-reader.test.ts b/tests/stdin-reader.test.ts index 613a0ba58..7288abfe4 100644 --- a/tests/stdin-reader.test.ts +++ b/tests/stdin-reader.test.ts @@ -391,6 +391,18 @@ describe("StdinReader — SGR mouse reports (issue #867)", () => { expect(events).toEqual([{ input: "", mouseScrollDown: true, mouseRow: 5, mouseCol: 10 }]); }); + it("decodes modified wheel reports as scroll events", () => { + const { reader, events } = setup(); + reader.feed("\x1b[<68;10;5M"); + reader.feed("\x1b[<69;11;5M"); + reader.feed("\x1b[<80;12;5M"); + expect(events).toEqual([ + { input: "", mouseScrollUp: true, mouseRow: 5, mouseCol: 10 }, + { input: "", mouseScrollDown: true, mouseRow: 5, mouseCol: 11 }, + { input: "", mouseScrollUp: true, mouseRow: 5, mouseCol: 12 }, + ]); + }); + it("dispatches left-click press + release", () => { const { reader, events } = setup(); reader.feed("\x1b[<0;3;7M"); diff --git a/tests/ui-slash-suggestions.test.tsx b/tests/ui-slash-suggestions.test.tsx index 3afb18021..5a67379fb 100644 --- a/tests/ui-slash-suggestions.test.tsx +++ b/tests/ui-slash-suggestions.test.tsx @@ -86,7 +86,7 @@ describe("SlashSuggestions", () => { ); }); - it("renders the bare slash release command surface as 48 total commands", () => { + it("renders the bare slash release command surface as 49 total commands", () => { const matches = suggestSlashCommands("", true); const names = matches.map((spec) => spec.cmd); const { lastFrame, unmount } = render( @@ -95,13 +95,13 @@ describe("SlashSuggestions", () => { const frame = lastFrame() ?? ""; unmount(); - expect(matches).toHaveLength(48); + expect(matches).toHaveLength(49); expect(names).toContain("language"); expect(names).toContain("weixin"); expect(names).toContain("btw"); expect(names).toContain("about"); expect(countAdvancedCommands(true)).toBe(10); - expect(frame).toContain("48 commands"); + expect(frame).toContain("49 commands"); expect(frame).toContain("+ 10 advanced"); }); From b8c91b0e99a7560d9a34cedb2d0821e97b4e5fcf Mon Sep 17 00:00:00 2001 From: Sivan Date: Fri, 29 May 2026 19:28:45 +0800 Subject: [PATCH 02/10] fix(cli): restrict copy cards to local TUI --- src/cli/ui/App.tsx | 8 +++- src/cli/ui/slash.ts | 1 + src/cli/ui/slash/submit-origin.ts | 9 +++++ tests/auto-git-rollback.test.ts | 62 +++++++++++++++++-------------- tests/slash.test.ts | 19 ++++++++++ 5 files changed, 70 insertions(+), 29 deletions(-) create mode 100644 src/cli/ui/slash/submit-origin.ts diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 80d72c94c..54a378d84 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -196,6 +196,7 @@ import { type PlanModeToggleSource, handleSlash, parseSlash, + shouldExposeTuiCardsToSlash, suggestSlashCommands, } from "./slash.js"; import { TurnTranslator } from "./state/TurnTranslator.js"; @@ -3009,6 +3010,11 @@ function AppInner({ if (slash) { const sink = eventSinkRef.current; const eventizer = eventizerRef.current; + const exposeTuiCards = shouldExposeTuiCardsToSlash({ + fromQQ, + fromTelegram, + fromWeixin, + }); if (sink && eventizer) { sink.append( eventizer.emitSlashInvoked(loop.currentTurn, slash.cmd, slash.args.join(" ")), @@ -3084,7 +3090,7 @@ function AppInner({ footer: args.footer, oneTime: false, }), - getCards: getCardsForSlash, + getCards: exposeTuiCards ? getCardsForSlash : undefined, dispatch: agentStore.dispatch, markPlanStepDone: (stepId: string) => { const steps = planStepsRef.current; diff --git a/src/cli/ui/slash.ts b/src/cli/ui/slash.ts index 0cf77135a..e239047d1 100644 --- a/src/cli/ui/slash.ts +++ b/src/cli/ui/slash.ts @@ -14,6 +14,7 @@ export { suggestSlashCommands, } from "./slash/commands.js"; export { handleSlash } from "./slash/dispatch.js"; +export { shouldExposeTuiCardsToSlash } from "./slash/submit-origin.js"; export type { SlashHandler } from "./slash/dispatch.js"; export type { McpServerSummary, diff --git a/src/cli/ui/slash/submit-origin.ts b/src/cli/ui/slash/submit-origin.ts new file mode 100644 index 000000000..ef715812b --- /dev/null +++ b/src/cli/ui/slash/submit-origin.ts @@ -0,0 +1,9 @@ +export interface SlashSubmitOrigin { + fromQQ: boolean; + fromTelegram: boolean; + fromWeixin: boolean; +} + +export function shouldExposeTuiCardsToSlash(origin: SlashSubmitOrigin): boolean { + return !(origin.fromQQ || origin.fromTelegram || origin.fromWeixin); +} diff --git a/tests/auto-git-rollback.test.ts b/tests/auto-git-rollback.test.ts index 0ed119e34..2dbfd334b 100644 --- a/tests/auto-git-rollback.test.ts +++ b/tests/auto-git-rollback.test.ts @@ -9,6 +9,8 @@ import { ToolRegistry } from "../src/tools.js"; import { registerFilesystemTools } from "../src/tools/filesystem.js"; import { ReadTracker } from "../src/tools/read-tracker.js"; +const AMBIENT_GIT_ENV_TEST_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 5_000; + function cleanGitEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; for (const key of Object.keys(env)) { @@ -175,32 +177,36 @@ describe("auto-git-rollback memory guard", () => { expect(await fs.readFile(join(root, "tracked.txt"), "utf8")).toBe("baseline\n"); }); - it("ignores ambient Git hook environment variables when checkpointing", async () => { - const outerRoot = await mkdtemp(join(tmpdir(), "reasonix-auto-git-outer-")); - git(outerRoot, ["init", "-q"]); - const previousGitDir = process.env.GIT_DIR; - const previousGitWorkTree = process.env.GIT_WORK_TREE; - process.env.GIT_DIR = join(outerRoot, ".git"); - process.env.GIT_WORK_TREE = outerRoot; - - try { - enableAutoGitRollback(home, root); - await fs.writeFile(join(root, "tracked.txt"), "dirty-before-edit\n", "utf8"); - - await tools.dispatch("read_file", { path: "tracked.txt" }, { readTracker }); - const out = await tools.dispatch( - "edit_file", - { path: "tracked.txt", search: "dirty-before-edit", replace: "after-edit" }, - { readTracker }, - ); - - expect(out).toMatch(/edited tracked\.txt/); - expect(git(root, ["show", "HEAD:tracked.txt"])).toBe("dirty-before-edit"); - expect(git(root, ["log", "-1", "--format=%s"])).toMatch(/pre-edit: edit_file tracked\.txt/); - } finally { - restoreProcessEnv("GIT_DIR", previousGitDir); - restoreProcessEnv("GIT_WORK_TREE", previousGitWorkTree); - await rm(outerRoot, { recursive: true, force: true }); - } - }); + it( + "ignores ambient Git hook environment variables when checkpointing", + async () => { + const outerRoot = await mkdtemp(join(tmpdir(), "reasonix-auto-git-outer-")); + git(outerRoot, ["init", "-q"]); + const previousGitDir = process.env.GIT_DIR; + const previousGitWorkTree = process.env.GIT_WORK_TREE; + process.env.GIT_DIR = join(outerRoot, ".git"); + process.env.GIT_WORK_TREE = outerRoot; + + try { + enableAutoGitRollback(home, root); + await fs.writeFile(join(root, "tracked.txt"), "dirty-before-edit\n", "utf8"); + + await tools.dispatch("read_file", { path: "tracked.txt" }, { readTracker }); + const out = await tools.dispatch( + "edit_file", + { path: "tracked.txt", search: "dirty-before-edit", replace: "after-edit" }, + { readTracker }, + ); + + expect(out).toMatch(/edited tracked\.txt/); + expect(git(root, ["show", "HEAD:tracked.txt"])).toBe("dirty-before-edit"); + expect(git(root, ["log", "-1", "--format=%s"])).toMatch(/pre-edit: edit_file tracked\.txt/); + } finally { + restoreProcessEnv("GIT_DIR", previousGitDir); + restoreProcessEnv("GIT_WORK_TREE", previousGitWorkTree); + await rm(outerRoot, { recursive: true, force: true }); + } + }, + AMBIENT_GIT_ENV_TEST_TIMEOUT_MS, + ); }); diff --git a/tests/slash.test.ts b/tests/slash.test.ts index efd61b808..7f3ba233e 100644 --- a/tests/slash.test.ts +++ b/tests/slash.test.ts @@ -8,6 +8,7 @@ import { detectSlashArgContext, handleSlash, parseSlash, + shouldExposeTuiCardsToSlash, suggestSlashCommands, } from "../src/cli/ui/slash.js"; import { DeepSeekClient, Usage } from "../src/client.js"; @@ -562,6 +563,24 @@ describe("handleSlash", () => { expect(r.info).toMatch(/interactive TUI/); }); + it("only exposes TUI card history to local slash submissions", () => { + expect( + shouldExposeTuiCardsToSlash({ + fromQQ: false, + fromTelegram: false, + fromWeixin: false, + }), + ).toBe(true); + + for (const origin of [ + { fromQQ: true, fromTelegram: false, fromWeixin: false }, + { fromQQ: false, fromTelegram: true, fromWeixin: false }, + { fromQQ: false, fromTelegram: false, fromWeixin: true }, + ]) { + expect(shouldExposeTuiCardsToSlash(origin)).toBe(false); + } + }); + describe("detectSlashArgContext", () => { it("returns null before the user commits to a slash name", () => { expect(detectSlashArgContext("/pr")).toBeNull(); From d7a591aba9a2cba1509ac7145ce97a7c7d6d0a36 Mon Sep 17 00:00:00 2001 From: lab1 Date: Fri, 29 May 2026 21:17:16 +0800 Subject: [PATCH 03/10] fix(loop): preserve long-session history on abort --- dashboard/src/App.tsx | 5 ++ dashboard/src/i18n/de.ts | 1 + dashboard/src/i18n/en.ts | 1 + dashboard/src/i18n/zh-CN.ts | 1 + dashboard/src/ui/statusbar.tsx | 10 +++- desktop/src/App.test.ts | 30 ++++++++++- desktop/src/App.tsx | 4 ++ desktop/src/i18n/de.ts | 1 + desktop/src/i18n/en.ts | 1 + desktop/src/i18n/ja.ts | 1 + desktop/src/i18n/zh-CN.ts | 1 + desktop/src/ui/context-panel.test.tsx | 1 + desktop/src/ui/statusbar.tsx | 10 +++- src/loop.ts | 2 +- tests/desktop-btw-status.test.ts | 1 + tests/desktop-user-message.test.ts | 1 + tests/loop-user-persist.test.ts | 78 +++++++++++++++++++++++++++ 17 files changed, 143 insertions(+), 6 deletions(-) diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d7126ba91..1b535fbe6 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -166,6 +166,7 @@ export type PendingRevision = { export type UsageStats = { totalCostUsd: number; + turnCostUsd: number; totalPromptTokens: number; totalCompletionTokens: number; cacheHitTokens: number; @@ -548,6 +549,7 @@ function mergeSessionFiles(existing: SessionFile[], adds: SessionFile[]): Sessio function zeroUsage(): UsageStats { return { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, @@ -584,6 +586,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { return { ...state, busy: true, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { @@ -751,6 +754,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { totalCompletionTokens: ev.totalCompletionTokens, cacheHitTokens: ev.cacheHitTokens, cacheMissTokens: ev.cacheMissTokens, + turnCostUsd: empty ? 0 : state.usage.turnCostUsd, lastCallCacheHit: empty ? null : state.usage.lastCallCacheHit, lastCallCacheMiss: empty ? null : state.usage.lastCallCacheMiss, }, @@ -956,6 +960,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { const hasCall = callHit > 0 || callMiss > 0; const usage: UsageStats = { totalCostUsd: state.usage.totalCostUsd + (ev.costUsd ?? 0), + turnCostUsd: state.usage.turnCostUsd + (ev.costUsd ?? 0), totalPromptTokens: state.usage.totalPromptTokens + (u?.prompt_tokens ?? 0), totalCompletionTokens: state.usage.totalCompletionTokens + (u?.completion_tokens ?? 0), cacheHitTokens: state.usage.cacheHitTokens + callHit, diff --git a/dashboard/src/i18n/de.ts b/dashboard/src/i18n/de.ts index 73ab4df3b..de0ac2beb 100644 --- a/dashboard/src/i18n/de.ts +++ b/dashboard/src/i18n/de.ts @@ -1306,6 +1306,7 @@ export const de: typeof en = { themeStyleGlacier: "Gletscher", themeStyleMidnight: "Mitternacht", thisTurn: "Dieser Turn", + session: "Sitzung", tokens: "Tokens", }, thread: { diff --git a/dashboard/src/i18n/en.ts b/dashboard/src/i18n/en.ts index bc047d945..95bc8c07d 100644 --- a/dashboard/src/i18n/en.ts +++ b/dashboard/src/i18n/en.ts @@ -1258,6 +1258,7 @@ export const en = { themeStyleGlacier: "Glacier", themeStyleMidnight: "Midnight", thisTurn: "This turn", + session: "Session", tokens: "Tokens", }, thread: { diff --git a/dashboard/src/i18n/zh-CN.ts b/dashboard/src/i18n/zh-CN.ts index 33078958f..c84a4b6de 100644 --- a/dashboard/src/i18n/zh-CN.ts +++ b/dashboard/src/i18n/zh-CN.ts @@ -1233,6 +1233,7 @@ export const zhCN = { themeStyleGlacier: "冰川", themeStyleMidnight: "午夜", thisTurn: "本轮", + session: "会话", tokens: "Tokens", }, thread: { diff --git a/dashboard/src/ui/statusbar.tsx b/dashboard/src/ui/statusbar.tsx index ed257d5dc..5d72b536e 100644 --- a/dashboard/src/ui/statusbar.tsx +++ b/dashboard/src/ui/statusbar.tsx @@ -70,7 +70,8 @@ export function StatusBar({ ? `${usage.cacheHitTokens.toLocaleString()} / ${totalTokens.toLocaleString()} tokens (${cacheHitPctDisplay}%)` : ""; const runningJobs = jobs.filter((j) => j.running).length; - const spent = formatMoney(usage.totalCostUsd, currency); + const turnSpent = formatMoney(usage.turnCostUsd, currency); + const sessionSpent = formatMoney(usage.totalCostUsd, currency); const balanceLabel = balance ? `${balance.currency === "USD" ? "$" : "¥"} ${balance.total.toFixed(2)}` : "—"; @@ -113,7 +114,12 @@ export function StatusBar({ {t("statusbar.thisTurn")} - {spent} + {turnSpent} + + + + {t("statusbar.session")} + {sessionSpent} diff --git a/desktop/src/App.test.ts b/desktop/src/App.test.ts index 5cf003f22..9f858e3c6 100644 --- a/desktop/src/App.test.ts +++ b/desktop/src/App.test.ts @@ -52,6 +52,7 @@ function initialState(): Parameters[0] { activePlan: null, usage: { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, @@ -136,7 +137,7 @@ function makePathPrompt( } describe("Desktop App reducer — usage", () => { - it("falls back prompt tokens to cache miss tokens when cache fields are absent", () => { + it("falls back prompt tokens to cache miss tokens and tracks turn cost", () => { const next = reduce(initialState(), { t: "incoming", event: { @@ -151,9 +152,36 @@ describe("Desktop App reducer — usage", () => { }); expect(next.usage.totalPromptTokens).toBe(1234); + expect(next.usage.turnCostUsd).toBe(0.001); expect(next.usage.cacheHitTokens).toBe(0); expect(next.usage.cacheMissTokens).toBe(1234); expect(next.usage.lastCallCacheMiss).toBe(1234); + + const accumulated = reduce(next, { + t: "incoming", + event: { + type: "model.final", + id: 2, + ts: "2026-05-27T00:00:01.000Z", + turn: 1, + content: "ok again", + usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, + costUsd: 0.002, + }, + }); + expect(accumulated.usage.turnCostUsd).toBeCloseTo(0.003, 6); + + const reset = reduce(accumulated, { + t: "incoming", + event: { + type: "user.message", + id: 3, + ts: "2026-05-27T00:00:02.000Z", + turn: 2, + text: "next turn", + }, + }); + expect(reset.usage.turnCostUsd).toBe(0); }); it("settles the pending assistant message when an error ends the turn (#1660)", () => { diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 286a4868e..11acfbdfe 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -221,6 +221,7 @@ export type PendingRevision = { export type UsageStats = { totalCostUsd: number; + turnCostUsd: number; totalPromptTokens: number; totalCompletionTokens: number; cacheHitTokens: number; @@ -780,6 +781,7 @@ function mergeSessionFiles(existing: SessionFile[], adds: SessionFile[]): Sessio function zeroUsage(): UsageStats { return { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, @@ -813,6 +815,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { return { ...state, busy: true, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { @@ -1197,6 +1200,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { const hasCall = promptTokens > 0 || callHit > 0 || callMiss > 0; const usage: UsageStats = { totalCostUsd: state.usage.totalCostUsd + (ev.costUsd ?? 0), + turnCostUsd: state.usage.turnCostUsd + (ev.costUsd ?? 0), totalPromptTokens: state.usage.totalPromptTokens + promptTokens, totalCompletionTokens: state.usage.totalCompletionTokens + (u?.completion_tokens ?? 0), cacheHitTokens: state.usage.cacheHitTokens + callHit, diff --git a/desktop/src/i18n/de.ts b/desktop/src/i18n/de.ts index 679e32dd3..93f8e3763 100644 --- a/desktop/src/i18n/de.ts +++ b/desktop/src/i18n/de.ts @@ -667,6 +667,7 @@ export const de: typeof en = { cache: "Cache", tokens: "Tokens", thisTurn: "dieser Turn", + session: "Sitzung", switchWorkspace: "Arbeitsbereich wechseln · {workspace}", switchCurrency: "Währung wechseln (CNY / USD)", balance: "Guthaben", diff --git a/desktop/src/i18n/en.ts b/desktop/src/i18n/en.ts index 059e95b71..c913a00a3 100644 --- a/desktop/src/i18n/en.ts +++ b/desktop/src/i18n/en.ts @@ -638,6 +638,7 @@ export const en = { cache: "cache", tokens: "tokens", thisTurn: "this turn", + session: "session", switchWorkspace: "Switch workspace · {workspace}", switchCurrency: "Switch currency (CNY / USD)", balance: "balance", diff --git a/desktop/src/i18n/ja.ts b/desktop/src/i18n/ja.ts index 32981d30d..f5ecc3ea4 100644 --- a/desktop/src/i18n/ja.ts +++ b/desktop/src/i18n/ja.ts @@ -661,6 +661,7 @@ export const ja: typeof en = { cache: "キャッシュ", tokens: "トークン", thisTurn: "このターン", + session: "セッション", switchWorkspace: "ワークスペース切替 · {workspace}", switchCurrency: "通貨切替 (CNY / USD)", balance: "残高", diff --git a/desktop/src/i18n/zh-CN.ts b/desktop/src/i18n/zh-CN.ts index 11f80343c..4f1831fbd 100644 --- a/desktop/src/i18n/zh-CN.ts +++ b/desktop/src/i18n/zh-CN.ts @@ -439,6 +439,7 @@ export const zhCN: typeof en = { cache: "缓存", tokens: "tokens", thisTurn: "本次", + session: "会话", switchWorkspace: "切换工作区 · {workspace}", switchCurrency: "切换货币 (CNY / USD)", balance: "余额", diff --git a/desktop/src/ui/context-panel.test.tsx b/desktop/src/ui/context-panel.test.tsx index 622832100..70c681969 100644 --- a/desktop/src/ui/context-panel.test.tsx +++ b/desktop/src/ui/context-panel.test.tsx @@ -13,6 +13,7 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn() })); const usage: UsageStats = { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, diff --git a/desktop/src/ui/statusbar.tsx b/desktop/src/ui/statusbar.tsx index 4e3bc2d77..e299c024a 100644 --- a/desktop/src/ui/statusbar.tsx +++ b/desktop/src/ui/statusbar.tsx @@ -74,7 +74,8 @@ export function StatusBar({ ? `${usage.cacheHitTokens.toLocaleString()} / ${cacheDenom.toLocaleString()} tokens (${cacheHitPctDisplay}%)` : ""; const runningJobs = jobs.filter((j) => j.running).length; - const spent = formatMoney(usage.totalCostUsd, currency); + const turnSpent = formatMoney(usage.turnCostUsd, currency); + const sessionSpent = formatMoney(usage.totalCostUsd, currency); const balanceLabel = balance ? `${balance.currency === "USD" ? "$" : "¥"} ${balance.total.toFixed(2)}` : "—"; @@ -117,7 +118,12 @@ export function StatusBar({ {t("statusbar.thisTurn")} - {spent} + {turnSpent} + + + + {t("statusbar.session")} + {sessionSpent} diff --git a/src/loop.ts b/src/loop.ts index 40fd0ee1e..09fe57e06 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -744,7 +744,7 @@ export class CacheFirstLoop { // when the user navigates away before the model responds). A failed // first round-trip still leaves the message in the log; the user can // /retry without re-typing. - const turnStartLogIndex = this.log.length; + const turnStartLogIndex = this.log.totalLength; this.appendAndPersist({ role: "user", content: userInput }); const toolSpecs = this.prefix.tools(); const rateLimitState = { shown: false }; diff --git a/tests/desktop-btw-status.test.ts b/tests/desktop-btw-status.test.ts index 7ae7a4e28..06fdb2a7b 100644 --- a/tests/desktop-btw-status.test.ts +++ b/tests/desktop-btw-status.test.ts @@ -58,6 +58,7 @@ function makeState(): AppState { activePlan: null, usage: { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, diff --git a/tests/desktop-user-message.test.ts b/tests/desktop-user-message.test.ts index b705905a7..28c59394f 100644 --- a/tests/desktop-user-message.test.ts +++ b/tests/desktop-user-message.test.ts @@ -60,6 +60,7 @@ function makeState(messages: ChatMessage[] = []): AppState { activePlan: null, usage: { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, diff --git a/tests/loop-user-persist.test.ts b/tests/loop-user-persist.test.ts index cc533a5f2..6e202f25d 100644 --- a/tests/loop-user-persist.test.ts +++ b/tests/loop-user-persist.test.ts @@ -224,4 +224,82 @@ describe("loop persists user message at step entry (issue #943)", () => { expect(secondRequestUsers).toEqual(["请按这次要求完整重写,不要局部微调"]); expect(loadSessionMessages(sessionName).filter((m) => m.role === "user")).toHaveLength(1); }); + + it("discarding an aborted turn preserves history outside the in-memory window", async () => { + const requestMessages: ChatMessage[][] = []; + let callCount = 0; + let markFirstRequestStarted!: () => void; + const firstRequestStarted = new Promise((resolve) => { + markFirstRequestStarted = resolve; + }); + const client = new DeepSeekClient({ + apiKey: "sk-test", + fetch: vi.fn( + async (_url: unknown, init: { body?: string; signal?: AbortSignal } | undefined) => { + const body = init?.body ? JSON.parse(init.body) : {}; + requestMessages.push(body.messages as ChatMessage[]); + if (callCount++ === 0) { + markFirstRequestStarted(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("This operation was aborted", "AbortError")), + ); + }); + } + return new Response( + JSON.stringify({ + choices: [ + { + index: 0, + message: { role: "assistant", content: "ok" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }, + ) as unknown as typeof fetch, + retry: { maxAttempts: 1 }, + }); + + const sessionName = "bug2287-discard-window-history"; + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + session: sessionName, + model: "deepseek-chat", + }); + + for (let i = 0; i < 105; i++) { + loop.appendAndPersist({ role: "user", content: `prior q ${i}` }); + loop.appendAndPersist({ role: "assistant", content: `prior a ${i}` }); + } + expect(loop.log.totalLength).toBeGreaterThan(loop.log.length); + + const interrupted = (async () => { + for await (const ev of loop.step("aborted current prompt")) { + if (ev.role === "done") break; + } + })(); + + await firstRequestStarted; + loop.abort({ discardCurrentTurn: true }); + await interrupted; + + await loop.run("next prompt"); + + const secondRequestContents = requestMessages[1]!.map((m) => m.content); + expect(secondRequestContents).toContain("prior q 104"); + expect(secondRequestContents).toContain("prior a 104"); + expect(secondRequestContents).not.toContain("aborted current prompt"); + expect(secondRequestContents).toContain("next prompt"); + + const persistedContents = loadSessionMessages(sessionName).map((m) => m.content); + expect(persistedContents).toContain("prior q 104"); + expect(persistedContents).toContain("prior a 104"); + expect(persistedContents).not.toContain("aborted current prompt"); + }); }); From 93f90e6d45848c947b7ca7afd6fb47536a6d4517 Mon Sep 17 00:00:00 2001 From: lab1 Date: Fri, 29 May 2026 21:34:34 +0800 Subject: [PATCH 04/10] fix(ui): preserve turn cost on steer messages --- dashboard/src/App.tsx | 16 ++++++++++++++-- desktop/src/App.test.ts | 29 +++++++++++++++++++++++++++-- desktop/src/App.tsx | 16 ++++++++++++++-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 1b535fbe6..264c007ca 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -320,6 +320,13 @@ function nextMessageTurn(messages: ChatMessage[]): number { return lastTurn + 1; } +function isIncomingUserNewTurn(state: State, turn: number): boolean { + return ( + !state.busy || + !state.messages.some((m) => (m.kind === "user" || m.kind === "assistant") && m.turn === turn) + ); +} + function reduce(state: State, action: Action): State { return withElidedTranscript(reduceRaw(state, action)); } @@ -330,6 +337,7 @@ function reduceRaw(state: State, action: Action): State { return { ...state, busy: true, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { kind: "user", text: action.text, clientId: action.clientId, turn: nextMessageTurn(state.messages) }, @@ -342,6 +350,7 @@ function reduceRaw(state: State, action: Action): State { ...state, busy: true, activeSkill: action.skill, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { @@ -583,17 +592,20 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { if (state.busy && last?.kind === "user" && last.text === ev.text) { return state; } + const turn = ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages); return { ...state, busy: true, - usage: { ...state.usage, turnCostUsd: 0 }, + usage: isIncomingUserNewTurn(state, turn) + ? { ...state.usage, turnCostUsd: 0 } + : state.usage, messages: [ ...state.messages, { kind: "user", text: ev.text, clientId: `remote-${ev.id}`, - turn: ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages), + turn, }, ], }; diff --git a/desktop/src/App.test.ts b/desktop/src/App.test.ts index 9f858e3c6..8f727fc14 100644 --- a/desktop/src/App.test.ts +++ b/desktop/src/App.test.ts @@ -171,17 +171,42 @@ describe("Desktop App reducer — usage", () => { }); expect(accumulated.usage.turnCostUsd).toBeCloseTo(0.003, 6); + const steered = reduce( + { + ...accumulated, + busy: true, + messages: [{ kind: "assistant", turn: 1, segments: [], pending: false }], + }, + { + t: "incoming", + event: { + type: "user.message", + id: 3, + ts: "2026-05-27T00:00:02.000Z", + turn: 1, + text: "same-turn steer", + }, + }, + ); + expect(steered.usage.turnCostUsd).toBeCloseTo(0.003, 6); + const reset = reduce(accumulated, { t: "incoming", event: { type: "user.message", - id: 3, - ts: "2026-05-27T00:00:02.000Z", + id: 4, + ts: "2026-05-27T00:00:03.000Z", turn: 2, text: "next turn", }, }); expect(reset.usage.turnCostUsd).toBe(0); + + const localReset = reduce( + { ...accumulated, usage: { ...accumulated.usage, turnCostUsd: 0.004 } }, + { t: "send_user", text: "local next turn", clientId: "local-1" }, + ); + expect(localReset.usage.turnCostUsd).toBe(0); }); it("settles the pending assistant message when an error ends the turn (#1660)", () => { diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 11acfbdfe..ebe959e8a 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -452,6 +452,13 @@ function nextMessageTurn(messages: ChatMessage[]): number { return lastTurn + 1; } +function isIncomingUserNewTurn(state: State, turn: number): boolean { + return ( + !state.busy || + !state.messages.some((m) => (m.kind === "user" || m.kind === "assistant") && m.turn === turn) + ); +} + let _errSeq = 0; function nextErrorId(): string { _errSeq += 1; @@ -468,6 +475,7 @@ function reduceRaw(state: State, action: Action): State { return { ...state, busy: true, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { kind: "user", text: action.text, clientId: action.clientId, turn: nextMessageTurn(state.messages) }, @@ -480,6 +488,7 @@ function reduceRaw(state: State, action: Action): State { ...state, busy: true, activeSkill: action.skill, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { @@ -812,17 +821,20 @@ export function applyIncoming(state: State, ev: IncomingEvent): State { function applyIncomingRaw(state: State, ev: IncomingEvent): State { switch (ev.type) { case "user.message": { + const turn = ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages); return { ...state, busy: true, - usage: { ...state.usage, turnCostUsd: 0 }, + usage: isIncomingUserNewTurn(state, turn) + ? { ...state.usage, turnCostUsd: 0 } + : state.usage, messages: [ ...state.messages, { kind: "user", text: ev.text, clientId: `remote-${ev.id}`, - turn: ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages), + turn, }, ], }; From e520d04a39423a040c92abe4c0357c3321835888 Mon Sep 17 00:00:00 2001 From: lab1 Date: Fri, 29 May 2026 22:19:36 +0800 Subject: [PATCH 05/10] test(cache): add offline cache regression guard --- .github/workflows/ci.yml | 3 + package.json | 1 + scripts/cache-guard.ts | 26 ++ src/telemetry/cache-guard.ts | 690 +++++++++++++++++++++++++++++++++++ tests/cache-guard.test.ts | 95 +++++ 5 files changed, 815 insertions(+) create mode 100644 scripts/cache-guard.ts create mode 100644 src/telemetry/cache-guard.ts create mode 100644 tests/cache-guard.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd36fcd29..1c3abf644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Cache guard + run: npm run cache:guard + - name: Build (tsup + dashboard) run: npm run build diff --git a/package.json b/package.json index 2886a814a..fb7efaabf 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:mutation": "stryker run", + "cache:guard": "tsx scripts/cache-guard.ts", "lint": "biome check src tests", "lint:fix": "biome check --write src tests", "format": "biome format --write src tests", diff --git a/scripts/cache-guard.ts b/scripts/cache-guard.ts new file mode 100644 index 000000000..8212051f6 --- /dev/null +++ b/scripts/cache-guard.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env tsx +import { renderCacheGuardReport, runCacheGuard } from "../src/telemetry/cache-guard.js"; + +function readFlag(name: string): string | null { + const prefix = `--${name}=`; + const match = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + return match ? match.slice(prefix.length) : null; +} + +const json = process.argv.includes("--json"); +const keepTemp = process.argv.includes("--keep-temp"); +const thresholdRaw = readFlag("threshold"); +const threshold = thresholdRaw === null ? undefined : Number(thresholdRaw); + +if (threshold !== undefined && (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1)) { + process.stderr.write("--threshold must be a number in (0, 1]. Example: --threshold=0.92\n"); + process.exit(2); +} + +const report = await runCacheGuard({ minHitRatio: threshold, keepTemp }); +if (json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} else { + process.stdout.write(`${renderCacheGuardReport(report)}\n`); +} +process.exitCode = report.passed ? 0 : 1; diff --git a/src/telemetry/cache-guard.ts b/src/telemetry/cache-guard.ts new file mode 100644 index 000000000..d70f42302 --- /dev/null +++ b/src/telemetry/cache-guard.ts @@ -0,0 +1,690 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DeepSeekClient } from "../client.js"; +import { CacheFirstLoop } from "../loop.js"; +import { buildAssistantMessage } from "../loop/messages.js"; +import { ImmutablePrefix } from "../memory/runtime.js"; +import { appendSessionMessage } from "../memory/session.js"; +import { countTokensBounded, estimateRequestTokens } from "../tokenizer.js"; +import { ToolRegistry } from "../tools.js"; +import type { ChatMessage, ToolCall, ToolSpec } from "../types.js"; + +const DEFAULT_MIN_HIT_RATIO = 0.85; +const MODEL = "deepseek-v4-flash"; +const MESSAGE_CACHE_KEYS = [ + "role", + "content", + "name", + "tool_call_id", + "tool_calls", + "reasoning_content", +] as const satisfies readonly (keyof ChatMessage)[]; + +export interface CacheGuardOptions { + /** Minimum estimated cache-hit ratio for transitions that should be warm. */ + minHitRatio?: number; + /** Keep the temporary HOME used for synthetic session files. */ + keepTemp?: boolean; +} + +export interface CapturedCacheRequest { + model: string; + messages: ChatMessage[]; + tools: ToolSpec[]; + rendered: string; + renderedTokens: number; + requestTokens: number; + immutablePrefixHash: string; +} + +export interface CacheGuardTransition { + from: number; + to: number; + estimatedHitRatio: number; + estimatedMissTokens: number; + immutablePrefixChanged: boolean; + expectedBreak: boolean; + passed: boolean; + reason?: string; +} + +export interface CacheGuardScenarioResult { + name: string; + description: string; + requests: number; + minEstimatedHitRatio: number | null; + maxEstimatedMissTokens: number; + expectedBreaks: number; + transitions: CacheGuardTransition[]; + passed: boolean; + error?: string; +} + +export interface CacheGuardReport { + passed: boolean; + threshold: number; + tempHome: string; + scenarios: CacheGuardScenarioResult[]; +} + +interface ScriptedResponse { + content: string; + reasoningContent?: string | null; + toolCalls?: ToolCall[]; + completionTokens?: number; +} + +interface ScenarioRun { + name: string; + description: string; + driver: ScriptedDeepSeek; + expectedBreaks: Set; +} + +class ScriptedDeepSeek { + readonly requests: CapturedCacheRequest[] = []; + private readonly responses: ScriptedResponse[] = []; + + enqueue(...responses: ScriptedResponse[]): void { + this.responses.push(...responses); + } + + readonly fetch: typeof fetch = async (_url, init) => { + const payload = parsePayload(init?.body); + const request = capturePayload(payload); + const previous = this.requests[this.requests.length - 1]; + this.requests.push(request); + + const next = this.responses.shift(); + if (!next) { + return new Response("cache guard has no scripted response left", { status: 500 }); + } + + const hitRatio = previous ? estimateRenderedHitRatio(previous.rendered, request.rendered) : 0; + const promptTokens = request.requestTokens; + const promptCacheHitTokens = Math.floor(promptTokens * hitRatio); + const promptCacheMissTokens = Math.max(0, promptTokens - promptCacheHitTokens); + const completionTokens = next.completionTokens ?? countTokensBounded(next.content || "ok"); + + return new Response( + JSON.stringify({ + choices: [ + { + index: 0, + message: { + role: "assistant", + content: next.content, + reasoning_content: next.reasoningContent ?? "", + ...(next.toolCalls && next.toolCalls.length > 0 + ? { tool_calls: next.toolCalls } + : {}), + }, + finish_reason: next.toolCalls && next.toolCalls.length > 0 ? "tool_calls" : "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + prompt_cache_hit_tokens: promptCacheHitTokens, + prompt_cache_miss_tokens: promptCacheMissTokens, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; +} + +export async function runCacheGuard(opts: CacheGuardOptions = {}): Promise { + const threshold = opts.minHitRatio ?? DEFAULT_MIN_HIT_RATIO; + const tempHome = mkdtempSync(join(tmpdir(), "reasonix-cache-guard-")); + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + try { + const scenarioRuns = await Promise.all([ + runPlainDialogueScenario(), + runToolRoundTripScenario(), + runMultiToolScenario(), + runReasoningRetentionScenario(), + runLongSessionResumeScenario(), + runMcpHotAddScenario(), + runProOneShotScenario(), + ]); + const scenarios = scenarioRuns.map((run) => + analyzeScenario( + run.name, + run.description, + run.driver.requests, + run.expectedBreaks, + threshold, + ), + ); + return { + passed: scenarios.every((scenario) => scenario.passed), + threshold, + tempHome, + scenarios, + }; + } finally { + if (previousHome === undefined) Reflect.deleteProperty(process.env, "HOME"); + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) Reflect.deleteProperty(process.env, "USERPROFILE"); + else process.env.USERPROFILE = previousUserProfile; + if (!opts.keepTemp) rmSync(tempHome, { recursive: true, force: true }); + } +} + +export function renderCacheGuardReport(report: CacheGuardReport): string { + const lines = [ + `cache guard: ${report.passed ? "PASS" : "FAIL"} threshold=${pct(report.threshold)}`, + "", + "scenario req min-hit max-miss breaks result", + ]; + for (const scenario of report.scenarios) { + const minHit = + scenario.minEstimatedHitRatio === null ? "n/a" : pct(scenario.minEstimatedHitRatio); + lines.push( + `${pad(scenario.name, 32)} ${String(scenario.requests).padStart(3)} ${minHit.padStart(7)} ${String( + scenario.maxEstimatedMissTokens, + ).padStart(8)} ${String(scenario.expectedBreaks).padStart(6)} ${ + scenario.passed ? "PASS" : "FAIL" + }`, + ); + for (const transition of scenario.transitions) { + if (transition.passed) continue; + lines.push( + ` - ${transition.from}->${transition.to}: hit=${pct( + transition.estimatedHitRatio, + )}, miss~${transition.estimatedMissTokens}, ${transition.reason ?? "cache regression"}`, + ); + } + if (scenario.error) lines.push(` - error: ${scenario.error}`); + } + return lines.join("\n"); +} + +export function analyzeScenario( + name: string, + description: string, + requests: readonly CapturedCacheRequest[], + expectedBreaks: ReadonlySet, + threshold: number, +): CacheGuardScenarioResult { + const transitions: CacheGuardTransition[] = []; + for (let i = 1; i < requests.length; i++) { + const previous = requests[i - 1]!; + const current = requests[i]!; + const expectedBreak = expectedBreaks.has(i); + const estimatedHitRatio = estimateRenderedHitRatio(previous.rendered, current.rendered); + const estimatedMissTokens = Math.max( + 0, + Math.round(current.requestTokens * (1 - estimatedHitRatio)), + ); + const immutablePrefixChanged = previous.immutablePrefixHash !== current.immutablePrefixHash; + const passed = expectedBreak || (!immutablePrefixChanged && estimatedHitRatio >= threshold); + const reason = passed + ? undefined + : immutablePrefixChanged + ? "immutable prefix changed unexpectedly" + : "estimated cache-hit ratio below threshold"; + transitions.push({ + from: i - 1, + to: i, + estimatedHitRatio, + estimatedMissTokens, + immutablePrefixChanged, + expectedBreak, + passed, + reason, + }); + } + const warmTransitions = transitions.filter((transition) => !transition.expectedBreak); + const minEstimatedHitRatio = + warmTransitions.length === 0 + ? null + : Math.min(...warmTransitions.map((transition) => transition.estimatedHitRatio)); + const maxEstimatedMissTokens = + warmTransitions.length === 0 + ? 0 + : Math.max(...warmTransitions.map((transition) => transition.estimatedMissTokens)); + return { + name, + description, + requests: requests.length, + minEstimatedHitRatio, + maxEstimatedMissTokens, + expectedBreaks: transitions.filter((transition) => transition.expectedBreak).length, + transitions, + passed: transitions.every((transition) => transition.passed), + }; +} + +function makeLoop(driver: ScriptedDeepSeek, opts: { session?: string } = {}): CacheFirstLoop { + const tools = makeGuardTools(); + const prefix = new ImmutablePrefix({ + system: stableSystemPrompt(), + toolSpecs: tools.specs(), + }); + const client = new DeepSeekClient({ + apiKey: "sk-cache-guard", + fetch: driver.fetch, + retry: { maxAttempts: 1 }, + }); + return new CacheFirstLoop({ + client, + prefix, + tools, + stream: false, + model: MODEL, + maxIterPerTurn: 8, + session: opts.session, + }); +} + +async function runPlainDialogueScenario(): Promise { + const driver = new ScriptedDeepSeek(); + driver.enqueue( + { content: "I inspected the request shape." }, + { content: "The follow-up keeps the prefix stable." }, + { content: "The final answer still reuses the warmed prefix." }, + ); + const loop = makeLoop(driver); + await runUser(loop, "Explain the current cache strategy in one paragraph."); + await runUser(loop, "Now summarize the risk if the system prompt changes."); + await runUser(loop, "Close with the mitigation."); + return { + name: "plain-dialogue", + description: "普通多轮问答,覆盖 assistant 历史追加后的 warm prefix。", + driver, + expectedBreaks: new Set(), + }; +} + +async function runToolRoundTripScenario(): Promise { + const driver = new ScriptedDeepSeek(); + driver.enqueue( + { + content: "", + reasoningContent: "Need to inspect a file before answering.", + toolCalls: [toolCall("read_file", { path: "src/loop.ts" }, "call_read_loop")], + }, + { content: "The file read result was incorporated without changing the immutable prefix." }, + { content: "A second user turn stays warm after the tool result is in history." }, + ); + const loop = makeLoop(driver); + await runUser(loop, "Read the loop implementation and explain where messages are built."); + await runUser(loop, "What cache-sensitive invariant should we keep?"); + return { + name: "tool-roundtrip", + description: "单工具调用 + tool result 上传 + 下一轮用户追问。", + driver, + expectedBreaks: new Set(), + }; +} + +async function runMultiToolScenario(): Promise { + const driver = new ScriptedDeepSeek(); + driver.enqueue( + { + content: "", + reasoningContent: "Search first, then inspect the matching file.", + toolCalls: [ + toolCall("search_content", { query: "healActiveLogBeforeSend" }, "call_search"), + toolCall("list_directory", { path: "src/loop" }, "call_list"), + ], + }, + { content: "Both tool results were consumed in one continuation." }, + { content: "The next turn remains cache-friendly." }, + ); + const loop = makeLoop(driver); + await runUser(loop, "Find the cache-sensitive healing paths."); + await runUser(loop, "Now give the short conclusion."); + return { + name: "multi-tool", + description: "同一轮多个 tool_calls,覆盖并行/批量结果进入下一次 prompt。", + driver, + expectedBreaks: new Set(), + }; +} + +async function runReasoningRetentionScenario(): Promise { + const driver = new ScriptedDeepSeek(); + driver.enqueue( + { content: "Reasoning-bearing answer.", reasoningContent: "private reasoning block" }, + { content: "Follow-up after reasoning retention pruning." }, + { content: "Another follow-up confirms the warmed prefix." }, + ); + const loop = makeLoop(driver); + await runUser(loop, "Answer with thinking-mode metadata present."); + await runUser(loop, "Ask a follow-up after the assistant reasoning is historical."); + await runUser(loop, "Ask one more follow-up."); + return { + name: "reasoning-retention", + description: "thinking-mode assistant reasoning round-trip / prune 路径。", + driver, + expectedBreaks: new Set(), + }; +} + +async function runLongSessionResumeScenario(): Promise { + const session = "cache-guard-long-session"; + for (let i = 0; i < 130; i++) { + appendSessionMessage(session, { + role: "user", + content: `historical user turn ${i}: ${longStableLine(i)}`, + }); + appendSessionMessage( + session, + buildAssistantMessage(`historical assistant turn ${i}: ${longStableLine(i)}`, [], MODEL, ""), + ); + } + const driver = new ScriptedDeepSeek(); + driver.enqueue( + { content: "Resumed long session successfully." }, + { content: "Follow-up after resume stayed warm." }, + ); + const loop = makeLoop(driver, { session }); + await runUser(loop, "Resume this long session and answer briefly."); + await runUser(loop, "One more follow-up in the same long session."); + return { + name: "long-session-resume", + description: "超过 200 条消息的窗口日志恢复,覆盖 toFullHistory()/session fallback。", + driver, + expectedBreaks: new Set(), + }; +} + +async function runMcpHotAddScenario(): Promise { + const driver = new ScriptedDeepSeek(); + driver.enqueue( + { content: "Before MCP hot-add." }, + { content: "After MCP hot-add; one cache break is expected." }, + { content: "The following turn should be warm again." }, + ); + const expectedBreaks = new Set(); + const loop = makeLoop(driver); + await runUser(loop, "Start before adding an MCP-like tool."); + expectedBreaks.add(driver.requests.length); + const mcpSpec = makeMcpToolSpec(); + loop.tools.register({ + name: mcpSpec.function.name, + description: mcpSpec.function.description, + parameters: mcpSpec.function.parameters, + readOnly: true, + fn: () => "mcp result", + }); + loop.prefix.addTool(mcpSpec); + await runUser(loop, "Now continue after the MCP tool was hot-added."); + await runUser(loop, "Verify the re-warmed prefix."); + return { + name: "mcp-hot-add", + description: "MCP 工具热添加允许一次预期 cache break,随后必须恢复高命中。", + driver, + expectedBreaks, + }; +} + +async function runProOneShotScenario(): Promise { + const driver = new ScriptedDeepSeek(); + driver.enqueue( + { content: "<<>>" }, + { content: "Pro one-shot answer." }, + { content: "Back on flash after one-shot escalation." }, + { content: "A stable flash follow-up." }, + ); + const loop = makeLoop(driver); + await runUser(loop, "Use the pro one-shot path if needed."); + const expectedBreaks = new Set([1, 2]); + await runUser(loop, "Confirm the model restored to flash."); + await runUser(loop, "Confirm the next flash turn is warm."); + return { + name: "pro-one-shot", + description: "Flash -> Pro one-shot escalation 是预期 break,恢复后下一轮必须 warm。", + driver, + expectedBreaks, + }; +} + +async function runUser(loop: CacheFirstLoop, prompt: string): Promise { + const errors: string[] = []; + await loop.run(prompt, (event) => { + if (event.role === "error") errors.push(event.error ?? "unknown loop error"); + }); + if (errors.length > 0) throw new Error(errors.join("; ")); +} + +function makeGuardTools(): ToolRegistry { + const tools = new ToolRegistry(); + tools.register({ + name: "read_file", + description: + "Read a UTF-8 text file from the project and return a bounded excerpt with path, line count, and relevant content.", + readOnly: true, + parameters: { + type: "object", + properties: { path: { type: "string", description: "Project-relative file path." } }, + required: ["path"], + }, + fn: (args: { path: string }) => + `file ${args.path}\n1 export function healActiveLogBeforeSend() {}\n2 // stable cache guard fixture`, + }); + tools.register({ + name: "search_content", + description: + "Search project text with a literal query and return matching file paths with compact snippets.", + readOnly: true, + parameters: { + type: "object", + properties: { query: { type: "string", description: "Literal query to search." } }, + required: ["query"], + }, + fn: (args: { query: string }) => + JSON.stringify({ query: args.query, matches: ["src/loop.ts:555", "src/tokenizer.ts:612"] }), + }); + tools.register({ + name: "list_directory", + description: + "List a project directory with stable ordering, including file names, child counts, and short type labels.", + readOnly: true, + parameters: { + type: "object", + properties: { path: { type: "string", description: "Project-relative directory path." } }, + required: ["path"], + }, + fn: (args: { path: string }) => + JSON.stringify({ path: args.path, entries: ["healing.ts", "messages.ts", "streaming.ts"] }), + }); + tools.register({ + name: "edit_file", + description: + "Apply a SEARCH/REPLACE edit block to one file. Included in the guard to keep mutating tool schemas in the prefix.", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + search: { type: "string" }, + replace: { type: "string" }, + }, + required: ["path", "search", "replace"], + }, + fn: () => "edit accepted by cache guard fixture", + }); + tools.register({ + name: "todo_write", + description: + "Write the current task checklist with pending, in_progress, and completed states.", + parameters: { + type: "object", + properties: { + todos: { + type: "array", + items: { + type: "object", + properties: { + content: { type: "string" }, + status: { type: "string", enum: ["pending", "in_progress", "completed"] }, + }, + required: ["content", "status"], + }, + }, + }, + required: ["todos"], + }, + fn: () => "todos saved", + }); + return tools; +} + +function capturePayload(payload: { + model?: string; + messages?: ChatMessage[]; + tools?: ToolSpec[]; +}): CapturedCacheRequest { + const model = payload.model ?? MODEL; + const messages = payload.messages ?? []; + const tools = payload.tools ?? []; + const rendered = renderCacheGuardSurface({ model, messages, tools }); + return { + model, + messages, + tools, + rendered, + renderedTokens: countTokensBounded(rendered), + requestTokens: estimateRequestTokens(messages, tools, true), + immutablePrefixHash: hashImmutablePrefix(model, messages, tools), + }; +} + +export function renderCacheGuardSurface(req: { + model: string; + messages: ChatMessage[]; + tools: ToolSpec[]; +}): string { + const systemMessages = req.messages + .filter((message) => message.role === "system") + .map(normalizeMessageForCache); + const conversationMessages = req.messages + .filter((message) => message.role !== "system") + .map(normalizeMessageForCache); + return [ + `model:${req.model}`, + `tools:${stableStringify(req.tools)}`, + `system:${stableStringify(systemMessages)}`, + `conversation:${stableStringify(conversationMessages)}`, + ].join("\n"); +} + +function hashImmutablePrefix(model: string, messages: ChatMessage[], tools: ToolSpec[]): string { + const system = messages.find((message) => message.role === "system")?.content ?? ""; + return hash({ model, system, tools }); +} + +function estimateRenderedHitRatio(previous: string, current: string): number { + const shared = commonPrefixLength(previous, current); + if (current.length === 0) return 1; + return Math.min(1, countTokensBounded(current.slice(0, shared)) / countTokensBounded(current)); +} + +function commonPrefixLength(a: string, b: string): number { + const max = Math.min(a.length, b.length); + let i = 0; + while (i < max && a.charCodeAt(i) === b.charCodeAt(i)) i++; + return i; +} + +function normalizeMessageForCache(message: ChatMessage): Record { + const normalized: Record = {}; + for (const key of MESSAGE_CACHE_KEYS) { + const value = message[key]; + if (value !== undefined) normalized[key] = value; + } + return normalized; +} + +function stableStringify(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => sortJson(item)); + if (value !== null && typeof value === "object") { + const input = value as Record; + const sorted: Record = {}; + for (const key of Object.keys(input).sort()) { + const child = input[key]; + if (child !== undefined) sorted[key] = sortJson(child); + } + return sorted; + } + return value; +} + +function parsePayload(body: unknown): { + model?: string; + messages?: ChatMessage[]; + tools?: ToolSpec[]; +} { + if (typeof body === "string") return JSON.parse(body); + if (body instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(body)); + return {}; +} + +function stableSystemPrompt(): string { + const lines = Array.from( + { length: 220 }, + (_, i) => + `Cache invariant ${String(i + 1).padStart(2, "0")}: preserve stable system text, tool schema order, message history, reasoning retention, MCP append semantics, and long-session recovery.`, + ); + return [ + "You are the Reasonix cache guard fixture. This prompt is intentionally large and deterministic.", + ...lines, + ].join("\n"); +} + +function longStableLine(i: number): string { + return `cache-safe historical payload ${i} ${"alpha beta gamma delta ".repeat(8)}`; +} + +function toolCall(name: string, args: Record, id: string): ToolCall { + return { + id, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }; +} + +function makeMcpToolSpec(): ToolSpec { + return { + type: "function", + function: { + name: "exa_search", + description: + "MCP-provided semantic web search tool. Hot-added in the cache guard to model an expected one-turn cache break.", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + domains: { type: "array", items: { type: "string" } }, + }, + required: ["query"], + }, + }, + }; +} + +function hash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function pct(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function pad(value: string, width: number): string { + return value.length >= width ? value : value + " ".repeat(width - value.length); +} diff --git a/tests/cache-guard.test.ts b/tests/cache-guard.test.ts new file mode 100644 index 000000000..131305efa --- /dev/null +++ b/tests/cache-guard.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + type CapturedCacheRequest, + analyzeScenario, + renderCacheGuardSurface, + runCacheGuard, +} from "../src/telemetry/cache-guard.js"; +import type { ChatMessage, ToolSpec } from "../src/types.js"; + +describe("cache guard", () => { + it("passes the built-in cache-sensitive dialogue scenarios", async () => { + const report = await runCacheGuard(); + + expect(report.passed).toBe(true); + expect(report.scenarios.map((scenario) => scenario.name)).toEqual([ + "plain-dialogue", + "tool-roundtrip", + "multi-tool", + "reasoning-retention", + "long-session-resume", + "mcp-hot-add", + "pro-one-shot", + ]); + for (const scenario of report.scenarios) { + expect(scenario.passed, scenario.name).toBe(true); + expect(scenario.requests, scenario.name).toBeGreaterThan(1); + } + }); + + it("fails an unexpected immutable-prefix change", () => { + const base = { + model: "deepseek-v4-flash", + messages: [], + tools: [], + rendered: "model:deepseek-v4-flash\ntools:[]\nprompt:system-a user-a", + renderedTokens: 100, + requestTokens: 100, + immutablePrefixHash: "same", + }; + const changed = { + ...base, + rendered: 'model:deepseek-v4-flash\ntools:[{"name":"new"}]\nprompt:system-a user-a', + immutablePrefixHash: "changed", + }; + + const result = analyzeScenario("fixture", "fixture", [base, changed], new Set(), 0.9); + + expect(result.passed).toBe(false); + expect(result.transitions[0]?.reason).toBe("immutable prefix changed unexpectedly"); + }); + + it("fails when historical thinking-mode assistant shape loses empty reasoning_content", () => { + const tools: ToolSpec[] = []; + const system: ChatMessage = { + role: "system", + content: "stable cache prefix ".repeat(800), + }; + const history: ChatMessage[] = Array.from({ length: 12 }, (_, i) => [ + { role: "user" as const, content: `question ${i}` }, + { role: "assistant" as const, content: `answer ${i}`, reasoning_content: "" }, + ]).flat(); + const currentHistory = history.map((message, i) => + i === 1 ? { role: "assistant" as const, content: message.content } : message, + ); + + const previous = cacheRequest([system, ...history], tools); + const current = cacheRequest( + [...currentHistory, { role: "user", content: "next question" }], + tools, + ); + + expect(previous.rendered).toContain('"reasoning_content":""'); + const result = analyzeScenario("fixture", "fixture", [previous, current], new Set(), 0.85); + + expect(result.passed).toBe(false); + expect(result.transitions[0]?.reason).toBe("estimated cache-hit ratio below threshold"); + }); +}); + +function cacheRequest(messages: ChatMessage[], tools: ToolSpec[]): CapturedCacheRequest { + const rendered = renderCacheGuardSurface({ + model: "deepseek-v4-flash", + messages, + tools, + }); + return { + model: "deepseek-v4-flash", + messages, + tools, + rendered, + renderedTokens: 100, + requestTokens: 100, + immutablePrefixHash: "same", + }; +} From a8eb58043def4729042bd2018a27b1750c60b97e Mon Sep 17 00:00:00 2001 From: lab1 Date: Sat, 30 May 2026 10:48:43 +0800 Subject: [PATCH 06/10] fix(desktop): improve chat UX and add regression gates --- .github/PULL_REQUEST_TEMPLATE.md | 2 + .github/workflows/ci.yml | 9 + desktop/src/App.test.ts | 18 +- desktop/src/App.tsx | 881 +++++++-- desktop/src/CodeView.tsx | 131 +- desktop/src/CommandPalette.tsx | 29 +- desktop/src/Markdown.tsx | 32 +- desktop/src/i18n/de.ts | 9 +- desktop/src/i18n/en.ts | 3 +- desktop/src/i18n/ja.ts | 4 +- desktop/src/i18n/zh-CN.ts | 3 +- desktop/src/icons.tsx | 330 +++- desktop/src/main.tsx | 7 +- desktop/src/notifications.test.ts | 2 +- desktop/src/notifications.ts | 5 +- desktop/src/styles.css | 1738 +++++++++++++---- desktop/src/ui/about.tsx | 21 +- desktop/src/ui/cards.tsx | 139 +- desktop/src/ui/composer.tsx | 289 ++- desktop/src/ui/context-panel.tsx | 74 +- desktop/src/ui/extra-cards.tsx | 79 +- desktop/src/ui/jobs-pop.tsx | 68 +- desktop/src/ui/jump-bar.tsx | 34 +- desktop/src/ui/live.tsx | 55 +- desktop/src/ui/settings.tsx | 73 +- desktop/src/ui/shortcut.tsx | 14 +- desktop/src/ui/sidebar.tsx | 150 +- desktop/src/ui/splash.tsx | 2 +- desktop/src/ui/statusbar.tsx | 46 +- desktop/src/ui/thread.tsx | 71 +- desktop/src/ui/useAutoCollapse.ts | 4 +- desktop/src/ui/useAutoScroll.ts | 4 +- desktop/src/ui/useDisableTextAssist.ts | 4 +- desktop/src/ui/workdir-pop.tsx | 36 +- desktop/vite.config.ts | 36 +- package.json | 12 +- tests/desktop-sidebar-new-chat-layout.test.ts | 3 +- 37 files changed, 3349 insertions(+), 1068 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ea827b0bb..a2def8e57 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,6 +15,8 @@ ## Checklist - [ ] `npm run verify` passes locally (lint + typecheck + tests + comment-policy gate) +- [ ] If desktop UI changed: `npm run verify:desktop` passes +- [ ] If desktop UI changed: buttons, popovers, sidebars, and chat auto-scroll were checked at narrow desktop widths - [ ] No `Co-Authored-By: Claude` trailer in commits - [ ] Comments follow CONTRIBUTING.md (no module-essay headers, no incident history) - [ ] No edits to `CHANGELOG.md` — release notes are maintainer-written at release time diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd36fcd29..30268ab30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,16 @@ jobs: with: node-version: ${{ matrix.node }} cache: npm + cache-dependency-path: | + package-lock.json + desktop/package-lock.json - name: Install dependencies run: npm ci + - name: Install desktop dependencies + run: npm ci --prefix desktop + - name: Lint (biome) run: npm run lint @@ -36,6 +42,9 @@ jobs: - name: Build (tsup + dashboard) run: npm run build + - name: Build desktop + run: npm run build:desktop + - name: Test (vitest + coverage) run: npm run test:coverage diff --git a/desktop/src/App.test.ts b/desktop/src/App.test.ts index 6cced4463..31c76b007 100644 --- a/desktop/src/App.test.ts +++ b/desktop/src/App.test.ts @@ -395,14 +395,14 @@ describe("desktop thread layout", () => { const side = 244; const ctx = 320; - expect( - getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx }), - ).toBe(580); - expect( - getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx }), - ).toBe(756); - expect( - getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx }), - ).toBe(1120); + expect(getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx })).toBe( + 580, + ); + expect(getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx })).toBe( + 756, + ); + expect(getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx })).toBe( + 1120, + ); }); }); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index a06e4715f..95bf73a6a 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -7,22 +7,53 @@ import { requestPermission as requestNotificationPermission, sendNotification, } from "@tauri-apps/plugin-notification"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { relaunch } from "@tauri-apps/plugin-process"; import { type Update, check } from "@tauri-apps/plugin-updater"; -import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { + type ReactNode, + forwardRef, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import { type ScrollerProps, Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { CommandPalette, Toast, buildCommands, useCommandPalette } from "./CommandPalette"; import { WorkspaceProvider } from "./Markdown"; -import { - nextAbortDraftCandidate, - restoreAbortedDraft, - type AbortDraftSource, -} from "./abort-draft"; +import { type AbortDraftSource, nextAbortDraftCandidate, restoreAbortedDraft } from "./abort-draft"; import { getLang, getLangLabel, getSupportedLangs, setLang, t, useLang } from "./i18n"; import { I } from "./icons"; import { + type ApprovalSnapshot, + deriveDesktopNotifications, + dispatchDesktopNotifications, + shouldShowCompletionToast, +} from "./notifications"; +import type { + CheckpointVerdict, + ChoiceVerdict, + ConfirmationChoice, + ExternalSessionApp, + ExternalSessionSource, + IncomingEvent, + JobInfo, + McpSpecInfo, + MemoryDetail, + MemoryEntryInfo, + OutgoingCommand, + PlanVerdict, + RevisionVerdict, + SettingsPatch, + SkillInfo, +} from "./protocol"; +import type { QQDesktopSettingsState } from "./qq-settings"; +import { + type SlashSettingsCommand, buildSlashSettingsDescriptors, parseSlashSettingsCommand, - type SlashSettingsCommand, } from "./slash-settings"; import { FONT_FAMILY, @@ -41,47 +72,23 @@ import { isThemeStyle, themeForStyle, } from "./theme"; -import type { - CheckpointVerdict, - ChoiceVerdict, - ConfirmationChoice, - ExternalSessionApp, - ExternalSessionSource, - IncomingEvent, - JobInfo, - McpSpecInfo, - MemoryDetail, - MemoryEntryInfo, - OutgoingCommand, - PlanVerdict, - RevisionVerdict, - SettingsPatch, - SkillInfo, -} from "./protocol"; -import { type QQDesktopSettingsState } from "./qq-settings"; +import { AboutModal } from "./ui/about"; +import { parseEditResult } from "./ui/cards"; import { Composer, type SlashCmd } from "./ui/composer"; import { ContextPanel } from "./ui/context-panel"; import { JobsPop } from "./ui/jobs-pop"; +import { JumpBar } from "./ui/jump-bar"; import { useElapsed } from "./ui/live"; -import { AboutModal } from "./ui/about"; import { SettingsModal, type PageId as SettingsPageId } from "./ui/settings"; -import { JumpBar } from "./ui/jump-bar"; -import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; -import { Sidebar } from "./ui/sidebar"; import { Shortcut, localizeShortcutText, shortcutText } from "./ui/shortcut"; +import { Sidebar } from "./ui/sidebar"; import { Splash, shouldShowSplash } from "./ui/splash"; -import { StatusBar } from "./ui/statusbar"; import { StartupFailure, - coerceStartupFailure, type StartupFailureState, + coerceStartupFailure, } from "./ui/startup-failure"; -import { - dispatchDesktopNotifications, - deriveDesktopNotifications, - shouldShowCompletionToast, - type ApprovalSnapshot, -} from "./notifications"; +import { StatusBar } from "./ui/statusbar"; import { ActivePlanTaskCard, AssistantMsg, @@ -95,18 +102,16 @@ import { TurnDivider, UserMsg, } from "./ui/thread"; -import { WorkdirPop } from "./ui/workdir-pop"; -import { parseEditResult } from "./ui/cards"; -import { useAutoCollapse } from "./ui/useAutoCollapse"; -import { useResizable } from "./ui/useResizable"; -import { useAutoScroll } from "./ui/useAutoScroll"; -import { useDisableTextAssist } from "./ui/useDisableTextAssist"; import { getThreadMaxWidth } from "./ui/thread-layout"; import { elideTranscriptMessages } from "./ui/transcript-elision"; -import { openUrl } from "@tauri-apps/plugin-opener"; +import { useAutoCollapse } from "./ui/useAutoCollapse"; +import { useDisableTextAssist } from "./ui/useDisableTextAssist"; +import { useResizable } from "./ui/useResizable"; +import { WorkdirPop } from "./ui/workdir-pop"; const RIGHT_SIDEBAR_COLLAPSE_WIDTH = 1120; const LEFT_SIDEBAR_COLLAPSE_WIDTH = 760; +const THREAD_BOTTOM_THRESHOLD = 80; const RESPONSIVE_STAGE = { WIDE: "wide", @@ -116,12 +121,30 @@ const RESPONSIVE_STAGE = { type ResponsiveStage = (typeof RESPONSIVE_STAGE)[keyof typeof RESPONSIVE_STAGE]; +type ApprovalQueueItem = { + key: string; + label: string; + node: ReactNode; +}; + function responsiveStage(width: number): ResponsiveStage { if (width < LEFT_SIDEBAR_COLLAPSE_WIDTH) return RESPONSIVE_STAGE.NARROW; if (width < RIGHT_SIDEBAR_COLLAPSE_WIDTH) return RESPONSIVE_STAGE.COMPACT; return RESPONSIVE_STAGE.WIDE; } +function ThreadTail() { + return
; +} + +function hasScrollableOverflow(el: HTMLElement): boolean { + return el.scrollHeight > el.clientHeight + THREAD_BOTTOM_THRESHOLD; +} + +function isElementAtBottom(el: HTMLElement): boolean { + return el.scrollTop + el.clientHeight >= el.scrollHeight - THREAD_BOTTOM_THRESHOLD; +} + export type AssistantSegment = | { kind: "text"; text: string } | { kind: "reasoning"; text: string } @@ -230,13 +253,20 @@ export type UsageStats = { liveLogTokens: number; }; -type WindowControls = Pick, "isFullscreen" | "isMaximized" | "setFullscreen" | "toggleMaximize">; +type WindowControls = Pick< + ReturnType, + "isFullscreen" | "isMaximized" | "setFullscreen" | "toggleMaximize" +>; export function readWindowExpanded(win: WindowControls, isMac: boolean): Promise { return isMac ? win.isFullscreen() : win.isMaximized(); } -export function toggleWindowExpanded(win: WindowControls, isMac: boolean, expanded: boolean): Promise { +export function toggleWindowExpanded( + win: WindowControls, + isMac: boolean, + expanded: boolean, +): Promise { if (isMac) return win.setFullscreen(!expanded); return win.toggleMaximize(); } @@ -410,9 +440,7 @@ function fallbackSkillDesc(skill: SkillInfo): string { ? t("app.skill.scope.global") : t("app.skill.scope.project"); const runAs = - skill.runAs === "subagent" - ? t("app.skill.runAs.subagent") - : t("app.skill.runAs.inline"); + skill.runAs === "subagent" ? t("app.skill.runAs.subagent") : t("app.skill.runAs.inline"); return t("app.skill.generic", { scope, runAs }); } @@ -442,7 +470,12 @@ function reduceRaw(state: State, action: Action): State { busy: true, messages: [ ...state.messages, - { kind: "user", text: action.text, clientId: action.clientId, turn: nextMessageTurn(state.messages) }, + { + kind: "user", + text: action.text, + clientId: action.clientId, + turn: nextMessageTurn(state.messages), + }, ], }; } @@ -594,9 +627,7 @@ function reduceRaw(state: State, action: Action): State { case "dismiss_error": return { ...state, - messages: state.messages.filter( - (m) => !(m.kind === "error" && m.id === action.id), - ), + messages: state.messages.filter((m) => !(m.kind === "error" && m.id === action.id)), }; case "mention_results": return { ...state, mentionResults: action.results }; @@ -671,16 +702,13 @@ function DiffStats({ stats }: { stats: FileStats }) { const total = stats.entries.length; return (
- @@ -1095,10 +1123,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { ...state.messages, { kind: "error", - message: - `Session "${ev.name}" loaded with no messages (${sizeNote}). ` + - `The file ~/.reasonix/sessions/${ev.name}.jsonl exists but couldn't be parsed — ` + - `start a new chat or restore from .jsonl.bak if you have one.`, + message: `Session "${ev.name}" loaded with no messages (${sizeNote}). The file ~/.reasonix/sessions/${ev.name}.jsonl exists but couldn't be parsed — start a new chat or restore from .jsonl.bak if you have one.`, id: nextErrorId(), }, ], @@ -1147,8 +1172,10 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { const m = state.messages[i]!; if (m.kind !== "assistant" || m.turn !== ev.turn) continue; let updated = m; - if (ev.channel === "content") updated = { ...m, segments: appendTextSegment(m.segments, "text", ev.text) }; - else if (ev.channel === "reasoning") updated = { ...m, segments: appendTextSegment(m.segments, "reasoning", ev.text) }; + if (ev.channel === "content") + updated = { ...m, segments: appendTextSegment(m.segments, "text", ev.text) }; + else if (ev.channel === "reasoning") + updated = { ...m, segments: appendTextSegment(m.segments, "reasoning", ev.text) }; const next = [...state.messages]; next[i] = updated; return { ...state, messages: next }; @@ -1158,8 +1185,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { case "model.final": { const u = ev.usage; const promptTokens = - u?.prompt_tokens ?? - (u?.prompt_cache_hit_tokens ?? 0) + (u?.prompt_cache_miss_tokens ?? 0); + u?.prompt_tokens ?? (u?.prompt_cache_hit_tokens ?? 0) + (u?.prompt_cache_miss_tokens ?? 0); const callHit = u?.prompt_cache_hit_tokens ?? 0; const callMiss = u?.prompt_cache_miss_tokens ?? Math.max(0, promptTokens - callHit); const hasCall = promptTokens > 0 || callHit > 0 || callMiss > 0; @@ -1176,18 +1202,19 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { }; // Walk backwards to clear pending flag on the matching assistant let settledPending = false; + let messages = state.messages; for (let i = state.messages.length - 1; i >= 0; i--) { const m = state.messages[i]!; if (m.kind !== "assistant" || m.turn !== ev.turn) continue; if (m.pending) { const s = [...state.messages]; s[i] = { ...m, pending: false }; - state = { ...state, messages: s }; + messages = s; } settledPending = true; break; } - return settledPending ? { ...state, usage } : { ...state, usage }; + return settledPending ? { ...state, messages, usage } : { ...state, usage }; } case "tool.preparing": { for (let i = state.messages.length - 1; i >= 0; i--) { @@ -1195,7 +1222,19 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { if (m.kind !== "assistant" || m.turn !== ev.turn) continue; if (m.segments.some((s) => s.kind === "tool" && s.callId === ev.callId)) return state; const next = [...state.messages]; - next[i] = { ...m, segments: [...m.segments, { kind: "tool" as const, callId: ev.callId, name: ev.name, args: "", startedAt: Date.now() }] }; + next[i] = { + ...m, + segments: [ + ...m.segments, + { + kind: "tool" as const, + callId: ev.callId, + name: ev.name, + args: "", + startedAt: Date.now(), + }, + ], + }; return { ...state, messages: next }; } return state; @@ -1209,13 +1248,26 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { const idx = m.segments.findIndex((s) => s.kind === "tool" && s.callId === ev.callId); if (idx >= 0) { const segs = [...m.segments]; - if (segs[idx]?.kind === "tool") segs[idx] = { ...(segs[idx] as AssistantSegment & { kind: "tool" }), args: ev.args }; + if (segs[idx]?.kind === "tool") + segs[idx] = { ...(segs[idx] as AssistantSegment & { kind: "tool" }), args: ev.args }; const msgs = [...nextState.messages]; msgs[i] = { ...m, segments: segs }; nextState = { ...nextState, messages: msgs }; } else { const msgs = [...nextState.messages]; - msgs[i] = { ...m, segments: [...m.segments, { kind: "tool" as const, callId: ev.callId, name: ev.name, args: ev.args, startedAt: Date.now() }] }; + msgs[i] = { + ...m, + segments: [ + ...m.segments, + { + kind: "tool" as const, + callId: ev.callId, + name: ev.name, + args: ev.args, + startedAt: Date.now(), + }, + ], + }; nextState = { ...nextState, messages: msgs }; } break; @@ -1247,10 +1299,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { return { ...state, busy: false, - messages: [ - ...state.messages, - { kind: "status", text: `≫ btw\n${ev.answer}` }, - ], + messages: [...state.messages, { kind: "status", text: `≫ btw\n${ev.answer}` }], }; case "status": return state; @@ -1303,7 +1352,11 @@ function formatConversationMarkdown(messages: ChatMessage[], userLabel: string): } function sanitizeFilename(name: string): string { - return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/^\.+/, "").slice(0, 200) || "session"; + const cleaned = Array.from(name, (ch) => { + const code = ch.charCodeAt(0); + return code < 32 || '<>:"/\\|?*'.includes(ch) ? "_" : ch; + }).join(""); + return cleaned.replace(/^\.+/, "").slice(0, 200) || "session"; } function defaultExportFilename(session: string): string { @@ -1423,12 +1476,19 @@ function TabRuntime({ >(undefined); const composerRef = useRef(null); const threadRef = useRef(null); - const threadInnerRef = useRef(null); const virtuosoRef = useRef(null); + const virtuosoScrollerRef = useRef(null); + const [virtuosoScroller, setVirtuosoScroller] = useState(null); + const autoFollowRef = useRef(true); + const userDetachedScrollRef = useRef(false); + const scrollFrameRef = useRef(0); + const scrollBusyRef = useRef(false); + const restoredScrollSessionRef = useRef(null); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsPage, setSettingsPage] = useState("general"); const [jobsOpen, setJobsOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); + const [approvalTrayExpanded, setApprovalTrayExpanded] = useState(false); const previousApprovalSnapshotRef = useRef({ confirms: [], pathAccess: [], @@ -1455,6 +1515,26 @@ function TabRuntime({ setSettingsOpen(true); }, []); const palette = useCommandPalette(active); + const VirtuosoScroller = useMemo( + () => + forwardRef(function VirtuosoScroller(props, ref) { + const setScrollerRef = useCallback( + (node: HTMLDivElement | null) => { + virtuosoScrollerRef.current = node; + setVirtuosoScroller(node); + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }, + [ref], + ); + + return
; + }), + [], + ); useEffect(() => { registerDispatch(tabId, dispatch); @@ -1537,13 +1617,10 @@ function TabRuntime({ } }, [clearAbortDraft, saveSettings, state.settings?.workspaceDir]); - const flashToast = useCallback( - (msg: string, opts?: { yolo?: boolean; duration?: number }) => { - setToast({ msg, yolo: opts?.yolo }); - window.setTimeout(() => setToast(null), opts?.duration ?? 1600); - }, - [], - ); + const flashToast = useCallback((msg: string, opts?: { yolo?: boolean; duration?: number }) => { + setToast({ msg, yolo: opts?.yolo }); + window.setTimeout(() => setToast(null), opts?.duration ?? 1600); + }, []); const applyReasoningEffort = useCallback( (reasoningEffort: Settings["reasoningEffort"]) => { @@ -1595,10 +1672,7 @@ function TabRuntime({ const handle = await webview.onDragDropEvent((event) => { if (!dropActiveRef.current) return; if (event.payload.type === "enter") { - document.body.style.setProperty( - "--drop-overlay-label", - `"${t("dragDrop.overlay")}"`, - ); + document.body.style.setProperty("--drop-overlay-label", `"${t("dragDrop.overlay")}"`); document.body.dataset.dragOver = "1"; return; } @@ -1692,7 +1766,12 @@ function TabRuntime({ const clientId = `skill-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const trimmedArgs = args?.trim() ?? ""; recordAbortDraft("skill_run", text); - dispatch({ t: "start_skill", skill: { name: skill.name, runAs: skill.runAs }, args: trimmedArgs, clientId }); + dispatch({ + t: "start_skill", + skill: { name: skill.name, runAs: skill.runAs }, + args: trimmedArgs, + clientId, + }); sendRpc({ cmd: "skill_run", name: skill.name, args: trimmedArgs || undefined }); if (!override) setDraft(""); return; @@ -1736,13 +1815,15 @@ function TabRuntime({ }, [clearAbortDraft]); // When /retry returns the last user text, set it as the composer draft + const retryTextRef = useRef(state.retryText); + retryTextRef.current = state.retryText; useEffect(() => { - if (state.retryNonce > 0 && state.retryText) { - setDraft(state.retryText); + const retryText = retryTextRef.current; + if (state.retryNonce > 0 && retryText) { + setDraft(retryText); composerRef.current?.focus(); } // Only fire when retryNonce changes — retryText alone would re-fire on re-renders - // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.retryNonce]); const onEditUserMsg = useCallback((t: string) => { @@ -1761,17 +1842,30 @@ function TabRuntime({ useEffect(() => { const currentSnapshot: ApprovalSnapshot = { confirms: state.pendingConfirms.map((c) => ({ id: c.id, command: c.command })), - pathAccess: state.pendingPathAccess.map((p) => ({ id: p.id, path: p.path, intent: p.intent })), + pathAccess: state.pendingPathAccess.map((p) => ({ + id: p.id, + path: p.path, + intent: p.intent, + })), choices: state.pendingChoices.map((c) => ({ id: c.id, question: c.question })), plans: state.pendingPlans.map((p) => ({ id: p.id, summary: p.summary, plan: p.plan })), - checkpoints: state.pendingCheckpoints.map((c) => ({ id: c.id, title: c.title, result: c.result })), - revisions: state.pendingRevisions.map((r) => ({ id: r.id, summary: r.summary, reason: r.reason })), + checkpoints: state.pendingCheckpoints.map((c) => ({ + id: c.id, + title: c.title, + result: c.result, + })), + revisions: state.pendingRevisions.map((r) => ({ + id: r.id, + summary: r.summary, + reason: r.reason, + })), }; const previousSnapshot = previousApprovalSnapshotRef.current; const wasBusy = wasBusyRef.current; - const busyDurationMs = wasBusy && !state.busy && busyStartedAtRef.current - ? Date.now() - busyStartedAtRef.current - : 0; + const busyDurationMs = + wasBusy && !state.busy && busyStartedAtRef.current + ? Date.now() - busyStartedAtRef.current + : 0; if (state.busy && busyStartedAtRef.current === null) { busyStartedAtRef.current = Date.now(); @@ -1822,6 +1916,18 @@ function TabRuntime({ state.pendingRevisions, ]); + const pendingApprovalCount = + state.pendingPlans.length + + state.pendingCheckpoints.length + + state.pendingRevisions.length + + state.pendingConfirms.length + + state.pendingPathAccess.length + + state.pendingChoices.length; + + useEffect(() => { + if (pendingApprovalCount <= 1) setApprovalTrayExpanded(false); + }, [pendingApprovalCount]); + const resolveConfirm = useCallback( (id: number, response: ConfirmationChoice) => { sendRpc({ cmd: "confirm_response", id, response }); @@ -1891,17 +1997,169 @@ function TabRuntime({ }, []); const [showJumpButton, setShowJumpButton] = useState(false); - const { scrollToBottom } = useAutoScroll( - threadRef, - threadInnerRef, - state.busy, - restoreScrollTop, + const refreshJumpButton = useCallback(() => { + const el = virtuosoScrollerRef.current; + if (!el) return; + setShowJumpButton( + userDetachedScrollRef.current && hasScrollableOverflow(el) && !isElementAtBottom(el), + ); + }, []); + + const scrollToBottom = useCallback( + (smooth = true) => { + autoFollowRef.current = true; + userDetachedScrollRef.current = false; + setShowJumpButton(false); + + const lastIndex = messageItems.length - 1; + if (lastIndex >= 0) { + virtuosoRef.current?.scrollToIndex({ + index: lastIndex, + align: "end", + behavior: smooth ? "smooth" : "auto", + }); + } + + const el = virtuosoScrollerRef.current; + if (el) { + el.scrollTo({ + top: el.scrollHeight, + behavior: smooth ? "smooth" : "auto", + }); + } + }, + [messageItems.length], + ); + + const scheduleScrollToBottom = useCallback( + (smooth = false) => { + if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current); + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = 0; + scrollToBottom(smooth); + }); + }, + [scrollToBottom], + ); + + const handleAtBottomStateChange = useCallback( + (atBottom: boolean) => { + if (atBottom) { + autoFollowRef.current = true; + userDetachedScrollRef.current = false; + setShowJumpButton(false); + return; + } + refreshJumpButton(); + }, + [refreshJumpButton], ); + const followOutput = useCallback((isAtBottom: boolean) => { + return isAtBottom || autoFollowRef.current ? "auto" : false; + }, []); + + useEffect(() => { + const el = virtuosoScroller; + if (!el) return; + + let pendingFrame = 0; + const markUserScrollIntent = () => { + if (pendingFrame) cancelAnimationFrame(pendingFrame); + pendingFrame = requestAnimationFrame(() => { + pendingFrame = 0; + const atBottom = isElementAtBottom(el); + autoFollowRef.current = atBottom; + userDetachedScrollRef.current = !atBottom; + refreshJumpButton(); + }); + }; + + let draggingScrollbar = false; + const onPointerDown = () => { + draggingScrollbar = true; + markUserScrollIntent(); + el.addEventListener("scroll", markUserScrollIntent, { passive: true }); + }; + const onPointerEnd = () => { + if (!draggingScrollbar) return; + draggingScrollbar = false; + el.removeEventListener("scroll", markUserScrollIntent); + markUserScrollIntent(); + }; + + el.addEventListener("wheel", markUserScrollIntent, { passive: true }); + el.addEventListener("touchmove", markUserScrollIntent, { passive: true }); + el.addEventListener("keydown", markUserScrollIntent); + el.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerEnd); + window.addEventListener("pointercancel", onPointerEnd); + + return () => { + if (pendingFrame) cancelAnimationFrame(pendingFrame); + el.removeEventListener("wheel", markUserScrollIntent); + el.removeEventListener("touchmove", markUserScrollIntent); + el.removeEventListener("keydown", markUserScrollIntent); + el.removeEventListener("pointerdown", onPointerDown); + el.removeEventListener("scroll", markUserScrollIntent); + window.removeEventListener("pointerup", onPointerEnd); + window.removeEventListener("pointercancel", onPointerEnd); + }; + }, [refreshJumpButton, virtuosoScroller]); + + useEffect(() => { + if (scrollBusyRef.current !== state.busy) { + if (state.busy || !userDetachedScrollRef.current) { + scheduleScrollToBottom(state.busy); + } + scrollBusyRef.current = state.busy; + } + }, [scheduleScrollToBottom, state.busy]); + + useEffect(() => { + if (messageItems.length === 0) return; + if (!autoFollowRef.current) { + refreshJumpButton(); + return; + } + scheduleScrollToBottom(false); + }, [messageItems, refreshJumpButton, scheduleScrollToBottom]); + + useEffect(() => { + return () => { + if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current); + }; + }, []); + + useEffect(() => { + const el = virtuosoScroller; + const sessionKey = state.currentSession ?? "__new__"; + if (!el || restoredScrollSessionRef.current === sessionKey) return; + restoredScrollSessionRef.current = sessionKey; + const id = window.setTimeout(() => { + const restore = restoreScrollTop(); + if (restore != null && restore > THREAD_BOTTOM_THRESHOLD) { + autoFollowRef.current = false; + userDetachedScrollRef.current = true; + el.scrollTop = restore; + refreshJumpButton(); + return; + } + scheduleScrollToBottom(false); + }, 80); + return () => window.clearTimeout(id); + }, [ + refreshJumpButton, + restoreScrollTop, + scheduleScrollToBottom, + state.currentSession, + virtuosoScroller, + ]); + // Persist the transcript scroll offset per session so a restart reopens // the conversation where the user left it (#1244). useEffect(() => { - const el = threadRef.current; + const el = virtuosoScroller; const session = state.currentSession; if (!el || !session) return; const key = `reasonix.scroll.${session}`; @@ -1919,7 +2177,7 @@ function TabRuntime({ el.removeEventListener("scroll", onScroll); clearTimeout(timer); }; - }, [state.currentSession]); + }, [state.currentSession, virtuosoScroller]); useEffect(() => { if (!active) return; @@ -2080,7 +2338,12 @@ function TabRuntime({ composerRef.current?.focus(); }, }, - { cmd: "/new", desc: t("app.cmd.newSession"), run: () => newChat(), kb: shortcutText(["mod", "N"]) }, + { + cmd: "/new", + desc: t("app.cmd.newSession"), + run: () => newChat(), + kb: shortcutText(["mod", "N"]), + }, { cmd: "/clear", desc: t("app.cmd.clearChat"), run: () => clearConversation() }, { cmd: "/abort", desc: t("app.cmd.abort"), run: () => abort(), kb: "esc" }, { @@ -2239,6 +2502,81 @@ function TabRuntime({ flashToast(t("app.toast.copiedMd")); }, [state.messages, flashToast]); + const approvalItems: ApprovalQueueItem[] = [ + ...state.pendingPlans.map((p) => ({ + key: `pp-${p.id}`, + label: t("thread.planConfirmationKind"), + node: ( + resolvePlan(p.id, { type: "approve" })} + onRefine={() => resolvePlan(p.id, { type: "refine" })} + onCancel={() => resolvePlan(p.id, { type: "cancel" })} + /> + ), + })), + ...state.pendingCheckpoints.map((c) => ({ + key: `cp-${c.id}`, + label: t("thread.checkpointKind"), + node: ( + resolveCheckpoint(c.id, { type: "continue" })} + onRevise={() => resolveCheckpoint(c.id, { type: "revise" })} + onStop={() => resolveCheckpoint(c.id, { type: "stop" })} + /> + ), + })), + ...state.pendingRevisions.map((r) => ({ + key: `rv-${r.id}`, + label: t("thread.planRevisionKind"), + node: ( + resolveRevision(r.id, { type: "accepted" })} + onReject={() => resolveRevision(r.id, { type: "rejected" })} + /> + ), + })), + ...state.pendingConfirms.map((c) => ({ + key: `cc-${c.id}`, + label: t("thread.shellConfirmationKind"), + node: ( + resolveConfirm(c.id, { type: "run_once" })} + onAlwaysAllow={(prefix) => resolveConfirm(c.id, { type: "always_allow", prefix })} + onDeny={() => resolveConfirm(c.id, { type: "deny" })} + /> + ), + })), + ...state.pendingPathAccess.map((p) => ({ + key: `pa-${p.id}`, + label: t("thread.pathAccessKind"), + node: ( + resolvePathAccess(p.id, { type: "run_once" })} + onAlwaysAllow={(prefix) => resolvePathAccess(p.id, { type: "always_allow", prefix })} + onDeny={() => resolvePathAccess(p.id, { type: "deny" })} + /> + ), + })), + ...state.pendingChoices.map((c) => ({ + key: `ch-${c.id}`, + label: t("thread.userChoiceKind"), + node: ( + resolveChoice(c.id, { type: "pick", optionId })} + onCancel={() => resolveChoice(c.id, { type: "cancel" })} + /> + ), + })), + ]; + const visibleApprovalItems = approvalTrayExpanded ? approvalItems : approvalItems.slice(0, 1); + const hiddenApprovalCount = Math.max(approvalItems.length - visibleApprovalItems.length, 0); + return ( s.cmd === cmd); - if (match) { match.run(); return; } + if (match) { + match.run(); + return; + } } send(text); }} @@ -2369,18 +2710,28 @@ function TabRuntime({ ref={virtuosoRef} style={{ height: "100%" }} totalCount={messageItems.length} - followOutput={"auto"} - atBottomStateChange={(atBottom) => setShowJumpButton(!atBottom)} + alignToBottom + followOutput={followOutput} + atBottomThreshold={THREAD_BOTTOM_THRESHOLD} + atBottomStateChange={handleAtBottomStateChange} + increaseViewportBy={{ top: 360, bottom: 720 }} + overscan={{ main: 240, reverse: 240 }} components={{ - Header: state.activePlan ? () => ( -
- dispatch({ t: "dismiss_plan" })} - /> - -
- ) : undefined, + Scroller: VirtuosoScroller, + Footer: ThreadTail, + Header: state.activePlan + ? () => ( +
+ dispatch({ t: "dismiss_plan" }) + } + /> + +
+ ) + : undefined, }} itemContent={(index) => { const m = state.messages[index]!; @@ -2415,6 +2766,7 @@ function TabRuntime({ )} {showJumpButton ? (
- {state.pendingPlans.length > 0 || state.pendingCheckpoints.length > 0 || state.pendingRevisions.length > 0 || state.pendingConfirms.length > 0 || state.pendingPathAccess.length > 0 || state.pendingChoices.length > 0 || !state.ready ? ( -
- {state.pendingPlans.map((p) => ( resolvePlan(p.id, { type: "approve" })} onRefine={() => resolvePlan(p.id, { type: "refine" })} onCancel={() => resolvePlan(p.id, { type: "cancel" })} />))} - {state.pendingCheckpoints.map((c) => ( resolveCheckpoint(c.id, { type: "continue" })} onRevise={() => resolveCheckpoint(c.id, { type: "revise" })} onStop={() => resolveCheckpoint(c.id, { type: "stop" })} />))} - {state.pendingRevisions.map((r) => ( resolveRevision(r.id, { type: "accepted" })} onReject={() => resolveRevision(r.id, { type: "rejected" })} />))} - {state.pendingConfirms.map((c) => ( resolveConfirm(c.id, { type: "run_once" })} onAlwaysAllow={(prefix) => resolveConfirm(c.id, { type: "always_allow", prefix })} onDeny={() => resolveConfirm(c.id, { type: "deny" })} />))} - {state.pendingPathAccess.map((p) => ( resolvePathAccess(p.id, { type: "run_once" })} onAlwaysAllow={(prefix) => resolvePathAccess(p.id, { type: "always_allow", prefix })} onDeny={() => resolvePathAccess(p.id, { type: "deny" })} />))} - {state.pendingChoices.map((c) => ( resolveChoice(c.id, { type: "pick", optionId })} onCancel={() => resolveChoice(c.id, { type: "cancel" })} />))} - {!state.ready ? (
{t("app.connecting")}
) : null} + {approvalItems.length > 0 || !state.ready ? ( +
+
+ + + {approvalItems[0]?.label ?? t("app.connecting")} + + {pendingApprovalCount > 1 ? ( + {pendingApprovalCount} + ) : null} + + {approvalItems.length > 1 ? ( + + ) : null} +
+
+ {visibleApprovalItems.map((item) => ( +
+ {item.node} +
+ ))} + {!approvalTrayExpanded && hiddenApprovalCount > 0 ? ( + + ) : null} + {!state.ready ? ( +
{t("app.connecting")}
+ ) : null} +
) : null} @@ -2612,6 +3000,7 @@ function TabRuntime({ function WinMinimize() { return ( + Minimize ); @@ -2619,23 +3008,66 @@ function WinMinimize() { function WinMaximize() { return ( - + Maximize + ); } function WinRestore() { return ( - - + Restore + + ); } function WinClose() { return ( - - + Close + + ); } @@ -2680,13 +3112,21 @@ function TitleBar({ }; void syncWindowState(); let unlisten: (() => void) | undefined; - win.listen("tauri://resize", async () => { - await syncWindowState(); - }).then((fn) => { unlisten = fn; }); + win + .listen("tauri://resize", async () => { + await syncWindowState(); + }) + .then((fn) => { + unlisten = fn; + }); let fullscreenUnlisten: (() => void) | undefined; - win.listen("tauri://fullscreen", async () => { - await syncWindowState(); - }).then((fn) => { fullscreenUnlisten = fn; }); + win + .listen("tauri://fullscreen", async () => { + await syncWindowState(); + }) + .then((fn) => { + fullscreenUnlisten = fn; + }); return () => { unlisten?.(); fullscreenUnlisten?.(); @@ -2799,43 +3239,100 @@ function TitleBar({ {menuOpen ? (
-
{ onOpenCommands(); setMenuOpen(false); }}> - -
{t("app.titlebar.commandPalette")}
+
-
+
-
+ + +
+ {t("app.titlebar.copyMd")} +
+ +
-
{ onClear(); setMenuOpen(false); }}> - -
{t("app.titlebar.clearChat")}
-
-
{ onOpenSettings(); setMenuOpen(false); }}> - -
{t("app.titlebar.settings")}
+ + + +
+ {t("app.titlebar.exportMd")} +
+ + +
+
) : null} @@ -2848,7 +3345,10 @@ function TitleBar({ type="button" className="win-ctrl" title={t("app.titlebar.minimize")} - onMouseDown={(e) => { e.stopPropagation(); win.minimize(); }} + onMouseDown={(e) => { + e.stopPropagation(); + win.minimize(); + }} > @@ -2856,7 +3356,10 @@ function TitleBar({ type="button" className="win-ctrl" title={isMaximized ? t("app.titlebar.restore") : t("app.titlebar.maximize")} - onMouseDown={(e) => { e.stopPropagation(); void toggleWindowExpanded(win, false, isMaximized); }} + onMouseDown={(e) => { + e.stopPropagation(); + void toggleWindowExpanded(win, false, isMaximized); + }} > {isMaximized ? : } @@ -2864,7 +3367,10 @@ function TitleBar({ type="button" className="win-ctrl close" title={t("app.titlebar.close")} - onMouseDown={(e) => { e.stopPropagation(); win.close(); }} + onMouseDown={(e) => { + e.stopPropagation(); + win.close(); + }} > @@ -2891,6 +3397,7 @@ function TabBar({ singleTab?: boolean; }) { useLang(); + const closeLabel = t("app.titlebar.close"); return (
{tabs.map((t) => { @@ -2901,33 +3408,37 @@ function TabBar({ .split(/[\\/]/) .pop() || "workspace"; return ( -
setActive(t.id)} - title={ws || label} - > - - {label} +
+ {!singleTab ? ( - { e.stopPropagation(); onClose(t.id); }} + title={closeLabel} + aria-label={closeLabel} > - + ) : null}
); })} -
+
+
); } @@ -2972,17 +3483,17 @@ function MainHead({ ) : null}
- { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); onOpenWorkdir({ top: r.bottom + 6, left: r.left }); }} - style={{ cursor: "pointer" }} title={workspaceDir ?? t("app.header.clickToSelect")} > {wsLabel} - + {model ? ( {model} @@ -3186,7 +3697,7 @@ function UpdateOverlay({ }) { useLang(); const ratio = - progress && progress.total && progress.total > 0 + progress?.total && progress.total > 0 ? Math.min(1, progress.downloaded / progress.total) : null; const statusText = @@ -3377,7 +3888,7 @@ export function App() { const stack = fontFamily === FONT_FAMILY.CUSTOM && custom ? custom - : FONT_FAMILY_STACK[fontFamily] ?? FONT_FAMILY_STACK.sans; + : (FONT_FAMILY_STACK[fontFamily] ?? FONT_FAMILY_STACK.sans); document.documentElement.style.setProperty("--font-sans", stack); localStorage.setItem("reasonix.fontFamily", fontFamily); localStorage.setItem("reasonix.customFontFamily", customFontFamily); @@ -3483,7 +3994,7 @@ export function App() { } }; - const setup = async () => { + const setup = async (_retryAttempt: number) => { startupStderrRef.current = []; setStartupFailure(null); const subs = await Promise.all([ @@ -3617,7 +4128,7 @@ export function App() { } } }; - void setup(); + void setup(startupRetryNonce); return () => { cancelled = true; for (const c of cleanups) c(); diff --git a/desktop/src/CodeView.tsx b/desktop/src/CodeView.tsx index 661f21323..d42abfbbc 100644 --- a/desktop/src/CodeView.tsx +++ b/desktop/src/CodeView.tsx @@ -4,13 +4,25 @@ import { useEffect, useRef, useState } from "react"; const DARK_THEME: PrismTheme = { plain: { color: "#dde1ea", backgroundColor: "transparent" }, styles: [ - { types: ["comment", "prolog", "doctype", "cdata"], style: { color: "#6d6e80", fontStyle: "italic" } }, + { + types: ["comment", "prolog", "doctype", "cdata"], + style: { color: "#6d6e80", fontStyle: "italic" }, + }, { types: ["punctuation"], style: { color: "#a8a9b8" } }, - { types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], style: { color: "#fbbf24" } }, - { types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], style: { color: "#86dcb1" } }, + { + types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], + style: { color: "#fbbf24" }, + }, + { + types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], + style: { color: "#86dcb1" }, + }, { types: ["operator", "entity", "url"], style: { color: "#84b9e8" } }, { types: ["atrule", "attr-value", "keyword"], style: { color: "#b4a8f0" } }, - { types: ["function", "class-name", "maybe-class-name"], style: { color: "#84b9e8", fontWeight: "500" } }, + { + types: ["function", "class-name", "maybe-class-name"], + style: { color: "#84b9e8", fontWeight: "500" }, + }, { types: ["regex", "important", "variable"], style: { color: "#f0c062" } }, { types: ["important", "bold"], style: { fontWeight: "bold" } }, { types: ["italic"], style: { fontStyle: "italic" } }, @@ -20,13 +32,25 @@ const DARK_THEME: PrismTheme = { const LIGHT_THEME: PrismTheme = { plain: { color: "#24292e", backgroundColor: "transparent" }, styles: [ - { types: ["comment", "prolog", "doctype", "cdata"], style: { color: "#6a737d", fontStyle: "italic" } }, + { + types: ["comment", "prolog", "doctype", "cdata"], + style: { color: "#6a737d", fontStyle: "italic" }, + }, { types: ["punctuation"], style: { color: "#24292e" } }, - { types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], style: { color: "#d73a49" } }, - { types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], style: { color: "#032f62" } }, + { + types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], + style: { color: "#d73a49" }, + }, + { + types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], + style: { color: "#032f62" }, + }, { types: ["operator", "entity", "url"], style: { color: "#d73a49" } }, { types: ["atrule", "attr-value", "keyword"], style: { color: "#d73a49" } }, - { types: ["function", "class-name", "maybe-class-name"], style: { color: "#6f42c1", fontWeight: "500" } }, + { + types: ["function", "class-name", "maybe-class-name"], + style: { color: "#6f42c1", fontWeight: "500" }, + }, { types: ["regex", "important", "variable"], style: { color: "#e36209" } }, { types: ["important", "bold"], style: { fontWeight: "bold" } }, { types: ["italic"], style: { fontStyle: "italic" } }, @@ -56,37 +80,82 @@ function usePrismTheme(): PrismTheme { export const PRISM_THEME = DARK_THEME; +function hashString(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return `${value.length}-${(hash >>> 0).toString(36)}`; +} + +function keyed(items: readonly T[], keyFor: (item: T) => string): { item: T; key: string }[] { + const seen = new Map(); + return items.map((item) => { + const base = keyFor(item); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return { item, key: count === 0 ? base : `${base}-${count}` }; + }); +} + const EXTS: Record = { - ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript", - js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript", - py: "python", pyi: "python", + ts: "typescript", + tsx: "tsx", + mts: "typescript", + cts: "typescript", + js: "javascript", + jsx: "jsx", + mjs: "javascript", + cjs: "javascript", + py: "python", + pyi: "python", rs: "rust", go: "go", - json: "json", jsonc: "json", - md: "markdown", mdx: "markdown", - css: "css", scss: "scss", less: "less", - html: "markup", htm: "markup", xml: "markup", svg: "markup", - yaml: "yaml", yml: "yaml", + json: "json", + jsonc: "json", + md: "markdown", + mdx: "markdown", + css: "css", + scss: "scss", + less: "less", + html: "markup", + htm: "markup", + xml: "markup", + svg: "markup", + yaml: "yaml", + yml: "yaml", toml: "toml", - sh: "bash", bash: "bash", zsh: "bash", fish: "bash", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "bash", sql: "sql", rb: "ruby", - java: "java", kt: "kotlin", + java: "java", + kt: "kotlin", swift: "swift", - c: "c", h: "c", - cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hxx: "cpp", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + hxx: "cpp", cs: "csharp", php: "php", lua: "lua", dart: "dart", - ex: "elixir", exs: "elixir", + ex: "elixir", + exs: "elixir", erl: "erlang", hs: "haskell", - clj: "clojure", cljs: "clojure", + clj: "clojure", + cljs: "clojure", zig: "zig", vue: "markup", svelte: "markup", - graphql: "graphql", gql: "graphql", + graphql: "graphql", + gql: "graphql", proto: "protobuf", dockerfile: "docker", }; @@ -117,14 +186,16 @@ export function CodeView({ {({ className, tokens, getLineProps, getTokenProps }) => (
-          {tokens.map((line, i) => (
-            
- {showLineNumbers && ( - {i + startLine} - )} + {keyed(tokens, (line) => + hashString(line.map((token) => `${token.types.join(".")}:${token.content}`).join("|")), + ).map(({ item: line, key }, i) => ( +
+ {showLineNumbers && {i + startLine}} - {line.map((token, k) => ( - + {keyed(line, (token) => + hashString(`${token.types.join(".")}:${token.content}`), + ).map(({ item: token, key: tokenKey }) => ( + ))}
diff --git a/desktop/src/CommandPalette.tsx b/desktop/src/CommandPalette.tsx index 63f903394..787538ef0 100644 --- a/desktop/src/CommandPalette.tsx +++ b/desktop/src/CommandPalette.tsx @@ -28,7 +28,7 @@ export type Command = { run: () => void; }; -export function useCommandPalette(active: boolean = true) { +export function useCommandPalette(active = true) { const [open, setOpen] = useState(false); useEffect(() => { // Skip in background tabs — each TabRuntime calls this hook, so without the gate Cmd+K toggles every tab's palette at once. @@ -181,10 +181,14 @@ const GROUP_ORDER: CommandGroup[] = ["nav", "action", "workspace", "settings"]; function groupLabel(g: CommandGroup): string { switch (g) { - case "nav": return t("palette.groupNav"); - case "action": return t("palette.groupAction"); - case "workspace": return t("palette.groupWorkspace"); - case "settings": return t("palette.groupSettings"); + case "nav": + return t("palette.groupNav"); + case "action": + return t("palette.groupAction"); + case "workspace": + return t("palette.groupWorkspace"); + case "settings": + return t("palette.groupSettings"); } } @@ -235,9 +239,9 @@ export function CommandPalette({ arr.push(c); byGroup.set(c.group, arr); } - return GROUP_ORDER - .map((g) => ({ group: g, items: byGroup.get(g) ?? [] })) - .filter((s) => s.items.length > 0); + return GROUP_ORDER.map((g) => ({ group: g, items: byGroup.get(g) ?? [] })).filter( + (s) => s.items.length > 0, + ); }, [filtered]); if (!open) return null; @@ -276,16 +280,15 @@ export function CommandPalette({
- {filtered.length === 0 ? ( -
{t("palette.empty")}
- ) : null} + {filtered.length === 0 ?
{t("palette.empty")}
: null} {grouped.map((section) => (
{groupLabel(section.group)}
{section.items.map((c) => { const i = filtered.indexOf(c); return ( -
)} -
+ ); })}
diff --git a/desktop/src/Markdown.tsx b/desktop/src/Markdown.tsx index 9544f4347..82ab54501 100644 --- a/desktop/src/Markdown.tsx +++ b/desktop/src/Markdown.tsx @@ -3,11 +3,11 @@ import { openPath, openUrl } from "@tauri-apps/plugin-opener"; import { Check, Copy, ExternalLink, FileText } from "lucide-react"; import { Children, + type ReactNode, cloneElement, createContext, isValidElement, memo, - type ReactNode, useContext, useState, } from "react"; @@ -24,7 +24,7 @@ async function openWithEditor( abs: string, line?: number, ): Promise { - if (editor && editor.trim()) { + if (editor?.trim()) { await invoke("open_in_editor", { command: editor, path: abs, line: line ?? null }); return; } @@ -145,28 +145,21 @@ function FilePill({ path, line }: { path: string; line?: string }) { } }; return ( - { e.preventDefault(); void copyOnly(e); }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - void openInEditor(); - } - }} title={t("markdown.filePillTitle")} > {path} {line && :{line}} {done && } - + ); } @@ -230,16 +223,18 @@ function normalizeMathDelimiters(source: string): string { .replace(/\\\(/g, "$$") .replace(/\\\)/g, "$$"); // Restore protected sequences - result = result.replace(/\x00LB\x00/g, "\\\\["); + result = result.split(LB).join("\\\\["); // Replace | with \vert inside math to prevent GFM table column splitting. // \vert renders identically to | in KaTeX — it's the same vertical-bar // glyph — but the markdown parser won't mistake it for a table separator. - result = result.replace(/\$\$([\s\S]*?)\$\$/g, (_: string, m: string) => - "$$" + m.replace(/\|/g, "\\vert ") + "$$", + result = result.replace( + /\$\$([\s\S]*?)\$\$/g, + (_: string, m: string) => `\$\$${m.replace(/\|/g, "\\vert ")}\$\$`, ); - result = result.replace(/\$([^$\n]+)\$/g, (_: string, m: string) => - "$" + m.replace(/\|/g, "\\vert ") + "$", + result = result.replace( + /\$([^$\n]+)\$/g, + (_: string, m: string) => `\$${m.replace(/\|/g, "\\vert ")}\$`, ); return result; @@ -349,7 +344,8 @@ export function extractFencedLang(children: ReactNode): string { function flattenChildText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(flattenChildText).join(""); - if (isValidElement(node)) return flattenChildText((node.props as { children?: ReactNode }).children); + if (isValidElement(node)) + return flattenChildText((node.props as { children?: ReactNode }).children); return ""; } diff --git a/desktop/src/i18n/de.ts b/desktop/src/i18n/de.ts index 384ff0236..e51df3438 100644 --- a/desktop/src/i18n/de.ts +++ b/desktop/src/i18n/de.ts @@ -186,7 +186,8 @@ export const de: typeof en = { webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)", webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)", webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)", - webSearchEngineBrave: "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)", + webSearchEngineBrave: + "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)", webSearchEngineOllama: "ollama — Ollama Cloud-Websuche (OLLAMA_API_KEY setzen)", webSearchEngineNote: "gilt für den nächsten web_search-Aufruf", webSearchEndpoint: "SearXNG-Endpunkt", @@ -288,7 +289,8 @@ export const de: typeof en = { "Jede OpenAI-kompatible ID, die dein Endpunkt bereitstellt (vLLM, Ollama, Together, …).", modelCustomActive: "Läuft aktuell auf benutzerdefinierter ID: {model}", contextTokensLabel: "Kontextfenstergröße", - contextTokensHint: "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.", + contextTokensHint: + "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.", contextTokensPlaceholder: "Automatisch", effortSection: "Reasoning-Effort", ctxWindow: "Kontext", @@ -699,7 +701,8 @@ export const de: typeof en = { importSessionCount: "{count} Sitzungen · importiert alle", importNotFound: "Keine lokalen Sitzungen gefunden", importPrivacyHint: "Bestehende App-Einstellungen bleiben unverändert.", - importResult: "{imported} Sitzung(en) importiert, {skipped} übersprungen, {failed} fehlgeschlagen.", + importResult: + "{imported} Sitzung(en) importiert, {skipped} übersprungen, {failed} fehlgeschlagen.", continue: "Weiter", refresh: "Aktualisieren", importSource: "Quelle", diff --git a/desktop/src/i18n/en.ts b/desktop/src/i18n/en.ts index 773fba16f..fba816a28 100644 --- a/desktop/src/i18n/en.ts +++ b/desktop/src/i18n/en.ts @@ -276,7 +276,8 @@ export const en = { modelCustomHint: "Any OpenAI-compatible id your endpoint serves (vLLM, Ollama, Together, …).", modelCustomActive: "Currently running on a custom id: {model}", contextTokensLabel: "Context window size", - contextTokensHint: "Override the prompt-side token cap for the current model (e.g. 1000000 for 1M). Leave empty to use the built-in default.", + contextTokensHint: + "Override the prompt-side token cap for the current model (e.g. 1000000 for 1M). Leave empty to use the built-in default.", contextTokensPlaceholder: "auto", effortSection: "Reasoning effort", ctxWindow: "Context", diff --git a/desktop/src/i18n/ja.ts b/desktop/src/i18n/ja.ts index 3a2a79bbf..0a9aaf871 100644 --- a/desktop/src/i18n/ja.ts +++ b/desktop/src/i18n/ja.ts @@ -51,6 +51,7 @@ export const ja = { searchPlaceholder: "最近のパスを検索…", empty: "最近のワークスペースはありません", browse: "ローカルを参照…", + removeRecent: "最近の一覧から削除", }, sidebar: { newChat: "新しいチャット", @@ -273,7 +274,8 @@ export const ja = { modelCustomHint: "エンドポイントが提供するOpenAI互換ID (vLLM, Ollama, Together, …)。", modelCustomActive: "現在カスタムIDで実行中: {model}", contextTokensLabel: "コンテキストウィンドウサイズ", - contextTokensHint: "現在のモデルのプロンプト側トークン上限を上書きします(例: 1000000 で 1M)。空欄なら既定値を使用。", + contextTokensHint: + "現在のモデルのプロンプト側トークン上限を上書きします(例: 1000000 で 1M)。空欄なら既定値を使用。", contextTokensPlaceholder: "自動", effortSection: "推論努力", ctxWindow: "コンテキスト", diff --git a/desktop/src/i18n/zh-CN.ts b/desktop/src/i18n/zh-CN.ts index 769b3e8e9..cab089ea4 100644 --- a/desktop/src/i18n/zh-CN.ts +++ b/desktop/src/i18n/zh-CN.ts @@ -270,7 +270,8 @@ export const zhCN: typeof en = { modelCustomHint: "任何 OpenAI 兼容的 ID(vLLM、Ollama、Together …)。", modelCustomActive: "当前运行自定义 ID:{model}", contextTokensLabel: "上下文窗口大小", - contextTokensHint: "为当前模型覆盖提示侧 token 上限(如 1000000 表示 1M)。留空使用内置默认值。", + contextTokensHint: + "为当前模型覆盖提示侧 token 上限(如 1000000 表示 1M)。留空使用内置默认值。", contextTokensPlaceholder: "自动", effortSection: "推理强度", ctxWindow: "上下文", diff --git a/desktop/src/icons.tsx b/desktop/src/icons.tsx index ade0d397a..b40385c07 100644 --- a/desktop/src/icons.tsx +++ b/desktop/src/icons.tsx @@ -14,6 +14,8 @@ function Ic({ size = 14, children, ...rest }: IconProps & { children: React.Reac strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" + aria-hidden="true" + focusable="false" {...rest} > {children} @@ -22,61 +24,291 @@ function Ic({ size = 14, children, ...rest }: IconProps & { children: React.Reac } export const I = { - plus: (p: IconProps) => (), - search: (p: IconProps) => (), - send: (p: IconProps) => (), - chev: (p: IconProps) => (), - chevR: (p: IconProps) => (), - check: (p: IconProps) => (), - x: (p: IconProps) => (), - pencil: (p: IconProps) => (), - terminal: (p: IconProps) => (), + plus: (p: IconProps) => ( + + + + ), + search: (p: IconProps) => ( + + + + + ), + send: (p: IconProps) => ( + + + + ), + chev: (p: IconProps) => ( + + + + ), + chevR: (p: IconProps) => ( + + + + ), + check: (p: IconProps) => ( + + + + ), + x: (p: IconProps) => ( + + + + ), + pencil: (p: IconProps) => ( + + + + + ), + terminal: (p: IconProps) => ( + + + + + ), brain: (p: IconProps) => ( ), - list: (p: IconProps) => (), - diff: (p: IconProps) => (), - globe: (p: IconProps) => (), - link: (p: IconProps) => (), - wrench: (p: IconProps) => (), - bot: (p: IconProps) => (), - archive: (p: IconProps) => (), - bookmark: (p: IconProps) => (), - warning: (p: IconProps) => (), - zap: (p: IconProps) => (), - database: (p: IconProps) => (), - cpu: (p: IconProps) => (), - coin: (p: IconProps) => (), - file: (p: IconProps) => (), - folder: (p: IconProps) => (), - image: (p: IconProps) => (), - paperclip: (p: IconProps) => (), - mic: (p: IconProps) => (), - sun: (p: IconProps) => (), - moon: (p: IconProps) => (), - panel_l: (p: IconProps) => (), - panel_r: (p: IconProps) => (), - cog: (p: IconProps) => (), - stop: (p: IconProps) => (), - play: (p: IconProps) => (), - more: (p: IconProps) => (), - pin: (p: IconProps) => (), - rotate: (p: IconProps) => (), - branch: (p: IconProps) => (), - at: (p: IconProps) => (), - slash: (p: IconProps) => (), - layers: (p: IconProps) => (), - download: (p: IconProps) => (), - upload: (p: IconProps) => (), - history: (p: IconProps) => (), - shield: (p: IconProps) => (), - warn: (p: IconProps) => (), - help: (p: IconProps) => (), - refresh: (p: IconProps) => (), - copy: (p: IconProps) => (), + list: (p: IconProps) => ( + + + + ), + diff: (p: IconProps) => ( + + + + + + ), + globe: (p: IconProps) => ( + + + + + ), + link: (p: IconProps) => ( + + + + + ), + wrench: (p: IconProps) => ( + + + + ), + bot: (p: IconProps) => ( + + + + + ), + archive: (p: IconProps) => ( + + + + + ), + bookmark: (p: IconProps) => ( + + + + ), + warning: (p: IconProps) => ( + + + + + ), + zap: (p: IconProps) => ( + + + + ), + database: (p: IconProps) => ( + + + + + ), + cpu: (p: IconProps) => ( + + + + + + ), + coin: (p: IconProps) => ( + + + + + ), + file: (p: IconProps) => ( + + + + + ), + folder: (p: IconProps) => ( + + + + ), + image: (p: IconProps) => ( + + + + + + ), + paperclip: (p: IconProps) => ( + + + + ), + mic: (p: IconProps) => ( + + + + + ), + sun: (p: IconProps) => ( + + + + + ), + moon: (p: IconProps) => ( + + + + ), + panel_l: (p: IconProps) => ( + + + + + ), + panel_r: (p: IconProps) => ( + + + + + ), + cog: (p: IconProps) => ( + + + + + ), + stop: (p: IconProps) => ( + + + + ), + play: (p: IconProps) => ( + + + + ), + more: (p: IconProps) => ( + + + + + + ), + pin: (p: IconProps) => ( + + + + ), + rotate: (p: IconProps) => ( + + + + + ), + branch: (p: IconProps) => ( + + + + + + + ), + at: (p: IconProps) => ( + + + + + ), + slash: (p: IconProps) => ( + + + + ), + layers: (p: IconProps) => ( + + + + + ), + download: (p: IconProps) => ( + + + + ), + upload: (p: IconProps) => ( + + + + ), + history: (p: IconProps) => ( + + + + ), + shield: (p: IconProps) => ( + + + + + ), + warn: (p: IconProps) => ( + + + + + ), + help: (p: IconProps) => ( + + + + + ), + refresh: (p: IconProps) => ( + + + + ), + copy: (p: IconProps) => ( + + + + + ), trash: (p: IconProps) => ( diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 6e44cafd1..76aa99fc1 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -12,12 +12,7 @@ import "@fontsource/inter/700.css"; import "katex/dist/katex.min.css"; import { createRoot } from "react-dom/client"; import { App } from "./App"; -import { - defaultStyleForTheme, - isTheme, - isThemeStyle, - themeForStyle, -} from "./theme"; +import { defaultStyleForTheme, isTheme, isThemeStyle, themeForStyle } from "./theme"; const stored = localStorage.getItem("reasonix.theme"); const storedStyle = localStorage.getItem("reasonix.themeStyle"); diff --git a/desktop/src/notifications.test.ts b/desktop/src/notifications.test.ts index 1829a40c5..fcf8d7a45 100644 --- a/desktop/src/notifications.test.ts +++ b/desktop/src/notifications.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { setLang } from "./i18n"; import { + type ApprovalSnapshot, COMPLETION_NOTIFY_MIN_MS, deriveDesktopNotifications, - type ApprovalSnapshot, } from "./notifications"; function emptySnapshot(): ApprovalSnapshot { diff --git a/desktop/src/notifications.ts b/desktop/src/notifications.ts index d38634053..ee2124d3e 100644 --- a/desktop/src/notifications.ts +++ b/desktop/src/notifications.ts @@ -115,10 +115,7 @@ export function shouldShowCompletionToast(args: { focused: boolean; }): boolean { return ( - args.focused && - args.wasBusy && - !args.isBusy && - args.busyDurationMs >= COMPLETION_NOTIFY_MIN_MS + args.focused && args.wasBusy && !args.isBusy && args.busyDurationMs >= COMPLETION_NOTIFY_MIN_MS ); } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index a93b368f7..0dc3ad568 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -95,11 +95,11 @@ body[data-drag-over="1"]::after { --card: oklch(99.5% 0.003 80); --card-hover: oklch(96.5% 0.009 78); --border: oklch(88% 0.016 76); - --border-strong: oklch(78% 0.020 72); + --border-strong: oklch(78% 0.02 72); --fg: oklch(22% 0.014 55); --fg-2: oklch(36% 0.013 55); --muted: oklch(53% 0.011 60); - --muted-2: oklch(67% 0.010 65); + --muted-2: oklch(67% 0.01 65); --accent: oklch(60% 0.19 38); --accent-soft: oklch(60% 0.19 38 / 0.1); @@ -113,11 +113,11 @@ body[data-drag-over="1"]::after { --danger-soft: oklch(54% 0.2 22 / 0.1); /* warm amber replaces cool violet in light mode */ --violet: oklch(62% 0.16 52); - --violet-soft: oklch(62% 0.16 52 / 0.10); + --violet-soft: oklch(62% 0.16 52 / 0.1); --shadow-sm: 0 1px 0 oklch(30% 0.05 50 / 0.05); --shadow-md: 0 8px 24px -10px oklch(30% 0.05 50 / 0.13); - --shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.20); + --shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.2); } [data-theme-style="porcelain"] { @@ -366,6 +366,15 @@ html[data-platform="macos"] .app { text-overflow: ellipsis; white-space: nowrap; } +.tab .tab-main { + min-width: 0; + flex: 1; + display: inline-flex; + align-items: center; + gap: 8px; + color: inherit; + text-align: left; +} .tab .close { width: 16px; height: 16px; @@ -401,8 +410,7 @@ html[data-platform="macos"] .app { display: grid; place-items: center; padding: 32px; - background: - radial-gradient(circle at top, var(--accent-soft), transparent 36%), + background: radial-gradient(circle at top, var(--accent-soft), transparent 36%), linear-gradient(180deg, transparent, oklch(0% 0 0 / 0.04)); } @@ -842,10 +850,10 @@ html[data-platform="macos"] .titlebar .tb-left { } .side-head { - padding: 10px 12px 8px; + padding: 10px 10px 8px; display: flex; align-items: center; - gap: 8px; + gap: 6px; } .side-head .new-btn { flex: 1; @@ -854,14 +862,17 @@ html[data-platform="macos"] .titlebar .tb-left { display: inline-flex; align-items: center; flex-wrap: nowrap; - gap: 8px; - padding: 0 10px; - font-size: 14px; + gap: 6px; + padding: 0 9px; + font-size: 13px; + line-height: 1; font-weight: 500; border-radius: 6px; background: var(--accent); color: oklch(99% 0 0); box-shadow: var(--shadow-sm); + white-space: nowrap; + overflow: hidden; } .side-head .new-btn:hover { background: var(--accent-strong); @@ -877,7 +888,7 @@ html[data-platform="macos"] .titlebar .tb-left { } .side-head .new-btn kbd { font-family: inherit; - font-size: 14px; + font-size: 11px; background: oklch(100% 0 0 / 0.18); padding: 1px 5px; border-radius: 3px; @@ -890,7 +901,7 @@ html[data-platform="macos"] .titlebar .tb-left { } .side-head .new-btn .shortcut kbd { min-width: 0; - font-size: 14px; + font-size: 11px; font-weight: 500; line-height: inherit; color: inherit; @@ -900,16 +911,11 @@ html[data-platform="macos"] .titlebar .tb-left { padding: 1px 5px; box-shadow: none; } -@container sidebar (min-width: 191px) { +@container sidebar (min-width: 272px) { .side-head .new-btn .shortcut { display: inline-flex; } } -@container sidebar (max-width: 240px) { - .side-head .new-btn .shortcut { - display: none; - } -} @container sidebar (max-width: 190px) { .side-head .new-btn > span:not(.shortcut) { display: none; @@ -1399,27 +1405,44 @@ html[data-platform="macos"] .titlebar .tb-left { color: var(--fg-2); cursor: pointer; } -.session-menu .sm-item > svg { flex-shrink: 0; opacity: 0.7; } +.session-menu .sm-item > svg { + flex-shrink: 0; + opacity: 0.7; +} .session-menu .sm-item:hover { background: var(--panel); color: var(--fg); } -.session-menu .sm-item:hover > svg { opacity: 1; } +.session-menu .sm-item:hover > svg { + opacity: 1; +} .session-menu .sm-item:disabled { opacity: 0.35; cursor: not-allowed; } -.session-menu .sm-item.danger { color: var(--danger); } -.session-menu .sm-item.danger > svg { opacity: 0.8; } -.session-menu .sm-item.danger:hover { background: var(--danger-soft); } +.session-menu .sm-item.danger { + color: var(--danger); +} +.session-menu .sm-item.danger > svg { + opacity: 0.8; +} +.session-menu .sm-item.danger:hover { + background: var(--danger-soft); +} .session-menu .sm-sep { height: 1px; background: var(--border); margin: 4px 0; } @keyframes sm-confirm-in { - from { opacity: 0; transform: scale(0.96) translateY(4px); } - to { opacity: 1; transform: none; } + from { + opacity: 0; + transform: scale(0.96) translateY(4px); + } + to { + opacity: 1; + transform: none; + } } .session-menu .sm-confirm { padding: 14px 12px 12px; @@ -1480,18 +1503,24 @@ html[data-platform="macos"] .titlebar .tb-left { background: var(--card); color: var(--fg-2); } -.session-menu .sm-confirm-cancel:hover { background: var(--panel); color: var(--fg); } +.session-menu .sm-confirm-cancel:hover { + background: var(--panel); + color: var(--fg); +} .session-menu .sm-confirm-ok { border: 1px solid transparent; background: var(--danger); color: oklch(99% 0 0); } -.session-menu .sm-confirm-ok:hover { background: oklch(52% 0.22 25); } +.session-menu .sm-confirm-ok:hover { + background: oklch(52% 0.22 25); +} /* ---- session delete confirmation popover (right-click) ---- */ .session-delete-popover { position: fixed; z-index: 80; + margin: 0; background: var(--card); border: 1px solid var(--border-strong); border-radius: 8px; @@ -1499,6 +1528,8 @@ html[data-platform="macos"] .titlebar .tb-left { padding: 12px; min-width: 220px; max-width: 280px; + max-height: min(72vh, 360px); + overflow-y: auto; font-size: 14px; color: var(--fg); animation: rise 0.16s ease-out; @@ -1519,10 +1550,12 @@ html[data-platform="macos"] .titlebar .tb-left { } .session-delete-popover .actions { display: flex; + flex-wrap: wrap; gap: 6px; } .session-delete-popover button { - flex: 1; + flex: 1 1 92px; + min-width: 0; display: inline-flex; align-items: center; justify-content: center; @@ -1533,6 +1566,9 @@ html[data-platform="macos"] .titlebar .tb-left { font-weight: 500; font-family: inherit; cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; transition: background 0.12s ease, color 0.12s ease; } .session-delete-popover button.cancel { @@ -1556,8 +1592,12 @@ html[data-platform="macos"] .titlebar .tb-left { .session-import-popover { position: fixed; z-index: 80; + margin: 0; width: 320px; max-width: min(320px, calc(100vw - 16px)); + max-height: min(82vh, 560px); + overflow-y: auto; + overscroll-behavior: contain; background: var(--card); border: 1px solid var(--border-strong); border-radius: 10px; @@ -1780,17 +1820,22 @@ html[data-platform="macos"] .titlebar .tb-left { } .session-import-popover .actions { display: flex; + flex-wrap: wrap; gap: 6px; margin-top: 4px; } .session-import-popover .actions button { - flex: 1; + flex: 1 1 120px; + min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 8px 12px; border: 1px solid var(--border-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .session-import-popover .actions button.cancel { background: transparent; @@ -1807,7 +1852,9 @@ html[data-platform="macos"] .titlebar .tb-left { } /* Folder-delete confirm needs more room: workspace name + session count */ -.folder-menu { max-width: 260px; } +.folder-menu { + max-width: 260px; +} .folder-menu .sm-confirm-desc { max-width: 220px; -webkit-line-clamp: 3; @@ -1831,12 +1878,17 @@ html[data-platform="macos"] .titlebar .tb-left { .side-foot .row { display: flex; align-items: center; + width: 100%; gap: 8px; padding: 6px 8px; + border: 0; border-radius: 6px; + background: transparent; font-size: 14px; + font-family: inherit; color: var(--fg-2); cursor: pointer; + text-align: left; } .side-foot .row:hover { background: var(--panel); @@ -1969,32 +2021,44 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { flex: 1; min-height: 0; position: relative; + overflow: hidden; } -.thread::-webkit-scrollbar { +.thread-scroller { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + overscroll-behavior: contain; +} +.thread-scroller:focus { + outline: none; +} +.thread-scroller::-webkit-scrollbar { width: 8px; } -.thread::-webkit-scrollbar-thumb { +.thread-scroller::-webkit-scrollbar-thumb { background-clip: content-box; border: 2px solid transparent; background-color: var(--border); border-radius: 999px; transition: border-width 150ms, background-color 200ms; } -.thread::-webkit-scrollbar-thumb:hover { +.thread-scroller::-webkit-scrollbar-thumb:hover { border-width: 0; background-color: var(--border-strong); } -.thread::-webkit-scrollbar-track { +.thread-scroller::-webkit-scrollbar-track { background: transparent; } .thread-inner { max-width: var(--thread-max-width, 740px); - margin: 0 auto 28px; + margin: 0 auto 18px; padding: 0 32px; } .thread-inner--standalone { margin-top: 28px; } +.thread-tail { + height: 10px; +} .thread-inner > div[data-turn] { content-visibility: auto; contain-intrinsic-size: auto 100px; @@ -2022,7 +2086,13 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { max-height: 240px; overflow-y: auto; scrollbar-width: none; - -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 84%, transparent 100%); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 16%, + #000 84%, + transparent 100% + ); mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 84%, transparent 100%); } .jump-scroll::-webkit-scrollbar { @@ -2035,18 +2105,31 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { justify-content: flex-end; width: 32px; min-height: 14px; + padding: 0; + border: 0; + background: transparent; cursor: pointer; flex-shrink: 0; } +.jump-item:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} .jump-dot { height: 3px; border-radius: 2px; background: var(--border-strong); transition: background 200ms, width 400ms cubic-bezier(0.34, 1.56, 0.64, 1); } -.jump-dot[data-d="0"] { background: var(--accent); } -.jump-dot[data-d="1"] { background: color-mix(in srgb, var(--accent) 60%, transparent); } -.jump-dot[data-d="2"] { background: color-mix(in srgb, var(--accent) 35%, transparent); } +.jump-dot[data-d="0"] { + background: var(--accent); +} +.jump-dot[data-d="1"] { + background: color-mix(in srgb, var(--accent) 60%, transparent); +} +.jump-dot[data-d="2"] { + background: color-mix(in srgb, var(--accent) 35%, transparent); +} .jump-preview { position: absolute; right: 100%; @@ -2070,7 +2153,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { /* Jump-to-bottom button — shown when user has scrolled up during streaming */ .thread-jump-bottom { - position: sticky; + position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); @@ -2085,7 +2168,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { background: var(--bg); color: var(--fg); cursor: pointer; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); transition: opacity 0.15s, transform 0.15s; opacity: 0.9; } @@ -2095,7 +2178,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { transform: translateX(-50%) scale(1.05); } [data-theme="light"] .thread-jump-bottom { - box-shadow: 0 2px 12px rgba(0,0,0,0.10); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); } /* ---------- TURN HEADERS ---------- */ @@ -2282,13 +2365,18 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { .markdown li:last-child { margin-bottom: 0; } -.msg-text ul ul, .markdown ul ul, -.msg-text ol ol, .markdown ol ol, -.msg-text ul ol, .markdown ul ol, -.msg-text ol ul, .markdown ol ul { +.msg-text ul ul, +.markdown ul ul, +.msg-text ol ol, +.markdown ol ol, +.msg-text ul ol, +.markdown ul ol, +.msg-text ol ul, +.markdown ol ul { margin: 4px 0; } -.msg-text h1, .markdown h1 { +.msg-text h1, +.markdown h1 { font-size: 1.45em; font-weight: 700; letter-spacing: -0.02em; @@ -2296,7 +2384,8 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { margin: 22px 0 10px; color: var(--fg); } -.msg-text h2, .markdown h2 { +.msg-text h2, +.markdown h2 { font-size: 1.2em; font-weight: 700; letter-spacing: -0.015em; @@ -2304,24 +2393,31 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { margin: 20px 0 8px; color: var(--fg); } -.msg-text h3, .markdown h3 { +.msg-text h3, +.markdown h3 { font-size: 1.05em; font-weight: 600; line-height: 1.35; margin: 18px 0 6px; color: var(--fg); } -.msg-text h4, .markdown h4, -.msg-text h5, .markdown h5, -.msg-text h6, .markdown h6 { +.msg-text h4, +.markdown h4, +.msg-text h5, +.markdown h5, +.msg-text h6, +.markdown h6 { font-size: 1em; font-weight: 600; margin: 14px 0 4px; color: var(--fg-2); } -.msg-text h1:first-child, .markdown h1:first-child, -.msg-text h2:first-child, .markdown h2:first-child, -.msg-text h3:first-child, .markdown h3:first-child { +.msg-text h1:first-child, +.markdown h1:first-child, +.msg-text h2:first-child, +.markdown h2:first-child, +.msg-text h3:first-child, +.markdown h3:first-child { margin-top: 0; } .msg-text blockquote, @@ -2607,9 +2703,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { cursor: pointer; vertical-align: baseline; text-decoration: none; - transition: - background 0.12s, - color 0.12s; + transition: background 0.12s, color 0.12s; white-space: nowrap; } .file-pill:hover { @@ -2755,10 +2849,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { border-radius: 999px; box-shadow: var(--shadow-sm); cursor: pointer; - transition: - background 0.13s ease, - color 0.13s ease, - transform 0.13s ease; + transition: background 0.13s ease, color 0.13s ease, transform 0.13s ease; } .proc-group.is-clipped .proc-group-toggle { position: absolute; @@ -3246,11 +3337,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { width: 80px; height: 56px; border-radius: 6px; - background: repeating-linear-gradient( - -45deg, - var(--panel-2) 0 6px, - var(--card) 6px 12px - ); + background: repeating-linear-gradient(-45deg, var(--panel-2) 0 6px, var(--card) 6px 12px); border: 1px solid var(--border); display: flex; align-items: center; @@ -3571,6 +3658,12 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { .chip .x { cursor: pointer; opacity: 0.5; + border: 0; + padding: 0; + background: transparent; + color: inherit; + display: inline-flex; + align-items: center; } .chip .x:hover { opacity: 1; @@ -3608,7 +3701,9 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { color: var(--muted); display: inline-flex; align-items: center; + justify-content: center; gap: 5px; + min-width: 30px; } .composer-foot .cf-btn:hover { background: var(--panel); @@ -3654,6 +3749,35 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { padding: 1px 5px; border-radius: 3px; } +.composer-model-direct { + position: relative; + min-width: 0; + flex: 0 1 auto; +} +.composer-tools-more { + position: relative; + display: none; + flex-shrink: 0; +} +.composer-tools-menu { + left: auto; + right: 0; + width: min(360px, calc(100vw - 44px)); + max-height: min(70vh, 520px); + display: block; + overflow-y: auto; +} +.composer-tools-menu .popup-list { + overflow: visible; +} +.composer-tools-actions { + border-bottom: 1px solid var(--border); +} +.popup-item:is(button) { + border: 0; + background: transparent; + font: inherit; +} .send-btn { width: 30px; @@ -3685,7 +3809,9 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { font-size: 14px; color: var(--muted-2); } -.hint-row .grow { flex: 1; } +.hint-row .grow { + flex: 1; +} .hint-row .hint-sep { width: 1px; height: 12px; @@ -3751,6 +3877,20 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { .popup .ph .grow { flex: 1; } +.popup-close { + border: 0; + padding: 2px; + border-radius: 5px; + background: transparent; + color: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; +} +.popup-close:hover { + background: var(--panel); + color: var(--fg); +} .popup-list { overflow-y: auto; overflow-x: auto; @@ -3760,6 +3900,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { min-width: max-content; } .popup-item { + width: 100%; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; @@ -3768,6 +3909,8 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { border-radius: 6px; cursor: pointer; font-size: 14px; + color: inherit; + text-align: left; } .popup-item:hover, .popup-item[data-active="true"] { @@ -3860,9 +4003,15 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; color: var(--muted); border-radius: 5px 5px 0 0; + border: 1px solid transparent; + background: transparent; font-family: inherit; cursor: pointer; } +.ctx-tab:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -2px; +} .ctx-tab:hover { color: var(--fg); } @@ -4178,18 +4327,36 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { gap: 6px; padding: 0 10px; height: 100%; + max-width: min(280px, 42vw); + border: 0; + background: transparent; + color: inherit; + font: inherit; cursor: pointer; + min-width: 0; +} +.statusbar .seg:disabled { + cursor: default; + opacity: 0.75; } .statusbar .seg:hover { background: var(--panel); color: var(--fg); } +.statusbar .seg:disabled:hover { + background: transparent; + color: var(--muted); +} .statusbar .seg.theme-trigger.active { background: var(--accent-soft); color: var(--accent); } .statusbar .seg .v { color: var(--fg); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .statusbar .seg .v.ok { color: var(--success); @@ -4359,7 +4526,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { display: inline-block; flex-shrink: 0; } -.status-dot.warn { background: var(--warning); } +.status-dot.warn { + background: var(--warning); +} .meta-label { font-size: 10px; @@ -4393,12 +4562,7 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { /* shimmer for streaming text */ .shimmer { - background: linear-gradient( - 90deg, - var(--fg-2) 0%, - var(--accent) 50%, - var(--fg-2) 100% - ); + background: linear-gradient(90deg, var(--fg-2) 0%, var(--accent) 50%, var(--fg-2) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; @@ -4453,7 +4617,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { animation-delay: 0.3s; } @keyframes bounce { - 0%, 80%, 100% { + 0%, + 80%, + 100% { transform: translateY(0); opacity: 0.4; } @@ -4464,12 +4630,7 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { } .thinking .label .sh { - background: linear-gradient( - 90deg, - var(--muted) 0%, - var(--accent) 50%, - var(--muted) 100% - ); + background: linear-gradient(90deg, var(--muted) 0%, var(--accent) 50%, var(--muted) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; @@ -4606,11 +4767,21 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background-position: -100% 0; } } -.skel-line.w-90 { width: 92%; } -.skel-line.w-70 { width: 72%; } -.skel-line.w-60 { width: 64%; } -.skel-line.w-40 { width: 44%; } -.skel-line.w-30 { width: 34%; } +.skel-line.w-90 { + width: 92%; +} +.skel-line.w-70 { + width: 72%; +} +.skel-line.w-60 { + width: 64%; +} +.skel-line.w-40 { + width: 44%; +} +.skel-line.w-30 { + width: 34%; +} /* progressive log (tool live output) */ .live-log { @@ -4639,15 +4810,27 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { opacity: 0; animation: line-in 0.25s ease-out forwards; } -.live-log .line.ok { color: var(--success); } -.live-log .line.dim { color: var(--muted); } +.live-log .line.ok { + color: var(--success); +} +.live-log .line.dim { + color: var(--muted); +} @keyframes line-in { - to { opacity: 1; } + to { + opacity: 1; + } } @keyframes rise { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } } .composer-busy-status { @@ -4713,7 +4896,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { justify-content: center; width: 14px; height: 14px; + border: 0; border-radius: 50%; + padding: 0; + background: transparent; cursor: pointer; color: var(--muted); } @@ -4741,8 +4927,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-radius: 50%; background: var(--accent); } -.queue-strip .pip.q { background: var(--muted-2); } -.queue-strip .pip.w { background: var(--warning); } +.queue-strip .pip.q { + background: var(--muted-2); +} +.queue-strip .pip.w { + background: var(--warning); +} /* ---- token stream rate bar ---- */ .tps { @@ -4770,14 +4960,34 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { opacity: 0.65; animation: bar 0.9s ease-in-out infinite; } -.tps .bars span:nth-child(1) { height: 30%; animation-delay: 0s; } -.tps .bars span:nth-child(2) { height: 65%; animation-delay: 0.1s; } -.tps .bars span:nth-child(3) { height: 90%; animation-delay: 0.2s; } -.tps .bars span:nth-child(4) { height: 50%; animation-delay: 0.3s; } -.tps .bars span:nth-child(5) { height: 75%; animation-delay: 0.4s; } +.tps .bars span:nth-child(1) { + height: 30%; + animation-delay: 0s; +} +.tps .bars span:nth-child(2) { + height: 65%; + animation-delay: 0.1s; +} +.tps .bars span:nth-child(3) { + height: 90%; + animation-delay: 0.2s; +} +.tps .bars span:nth-child(4) { + height: 50%; + animation-delay: 0.3s; +} +.tps .bars span:nth-child(5) { + height: 75%; + animation-delay: 0.4s; +} @keyframes bar { - 0%, 100% { transform: scaleY(0.5); } - 50% { transform: scaleY(1); } + 0%, + 100% { + transform: scaleY(0.5); + } + 50% { + transform: scaleY(1); + } } /* shell card while running */ @@ -4805,8 +5015,13 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { animation: pulse-soft 1.6s ease-in-out infinite; } @keyframes pulse-soft { - 0%, 100% { opacity: 0; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } } .user-status { display: inline-flex; @@ -4842,8 +5057,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background: oklch(0% 0 0 / 0.18); } @keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .settings { @@ -4876,12 +5095,17 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .settings-side .row { display: flex; align-items: center; + width: 100%; gap: 10px; padding: 7px 10px; + border: 0; border-radius: 6px; + background: transparent; font-size: 14px; + font-family: inherit; color: var(--fg-2); cursor: pointer; + text-align: left; } .settings-side .row:hover { background: var(--panel); @@ -4927,7 +5151,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; margin-top: 2px; } -.settings-head .grow { flex: 1; } +.settings-head .grow { + flex: 1; +} .settings-head .close-btn { width: 26px; height: 26px; @@ -5284,7 +5510,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); margin-top: 2px; } -.scard .grow { flex: 1; } +.scard .grow { + flex: 1; +} .scard .mcp-spec-body { min-width: 0; flex: 1 1 auto; @@ -5339,6 +5567,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 14px; position: relative; cursor: pointer; + color: var(--fg); + font: inherit; + text-align: left; + width: 100%; } .mcard[data-on="true"] { border-color: var(--accent); @@ -5372,8 +5604,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-family: inherit; font-size: 14px; } -.mcard .spec .k { color: var(--muted); } -.mcard .spec .v { color: var(--fg); } +.mcard .spec .k { + color: var(--muted); +} +.mcard .spec .v { + color: var(--fg); +} .mcard .price { margin-top: 8px; padding-top: 8px; @@ -5445,13 +5681,16 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { text-transform: uppercase; } .mem-edit .scope[data-s="project"] { - background: var(--accent-soft); color: var(--accent); + background: var(--accent-soft); + color: var(--accent); } .mem-edit .scope[data-s="user"] { - background: var(--violet-soft); color: var(--violet); + background: var(--violet-soft); + color: var(--violet); } .mem-edit .scope[data-s="global"] { - background: var(--success-soft); color: var(--success); + background: var(--success-soft); + color: var(--success); } .mem-edit .txt { font-size: 14px; @@ -5489,8 +5728,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { margin-top: 4px; letter-spacing: -0.02em; } -.bill-card .v.ok { color: var(--success); } -.bill-card .v.acc { color: var(--accent); } +.bill-card .v.ok { + color: var(--success); +} +.bill-card .v.acc { + color: var(--accent); +} .bill-card .sub { font-size: 14px; color: var(--muted); @@ -5540,8 +5783,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .usage-table td.num { text-align: right; } -.usage-table td .pos { color: var(--accent); } -.usage-table td .ok { color: var(--success); } +.usage-table td .pos { + color: var(--accent); +} +.usage-table td .ok { + color: var(--success); +} /* plan-approved banner inline in thread */ .plan-banner { @@ -5628,47 +5875,149 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { TONE PALETTE — systematized ============================================================ */ :root { - --tone-ok: oklch(72% 0.16 152); - --tone-ok-soft: oklch(72% 0.16 152 / 0.12); - --tone-warn: oklch(78% 0.16 80); + --tone-ok: oklch(72% 0.16 152); + --tone-ok-soft: oklch(72% 0.16 152 / 0.12); + --tone-warn: oklch(78% 0.16 80); --tone-warn-soft: oklch(78% 0.16 80 / 0.14); - --tone-err: oklch(68% 0.20 25); - --tone-err-soft: oklch(68% 0.20 25 / 0.14); - --tone-info: oklch(70% 0.13 230); + --tone-err: oklch(68% 0.2 25); + --tone-err-soft: oklch(68% 0.2 25 / 0.14); + --tone-info: oklch(70% 0.13 230); --tone-info-soft: oklch(70% 0.13 230 / 0.14); - --tone-brand: oklch(66% 0.18 38); - --tone-brand-soft:oklch(66% 0.18 38 / 0.14); - --tone-accent: var(--accent); + --tone-brand: oklch(66% 0.18 38); + --tone-brand-soft: oklch(66% 0.18 38 / 0.14); + --tone-accent: var(--accent); --tone-accent-soft: var(--accent-soft); - --tone-ghost: oklch(60% 0.005 250); - --tone-ghost-soft:oklch(60% 0.005 250 / 0.10); + --tone-ghost: oklch(60% 0.005 250); + --tone-ghost-soft: oklch(60% 0.005 250 / 0.1); /* states */ - --st-running: oklch(72% 0.16 200); - --st-done: var(--tone-ok); - --st-failed: var(--tone-err); - --st-queued: oklch(65% 0.005 250); - --st-blocked: oklch(70% 0.14 50); - --st-skipped: oklch(60% 0.04 250); - --st-aborted: oklch(60% 0.13 25); + --st-running: oklch(72% 0.16 200); + --st-done: var(--tone-ok); + --st-failed: var(--tone-err); + --st-queued: oklch(65% 0.005 250); + --st-blocked: oklch(70% 0.14 50); + --st-skipped: oklch(60% 0.04 250); + --st-aborted: oklch(60% 0.13 25); } [data-theme="light"] { - --tone-ok: oklch(50% 0.16 152); - --tone-warn: oklch(58% 0.16 75); - --tone-err: oklch(56% 0.20 25); - --tone-info: oklch(55% 0.15 230); - --tone-brand: oklch(50% 0.18 258); - --tone-ghost: oklch(45% 0.005 250); + --tone-ok: oklch(50% 0.16 152); + --tone-warn: oklch(58% 0.16 75); + --tone-err: oklch(56% 0.2 25); + --tone-info: oklch(55% 0.15 230); + --tone-brand: oklch(50% 0.18 258); + --tone-ghost: oklch(45% 0.005 250); } /* ============================================================ APPROVAL CARD — universal (plan / edit / shell / path / checkpoint / refinement / revision) ============================================================ */ -.approval { - border: 1px solid var(--border); - background: var(--card); - border-radius: 10px; - overflow: hidden; - position: relative; +.approval-tray { + width: 100%; + max-width: var(--thread-max-width, 740px); + margin: 0 auto; + padding: 0 32px 8px; + flex-shrink: 0; +} +.approval-tray-head { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 2px; + color: var(--muted); + font-size: 14px; +} +.approval-tray-title { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--fg-2); +} +.approval-tray-title span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.approval-tray-count { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent-soft); + color: var(--accent); + font-size: 12px; + font-weight: 600; +} +.approval-tray-head .grow { + flex: 1; +} +.approval-tray-toggle, +.approval-queue-more { + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg-2); + border-radius: 6px; + font: inherit; + font-size: 13px; + cursor: pointer; +} +.approval-tray-toggle { + padding: 3px 8px; + flex-shrink: 0; +} +.approval-tray-toggle:hover, +.approval-queue-more:hover { + background: var(--panel-2); + color: var(--fg); +} +.approval-stack { + display: grid; + gap: 8px; + max-height: min(28vh, 340px); + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.approval-stack::-webkit-scrollbar { + width: 8px; +} +.approval-stack::-webkit-scrollbar-thumb { + background-color: var(--border); + border: 2px solid transparent; + background-clip: content-box; + border-radius: 999px; +} +.approval-slot { + min-width: 0; +} +.approval-queue-more { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 10px; +} +.approval-connecting { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + color: var(--muted); + font-family: inherit; + font-size: 13px; +} +.approval { + border: 1px solid var(--border); + background: var(--card); + border-radius: 10px; + overflow: hidden; + position: relative; } .approval::before { content: ""; @@ -5677,12 +6026,24 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { width: 3px; background: var(--approval-accent, var(--accent)); } -.approval[data-tone="ok"] { --approval-accent: var(--tone-ok); } -.approval[data-tone="warn"] { --approval-accent: var(--tone-warn); } -.approval[data-tone="danger"] { --approval-accent: var(--tone-err); } -.approval[data-tone="info"] { --approval-accent: var(--tone-info); } -.approval[data-tone="brand"] { --approval-accent: var(--tone-brand); } -.approval[data-tone="ghost"] { --approval-accent: var(--tone-ghost); } +.approval[data-tone="ok"] { + --approval-accent: var(--tone-ok); +} +.approval[data-tone="warn"] { + --approval-accent: var(--tone-warn); +} +.approval[data-tone="danger"] { + --approval-accent: var(--tone-err); +} +.approval[data-tone="info"] { + --approval-accent: var(--tone-info); +} +.approval[data-tone="brand"] { + --approval-accent: var(--tone-brand); +} +.approval[data-tone="ghost"] { + --approval-accent: var(--tone-ghost); +} .approval .ap-head { display: flex; @@ -5700,7 +6061,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background: var(--approval-accent); color: oklch(99% 0 0); } -[data-theme="light"] .approval .ap-ico { color: oklch(100% 0 0); } +[data-theme="light"] .approval .ap-ico { + color: oklch(100% 0 0); +} .approval .ap-kind { font-family: inherit; font-size: 14px; @@ -5769,16 +6132,42 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .approval .ap-foot { display: flex; align-items: center; + justify-content: flex-end; + flex-wrap: wrap; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg-2); } -.approval .ap-foot .grow { flex: 1; } +.approval .ap-foot .btn { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.approval .ap-foot .grow { + flex: 1 1 16px; + min-width: 12px; +} .approval .ap-foot .meta { font-family: inherit; font-size: 14px; color: var(--muted); + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} +.approval .ap-body .btn { + min-width: 0; + max-width: 100%; +} +.approval .ap-body .btn > div { + min-width: 0; + overflow: hidden; } /* ============================================================ @@ -5798,20 +6187,43 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-bottom: 1px solid var(--border); } .task-card .th .ico { - width: 24px; height: 24px; border-radius: 6px; - display: inline-flex; align-items: center; justify-content: center; - background: var(--accent-soft); color: var(--accent); + width: 24px; + height: 24px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent-soft); + color: var(--accent); +} +.task-card .th .tt { + font-size: 14px; + font-weight: 600; +} +.task-card .th .ss { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 1px; +} +.task-card .th .grow { + flex: 1; } -.task-card .th .tt { font-size: 14px; font-weight: 600; } -.task-card .th .ss { font-family: inherit; font-size: 14px; color: var(--muted); margin-top: 1px; } -.task-card .th .grow { flex: 1; } .task-card .th .meter { - width: 64px; height: 4px; border-radius: 999px; + width: 64px; + height: 4px; + border-radius: 999px; background: var(--panel); overflow: hidden; } -.task-card .th .meter > span { display: block; height: 100%; background: var(--accent); } -.task-card .tb { padding: 6px 0; } +.task-card .th .meter > span { + display: block; + height: 100%; + background: var(--accent); +} +.task-card .tb { + padding: 6px 0; +} .task-step { display: grid; grid-template-columns: 48px 18px 1fr auto; @@ -5820,11 +6232,23 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 8px 14px; border-left: 2px solid transparent; } -.task-step[data-state="running"] { border-left-color: var(--st-running); background: oklch(72% 0.16 200 / 0.04); } -.task-step[data-state="done"] { border-left-color: var(--st-done); } -.task-step[data-state="failed"] { border-left-color: var(--st-failed); background: oklch(68% 0.20 25 / 0.05); } -.task-step[data-state="blocked"] { border-left-color: var(--st-blocked); } -.task-step[data-state="skipped"] { opacity: 0.55; } +.task-step[data-state="running"] { + border-left-color: var(--st-running); + background: oklch(72% 0.16 200 / 0.04); +} +.task-step[data-state="done"] { + border-left-color: var(--st-done); +} +.task-step[data-state="failed"] { + border-left-color: var(--st-failed); + background: oklch(68% 0.2 25 / 0.05); +} +.task-step[data-state="blocked"] { + border-left-color: var(--st-blocked); +} +.task-step[data-state="skipped"] { + opacity: 0.55; +} .task-step .nx { font-family: inherit; font-size: 14px; @@ -5832,34 +6256,127 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding-top: 1px; } .task-step .st { - width: 14px; height: 14px; border-radius: 50%; + width: 14px; + height: 14px; + border-radius: 50%; margin-top: 2px; position: relative; } -.task-step[data-state="queued"] .st { border: 1.5px dashed var(--border-strong); } -.task-step[data-state="running"] .st { background: var(--st-running); animation: pulse 1.4s ease-in-out infinite; } -.task-step[data-state="done"] .st { background: var(--st-done); } -.task-step[data-state="done"] .st::after { content:"\2713"; position:absolute; inset:0; color:#fff; font-size:14px; display:flex; align-items:center; justify-content:center; } -.task-step[data-state="failed"] .st { background: var(--st-failed); } -.task-step[data-state="failed"] .st::after { content:"!"; position:absolute; inset:0; color:#fff; font-size:14px; font-weight:700; display:flex; align-items:center; justify-content:center; } -.task-step[data-state="blocked"] .st { background: var(--st-blocked); } -.task-step[data-state="blocked"] .st::after { content:"\f7"; position:absolute; inset:0; color:#fff; font-size:14px; display:flex; align-items:center; justify-content:center; } -.task-step[data-state="skipped"] .st { background: var(--st-skipped); } -.task-step[data-state="skipped"] .st::after { content:"\2014"; position:absolute; inset:0; color:#fff; font-size:14px; display:flex; align-items:center; justify-content:center; } -.task-step .l { font-size: 14px; color: var(--fg); } -.task-step .l .h { color: var(--muted); font-size: 14px; margin-top: 2px; font-family: inherit; } -.task-step .t { font-family: inherit; font-size: 14px; color: var(--muted); } +.task-step[data-state="queued"] .st { + border: 1.5px dashed var(--border-strong); +} +.task-step[data-state="running"] .st { + background: var(--st-running); + animation: pulse 1.4s ease-in-out infinite; +} +.task-step[data-state="done"] .st { + background: var(--st-done); +} +.task-step[data-state="done"] .st::after { + content: "\2713"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} +.task-step[data-state="failed"] .st { + background: var(--st-failed); +} +.task-step[data-state="failed"] .st::after { + content: "!"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} +.task-step[data-state="blocked"] .st { + background: var(--st-blocked); +} +.task-step[data-state="blocked"] .st::after { + content: "\f7"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} +.task-step[data-state="skipped"] .st { + background: var(--st-skipped); +} +.task-step[data-state="skipped"] .st::after { + content: "\2014"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} +.task-step .l { + font-size: 14px; + color: var(--fg); +} +.task-step .l .h { + color: var(--muted); + font-size: 14px; + margin-top: 2px; + font-family: inherit; +} +.task-step .t { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} /* plan state extensions */ -.plan-row[data-state="failed"] .check { background: var(--st-failed); border-color: var(--st-failed); } -.plan-row[data-state="blocked"] .check { background: var(--st-blocked); border-color: var(--st-blocked); } -.plan-row[data-state="skipped"] .check { background: var(--st-skipped); border-color: var(--st-skipped); } -.plan-row[data-state="failed"] .check::after { content:"!"; color:#fff; font-size:14px; font-weight:700; } -.plan-row[data-state="blocked"] .check::after { content:"\f7"; color:#fff; font-size:14px; } -.plan-row[data-state="skipped"] .check::after { content:"\2014"; color:#fff; font-size:14px; } -.plan-row[data-state="failed"] > .body > .l { color: var(--st-failed); } -.plan-row[data-state="blocked"] > .body > .l { color: var(--st-blocked); } -.plan-row[data-state="skipped"] > .body > .l { color: var(--st-skipped); text-decoration: line-through; } +.plan-row[data-state="failed"] .check { + background: var(--st-failed); + border-color: var(--st-failed); +} +.plan-row[data-state="blocked"] .check { + background: var(--st-blocked); + border-color: var(--st-blocked); +} +.plan-row[data-state="skipped"] .check { + background: var(--st-skipped); + border-color: var(--st-skipped); +} +.plan-row[data-state="failed"] .check::after { + content: "!"; + color: #fff; + font-size: 14px; + font-weight: 700; +} +.plan-row[data-state="blocked"] .check::after { + content: "\f7"; + color: #fff; + font-size: 14px; +} +.plan-row[data-state="skipped"] .check::after { + content: "\2014"; + color: #fff; + font-size: 14px; +} +.plan-row[data-state="failed"] > .body > .l { + color: var(--st-failed); +} +.plan-row[data-state="blocked"] > .body > .l { + color: var(--st-blocked); +} +.plan-row[data-state="skipped"] > .body > .l { + color: var(--st-skipped); + text-decoration: line-through; +} /* ============================================================ WARN CARD @@ -5873,10 +6390,27 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { grid-template-columns: 24px 1fr; gap: 10px; } -.warn-card .ico { color: var(--tone-warn); margin-top: 1px; } -.warn-card .tt { font-size: 14px; font-weight: 600; color: var(--fg); } -.warn-card .ds { font-size: 14px; color: var(--fg-2); margin-top: 4px; line-height: 1.55; } -.warn-card .ds code { background: var(--panel); padding: 1px 5px; border-radius: 3px; font-size: 14px; } +.warn-card .ico { + color: var(--tone-warn); + margin-top: 1px; +} +.warn-card .tt { + font-size: 14px; + font-weight: 600; + color: var(--fg); +} +.warn-card .ds { + font-size: 14px; + color: var(--fg-2); + margin-top: 4px; + line-height: 1.55; +} +.warn-card .ds code { + background: var(--panel); + padding: 1px 5px; + border-radius: 3px; + font-size: 14px; +} /* ============================================================ TIP CARD @@ -5888,16 +6422,28 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 12px 14px 8px; } .tip-card .head { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; margin-bottom: 8px; } .tip-card .head .ico { - width: 22px; height: 22px; border-radius: 5px; - display: inline-flex; align-items: center; justify-content: center; - background: var(--tone-info); color: oklch(99% 0 0); + width: 22px; + height: 22px; + border-radius: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--tone-info); + color: oklch(99% 0 0); +} +.tip-card .head .topic { + font-size: 14px; + font-weight: 600; +} +.tip-card .head .grow { + flex: 1; } -.tip-card .head .topic { font-size: 14px; font-weight: 600; } -.tip-card .head .grow { flex: 1; } .tip-card .head .pill { font-family: inherit; font-size: 14px; @@ -5906,7 +6452,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-radius: 4px; color: var(--tone-info); } -.tip-card .sec { margin-bottom: 8px; } +.tip-card .sec { + margin-bottom: 8px; +} .tip-card .sec .stt { font-family: inherit; font-size: 14px; @@ -5946,26 +6494,47 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; } .doctor-card .dh { - display: flex; align-items: center; gap: 10px; + display: flex; + align-items: center; + gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); } .doctor-card .dh .ico { - width: 24px; height: 24px; border-radius: 6px; - background: var(--tone-info-soft); color: var(--tone-info); - display: inline-flex; align-items: center; justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: var(--tone-info-soft); + color: var(--tone-info); + display: inline-flex; + align-items: center; + justify-content: center; +} +.doctor-card .dh .tt { + font-size: 14px; + font-weight: 600; +} +.doctor-card .dh .grow { + flex: 1; } -.doctor-card .dh .tt { font-size: 14px; font-weight: 600; } -.doctor-card .dh .grow { flex: 1; } .doctor-card .dh .summary { font-family: inherit; font-size: 14px; - display: inline-flex; gap: 8px; + display: inline-flex; + gap: 8px; +} +.doctor-card .dh .summary .b { + font-weight: 600; +} +.doctor-card .dh .summary .ok { + color: var(--tone-ok); +} +.doctor-card .dh .summary .warn { + color: var(--tone-warn); +} +.doctor-card .dh .summary .err { + color: var(--tone-err); } -.doctor-card .dh .summary .b { font-weight: 600; } -.doctor-card .dh .summary .ok { color: var(--tone-ok); } -.doctor-card .dh .summary .warn { color: var(--tone-warn); } -.doctor-card .dh .summary .err { color: var(--tone-err); } .doctor-row { display: grid; grid-template-columns: 22px 1fr auto; @@ -5974,22 +6543,46 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-bottom: 1px dashed var(--border); align-items: center; } -.doctor-row:last-child { border-bottom: none; } +.doctor-row:last-child { + border-bottom: none; +} .doctor-row .ic { - width: 18px; height: 18px; border-radius: 50%; - display: inline-flex; align-items: center; justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; color: oklch(99% 0 0); - font-size: 14px; font-weight: 700; + font-size: 14px; + font-weight: 700; +} +.doctor-row[data-s="ok"] .ic { + background: var(--tone-ok); +} +.doctor-row[data-s="warn"] .ic { + background: var(--tone-warn); +} +.doctor-row[data-s="fail"] .ic { + background: var(--tone-err); } -.doctor-row[data-s="ok"] .ic { background: var(--tone-ok); } -.doctor-row[data-s="warn"] .ic { background: var(--tone-warn); } -.doctor-row[data-s="fail"] .ic { background: var(--tone-err); } .doctor-row .ic::after { content: attr(data-mark); } -.doctor-row .body .nm { font-size: 14px; } -.doctor-row .body .sub { font-family: inherit; font-size:14px; color: var(--muted); margin-top: 2px; } -.doctor-row .v { font-family: inherit; font-size:14px; color: var(--fg-2); } +.doctor-row .body .nm { + font-size: 14px; +} +.doctor-row .body .sub { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 2px; +} +.doctor-row .v { + font-family: inherit; + font-size: 14px; + color: var(--fg-2); +} /* ============================================================ USAGE CARD — full @@ -6003,11 +6596,23 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .usage-full .uh { padding: 10px 14px; border-bottom: 1px solid var(--border); - display: flex; gap: 8px; align-items: center; + display: flex; + gap: 8px; + align-items: center; +} +.usage-full .uh .grow { + flex: 1; +} +.usage-full .uh .tt { + font-size: 14px; + font-weight: 600; +} +.usage-full .uh .ss { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 1px; } -.usage-full .uh .grow { flex: 1; } -.usage-full .uh .tt { font-size: 14px; font-weight: 600; } -.usage-full .uh .ss { font-family: inherit; font-size: 14px; color: var(--muted); margin-top:1px; } .usage-full .ub { display: grid; grid-template-columns: repeat(4, 1fr); @@ -6016,7 +6621,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 12px 14px; border-right: 1px solid var(--border); } -.usage-full .ucol:last-child { border-right: none; } +.usage-full .ucol:last-child { + border-right: none; +} .usage-full .ucol .l { font-family: inherit; font-size: 14px; @@ -6025,34 +6632,67 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); } .usage-full .ucol .v { - font-size: 17px; font-weight: 600; + font-size: 17px; + font-weight: 600; margin-top: 4px; letter-spacing: -0.02em; } -.usage-full .ucol .v.acc { color: var(--accent); } -.usage-full .ucol .v.ok { color: var(--tone-ok); } -.usage-full .ucol .v.vio { color: var(--violet); } -.usage-full .ucol .pct { font-family: inherit; font-size: 14px; color: var(--muted); margin-top: 2px; } +.usage-full .ucol .v.acc { + color: var(--accent); +} +.usage-full .ucol .v.ok { + color: var(--tone-ok); +} +.usage-full .ucol .v.vio { + color: var(--violet); +} +.usage-full .ucol .pct { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 2px; +} .usage-full .stack { - display: flex; height: 6px; + display: flex; + height: 6px; border-top: 1px solid var(--border); } -.usage-full .stack span { display: block; height: 100%; } -.usage-full .stack .s1 { background: var(--accent); } -.usage-full .stack .s2 { background: var(--violet); } -.usage-full .stack .s3 { background: var(--tone-ok); } -.usage-full .stack .s4 { background: var(--border-strong); } +.usage-full .stack span { + display: block; + height: 100%; +} +.usage-full .stack .s1 { + background: var(--accent); +} +.usage-full .stack .s2 { + background: var(--violet); +} +.usage-full .stack .s3 { + background: var(--tone-ok); +} +.usage-full .stack .s4 { + background: var(--border-strong); +} .usage-full .uf { - display: flex; gap: 6px; flex-wrap: wrap; + display: flex; + gap: 6px; + flex-wrap: wrap; padding: 8px 14px; border-top: 1px solid var(--border); background: var(--bg-2); - font-family: inherit; font-size: 14px; + font-family: inherit; + font-size: 14px; color: var(--muted); } -.usage-full .uf .x { display:inline-flex; align-items:center; gap:4px; } +.usage-full .uf .x { + display: inline-flex; + align-items: center; + gap: 4px; +} .usage-full .uf .x .sw { - width: 8px; height: 8px; border-radius: 2px; + width: 8px; + height: 8px; + border-radius: 2px; } /* ============================================================ @@ -6065,7 +6705,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; } .mem-groups .gh { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 9px 12px; border-bottom: 1px solid var(--border); font-family: inherit; @@ -6075,13 +6717,26 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); background: var(--bg-2); } -.mem-groups .gh.first {} -.mem-groups .gh .sw { width: 6px; height: 6px; border-radius: 50%; } -.mem-groups .gh[data-g="user"] .sw { background: var(--violet); } -.mem-groups .gh[data-g="feedback"] .sw { background: var(--tone-warn); } -.mem-groups .gh[data-g="project"] .sw { background: var(--accent); } -.mem-groups .gh[data-g="reference"] .sw { background: var(--tone-info); } -.mem-groups .gh .grow { flex: 1; } +.mem-groups .gh .sw { + width: 6px; + height: 6px; + border-radius: 50%; +} +.mem-groups .gh[data-g="user"] .sw { + background: var(--violet); +} +.mem-groups .gh[data-g="feedback"] .sw { + background: var(--tone-warn); +} +.mem-groups .gh[data-g="project"] .sw { + background: var(--accent); +} +.mem-groups .gh[data-g="reference"] .sw { + background: var(--tone-info); +} +.mem-groups .gh .grow { + flex: 1; +} .mem-groups .gh .cnt { background: var(--card); border: 1px solid var(--border); @@ -6099,10 +6754,20 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--fg-2); align-items: start; } -.mem-groups .mrow:last-child { border-bottom: none; } -.mem-groups .mrow .b { color: var(--muted-2); } -.mem-groups .mrow .t { line-height: 1.55; } -.mem-groups .mrow .meta { font-family: inherit; font-size: 14px; color: var(--muted); } +.mem-groups .mrow:last-child { + border-bottom: none; +} +.mem-groups .mrow .b { + color: var(--muted-2); +} +.mem-groups .mrow .t { + line-height: 1.55; +} +.mem-groups .mrow .meta { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} /* ============================================================ SUBAGENT NESTED @@ -6114,18 +6779,34 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; } .subagent-nested .sh { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); } .subagent-nested .sh .ico { - width: 22px; height: 22px; border-radius: 5px; - background: var(--violet-soft); color: var(--violet); - display: inline-flex; align-items: center; justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + background: var(--violet-soft); + color: var(--violet); + display: inline-flex; + align-items: center; + justify-content: center; +} +.subagent-nested .sh .nm { + font-size: 14px; + font-weight: 600; +} +.subagent-nested .sh .ss { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} +.subagent-nested .sh .grow { + flex: 1; } -.subagent-nested .sh .nm { font-size: 14px; font-weight: 600; } -.subagent-nested .sh .ss { font-family: inherit; font-size: 14px; color: var(--muted); } -.subagent-nested .sh .grow { flex: 1; } .subagent-nested .nest { border-left: 2px solid var(--violet); margin-left: 22px; @@ -6147,20 +6828,61 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); margin-bottom: 4px; } -.subagent-nested .nest .lab .dot { - display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; vertical-align: middle; -} -.subagent-nested .nest .lab.reason .dot { background: var(--violet); } -.subagent-nested .nest .lab.tool .dot { background: var(--accent); } -.subagent-nested .nest .lab.stream .dot { background: var(--tone-info); } -.subagent-nested .nest .lab.diff .dot { background: var(--tone-ok); } -.subagent-nested .nest .lab.err .dot { background: var(--tone-err); } -.subagent-nested .nest .txt { color: var(--fg-2); line-height: 1.55; } -.subagent-nested .nest .txt code { background: var(--panel); padding:1px 5px; border-radius:3px; font-size:14px; } -.subagent-nested .nest .mono { font-family: "Geist Mono", monospace; font-size: 14px; color: var(--fg-2); white-space: pre; } -.subagent-nested .nest .diffbox { font-family: "Geist Mono", monospace; font-size: 14px; } -.subagent-nested .nest .diffbox .add { color: var(--tone-ok); background: oklch(72% 0.16 152 / 0.08); padding: 0 4px; } -.subagent-nested .nest .diffbox .del { color: var(--tone-err); background: oklch(68% 0.20 25 / 0.08); padding: 0 4px; text-decoration: line-through; opacity: 0.85; } +.subagent-nested .nest .lab .dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 5px; + vertical-align: middle; +} +.subagent-nested .nest .lab.reason .dot { + background: var(--violet); +} +.subagent-nested .nest .lab.tool .dot { + background: var(--accent); +} +.subagent-nested .nest .lab.stream .dot { + background: var(--tone-info); +} +.subagent-nested .nest .lab.diff .dot { + background: var(--tone-ok); +} +.subagent-nested .nest .lab.err .dot { + background: var(--tone-err); +} +.subagent-nested .nest .txt { + color: var(--fg-2); + line-height: 1.55; +} +.subagent-nested .nest .txt code { + background: var(--panel); + padding: 1px 5px; + border-radius: 3px; + font-size: 14px; +} +.subagent-nested .nest .mono { + font-family: "Geist Mono", monospace; + font-size: 14px; + color: var(--fg-2); + white-space: pre; +} +.subagent-nested .nest .diffbox { + font-family: "Geist Mono", monospace; + font-size: 14px; +} +.subagent-nested .nest .diffbox .add { + color: var(--tone-ok); + background: oklch(72% 0.16 152 / 0.08); + padding: 0 4px; +} +.subagent-nested .nest .diffbox .del { + color: var(--tone-err); + background: oklch(68% 0.2 25 / 0.08); + padding: 0 4px; + text-decoration: line-through; + opacity: 0.85; +} /* ============================================================ CODE SEARCH @@ -6174,17 +6896,29 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .code-search .ch { padding: 9px 12px; border-bottom: 1px solid var(--border); - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .code-search .ch .pat { font-family: inherit; font-size: 14px; color: var(--accent); } -.code-search .ch .grow { flex: 1; } -.code-search .ch .stat { font-family: inherit; font-size: 14px; color: var(--muted); } -.code-search .file-block { border-bottom: 1px solid var(--border); } -.code-search .file-block:last-child { border-bottom: none; } +.code-search .ch .grow { + flex: 1; +} +.code-search .ch .stat { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} +.code-search .file-block { + border-bottom: 1px solid var(--border); +} +.code-search .file-block:last-child { + border-bottom: none; +} .code-search .fh { padding: 6px 12px; background: var(--bg-2); @@ -6194,7 +6928,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { display: flex; gap: 8px; } -.code-search .fh .n { color: var(--muted); margin-left: auto; } +.code-search .fh .n { + color: var(--muted); + margin-left: auto; +} .code-search .hit { display: grid; grid-template-columns: 44px 1fr; @@ -6204,9 +6941,19 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 3px 12px; align-items: center; } -.code-search .hit:hover { background: var(--bg-2); } -.code-search .hit .ln { color: var(--muted-2); text-align: right; } -.code-search .hit .ct { color: var(--fg-2); white-space: pre; overflow: hidden; text-overflow: ellipsis; } +.code-search .hit:hover { + background: var(--bg-2); +} +.code-search .hit .ln { + color: var(--muted-2); + text-align: right; +} +.code-search .hit .ct { + color: var(--fg-2); + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} .code-search .hit .ct mark { background: oklch(78% 0.16 80 / 0.35); color: var(--fg); @@ -6224,22 +6971,49 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 12px 14px 10px; } .ctx-card .h { - display: flex; align-items: baseline; gap: 6px; + display: flex; + align-items: baseline; + gap: 6px; margin-bottom: 8px; } -.ctx-card .h .tt { font-size: 14px; font-weight: 600; } -.ctx-card .h .grow { flex: 1; } -.ctx-card .h .v { font-family: inherit; font-size: 14px; color: var(--fg); } -.ctx-card .h .v .mut { color: var(--muted); } +.ctx-card .h .tt { + font-size: 14px; + font-weight: 600; +} +.ctx-card .h .grow { + flex: 1; +} +.ctx-card .h .v { + font-family: inherit; + font-size: 14px; + color: var(--fg); +} +.ctx-card .h .v .mut { + color: var(--muted); +} .ctx-card .bar { - display: flex; height: 8px; border-radius: 999px; overflow: hidden; + display: flex; + height: 8px; + border-radius: 999px; + overflow: hidden; background: var(--panel); } -.ctx-card .bar span { display: block; height: 100%; } -.ctx-card .bar .system { background: var(--accent); } -.ctx-card .bar .tools { background: var(--violet); } -.ctx-card .bar .log { background: var(--tone-ok); } -.ctx-card .bar .input { background: var(--tone-warn); } +.ctx-card .bar span { + display: block; + height: 100%; +} +.ctx-card .bar .system { + background: var(--accent); +} +.ctx-card .bar .tools { + background: var(--violet); +} +.ctx-card .bar .log { + background: var(--tone-ok); +} +.ctx-card .bar .input { + background: var(--tone-warn); +} .ctx-card .legend { display: grid; grid-template-columns: repeat(4, 1fr); @@ -6248,10 +7022,23 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-family: inherit; font-size: 14px; } -.ctx-card .legend > div { display: flex; gap: 5px; align-items: center; } -.ctx-card .legend .sw { width: 8px; height: 8px; border-radius: 2px; } -.ctx-card .legend .l { color: var(--muted); } -.ctx-card .legend .v { color: var(--fg); margin-left: auto; } +.ctx-card .legend > div { + display: flex; + gap: 5px; + align-items: center; +} +.ctx-card .legend .sw { + width: 8px; + height: 8px; + border-radius: 2px; +} +.ctx-card .legend .l { + color: var(--muted); +} +.ctx-card .legend .v { + color: var(--fg); + margin-left: auto; +} .ctx-card .ttop { margin-top: 12px; padding-top: 9px; @@ -6274,10 +7061,24 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; align-items: center; } -.ctx-card .ttop .row .n { color: var(--fg-2); } -.ctx-card .ttop .row .bbar { background: var(--panel); border-radius:2px; height: 4px; overflow:hidden; } -.ctx-card .ttop .row .bbar span { display:block; height:100%; background: var(--violet); } -.ctx-card .ttop .row .v { color: var(--muted); text-align: right; } +.ctx-card .ttop .row .n { + color: var(--fg-2); +} +.ctx-card .ttop .row .bbar { + background: var(--panel); + border-radius: 2px; + height: 4px; + overflow: hidden; +} +.ctx-card .ttop .row .bbar span { + display: block; + height: 100%; + background: var(--violet); +} +.ctx-card .ttop .row .v { + color: var(--muted); + text-align: right; +} /* ============================================================ FALLBACK @@ -6297,10 +7098,21 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; color: var(--muted); } -.fallback-card .hd { color: var(--fg-2); margin-bottom: 4px; } -.fallback-card .kv { display: grid; grid-template-columns: 70px 1fr; gap: 4px 12px; } -.fallback-card .kv .k { color: var(--muted-2); } -.fallback-card .kv .v { color: var(--fg-2); } +.fallback-card .hd { + color: var(--fg-2); + margin-bottom: 4px; +} +.fallback-card .kv { + display: grid; + grid-template-columns: 70px 1fr; + gap: 4px 12px; +} +.fallback-card .kv .k { + color: var(--muted-2); +} +.fallback-card .kv .v { + color: var(--fg-2); +} /* ============================================================ LIVE CARD VARIANTS @@ -6319,37 +7131,86 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { max-width: 100%; } .live-card .lc-ico { - width: 16px; height: 16px; - display: inline-flex; align-items: center; justify-content: center; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--lc-color); +} +.live-card .lc-body { + color: var(--fg-2); + font-size: 14px; +} +.live-card .lc-body .b { color: var(--lc-color); + font-weight: 600; +} +.live-card .lc-act { + color: var(--lc-color); + padding: 0 6px; +} +.live-card .lc-act:hover { + background: var(--lc-soft); + border-radius: 4px; +} + +.live-card[data-v="thinking"] { + --lc-color: var(--violet); + --lc-soft: var(--violet-soft); +} +.live-card[data-v="undo"] { + --lc-color: var(--tone-info); + --lc-soft: var(--tone-info-soft); +} +.live-card[data-v="ctxPressure"] { + --lc-color: var(--tone-warn); + --lc-soft: var(--tone-warn-soft); +} +.live-card[data-v="aborted"] { + --lc-color: var(--st-aborted); + --lc-soft: oklch(60% 0.13 25 / 0.1); +} +.live-card[data-v="retry"] { + --lc-color: var(--tone-warn); + --lc-soft: var(--tone-warn-soft); +} +.live-card[data-v="checkpoint"] { + --lc-color: var(--tone-ok); + --lc-soft: var(--tone-ok-soft); +} +.live-card[data-v="stepProgress"] { + --lc-color: var(--accent); + --lc-soft: var(--accent-soft); +} +.live-card[data-v="mcpEvent"] { + --lc-color: var(--violet); + --lc-soft: var(--violet-soft); +} +.live-card[data-v="sessionOp"] { + --lc-color: var(--tone-ghost); + --lc-soft: var(--tone-ghost-soft); } -.live-card .lc-body { color: var(--fg-2); font-size: 14px; } -.live-card .lc-body .b { color: var(--lc-color); font-weight: 600; } -.live-card .lc-act { color: var(--lc-color); padding: 0 6px; } -.live-card .lc-act:hover { background: var(--lc-soft); border-radius: 4px; } - -.live-card[data-v="thinking"] { --lc-color: var(--violet); --lc-soft: var(--violet-soft); } -.live-card[data-v="undo"] { --lc-color: var(--tone-info); --lc-soft: var(--tone-info-soft); } -.live-card[data-v="ctxPressure"] { --lc-color: var(--tone-warn); --lc-soft: var(--tone-warn-soft); } -.live-card[data-v="aborted"] { --lc-color: var(--st-aborted); --lc-soft: oklch(60% 0.13 25 / 0.10); } -.live-card[data-v="retry"] { --lc-color: var(--tone-warn); --lc-soft: var(--tone-warn-soft); } -.live-card[data-v="checkpoint"] { --lc-color: var(--tone-ok); --lc-soft: var(--tone-ok-soft); } -.live-card[data-v="stepProgress"]{ --lc-color: var(--accent); --lc-soft: var(--accent-soft); } -.live-card[data-v="mcpEvent"] { --lc-color: var(--violet); --lc-soft: var(--violet-soft); } -.live-card[data-v="sessionOp"] { --lc-color: var(--tone-ghost); --lc-soft: var(--tone-ghost-soft); } .live-row { - display: flex; flex-wrap: wrap; gap: 6px; + display: flex; + flex-wrap: wrap; + gap: 6px; } .live-card.step .lc-meter { - width: 60px; height: 4px; + width: 60px; + height: 4px; background: var(--panel); border-radius: 999px; overflow: hidden; margin-left: 4px; } -.live-card.step .lc-meter > span { display: block; height: 100%; background: var(--accent); } +.live-card.step .lc-meter > span { + display: block; + height: 100%; + background: var(--accent); +} /* tone palette gallery */ .tone-gallery { @@ -6370,8 +7231,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { letter-spacing: 0.04em; text-transform: uppercase; } -[data-theme="light"] .tone-gallery .sw { color: oklch(100% 0 0); } - +[data-theme="light"] .tone-gallery .sw { + color: oklch(100% 0 0); +} /* horizontal-cramp fixes */ .main-head h1 { @@ -6401,7 +7263,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow-x: auto; scrollbar-width: none; } -.statusbar::-webkit-scrollbar { display: none; } +.statusbar::-webkit-scrollbar { + display: none; +} /* ============================================================ Composer narrow-width adaptation ============================================================ */ @@ -6454,14 +7318,35 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { /* Collapse composer-foot when the composer column itself is cramped — container query so panel state, not viewport width, drives it. */ @container composer (max-width: 620px) { - .composer-foot .cf-btn .label { display: none; } + .composer-foot .cf-btn .label { + display: none; + } +} +@container composer (max-width: 560px) { + .composer-foot .composer-secondary-action { + display: none; + } + .composer-tools-more { + display: inline-flex; + } } @container composer (max-width: 520px) { - .hint-row > span:first-child { display: none; } - .composer-foot .model-pill { max-width: 140px; } + .hint-row > span:first-child { + display: none; + } + .composer-foot .model-pill { + max-width: 140px; + } +} +@container composer (max-width: 460px) { + .composer-model-direct { + display: none; + } } @container composer (max-width: 420px) { - .composer-foot .model-pill { max-width: 100px; } + .composer-foot .model-pill { + max-width: 100px; + } } /* Main head — same compression behavior */ @@ -6469,7 +7354,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { white-space: nowrap; } @media (max-width: 1000px) { - .main-head .h-btn:not(.primary) span:not(:first-child) { display: none; } + .main-head .h-btn:not(.primary) span:not(:first-child) { + display: none; + } } /* ============================================================ @@ -6487,12 +7374,17 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { } .mode-switch[data-mode="yolo"] { border-color: var(--tone-err); - box-shadow: 0 0 0 2px oklch(68% 0.20 25 / 0.18); + box-shadow: 0 0 0 2px oklch(68% 0.2 25 / 0.18); animation: yolo-pulse 1.8s ease-in-out infinite; } @keyframes yolo-pulse { - 0%, 100% { box-shadow: 0 0 0 2px oklch(68% 0.20 25 / 0.18); } - 50% { box-shadow: 0 0 0 3px oklch(68% 0.20 25 / 0.32); } + 0%, + 100% { + box-shadow: 0 0 0 2px oklch(68% 0.2 25 / 0.18); + } + 50% { + box-shadow: 0 0 0 3px oklch(68% 0.2 25 / 0.32); + } } .ms-seg { display: inline-flex; @@ -6504,25 +7396,33 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); transition: background 0.12s ease, color 0.12s ease; } -.ms-seg:hover { color: var(--fg); } +.ms-seg:hover { + color: var(--fg); +} .ms-seg[data-on="true"][data-k="review"] { - background: var(--tone-info-soft); color: var(--tone-info); + background: var(--tone-info-soft); + color: var(--tone-info); } .ms-seg[data-on="true"][data-k="plan"] { - background: var(--tone-warn-soft); color: var(--tone-warn); + background: var(--tone-warn-soft); + color: var(--tone-warn); } .ms-seg[data-on="true"][data-k="auto"] { - background: var(--accent-soft); color: var(--accent); + background: var(--accent-soft); + color: var(--accent); } .ms-seg[data-on="true"][data-k="yolo"] { - background: var(--tone-err); color: oklch(99% 0 0); + background: var(--tone-err); + color: oklch(99% 0 0); +} +[data-theme="light"] .ms-seg[data-on="true"][data-k="yolo"] { + color: oklch(100% 0 0); } -[data-theme="light"] .ms-seg[data-on="true"][data-k="yolo"] { color: oklch(100% 0 0); } /* YOLO toast variant */ .toast-yolo { border-color: var(--tone-err); - box-shadow: 0 0 0 1px oklch(68% 0.20 25 / 0.18), var(--shadow-lg); + box-shadow: 0 0 0 1px oklch(68% 0.2 25 / 0.18), var(--shadow-lg); display: flex; align-items: center; gap: 8px; @@ -6544,8 +7444,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { /* Hide mode labels when narrow; keep icons */ @media (max-width: 1100px) { - .mode-switch .ms-seg > span { display: none; } - .mode-switch .ms-seg { padding: 4px 7px; } + .mode-switch .ms-seg > span { + display: none; + } + .mode-switch .ms-seg { + padding: 4px 7px; + } } /* ============================================================ @@ -6569,8 +7473,14 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { white-space: nowrap; } @keyframes toast-rise { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: none; } + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } } /* ============================================================ @@ -6588,7 +7498,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding-top: 12vh; animation: fade-in 0.18s ease-out; } -[data-theme="light"] .cmdk-mask { background: oklch(0% 0 0 / 0.2); } +[data-theme="light"] .cmdk-mask { + background: oklch(0% 0 0 / 0.2); +} .cmdk { width: min(640px, 92vw); max-height: 70vh; @@ -6630,7 +7542,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow-y: auto; padding: 4px 0; } -.cmdk-group { padding: 4px 0; } +.cmdk-group { + padding: 4px 0; +} .cmdk-gh { padding: 6px 14px 2px; font-family: inherit; @@ -6644,8 +7558,13 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { grid-template-columns: 22px 1fr auto auto; gap: 10px; align-items: center; + width: 100%; padding: 7px 14px; + border: 0; + background: transparent; + font: inherit; font-size: 14px; + text-align: left; cursor: pointer; color: var(--fg-2); } @@ -6653,7 +7572,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background: var(--accent-soft); color: var(--fg); } -.cmdk-row[data-active="true"] .ic { color: var(--accent); } +.cmdk-row[data-active="true"] .ic { + color: var(--accent); +} .cmdk-row .ic { color: var(--muted); display: inline-flex; @@ -6680,7 +7601,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 1px 5px; border-radius: 4px; } -.cmdk-row .kb-empty { display: inline-block; width: 1px; } +.cmdk-row .kb-empty { + display: inline-block; + width: 1px; +} .cmdk-empty { padding: 24px; text-align: center; @@ -6752,16 +7676,38 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { } .wd-row { display: grid; - grid-template-columns: 20px 1fr auto; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + padding: 0 8px 0 0; +} +.wd-row:hover { + background: var(--bg-2); +} +.wd-row-main { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); gap: 8px; align-items: center; - padding: 7px 12px; + min-width: 0; + width: 100%; + padding: 7px 0 7px 12px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; cursor: pointer; } -.wd-row:hover { background: var(--bg-2); } -.wd-row .ic { color: var(--muted); display: inline-flex; } -.wd-row .b { min-width: 0; } -.wd-row .b .p { +.wd-row-main .ic { + color: var(--muted); + display: inline-flex; +} +.wd-row-main .b { + min-width: 0; + display: block; +} +.wd-row-main .b .p { + display: block; font-size: 14px; font-family: inherit; color: var(--fg); @@ -6769,7 +7715,8 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; text-overflow: ellipsis; } -.wd-row .b .br { +.wd-row-main .b .br { + display: block; font-family: inherit; font-size: 14px; color: var(--muted); @@ -6778,7 +7725,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { text-overflow: ellipsis; margin-top: 1px; } -.wd-row .pin { color: var(--accent); } +.wd-row .pin { + color: var(--accent); +} .wd-row .wd-del { background: transparent; border: none; @@ -6796,7 +7745,7 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { opacity: 1; } .wd-row .wd-del:hover { - background: var(--bg-3, rgba(255,255,255,0.08)); + background: var(--bg-3, rgba(255, 255, 255, 0.08)); color: var(--tone-err, #ff3b30); } .wd-foot { @@ -6830,6 +7779,13 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .main-head .sub .ws-crumb:hover { color: var(--accent); } +.main-head .sub .ws-crumb { + display: inline-flex; + align-items: center; + gap: 4px; + color: inherit; + cursor: pointer; +} /* ============================================================ Splash — opening intro (sub-sea drift, "Reasonix" reveal) @@ -6923,7 +7879,8 @@ html[data-platform="macos"] .splash { animation-delay: 0.32s; } @keyframes splash-pulse { - 0%, 100% { + 0%, + 100% { opacity: 0.25; transform: scale(0.8); } @@ -7052,16 +8009,34 @@ html[data-platform="macos"] .splash { .jobs-body > .job-row:first-child { border-top: none; } +.jr-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; +} +.jr-line:hover { + background: var(--bg-2); +} .jr-main { display: grid; - grid-template-columns: 22px auto 1fr 60px auto; + grid-template-columns: 22px auto minmax(0, 1fr) 60px; gap: 10px; align-items: center; + width: 100%; padding: 10px 14px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; cursor: pointer; } .jr-main:hover { - background: var(--bg-2); + background: transparent; +} +.jr-main:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -2px; } .jr-state { display: inline-flex; @@ -7124,6 +8099,7 @@ html[data-platform="macos"] .splash { display: inline-flex; gap: 4px; align-items: center; + padding-right: 14px; } .jr-exit { font-family: inherit; @@ -7279,8 +8255,14 @@ html[data-platform="macos"] .splash { } @keyframes slide-down { - from { opacity: 0; transform: translateY(-6px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } } .msg-approval { @@ -7288,8 +8270,14 @@ html[data-platform="macos"] .splash { } @keyframes card-in { - from { opacity: 0; transform: translateY(4px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { + opacity: 0; + transform: translateY(4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } .toast { @@ -7297,8 +8285,14 @@ html[data-platform="macos"] .splash { } @keyframes toast-fall { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(12px); } + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(12px); + } } .statusbar .seg { diff --git a/desktop/src/ui/about.tsx b/desktop/src/ui/about.tsx index c475acc53..f1023419a 100644 --- a/desktop/src/ui/about.tsx +++ b/desktop/src/ui/about.tsx @@ -49,9 +49,19 @@ export function AboutModal({ onClose }: { onClose: () => void }) { }, []); return ( -
-
e.stopPropagation()}> -
@@ -88,10 +98,7 @@ export function AboutModal({ onClose }: { onClose: () => void }) { ); } -function CheckStatus({ - check, - onOpenReleases, -}: { check: CheckState; onOpenReleases: () => void }) { +function CheckStatus({ check, onOpenReleases }: { check: CheckState; onOpenReleases: () => void }) { if (check.kind === "idle" || check.kind === "checking") return null; if (check.kind === "up-to-date") { return ( diff --git a/desktop/src/ui/cards.tsx b/desktop/src/ui/cards.tsx index 6dbde8d36..db2533ee1 100644 --- a/desktop/src/ui/cards.tsx +++ b/desktop/src/ui/cards.tsx @@ -1,11 +1,55 @@ -import { memo, useState, type ReactNode } from "react"; -import { I } from "../icons"; +import { type ReactNode, memo, useState } from "react"; import { Markdown } from "../Markdown"; import { t, useLang } from "../i18n"; +import { I } from "../icons"; import { Shortcut } from "./shortcut"; type Tone = "default" | "success" | "warning" | "danger" | "accent" | "violet"; +function hashString(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return `${value.length}-${(hash >>> 0).toString(36)}`; +} + +function keyed(items: readonly T[], keyFor: (item: T) => string): { item: T; key: string }[] { + const seen = new Map(); + return items.map((item) => { + const base = keyFor(item); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return { item, key: count === 0 ? base : `${base}-${count}` }; + }); +} + +function renderReasoningInline(text: string): ReactNode[] { + const out: ReactNode[] = []; + const re = /`([^`]+)`|\*\*([^*]+)\*\*/g; + let last = 0; + let match: RegExpExecArray | null = re.exec(text); + while (match) { + if (match.index > last) out.push(text.slice(last, match.index)); + const code = match[1]; + const strong = match[2]; + const raw = match[0]; + if (code !== undefined) { + out.push( + + {code} + , + ); + } else if (strong !== undefined) { + out.push({strong}); + } + last = match.index + raw.length; + match = re.exec(text); + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + export function Card({ tone = "default", icon, @@ -70,15 +114,25 @@ export type PlanItem = { note?: string; }; -function derivePlanBadge(items: PlanItem[]): { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string } { - if (items.some((x) => x.status === "failed")) return { state: "failed", label: t("planBadge.failed") }; - if (items.some((x) => x.status === "blocked")) return { state: "blocked", label: t("planBadge.blocked") }; - if (items.some((x) => x.status === "active")) return { state: "running", label: t("planBadge.running") }; - if (items.length > 0 && items.every((x) => x.status === "done")) return { state: "done", label: t("planBadge.done") }; +function derivePlanBadge(items: PlanItem[]): { + state: "running" | "done" | "failed" | "waiting" | "blocked"; + label: string; +} { + if (items.some((x) => x.status === "failed")) + return { state: "failed", label: t("planBadge.failed") }; + if (items.some((x) => x.status === "blocked")) + return { state: "blocked", label: t("planBadge.blocked") }; + if (items.some((x) => x.status === "active")) + return { state: "running", label: t("planBadge.running") }; + if (items.length > 0 && items.every((x) => x.status === "done")) + return { state: "done", label: t("planBadge.done") }; return { state: "waiting", label: t("planBadge.pending") }; } -function StatusIcon({ state, label }: { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string }) { +function StatusIcon({ + state, + label, +}: { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string }) { switch (state) { case "running": return ; @@ -127,7 +181,9 @@ export function PlanCardView({ items, title }: { items: PlanItem[]; title?: stri
) : null}
- {it.status === "active" ? : null} + + {it.status === "active" ? : null} + ))} @@ -151,6 +207,7 @@ export function ReasoningCard({ model?: string; }) { useLang(); + const paragraphs = keyed(text.split(/\n\n+/), hashString); return (
- {text.split(/\n\n+/).map((para, i) => ( -

$1') - .replace(/\*\*([^*]+)\*\*/g, "$1"), - }} - /> + {paragraphs.map(({ item: para, key }) => ( +

{renderReasoningInline(para)}

))}
{model || tokens !== undefined ? ( @@ -231,6 +281,7 @@ export function ShellCard({ }) { useLang(); const tone: Tone = state === "failed" ? "danger" : state === "done" ? "success" : "warning"; + const outputLines = output ? keyed(output.split("\n"), hashString) : []; return ( {output ? (
-            {output.split("\n").map((ln, i) => {
+            {outputLines.map(({ item: ln, key }) => {
               if (ln.startsWith(" ✓") || ln.startsWith("✓"))
                 return (
-                  
+
{ln}
); if (ln.startsWith(" ✗") || ln.startsWith("✗") || /error/i.test(ln)) return ( -
+
{ln}
); - return
{ln}
; + return
{ln}
; })}
) : null} @@ -484,6 +535,7 @@ export function DiffCard({ useLang(); const adds = lines.filter((x) => x.t === "add").length; const rms = lines.filter((x) => x.t === "rm").length; + const keyedLines = keyed(lines, (line) => hashString(JSON.stringify(line))); return (
- {lines.map((ln, i) => { + {keyedLines.map(({ item: ln, key }) => { if (ln.t === "hunk") return ( -
+
{ln.s}
); @@ -515,7 +567,7 @@ export function DiffCard({ const l = ln.t === "ctx" ? ln.l : ln.t === "rm" ? ln.l : undefined; const r = ln.t === "ctx" ? ln.r : ln.t === "add" ? ln.r : undefined; return ( -
+
{l ?? ""} {r ?? ""} @@ -552,7 +604,11 @@ export function DiffCard({ // ---- Error ---- -export function ErrorCard({ message, hint, code }: { message: string; hint?: ReactNode; code?: string }) { +export function ErrorCard({ + message, + hint, + code, +}: { message: string; hint?: ReactNode; code?: string }) { useLang(); return ( `${hashString(r.url)}-${hashString(r.title)}-${hashString(r.snippet)}`, + ); return ( "{query}" - {results.length} {t("cards.hits")} + + {results.length} {t("cards.hits")} + } >
- {results.map((r, i) => ( -
+ {keyedResults.map(({ item: r, key }) => ( +
{r.url} @@ -625,6 +687,10 @@ export function SubagentCard({ }) { useLang(); const done = children.filter((c) => c.status === "done").length; + const keyedChildren = keyed( + children, + (child) => `${child.avatar}-${child.what}-${child.role}-${child.status}`, + ); return (
- {children.map((c, i) => ( -
+ {keyedChildren.map(({ item: c, key }) => ( +
{c.avatar}
{c.what}
@@ -674,17 +740,22 @@ export type MemRow = { scope: string; txt: string }; export function MemoryCard({ rows }: { rows: MemRow[] }) { useLang(); + const keyedRows = keyed(rows, (m) => `${m.scope}-${m.txt}`); return ( } kind="memory" name={t("cards.memoryName")} - meta={+ {rows.length} {t("cards.memoryCountSuffix")}} + meta={ + + + {rows.length} {t("cards.memoryCountSuffix")} + + } >
- {rows.map((m, i) => ( -
+ {keyedRows.map(({ item: m, key }) => ( +
{m.scope} {m.txt}
diff --git a/desktop/src/ui/composer.tsx b/desktop/src/ui/composer.tsx index bfa8bb503..e1d06280e 100644 --- a/desktop/src/ui/composer.tsx +++ b/desktop/src/ui/composer.tsx @@ -1,3 +1,5 @@ +import { invoke } from "@tauri-apps/api/core"; +import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { type ChangeEvent, type KeyboardEvent, @@ -9,14 +11,9 @@ import { useState, } from "react"; import type React from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; -import { t, type TKey } from "../i18n"; +import { type TKey, t } from "../i18n"; import { I } from "../icons"; -import { - DEFAULT_COMPOSER_ROWS, - applyComposerTextareaAutosize, -} from "./composer-sizing"; +import { DEFAULT_COMPOSER_ROWS, applyComposerTextareaAutosize } from "./composer-sizing"; import { fmtElapsed } from "./live"; import { Shortcut } from "./shortcut"; @@ -29,7 +26,12 @@ const EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "max"]; const MODE_INFO: ModeEntry[] = [ { k: "plan", label: "editMode.plan", icon: , hint: "editMode.planHint" }, - { k: "review", label: "editMode.review", icon: , hint: "editMode.reviewHint" }, + { + k: "review", + label: "editMode.review", + icon: , + hint: "editMode.reviewHint", + }, { k: "auto", label: "editMode.auto", icon: , hint: "editMode.autoHint" }, { k: "yolo", label: "editMode.yolo", icon: , hint: "editMode.yoloHint" }, ]; @@ -74,14 +76,9 @@ export type MentionItem = { desc?: string; }; -export type Chip = - | { kind: "at"; label: string } - | { kind: "slash"; label: string }; +export type Chip = { kind: "at"; label: string } | { kind: "slash"; label: string }; -type Popup = - | { kind: "slash"; query: string } - | { kind: "at"; query: string; nonce: number } - | null; +type Popup = { kind: "slash"; query: string } | { kind: "at"; query: string; nonce: number } | null; function slashIcon(cmd: string) { const m: Record = { @@ -193,8 +190,10 @@ export function Composer({ const [popup, setPopup] = useState(null); const [activeIdx, setActiveIdx] = useState(0); const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [toolsMenuOpen, setToolsMenuOpen] = useState(false); const nonceRef = useRef(0); const modelWrapRef = useRef(null); + const toolsWrapRef = useRef(null); // macOS Chinese IME fires compositionend BEFORE the confirm keydown. const composingRef = useRef(false); const compositionEndedAtRef = useRef(0); @@ -203,6 +202,23 @@ export function Composer({ const historyRef = useRef(initialHistory ? [...initialHistory].reverse() : []); const [browseIdx, setBrowseIdx] = useState(-1); const savedDraftRef = useRef(""); + const queuedSendRows = useMemo(() => { + const seen = new Map(); + return (queuedSends ?? []).map((text, index) => { + const occurrence = seen.get(text) ?? 0; + seen.set(text, occurrence + 1); + return { key: `${text}-${occurrence}`, text, index }; + }); + }, [queuedSends]); + const chipRows = useMemo(() => { + const seen = new Map(); + return chips.map((chip, index) => { + const base = `${chip.kind}-${chip.label}`; + const occurrence = seen.get(base) ?? 0; + seen.set(base, occurrence + 1); + return { key: `${base}-${occurrence}`, chip, index }; + }); + }, [chips]); // `initialHistory` arrives asynchronously (settings load after mount). // Sync historyRef when it first becomes available and the user hasn't @@ -218,9 +234,7 @@ export function Composer({ workspaceDir && picked.startsWith(workspaceDir) ? picked.slice(workspaceDir.length).replace(/^[\\/]+/, "") : picked; - setDraft((current) => - current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `, - ); + setDraft((current) => (current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `)); setChips((c) => [...c, { kind: "at", label: rel }]); onMentionPicked?.(rel); textareaRef.current?.focus(); @@ -245,10 +259,7 @@ export function Composer({ useEffect(() => { if (!modelMenuOpen) return; const onDown = (e: MouseEvent) => { - if ( - modelWrapRef.current && - !modelWrapRef.current.contains(e.target as Node) - ) { + if (modelWrapRef.current && !modelWrapRef.current.contains(e.target as Node)) { setModelMenuOpen(false); } }; @@ -256,6 +267,17 @@ export function Composer({ return () => window.removeEventListener("mousedown", onDown); }, [modelMenuOpen]); + useEffect(() => { + if (!toolsMenuOpen) return; + const onDown = (e: MouseEvent) => { + if (toolsWrapRef.current && !toolsWrapRef.current.contains(e.target as Node)) { + setToolsMenuOpen(false); + } + }; + window.addEventListener("mousedown", onDown); + return () => window.removeEventListener("mousedown", onDown); + }, [toolsMenuOpen]); + const attachFile = async (filter?: "image") => { try { const picked = await openFileDialog({ @@ -325,12 +347,14 @@ export function Composer({ return base; }, [popup, mentionResults]); - const items = - popup?.kind === "slash" ? slashItems : popup?.kind === "at" ? atItems : []; + const popupKind = popup?.kind; + const items = popupKind === "slash" ? slashItems : popupKind === "at" ? atItems : []; useEffect(() => { - setActiveIdx(0); - }, [popup?.kind]); + if (!popupKind || popupKind === "slash" || popupKind === "at") { + setActiveIdx(0); + } + }, [popupKind]); useEffect(() => { setActiveIdx((i) => (items.length ? Math.min(i, items.length - 1) : 0)); @@ -361,6 +385,19 @@ export function Composer({ const dismiss = () => setPopup(null); + const openSlashPopup = () => { + setToolsMenuOpen(false); + setModelMenuOpen(false); + setPopup({ kind: "slash", query: "" }); + }; + + const openMentionPopup = () => { + setToolsMenuOpen(false); + setModelMenuOpen(false); + const nonce = ++nonceRef.current; + setPopup({ kind: "at", query: "", nonce }); + }; + const pickItem = (idx: number) => { const it = items[idx]; if (!it || !popup) return; @@ -436,9 +473,7 @@ export function Composer({ } if (e.key === "ArrowUp") { e.preventDefault(); - setActiveIdx((i) => - items.length ? (i - 1 + items.length) % items.length : 0, - ); + setActiveIdx((i) => (items.length ? (i - 1 + items.length) % items.length : 0)); return; } if (e.key === "Escape") { @@ -513,18 +548,23 @@ export function Composer({ return (
- {queuedSends && queuedSends.length > 0 ? ( + {queuedSendRows.length > 0 ? (
- {t("composer.queueCount", { n: queuedSends.length })} + {t("composer.queueCount", { n: queuedSendRows.length })} - {queuedSends.map((text, i) => ( - - {text} + {queuedSendRows.map((row) => ( + + {row.text} {onDequeueSend ? ( - onDequeueSend(i)}> + ) : null} ))} @@ -537,9 +577,7 @@ export function Composer({ {busyLabel} - - {fmtElapsed(busyElapsedMs ?? 0)} - + {fmtElapsed(busyElapsedMs ?? 0)} @@ -568,24 +606,20 @@ export function Composer({
- {chips.length > 0 ? ( + {chipRows.length > 0 ? (
- {chips.map((c, i) => ( - - {c.kind === "slash" ? ( - - ) : ( - - )} - {c.label} - ( + + {row.chip.kind === "slash" ? : } + {row.chip.label} + ))}
@@ -598,7 +632,9 @@ export function Composer({ onChange={handleChange} onPaste={(e) => void handlePaste(e)} onKeyDown={handleKeyDown} - onCompositionStart={() => { composingRef.current = true; }} + onCompositionStart={() => { + composingRef.current = true; + }} onCompositionEnd={() => { composingRef.current = false; compositionEndedAtRef.current = Date.now(); @@ -630,8 +666,8 @@ export function Composer({
+
+ + {toolsMenuOpen ? ( +
+
+ ... + {t("app.titlebar.more")} +
+
+ + +
+ { + onModelChange(m); + setToolsMenuOpen(false); + }} + onPickEffort={(e) => { + onEffortChange(e); + setToolsMenuOpen(false); + }} + /> +
+ ) : null} +
{busy ? (
{items.length === 0 ? ( @@ -783,9 +877,15 @@ function Popup({
) : null} {items.map((it, i) => ( -
onPick(i)} onMouseEnter={() => onHover(i, it)} @@ -810,10 +910,8 @@ function Popup({ )}
- - {kind === "slash" ? ((it as SlashCmd).kb ?? "") : ""} - -
+ {kind === "slash" ? ((it as SlashCmd).kb ?? "") : ""} + ))}
@@ -844,7 +942,6 @@ function ModelEffortMenu({ onPickModel: (model: string) => void; onPickEffort: (effort: ReasoningEffort) => void; }) { - const [draft, setDraft] = useState(modelLabel); return (
+ +
+ ); +} + +function ModelEffortContent({ + modelLabel, + currentEffort, + onPickModel, + onPickEffort, +}: { + modelLabel: string; + currentEffort: ReasoningEffort; + onPickModel: (model: string) => void; + onPickEffort: (effort: ReasoningEffort) => void; +}) { + const [draft, setDraft] = useState(modelLabel); + return ( + <>
M {t("composer.switchModel")}
{KNOWN_MODELS.map((m) => ( -
{m}
-
+ ))}
{EFFORTS.map((e) => ( -
{e}
{t(`effort.${e}Desc` as TKey)}
-
+ ))}
-
+ ); } diff --git a/desktop/src/ui/context-panel.tsx b/desktop/src/ui/context-panel.tsx index 876d77169..f81b95d1c 100644 --- a/desktop/src/ui/context-panel.tsx +++ b/desktop/src/ui/context-panel.tsx @@ -44,21 +44,26 @@ export function ContextPanel({ const usedPct = Math.min(100, (used / CONTEXT_MAX_TOKENS) * 100); const cachedPct = Math.min(100, (cached / CONTEXT_MAX_TOKENS) * 100); const free = Math.max(0, CONTEXT_MAX_TOKENS - reserved - used - cached); + const tabs: { id: Tab; label: string }[] = [ + { id: "files", label: t("contextPanel.filesTab") }, + { id: "tools", label: t("contextPanel.toolsTab") }, + { id: "memory", label: t("contextPanel.memoryTab") }, + { id: "rules", label: t("contextPanel.rulesTab") }, + ]; return (