@itookit/dsht 0.6.7 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/dist/controller/connection-streams.d.ts +4 -1
- package/dist/controller/connection-streams.js +15 -2
- package/dist/transport/events.js +10 -3
- package/dist/ui/app.js +59 -23
- package/dist/ui/chat/status.js +8 -2
- package/dist/ui/dialogs/loop.js +7 -6
- package/dist/ui/dialogs/picker.d.ts +11 -0
- package/dist/ui/dialogs/picker.js +17 -2
- package/dist/ui/dialogs/use-panels.d.ts +2 -0
- package/dist/ui/dialogs/use-panels.js +3 -3
- package/package.json +1 -1
package/README.i18n.yaml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# Git blob hashes of the reviewed bilingual pair.
|
|
2
|
-
README.md:
|
|
3
|
-
README.zh.md:
|
|
2
|
+
README.md: 7bb251fe28755b8230a0988a62f1ce0a1b393065
|
|
3
|
+
README.zh.md: c443fdc10ef24dcc39bc3ed6ff57b8702e995d9f
|
package/README.md
CHANGED
|
@@ -374,7 +374,7 @@ Tab completes the leading slash command, extending an ambiguous draft to the sha
|
|
|
374
374
|
| `/think SEQ` | Toggle one loaded thought; `live` toggles the active attempt |
|
|
375
375
|
| `/help`, `/quit` | List every command with its description, or exit |
|
|
376
376
|
|
|
377
|
-
Slash commands work in both pickers and the conversation composer. Typing `/` displays matching commands, and once a name is settled and a space follows, the composer shows that command's usage and one-line description under the draft, so an argument never has to be remembered; `/help` lists every command with its description, and PgUp/PgDn changes pages. `/loop` additionally lists its `loop.yaml` records under the composer as soon as the draft names a record, so a run never has to be spelled out flag by flag. The `/help`, `/cost`, and `/status` panels remain open until the next command or Esc; `/history` also expires after ten seconds, so a forgotten lookup releases the composer. Esc leaves the draft in place; while a long operation owns the client — a search, an export, a history page load — it cancels that operation first, and otherwise it cancels whatever a command opened: a picker returns to the conversation it was opened over — without reloading it — and at startup, with no conversation yet, the session list steps back to the workspace list. `/workspace` and `/workspaces` alias `/ws`; `/session` and `/sessions` alias `/resume`. Names may contain spaces; quotes around the complete target are optional. The unquoted target `all` is reserved for `/resume all`; use `/resume "all"` or an ID to open a session titled `all`. Ambiguous targets require a full ID. Switching a workspace opens its sessions and detaches the old transcript; switching sessions updates the workspace label. Neither operation cancels a remote agent.
|
|
377
|
+
Slash commands work in both pickers and the conversation composer. Typing `/` displays matching commands, and once a name is settled and a space follows, the composer shows that command's usage and one-line description under the draft, so an argument never has to be remembered; `/help` lists every command with its description, and PgUp/PgDn changes pages. `/loop` additionally lists its `loop.yaml` records under the composer as soon as the draft names a record, so a run never has to be spelled out flag by flag. The `/help`, `/cost`, and `/status` panels remain open until the next command or Esc; `/history` also expires after ten seconds, so a forgotten lookup releases the composer. The arrows of every list menu — the workspace and session pickers, the record list, `@` completion, the parameter form — wrap around: ↑ on the first row lands on the last and ↓ on the last returns to the first, so neither end is a dead key. A panel that reads no free text — `/model`, `/prompts`, `/queue`, `/history`, `/think`, and the removal confirmations — parks the composer while it is open, so a keystroke aimed at the list can only reach the list; Esc closes the panel and hands the composer back. The startup lists and the conversation composer are unaffected and still accept slash commands. Esc leaves the draft in place; while a long operation owns the client — a search, an export, a history page load — it cancels that operation first, and otherwise it cancels whatever a command opened: a picker returns to the conversation it was opened over — without reloading it — and at startup, with no conversation yet, the session list steps back to the workspace list. `/workspace` and `/workspaces` alias `/ws`; `/session` and `/sessions` alias `/resume`. Names may contain spaces; quotes around the complete target are optional. The unquoted target `all` is reserved for `/resume all`; use `/resume "all"` or an ID to open a session titled `all`. Ambiguous targets require a full ID. Switching a workspace opens its sessions and detaches the old transcript; switching sessions updates the workspace label. Neither operation cancels a remote agent.
|
|
378
378
|
|
|
379
379
|
`/prompt` opens the shortcut prompts you saved, and `/prompt TEXT` saves TEXT as one; the list takes ↑/↓, Enter uses the selected prompt, `e` edits it, and `d` or the dedicated Delete key removes it. Choosing a prompt puts its text into the composer as an ordinary draft instead of sending it, so it can still be changed before Enter sends it. An edit started with `e` is the opposite: Enter writes the text back to the list and never sends it, and Esc abandons the edit and restores the draft it replaced. The prompts live in one private file (`<state>/prompts.json`, mode 0600) beside the memory log, and that one list is shared by every workspace and every host this client talks to, so a shortcut is written once and used everywhere; an identical text is not saved twice, a prompt is limited to 8 KiB, and the list holds at most 500 entries. A host is not needed to save or list them, and a file this build cannot read leaves the list empty and says so in the panel instead of stopping the client.
|
|
380
380
|
|
|
@@ -396,9 +396,9 @@ Pending ordinary messages appear inside the composer, with up to two previews. `
|
|
|
396
396
|
|
|
397
397
|
Below 62 terminal columns, active streaming reasoning defaults to one folded row. Wider terminals expand active reasoning and fold it on completion. `/think live` toggles the current reasoning; a new response restores the default.
|
|
398
398
|
|
|
399
|
-
Approvals offer 1. Allow once, 2. Deny, and 3. Stop turn. With an empty composer,
|
|
399
|
+
Approvals offer 1. Allow once, 2. Deny, and 3. Stop turn. With an empty composer, 1–3 answers outright, or ↑/↓ selects and Enter confirms; no action is selected initially, Escape clears the highlight, and a replayed request starts unselected again. Existing drafts keep normal typing, and `/allow`, `/deny`, and `/cancel` remain available.
|
|
400
400
|
|
|
401
|
-
User questions show progress, numbered options, and descriptions. Selection lists, pending questions, approvals, and file completions share the composer border with the text input. Pending questions and approvals retain recent conversation history above the composer. The history viewport fits the remaining height, removes extra vertical margins while a dialog is open, and refreshes on explicit scrolling or viewport resizing. The visible option window adapts to terminal height and follows the highlighted choice. With an empty composer, ↑/↓
|
|
401
|
+
User questions show progress, numbered options, and descriptions. Selection lists, pending questions, approvals, and file completions share the composer border with the text input. Pending questions and approvals retain recent conversation history above the composer. The history viewport fits the remaining height, removes extra vertical margins while a dialog is open, and refreshes on explicit scrolling or viewport resizing. The visible option window adapts to terminal height and follows the highlighted choice. With an empty composer, ↑/↓ moves the highlight and Enter confirms, while 1–9 answers a single-choice question outright — the number is the choice, with no second Enter. For multi-select questions, Space or 1–9 toggles checkboxes, and Enter confirms the selection. Options beyond nine remain reachable with arrows. Only the fixed choices are numbered: Other answer is an input row, so it carries no number and is reached with the arrows and Enter. Choose Other answer to type numeric free text; ordinary text answers remain supported. Existing drafts keep normal typing, and Escape leaves the question: with the options showing it dismisses the whole set, which the host records as a cancellation, while inside Other the first Escape only returns to the options. All questions are submitted together as structured selected labels and optional custom text; a failed submission preserves the answers for retry. Recognized question and approval events are retained by event ID for the connection, including replay before session selection; only the selected session displays them. Switching pickers does not decline those requests. Unrecognized waterfalls still delegate with `next`. The live host replays pending events after client reconnection; client restart does not preserve unsubmitted answer drafts. Normal TUI shutdown cancels a running turn. A cancelled/failed tool call or a host restart cannot restore the original wait from local UI state; send a new prompt requesting the questions again. Failed submissions retain their input; an interrupted HTTP response can leave delivery uncertain, so check the transcript before manually resending. The client never retries a mutation automatically.
|
|
402
402
|
|
|
403
403
|
The header stays above the scrollable conversation while the composer and status stay below it. The always-visible keyboard legend is removed; `/help` contains the full shortcuts and pickers retain their local navigation hints. The single-line header prioritizes the latest session title (falling back to the ID), with the workspace name after it on wider terminals; host and connection labels are omitted. Without a selected session, it shows the workspace name or All workspaces. A divider sits below the title; `/status` retains the complete session ID. The cancellation acknowledgement stays visible through incoming history until the host reports idle; acceptance does not mean a tool process has already exited.
|
|
404
404
|
|
|
@@ -418,7 +418,7 @@ The footer groups `◐ Working · 8s · Ctrl+C Stop`, `● Ready`, or `? Needs y
|
|
|
418
418
|
|
|
419
419
|
Working time uses the retained `turn/start` timestamp. If that timestamp is unavailable, `(observed)` in the details panel means time since this client observed the run; reconnecting can reset this fallback. The clock stops when the host reports idle. The status includes model generation, tool execution, and approval waits, not just streamed text. Offline status is explicitly marked as last known. The expanded panel merges related values onto one row and prints counts in the compact form the single-row bar already uses (`Context ~40% (400.6K/1M) · 229.7M tok`), so a 24-row terminal shows every detail on one screen; ↑/↓ scroll it one line at a time, PgUp/PgDn one screen, and the footer names the visible range and the total.
|
|
420
420
|
|
|
421
|
-
The single-row bar keeps its groups by value rather than by column: `◐ 6:18 · bash 1:08 · ^C │ v4-flash · high · ctx 30% · S¥2.49* · ¥: 5.00 (12.34) · 2 turns · 34.5M tok · hit 92%`. The live phase is reported as a fact — `think 28s` while reasoning, the tool name with its age while a tool runs, and `write 12s` while the answer streams — and never inferred from silence, because a long reasoning step and a quiet tool are not stalls. It names the newest event and keeps its age until another stripe of work starts or the turn closes, so the quiet stretch after a command is still time the turn spent working, and `● Ready` drops the phase because only the host knows the turn ended. The two cost groups never substitute for each other: `S¥2.49*` is this session, and reports `S?` while the ledger has not priced it, while `¥: 5.00 (12.34)` is today's spend with the all-time total. When the width runs out the least valuable group is dropped first (the cache-hit share, tokens, turns, effort, model, today's spend, then context); the session cost is never dropped but moves to a second row, and the state cluster gives up the phase and the stop hint only below about twenty columns. A paused clock names its reason (`⏸ copy`, `⏸ dialog`, `⏸ history`) instead of freezing silently, and `! Offline` or
|
|
421
|
+
The single-row bar keeps its groups by value rather than by column: `◐ 6:18 · bash 1:08 · ^C │ v4-flash · high · ctx 30% · S¥2.49* · ¥: 5.00 (12.34) · 2 turns · 34.5M tok · hit 92%`. The live phase is reported as a fact — `think 28s` while reasoning, the tool name with its age while a tool runs, and `write 12s` while the answer streams — and never inferred from silence, because a long reasoning step and a quiet tool are not stalls. It names the newest event and keeps its age until another stripe of work starts or the turn closes, so the quiet stretch after a command is still time the turn spent working, and `● Ready` drops the phase because only the host knows the turn ended. The two cost groups never substitute for each other: `S¥2.49*` is this session, and reports `S?` while the ledger has not priced it, while `¥: 5.00 (12.34)` is today's spend with the all-time total. When the width runs out the least valuable group is dropped first (the cache-hit share, tokens, turns, effort, model, today's spend, then context); the session cost is never dropped but moves to a second row, and the state cluster gives up the phase and the stop hint only below about twenty columns. A paused clock names its reason (`⏸ copy`, `⏸ dialog`, `⏸ history`) instead of freezing silently, and `! Offline` or a named degradation replaces the state token entirely: `⚠ Metrics` when the live-control stream is unavailable or undecodable, `⚠ Models` when the model catalog could not be read, and `⚠ Presets` when the preset roster could not be read. The name says which subsystem broke; `/status` carries the message, and a degradation that recovers (a control frame that decodes again) clears itself. While this client still owes an approval or an answer, `? Needs you` takes the token over instead — including while a dialog has paused the clock, because the owed answer is the thing to act on.
|
|
422
422
|
|
|
423
423
|
Turns come from the complete session's `sessionStats.turns` projection. Context occupancy is marked `~`: Harness combines provider usage with estimated surface changes and the latest route capacity. Token totals come from the complete session's `tokenUsage` projection, with separate uncached input, output, cache-read, and cache-write buckets; reasoning is already included in output. The cache-hit share is the cache-read bucket over all three billed prompt buckets, and it gains decimal places rather than reporting a partial hit as `100%`. Session and day costs come from the ledger's per-session slices, so a session with no slice yet is unknown, not the day's spend. Totals update when the host publishes usage, not on every streamed character. Missing measurements display `unknown` or `?`. Control-stream baselines replace state on reconnect, and per-key watermarks prevent an older follow snapshot from overwriting newer metrics.
|
|
424
424
|
|
package/README.zh.md
CHANGED
|
@@ -374,7 +374,7 @@ Tab 补全开头的 slash 命令,多个候选时补到公共前缀;回车也
|
|
|
374
374
|
| `/think SEQ` | 切换一条已加载思考的展开状态;`live` 表示当前尝试 |
|
|
375
375
|
| `/help`, `/quit` | 列出每条命令及其说明,或退出 |
|
|
376
376
|
|
|
377
|
-
Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示匹配命令;命令名确定并跟一个空格后,输入框下方会显示该命令的用法和单行说明,因此参数不必靠记忆;`/help` 分页列出命令及其单行说明,PgUp/PgDn 翻页。`/loop` 还会在草稿仍在写记录名时,把 `loop.yaml` 的记录列表显示在输入框下方,因此运行一次循环不必逐个敲出旗标。`/help`、`/cost`、`/status` 面板保持打开,直到下一条命令或 Esc;`/history`
|
|
377
|
+
Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示匹配命令;命令名确定并跟一个空格后,输入框下方会显示该命令的用法和单行说明,因此参数不必靠记忆;`/help` 分页列出命令及其单行说明,PgUp/PgDn 翻页。`/loop` 还会在草稿仍在写记录名时,把 `loop.yaml` 的记录列表显示在输入框下方,因此运行一次循环不必逐个敲出旗标。`/help`、`/cost`、`/status` 面板保持打开,直到下一条命令或 Esc;`/history` 还会在十秒后自动关闭,避免遗留列表一直占用输入框。列表类菜单(工作区/会话列表、记录列表、`@` 补全、参数表单等)的方向键是循环的:在首行继续向上回到末行,在末行继续向下回到首行,两端都不是死键。不读取自由文本的面板(`/model`、`/prompts`、`/queue`、`/history`、`/think` 与删除确认等)打开期间输入框停用,按键只归该面板,Esc 关闭后才恢复输入;启动列表与对话输入框不受影响,仍可直接输入 slash 命令。Esc 保留输入内容;当有长操作占用客户端时(搜索、导出、加载历史页),它先取消那个操作,其余情况才去取消命令打开的面板:选择器会回到打开它的那个对话(不重新加载),启动阶段还没有对话时,会话列表则退回工作区列表。`/workspace`、`/workspaces` 是 `/ws` 的别名;`/session`、`/sessions` 是 `/resume` 的别名。名称可以包含空格,完整目标两侧的引号可选。不带引号的目标 `all` 保留给 `/resume all`;打开标题为 `all` 的会话时,使用 `/resume "all"` 或其 ID。目标有歧义时必须提供完整 ID。切换工作区会打开其会话列表并解除旧对话订阅;切换会话会同步工作区标签。两种操作均不会取消远程代理。
|
|
378
378
|
|
|
379
379
|
`/prompt` 打开你保存的快捷提示词列表,`/prompt TEXT` 则把 TEXT 保存为一条;列表支持 ↑/↓ 选择、Enter 使用、`e` 编辑、`d` 或独立 Delete 键删除。选择一条提示词只会把文本放进输入框作为普通草稿,不会立即发送,因此还能继续修改后再按 Enter 发送;用 `e` 进入的编辑相反:Enter 把文本写回列表而绝不发送,Esc 放弃编辑并恢复此前的草稿。提示词保存在与内存日志同级的单个私有文件(`<state>/prompts.json`,权限 0600)中,该列表在本客户端连接的所有工作区与主机间共用,因此写一次即可到处使用;相同文本不会重复保存,单条上限 8 KiB,列表最多 500 条。保存和列出都不需要连接服务端;本版本无法读取的文件只会让列表为空并在面板中说明,而不会阻止客户端启动。
|
|
380
380
|
|
|
@@ -396,9 +396,9 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
396
396
|
|
|
397
397
|
终端少于 62 列时,最新流式思考也默认折叠为一行;宽屏保持流式展开、完成后折叠。`/think live` 可手动展开或折叠当前思考,新回复恢复默认行为。
|
|
398
398
|
|
|
399
|
-
审批提供 1. 允许一次、2. 拒绝、3. 停止当前轮次。输入框为空时,按 1–3
|
|
399
|
+
审批提供 1. 允许一次、2. 拒绝、3. 停止当前轮次。输入框为空时,按 1–3 直接作答,或用 ↑/↓ 选中后按 Enter 确认;初始不选中任何操作,Esc 清除高亮,请求重放时重新回到未选中。已有草稿时仍按正常文字输入处理,也保留 `/allow`、`/deny` 和 `/cancel` 命令。
|
|
400
400
|
|
|
401
|
-
用户问题显示题目进度、编号选项和说明。选择列表、待答问题、审批和文件补全与文本输入共用一个输入框边框。待答问题和审批会在输入框上方保留最近对话历史,历史视口使用剩余高度;对话框打开时减少上下留白,并在手动滚动或视口高度变化时刷新;可见选项数量按终端高度调整,并跟随当前高亮项滚动。输入框为空时,↑/↓
|
|
401
|
+
用户问题显示题目进度、编号选项和说明。选择列表、待答问题、审批和文件补全与文本输入共用一个输入框边框。待答问题和审批会在输入框上方保留最近对话历史,历史视口使用剩余高度;对话框打开时减少上下留白,并在手动滚动或视口高度变化时刷新;可见选项数量按终端高度调整,并跟随当前高亮项滚动。输入框为空时,↑/↓ 移动高亮、Enter 确认,单选题按 1–9 直接作答(数字键就是选择,不再需要再按一次回车),多选题用空格或 1–9 勾选/取消勾选、Enter 确认,超过九个选项仍可通过方向键访问。只有固定选项带编号;Other answer 是一个输入行,因此不编号,用方向键选中后回车进入。选择 Other answer 后可输入纯数字自由文本,也保留普通文本回答。已有草稿时按正常文字输入处理;Esc 可退出提问:选项模式下放弃整组问题(服务端记为取消),Other 模式下第一次 Esc 只返回选项。全部题目回答完成后,一次提交结构化选项标签及可选自定义文本;失败时保留答案以便重试。已识别的提问和审批事件在本次连接中按事件 ID 保留,包括早于会话选择到达的重放事件;只展示当前会话对应的请求。切换列表不会退回这些请求,不识别的 waterfall 仍通过 `next` 委托后续处理。存活服务端在客户端重连后重发待答事件;客户端重启不保留尚未提交的回答草稿。正常退出 TUI 会取消正在运行的轮次。调用已取消/失败或服务端已重启时,无法靠本地 UI 状态恢复原等待,需要发送新提示词要求重新提问。提交失败时保留输入;HTTP 响应中断可能导致投递状态不确定,手动重发前应检查会话记录。客户端不会自动重试修改请求。
|
|
402
402
|
|
|
403
403
|
标题固定在可滚动对话区域上方,输入框和状态栏保留在下方。底部不再常驻快捷键说明,完整快捷键放在 `/help`,选择器只显示当前需要的导航提示。顶部单行优先显示最新会话标题(无标题时回退到 ID),宽屏时在其后附上工作区名称,不再显示主机地址和连接状态。未选择会话时显示工作区名称或 All workspaces,下方为分隔线;`/status` 保留完整会话 ID。取消回执在后续历史消息到达时保持可见,直到服务端报告空闲;接受取消不表示工具进程已经退出。
|
|
404
404
|
|
|
@@ -418,7 +418,7 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
418
418
|
|
|
419
419
|
工作计时使用已加载日志的 `turn/start` 时间戳。缺少该时间戳时,详情面板中的 `(observed)` 表示从客户端观察到运行开始计时;重连可能重置此备用计时。服务端报告空闲后停止计时。运行状态涵盖模型生成、工具执行及审批等待,不仅是文本输出。断线时明确标注为最后已知状态。展开面板把相关值合并到一行,并用单行状态栏已有的紧凑计数(`Context ~40% (400.6K/1M) · 229.7M tok`),因此 24 行终端可以一屏看到全部详情;`↑`/`↓` 逐行滚动,`PgUp`/`PgDn` 翻屏,页脚标出可见区间与总数。
|
|
420
420
|
|
|
421
|
-
单行状态栏按价值而不是按列来保留分组:`◐ 6:18 · bash 1:08 · ^C │ v4-flash · high · ctx 30% · S¥2.49* · ¥: 5.00 (12.34) · 2 turns · 34.5M tok · hit 92%`。当前阶段只报事实——推理时是 `think 28s`,工具运行时是工具名加已等待时长,回答流式输出时是 `write 12s`——绝不从静默推断异常,因为长时间推理与安静运行的工具都不是卡住。它显示的是**当前事件**的名字与年龄,并在下一段工作开始或回合关闭前保持不变:命令返回之后、下一次增量到达之前的静默期仍被算作这个回合的工作时间;`● Ready` 不显示阶段,因为只有宿主知道回合已经结束。两个费用分组互不替代:`S¥2.49*` 只报本会话,账本还没为它定价时如实写 `S?`;`¥: 5.00 (12.34)` 只报今天花费与历史总计。宽度不足时先丢价值最低的分组(缓存命中率、token、回合、effort、模型、当天花费,然后 ctx);本会话费用永不丢弃,只会挪到第二行;状态簇只在约二十列以下才让出阶段与停止提示。暂停的时钟会说明原因(`⏸ copy`、`⏸ dialog`、`⏸ history`)而不是无声冻结;`! Offline`
|
|
421
|
+
单行状态栏按价值而不是按列来保留分组:`◐ 6:18 · bash 1:08 · ^C │ v4-flash · high · ctx 30% · S¥2.49* · ¥: 5.00 (12.34) · 2 turns · 34.5M tok · hit 92%`。当前阶段只报事实——推理时是 `think 28s`,工具运行时是工具名加已等待时长,回答流式输出时是 `write 12s`——绝不从静默推断异常,因为长时间推理与安静运行的工具都不是卡住。它显示的是**当前事件**的名字与年龄,并在下一段工作开始或回合关闭前保持不变:命令返回之后、下一次增量到达之前的静默期仍被算作这个回合的工作时间;`● Ready` 不显示阶段,因为只有宿主知道回合已经结束。两个费用分组互不替代:`S¥2.49*` 只报本会话,账本还没为它定价时如实写 `S?`;`¥: 5.00 (12.34)` 只报今天花费与历史总计。宽度不足时先丢价值最低的分组(缓存命中率、token、回合、effort、模型、当天花费,然后 ctx);本会话费用永不丢弃,只会挪到第二行;状态簇只在约二十列以下才让出阶段与停止提示。暂停的时钟会说明原因(`⏸ copy`、`⏸ dialog`、`⏸ history`)而不是无声冻结;`! Offline` 或具名降级会整体替换状态标记:实时控制流不可用或无法解码时是 `⚠ Metrics`,模型目录读不到时是 `⚠ Models`,preset 名册读不到时是 `⚠ Presets`。名字说明是哪个子系统坏了,具体信息在 `/status` 里,而已经恢复的降级(又有帧能解码)会自行清除。本客户端还欠一个审批或回答时,`? Needs you` 会接管状态标记(对话框暂停时钟时也一样),因为需要动手的正是这个欠下的回答。
|
|
422
422
|
|
|
423
423
|
轮次数来自完整会话的 `sessionStats.turns` 投影。上下文占用标为 `~`:Harness 将供应商用量与对话变化估算值、最新模型容量结合。Token 总量来自完整会话的 `tokenUsage` 投影,分别显示非缓存输入、输出、缓存读取和缓存写入;思考 token 已包含在输出中。缓存命中率是缓存读取占三个互斥提示侧桶(非缓存输入、缓存读取、缓存写入)之和的比例,遇到部分命中时增加小数位而不是报成 `100%`。本会话与当天费用来自账本按会话保存的切片,因此还没有切片的会话显示为未知,而不是当天的花费。总量随服务端用量投影更新,不按流式字符计数。缺失数据显示 `unknown` 或 `?`。重连时控制流基线整体替换状态,每个投影键的序号防止旧 follow 快照覆盖较新的指标。
|
|
424
424
|
|
|
@@ -5,7 +5,8 @@ export interface StreamHost {
|
|
|
5
5
|
identified(clientId: string): void;
|
|
6
6
|
event(event: HostEvent): boolean;
|
|
7
7
|
changed(): void;
|
|
8
|
-
|
|
8
|
+
/** Publish a live-metrics degradation, or clear it when a frame decodes again. */
|
|
9
|
+
degraded(message: string | undefined): void;
|
|
9
10
|
fail(error: Error): void;
|
|
10
11
|
}
|
|
11
12
|
export declare class ConnectionStreams {
|
|
@@ -14,6 +15,8 @@ export declare class ConnectionStreams {
|
|
|
14
15
|
private readonly subscriptions;
|
|
15
16
|
private readonly abort;
|
|
16
17
|
private readonly signal;
|
|
18
|
+
/** Whether a control frame failed to decode in this generation, so recovery can clear it once. */
|
|
19
|
+
private degradedFrame;
|
|
17
20
|
constructor(client: Client, host: StreamHost, signal: AbortSignal);
|
|
18
21
|
start(): Promise<void>;
|
|
19
22
|
close(): void;
|
|
@@ -8,6 +8,8 @@ export class ConnectionStreams {
|
|
|
8
8
|
subscriptions = new Set();
|
|
9
9
|
abort = new AbortController();
|
|
10
10
|
signal;
|
|
11
|
+
/** Whether a control frame failed to decode in this generation, so recovery can clear it once. */
|
|
12
|
+
degradedFrame = false;
|
|
11
13
|
constructor(client, host, signal) {
|
|
12
14
|
this.client = client;
|
|
13
15
|
this.host = host;
|
|
@@ -34,10 +36,21 @@ export class ConnectionStreams {
|
|
|
34
36
|
try {
|
|
35
37
|
this.host.event({ kind: 'control', frame: controlFrame(value) });
|
|
36
38
|
this.host.changed();
|
|
39
|
+
// One undecodable frame is not a permanent condition: a frame that decodes proves live
|
|
40
|
+
// metrics work again, so the degradation raised earlier in this generation ends here.
|
|
41
|
+
if (this.degradedFrame) {
|
|
42
|
+
this.degradedFrame = false;
|
|
43
|
+
this.host.degraded(undefined);
|
|
44
|
+
}
|
|
37
45
|
}
|
|
38
46
|
catch (error) {
|
|
39
|
-
// Missing live metrics must not blind a running conversation.
|
|
40
|
-
|
|
47
|
+
// Missing live metrics must not blind a running conversation. Only the first failure of an
|
|
48
|
+
// episode is published: a baseline this client could not apply leaves every later update
|
|
49
|
+
// reporting "before baseline", and that follow-up must not bury the error that caused it.
|
|
50
|
+
if (!this.degradedFrame) {
|
|
51
|
+
this.degradedFrame = true;
|
|
52
|
+
this.host.degraded(`Live metrics degraded: ${errorText(error)}`);
|
|
53
|
+
}
|
|
41
54
|
}
|
|
42
55
|
return true;
|
|
43
56
|
}, error => {
|
package/dist/transport/events.js
CHANGED
|
@@ -44,10 +44,13 @@ export function controlFrame(value) {
|
|
|
44
44
|
const frame = object(value);
|
|
45
45
|
if (frame.type === 'baseline') {
|
|
46
46
|
const baseline = object(frame.value);
|
|
47
|
-
|
|
47
|
+
// The baseline's sections are capabilities, not a fixed schema: a host that reports no queue or
|
|
48
|
+
// job stream simply omits them, and demanding the newer three-section shape would discard the
|
|
49
|
+
// projection snapshot those hosts do send. An absent section is empty, not an error.
|
|
50
|
+
const projections = optionalRows(baseline.projections)
|
|
48
51
|
.map(([id, snapshot]) => [id, projectionSnapshot(snapshot)]);
|
|
49
|
-
const queues =
|
|
50
|
-
const jobs =
|
|
52
|
+
const queues = optionalRows(baseline.queues).map(([id, items]) => [id, queuedInputs(items)]);
|
|
53
|
+
const jobs = optionalRows(baseline.jobs).map(([id, items]) => [id, activeJobs(items)]);
|
|
51
54
|
return { kind: 'baseline', projections: new Map(projections), queues: new Map(queues), jobs: new Map(jobs) };
|
|
52
55
|
}
|
|
53
56
|
if (frame.type === 'projection' || frame.type === 'queue' || frame.type === 'jobs') {
|
|
@@ -64,6 +67,10 @@ export function controlFrame(value) {
|
|
|
64
67
|
}
|
|
65
68
|
throw new Error('Unknown session control frame');
|
|
66
69
|
}
|
|
70
|
+
/** Read a baseline section that a host may not provide, as an empty list rather than a throw. */
|
|
71
|
+
function optionalRows(value) {
|
|
72
|
+
return value === undefined ? [] : Object.entries(object(value));
|
|
73
|
+
}
|
|
67
74
|
/** Read a possibly-absent string field without turning protocol drift into a throw. */
|
|
68
75
|
function optionalString(value) {
|
|
69
76
|
return typeof value === 'string' ? value : undefined;
|
package/dist/ui/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { plainRows } from "./chat/shell-view.js";
|
|
|
21
21
|
import { useHistoryView } from "./chat/use-history-view.js";
|
|
22
22
|
import { Frozen } from "./frozen.js";
|
|
23
23
|
import { CopyMode } from "./copy-mode.js";
|
|
24
|
+
import { cycle } from "./dialogs/picker.js";
|
|
24
25
|
import { HelpPanel, HistoryDialog, ModelDialog, OfflinePanel, PickerScreen, PromptsDialog, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
|
|
25
26
|
import { offlineGuidance } from "./offline.js";
|
|
26
27
|
import { LoopDialog, LoopMenu } from "./dialogs/loop.js";
|
|
@@ -191,10 +192,17 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
191
192
|
const loopMenuOpen = loopCandidates.length > 0 && loopForm === undefined && dismissedLoopMenu !== input;
|
|
192
193
|
const loopMenuCursor = Math.min(loopMenuIndex, Math.max(0, loopCandidates.length - 1));
|
|
193
194
|
const { openSurfaces, reservedKeys: surfaceReservedKeys } = panelState;
|
|
194
|
-
|
|
195
|
+
// A modal owns the screen: a panel, an interaction the operator still owes an answer to, or a
|
|
196
|
+
// startup screen. Only a modal pauses the clock — and says so — because only a modal is somewhere
|
|
197
|
+
// the reader stopped to look rather than somewhere they are still writing.
|
|
198
|
+
const modalOpen = !!(openSurfaces.length || pending || state.screen !== 'chat');
|
|
199
|
+
// The composer's `@` menu is a completion list under the draft, not a dialog: it needs the layout
|
|
200
|
+
// room a panel gets, but calling it a dialog made the status bar report a pause that was not there
|
|
201
|
+
// and hid the one surface that could explain a key which the menu itself was holding.
|
|
202
|
+
const dialogOpen = modalOpen || referenceOpen;
|
|
195
203
|
const displayPaused = copyMode || dialogOpen;
|
|
196
204
|
// Startup screens need live connection feedback even while their picker remains open.
|
|
197
|
-
const statusPaused = copyMode || (state.screen === 'chat' &&
|
|
205
|
+
const statusPaused = copyMode || (state.screen === 'chat' && modalOpen);
|
|
198
206
|
const matches = referenceOpen && lookup?.draft === input && lookup.sessionId === state.sessionId ? lookup : undefined;
|
|
199
207
|
useEffect(() => {
|
|
200
208
|
if (!referenceOpen)
|
|
@@ -238,6 +246,9 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
238
246
|
// One open surface owns the arrow and digit keys; the picker screens and the composer are not
|
|
239
247
|
// keyboard owners. The traits live in `surfaces`, so no panel is named here.
|
|
240
248
|
const panelBlocksKeys = openSurfaces.some(surface => surface.blocksKeys);
|
|
249
|
+
// A menu that reads no free text parks the composer it covers: the draft below is neither a field
|
|
250
|
+
// of that menu nor something it can submit, so a keystroke aimed at the list must not land in it.
|
|
251
|
+
const panelParksComposer = openSurfaces.some(surface => surface.parksComposer);
|
|
241
252
|
// Composer recall yields only to a surface that uses the arrows itself: the pickers, and a status
|
|
242
253
|
// panel with more lines than the view holds. The help and cost panels and a fitting status panel
|
|
243
254
|
// leave the arrows with the history, and Ctrl+P/N reach it from every surface.
|
|
@@ -480,10 +491,16 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
480
491
|
if (approvalKeysActive && !input && controller.state.online && controller.queries.foreground === undefined
|
|
481
492
|
&& controller.state.pending[0]?.eventId === eventId && !key.ctrl && !key.meta) {
|
|
482
493
|
const digit = /^[1-3]$/.test(_value) ? Number(_value) - 1 : -1;
|
|
483
|
-
|
|
494
|
+
// A numbered approval is answered by its number: each row *is* the decision, so 1–3 settles it
|
|
495
|
+
// at once instead of leaving a highlight that still needs an Enter.
|
|
496
|
+
if (digit >= 0) {
|
|
497
|
+
controller.actions.setApproval({ eventId, index: digit });
|
|
498
|
+
operate(() => digit === 2 ? controller.actions.cancelTurn() : controller.actions.approve(digit === 0));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (key.upArrow || key.downArrow) {
|
|
484
502
|
// An unselected list enters at the first, non-destructive choice, so a stray arrow plus Enter cannot cancel.
|
|
485
|
-
const index =
|
|
486
|
-
: Math.max(0, Math.min(2, approvalIndex + (key.upArrow ? -1 : 1)));
|
|
503
|
+
const index = approvalIndex < 0 ? 0 : cycle(approvalIndex, key.upArrow ? -1 : 1, 3);
|
|
487
504
|
controller.actions.setApproval({ eventId, index });
|
|
488
505
|
return;
|
|
489
506
|
}
|
|
@@ -497,16 +514,23 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
497
514
|
if (questionKeysActive && !input && controller.queries.foreground === undefined && !key.ctrl && !key.meta) {
|
|
498
515
|
const digit = /^[1-9]$/.test(_value) ? Number(_value) - 1 : -1;
|
|
499
516
|
if (key.upArrow || key.downArrow) {
|
|
500
|
-
|
|
517
|
+
// The input row is one more stop after the options, so the ring is one longer than the list.
|
|
518
|
+
controller.actions.setOption({ ...choiceState, cursor: cycle(optionCursor, key.upArrow ? -1 : 1, options.length + 1) });
|
|
501
519
|
return;
|
|
502
520
|
}
|
|
503
|
-
if (digit >= 0 && digit
|
|
521
|
+
if ((digit >= 0 && digit < options.length) || _value === ' ' && question.multiSelect === true && optionCursor < options.length) {
|
|
504
522
|
const index = digit >= 0 ? digit : optionCursor;
|
|
505
|
-
const label =
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
523
|
+
const label = string(options[index].label);
|
|
524
|
+
if (question.multiSelect === true) {
|
|
525
|
+
const selected = choiceState.selected.includes(label)
|
|
526
|
+
? choiceState.selected.filter(item => item !== label) : [...choiceState.selected, label];
|
|
527
|
+
controller.actions.setOption({ ...choiceState, cursor: index, selected });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// A single choice is answered by its number: the option is the whole answer, so only a second
|
|
531
|
+
// question — not a second keypress — stands between the number and the submission.
|
|
532
|
+
controller.actions.setOption({ ...choiceState, cursor: index });
|
|
533
|
+
operate(() => controller.actions.answerQuestion({ selected: [label] }));
|
|
510
534
|
return;
|
|
511
535
|
}
|
|
512
536
|
if (key.return) {
|
|
@@ -547,11 +571,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
547
571
|
return;
|
|
548
572
|
}
|
|
549
573
|
if (key.upArrow) {
|
|
550
|
-
setLoopMenuIndex(
|
|
574
|
+
setLoopMenuIndex(cycle(loopMenuCursor, -1, loopCandidates.length));
|
|
551
575
|
return;
|
|
552
576
|
}
|
|
553
577
|
if (key.downArrow) {
|
|
554
|
-
setLoopMenuIndex(
|
|
578
|
+
setLoopMenuIndex(cycle(loopMenuCursor, 1, loopCandidates.length));
|
|
555
579
|
return;
|
|
556
580
|
}
|
|
557
581
|
}
|
|
@@ -559,9 +583,9 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
559
583
|
if (key.tab)
|
|
560
584
|
pickReference();
|
|
561
585
|
else if (key.upArrow)
|
|
562
|
-
setReferenceIndex(
|
|
586
|
+
setReferenceIndex(cycle(referenceIndex, -1, matches?.items.length ?? 1));
|
|
563
587
|
else if (key.downArrow)
|
|
564
|
-
setReferenceIndex(
|
|
588
|
+
setReferenceIndex(cycle(referenceIndex, 1, matches?.items.length ?? 1));
|
|
565
589
|
return;
|
|
566
590
|
}
|
|
567
591
|
const recallPrevious = key.upArrow || key.ctrl && _value === 'p';
|
|
@@ -649,8 +673,14 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
649
673
|
candidates: loopCandidates.length, chosen: highlighted.name });
|
|
650
674
|
}
|
|
651
675
|
const line = highlighted === undefined ? raw : `/loop ${highlighted.name}`;
|
|
676
|
+
// A mention menu with nothing left to insert owns no key: its Enter can only mean "accept the
|
|
677
|
+
// highlighted candidate", and with no candidate it meant nothing at all — a dead Enter on a draft
|
|
678
|
+
// that was perfectly sendable. The menu stays on screen (its "No matching host files" line is the
|
|
679
|
+
// feedback), but the draft is submitted. While the lookup is still in flight `matches` is
|
|
680
|
+
// undefined and the menu keeps Enter, so a slow listing cannot be raced by a send.
|
|
681
|
+
const referenceEmpty = referenceOpen && matches !== undefined && matches.items.length === 0;
|
|
652
682
|
// The pipeline decides what the line means; the root only carries out the three stages' results.
|
|
653
|
-
const submission = interpret({ line, referenceOpen, copyMode, screen: state.screen });
|
|
683
|
+
const submission = interpret({ line, referenceOpen: referenceOpen && !referenceEmpty, copyMode, screen: state.screen });
|
|
654
684
|
if (submission.kind === 'mode') {
|
|
655
685
|
if (submission.action.kind === 'ignore')
|
|
656
686
|
return;
|
|
@@ -991,7 +1021,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
991
1021
|
// Reading older history pauses the clock without freezing the connection state on picker screens.
|
|
992
1022
|
// A paused clock is named rather than left frozen: a stopped number looks like a stall.
|
|
993
1023
|
const pauseReason = copyMode ? 'copy'
|
|
994
|
-
: state.screen === 'chat' &&
|
|
1024
|
+
: state.screen === 'chat' && modalOpen ? 'dialog'
|
|
995
1025
|
: state.screen === 'chat' && position > 0 ? 'history'
|
|
996
1026
|
: undefined;
|
|
997
1027
|
const statusFrozen = pauseReason !== undefined;
|
|
@@ -1054,7 +1084,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
1054
1084
|
// A rollup of badges is read through the key beside it; spelled-out states need no key, and a
|
|
1055
1085
|
// terminal too narrow for the whole key gets the badges alone rather than half a legend.
|
|
1056
1086
|
legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && foreground === undefined && !input, canSelect: () => !input && controller.state.online && controller.queries.foreground === undefined })
|
|
1057
|
-
: _jsx(OfflinePanel, { guidance: offline }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined }), promptsOpen && state.screen === 'chat' && _jsx(PromptsDialog, { identity: "prompts", prompts: controller.queries.prompts, error: controller.queries.promptsError, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onChoose: text => {
|
|
1087
|
+
: _jsx(OfflinePanel, { guidance: offline }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined }), promptsOpen && state.screen === 'chat' && _jsx(PromptsDialog, { identity: "prompts", prompts: controller.queries.prompts, error: controller.queries.promptsError, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onChoose: text => {
|
|
1088
|
+
setInput(text);
|
|
1089
|
+
// A shortcut is a finished line, not a draft being written: if it ends in a mention, the
|
|
1090
|
+
// completion menu must not open and own Enter, or choosing it would dead-key the composer.
|
|
1091
|
+
setDismissedReference(text);
|
|
1092
|
+
openPrompts(false);
|
|
1093
|
+
}, onEdit: prompt => {
|
|
1058
1094
|
setComposerIntent({
|
|
1059
1095
|
hint: 'Editing saved prompt · Enter saves · Esc cancels',
|
|
1060
1096
|
emptyNotice: 'Type the prompt text; Enter then saves it',
|
|
@@ -1064,10 +1100,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
1064
1100
|
setInput(prompt.text);
|
|
1065
1101
|
openPrompts(false);
|
|
1066
1102
|
}, onRemove: id => operate(async () => { if (await controller.actions.deletePrompt(id))
|
|
1067
|
-
setNotice('Deleted saved prompt'); }) }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => setHistoryPanel(undefined) }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(question.header)}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? question.question : pending.kind === 'approval' ? pending.description : '') }), question?.detail && _jsx(Text, { children: safeText(question.detail) }), pending.kind === 'approval' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\
|
|
1068
|
-
.slice(optionStart, optionStart + optionPageSize).map(({ option, index }) => _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { color: index === optionCursor ? theme.accent : undefined, wrap: "truncate-end", children: [index === optionCursor ? '❯ ' : ' ', index + 1
|
|
1069
|
-
? '↑ ↓ move · Space / 1–9 toggle · Enter confirm · Esc dismisses' : '↑ ↓
|
|
1103
|
+
setNotice('Deleted saved prompt'); }) }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => setHistoryPanel(undefined) }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(question.header)}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? question.question : pending.kind === 'approval' ? pending.description : '') }), question?.detail && _jsx(Text, { children: safeText(question.detail) }), pending.kind === 'approval' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "1\u20133 answer \u00B7 \u2191 \u2193 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
|
|
1104
|
+
.slice(optionStart, optionStart + optionPageSize).map(({ option, index }) => _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { color: index === optionCursor ? theme.accent : undefined, wrap: "truncate-end", children: [index === optionCursor ? '❯ ' : ' ', index < options.length ? `${index + 1}. ` : '', question?.multiSelect === true && index < options.length ? choiceState.selected.includes(option.label) ? '[x] ' : '[ ] ' : '', safeText(option.label)] }), option.description && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', safeText(option.description)] })] }, index)), _jsx(Text, { dimColor: true, children: choiceState.custom ? 'Type your answer below · Esc returns to options' : question?.multiSelect === true
|
|
1105
|
+
? '↑ ↓ move · Space / 1–9 toggle · Enter confirm · Esc dismisses' : '↑ ↓ move · 1–9 answer · Enter confirm · Esc dismisses' })] }), _jsx(Text, { dimColor: true, children: question ? options.length > 0
|
|
1070
1106
|
? 'Choose "Other answer" to type · the draft is kept while this is open'
|
|
1071
1107
|
: 'Esc dismisses the question · the draft is kept while this is open'
|
|
1072
|
-
: 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(input); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : surfaceReservedKeys.length ? surfaceReservedKeys : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: !copyMode && !answerPending && !loopForm, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex }), loopMenuOpen && _jsx(LoopMenu, { records: loopCandidates, index: loopMenuCursor, source: controller.queries.loopSource })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), commandHint && _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.accent, children: [commandHint.command, commandHint.usage === undefined ? '' : ` ${commandHint.usage}`] }), " \u00B7 ", commandHint.description] }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { source: costSource }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { source: statusSource, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
|
|
1108
|
+
: 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(input); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : surfaceReservedKeys.length ? surfaceReservedKeys : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending || panelParksComposer ? theme.colors.muted : theme.accent, focus: !copyMode && !answerPending && !loopForm && !panelParksComposer, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex }), loopMenuOpen && _jsx(LoopMenu, { records: loopCandidates, index: loopMenuCursor, source: controller.queries.loopSource })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), commandHint && _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.accent, children: [commandHint.command, commandHint.usage === undefined ? '' : ` ${commandHint.usage}`] }), " \u00B7 ", commandHint.description] }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { source: costSource }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { source: statusSource, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
|
|
1073
1109
|
}
|
package/dist/ui/chat/status.js
CHANGED
|
@@ -273,10 +273,16 @@ export const StatusBar = memo(function StatusBar({ source, expanded = false, wid
|
|
|
273
273
|
// The state token reports a fact and never guesses: a paused clock is named, offline and errors
|
|
274
274
|
// take the token over, and an unknown phase simply leaves the phase group empty. An answer this
|
|
275
275
|
// client still owes outranks the paused reason, because that reason is only why the clock stopped.
|
|
276
|
+
// A degraded metadata load names the subsystem it broke, so the bar itself answers "what is
|
|
277
|
+
// wrong" and the expanded panel only has to add the message. Live metrics lead because the rest
|
|
278
|
+
// of the bar reads them; presets are the quietest of the three and never hide a louder failure.
|
|
279
|
+
const degraded = source.controlError !== undefined ? '⚠ Metrics'
|
|
280
|
+
: source.modelError !== undefined ? '⚠ Models'
|
|
281
|
+
: source.presetError !== undefined ? '⚠ Presets' : undefined;
|
|
276
282
|
const stateToken = !source.online
|
|
277
283
|
? { text: '! Offline', color: theme.status.offline }
|
|
278
|
-
:
|
|
279
|
-
? { text:
|
|
284
|
+
: degraded !== undefined
|
|
285
|
+
? { text: degraded, color: theme.status.warning }
|
|
280
286
|
: source.pendingCount > 0
|
|
281
287
|
? { text: '? Needs you', color: theme.status.critical }
|
|
282
288
|
: pauseReason !== undefined
|
package/dist/ui/dialogs/loop.js
CHANGED
|
@@ -6,6 +6,7 @@ import { validLoopOption } from "../../slash/parse.js";
|
|
|
6
6
|
import { safeText } from "../../text.js";
|
|
7
7
|
import { useCopyMode } from "../copy-mode.js";
|
|
8
8
|
import { useTheme } from "../theme/index.js";
|
|
9
|
+
import { cycle } from "./picker.js";
|
|
9
10
|
const FIELDS = [
|
|
10
11
|
{ field: 'from', label: 'From', hint: 'first round, 1 or more' },
|
|
11
12
|
{ field: 'to', label: 'To', hint: 'last round, at least From' },
|
|
@@ -137,10 +138,10 @@ export function LoopDialog({ record, enabled, onStart, onBack, onClose }) {
|
|
|
137
138
|
setError(undefined);
|
|
138
139
|
return true;
|
|
139
140
|
};
|
|
140
|
-
const move = (
|
|
141
|
+
const move = (delta) => {
|
|
141
142
|
if (!applyEdit())
|
|
142
143
|
return;
|
|
143
|
-
setRow(
|
|
144
|
+
setRow(cycle(row, delta, rows.length));
|
|
144
145
|
setEdit(undefined);
|
|
145
146
|
setError(undefined);
|
|
146
147
|
};
|
|
@@ -157,11 +158,11 @@ export function LoopDialog({ record, enabled, onStart, onBack, onClose }) {
|
|
|
157
158
|
return;
|
|
158
159
|
}
|
|
159
160
|
if (key.upArrow) {
|
|
160
|
-
move(
|
|
161
|
+
move(-1);
|
|
161
162
|
return;
|
|
162
163
|
}
|
|
163
164
|
if (key.downArrow) {
|
|
164
|
-
move(
|
|
165
|
+
move(1);
|
|
165
166
|
return;
|
|
166
167
|
}
|
|
167
168
|
if (current.kind === 'var') {
|
|
@@ -179,7 +180,7 @@ export function LoopDialog({ record, enabled, onStart, onBack, onClose }) {
|
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
182
|
if (key.return)
|
|
182
|
-
move(
|
|
183
|
+
move(1);
|
|
183
184
|
return;
|
|
184
185
|
}
|
|
185
186
|
if (current.kind === 'field') {
|
|
@@ -195,7 +196,7 @@ export function LoopDialog({ record, enabled, onStart, onBack, onClose }) {
|
|
|
195
196
|
return;
|
|
196
197
|
}
|
|
197
198
|
if (key.return)
|
|
198
|
-
move(
|
|
199
|
+
move(1);
|
|
199
200
|
return;
|
|
200
201
|
}
|
|
201
202
|
if (!key.return)
|
|
@@ -23,6 +23,17 @@ export interface Choice {
|
|
|
23
23
|
edit?(): void;
|
|
24
24
|
remove?(): void;
|
|
25
25
|
}
|
|
26
|
+
/** One step around a closed ring, so a list has no dead end at either edge.
|
|
27
|
+
*
|
|
28
|
+
* Every menu in the client moves this way: holding ↑ on the first row lands on the last, and ↓ on
|
|
29
|
+
* the last wraps to the first. A menu therefore never needs an extra key to reach a row the arrows
|
|
30
|
+
* cannot pass, and an empty list stays on index 0 rather than producing a negative one.
|
|
31
|
+
* @param index - Current row.
|
|
32
|
+
* @param delta - Rows to move, positive for down.
|
|
33
|
+
* @param length - Number of rows in the ring.
|
|
34
|
+
* @returns The wrapped index.
|
|
35
|
+
*/
|
|
36
|
+
export declare function cycle(index: number, delta: number, length: number): number;
|
|
26
37
|
/** Keyboard-driven list with paging and optional removal.
|
|
27
38
|
* @param props - Choices, availability, page size, hint text and the usable column count.
|
|
28
39
|
* @returns The rendered list rows and its navigation hint.
|
|
@@ -10,6 +10,21 @@ import { useTheme } from "../theme/index.js";
|
|
|
10
10
|
function cellsWidth(cells) {
|
|
11
11
|
return (cells ?? []).reduce((sum, cell) => sum + stringWidth(cell.text), 0);
|
|
12
12
|
}
|
|
13
|
+
/** One step around a closed ring, so a list has no dead end at either edge.
|
|
14
|
+
*
|
|
15
|
+
* Every menu in the client moves this way: holding ↑ on the first row lands on the last, and ↓ on
|
|
16
|
+
* the last wraps to the first. A menu therefore never needs an extra key to reach a row the arrows
|
|
17
|
+
* cannot pass, and an empty list stays on index 0 rather than producing a negative one.
|
|
18
|
+
* @param index - Current row.
|
|
19
|
+
* @param delta - Rows to move, positive for down.
|
|
20
|
+
* @param length - Number of rows in the ring.
|
|
21
|
+
* @returns The wrapped index.
|
|
22
|
+
*/
|
|
23
|
+
export function cycle(index, delta, length) {
|
|
24
|
+
if (length <= 0)
|
|
25
|
+
return 0;
|
|
26
|
+
return ((index + delta) % length + length) % length;
|
|
27
|
+
}
|
|
13
28
|
/** Keyboard-driven list with paging and optional removal.
|
|
14
29
|
* @param props - Choices, availability, page size, hint text and the usable column count.
|
|
15
30
|
* @returns The rendered list rows and its navigation hint.
|
|
@@ -31,9 +46,9 @@ export function Picker({ choices, enabled, canSelect, pageSize = 12, hint, width
|
|
|
31
46
|
if (!canSelect() || key.eventType === 'release')
|
|
32
47
|
return;
|
|
33
48
|
if (key.upArrow)
|
|
34
|
-
setSelected(
|
|
49
|
+
setSelected(cycle(current, -1, choices.length));
|
|
35
50
|
else if (key.downArrow)
|
|
36
|
-
setSelected(
|
|
51
|
+
setSelected(cycle(current, 1, choices.length));
|
|
37
52
|
else if (_input === 'e' && !key.ctrl && !key.meta)
|
|
38
53
|
choices[current]?.edit?.();
|
|
39
54
|
else if (_input === 'd' && !key.ctrl && !key.meta || key.delete && /^\x1b\[3(?:;\d+)?~$/.test(rawKey.current))
|
|
@@ -22,6 +22,8 @@ interface Surface {
|
|
|
22
22
|
open: boolean;
|
|
23
23
|
arrows?: boolean;
|
|
24
24
|
blocksKeys?: boolean;
|
|
25
|
+
/** The panel reads no free text, so the composer behind it is parked until the panel closes. */
|
|
26
|
+
parksComposer?: boolean;
|
|
25
27
|
reserved?: readonly string[];
|
|
26
28
|
}
|
|
27
29
|
/** The externally owned peek view participates in routing without duplicating its state. */
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
const EMPTY = { thoughts: false, queue: false, prompts: false, help: false, cost: false, status: false };
|
|
4
4
|
const TRAITS = [
|
|
5
|
-
{ name: 'queue', reserved: ['d'] },
|
|
6
|
-
{ name: 'prompts', arrows: true, blocksKeys: true, reserved: ['d', 'e'] },
|
|
7
|
-
...['removal', 'model', 'loop', 'thoughts', 'history', 'search'].map(name => ({ name, arrows: true, blocksKeys: true })),
|
|
5
|
+
{ name: 'queue', reserved: ['d'], parksComposer: true },
|
|
6
|
+
{ name: 'prompts', arrows: true, blocksKeys: true, reserved: ['d', 'e'], parksComposer: true },
|
|
7
|
+
...['removal', 'model', 'loop', 'thoughts', 'history', 'search'].map(name => ({ name, arrows: true, blocksKeys: true, parksComposer: true })),
|
|
8
8
|
...['help', 'cost', 'status'].map(name => ({ name, blocksKeys: true })),
|
|
9
9
|
];
|
|
10
10
|
/** The externally owned peek view participates in routing without duplicating its state. */
|