@songtonyli/dsh-cli 0.1.13 → 0.1.14

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.md CHANGED
@@ -26,7 +26,7 @@ workspace and no build step. Native packages that ship one build per platform
26
26
  ripgrep, the PTY backend's addons, sharp, koffi, and the Landlock launcher — are
27
27
  ordinary optional dependencies, so npm resolves the right one for your machine.
28
28
 
29
- Requires Node `^22.19.0 || >=24.0.0`. Version 0.1.13; `dsh --version`
29
+ Requires Node `^22.19.0 || >=24.0.0`. Version 0.1.14; `dsh --version`
30
30
  also reports the harness build the package was assembled from.
31
31
 
32
32
  ## License
package/bin/dsh.mjs CHANGED
@@ -18,7 +18,7 @@ const argv = process.argv.slice(2)
18
18
  // version this package publishes. Reporting only one of the two would make an
19
19
  // ordinary `npm install @songtonyli/dsh-cli@x` look like it installed something else.
20
20
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-V')) {
21
- console.log('@songtonyli/dsh-cli 0.1.13 (deepseek-harness 0.1.6-alpha.2)')
21
+ console.log('@songtonyli/dsh-cli 0.1.14 (deepseek-harness 0.1.6-alpha.2)')
22
22
  process.exit(0)
23
23
  }
24
24
 
@@ -160,7 +160,7 @@ window.__ModuleLoader__.load({
160
160
  const SCROLLBAR_LINGER_MS = 2e3;
161
161
  /** Format complete-build metadata for the local brand badge. */
162
162
  function localBuildVersion() {
163
- return `0.1.6-alpha.2-c107a31` + ({}.DSH_CLIENT_GIT_DIRTY === "true" ? "-dirty" : "");
163
+ return `0.1.6-alpha.2-1d90b61-dirty`;
164
164
  }
165
165
  /** Each panel row subscribes only to its own selection state. */
166
166
  function PanelRow({ id, label, wide, usePanelInfo, selectPanel, renderSlot }) {
@@ -3319,7 +3319,7 @@ call: (method, args = null) => env.invoke(method, args) }, harnessTrap(), ...Obj
3319
3319
  occupants: ["client-ui-plan PlanReviewOpen"],
3320
3320
  replaceRisk: "none",
3321
3321
  example: "return {\n inject: ['slots'],\n apply(ctx) {\n ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register(\n { name: 'conversation.plan-review.actions', id: 'my-entry', order: 100, label: 'My entry' },\n () => React.createElement('div', null, 'hello'),\n ))\n },\n}",
3322
- source: "packages/client/ui-user-questions/src/client/contract/slots.ts:21"
3322
+ source: "packages/client/ui-user-questions/src/client/contract/slots.ts:22"
3323
3323
  },
3324
3324
  {
3325
3325
  key: "conversation.session",
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/bundle/tui-app/README.md
5
- README.md: d6882721f6938b3c7bd0bbf0637166de2c84fb1d
6
- README.zh.md: d6fd797cf514205ffe396b2e95529afbd00ed4e6
5
+ README.md: 609455db6daa218a2d71ed685495d1e9a5ca7283
6
+ README.zh.md: 4b943b769e10c62bfe960fb133f311d759f63093
@@ -40,11 +40,11 @@ On quit the app prints `dsh: session <id> saved; resume with: dsh --profile tui
40
40
 
41
41
  ### The screen
42
42
 
43
- The header names the session by its title once one is generated or set, with the id beside it. The transcript grows in the terminal's own scrollback: your prompts start with `›` (attachments listed under them), assistant reasoning is dim above the Markdown reply, and each tool call is a card with a status glyph, the tool name, the presenter headline, and a body folded to `toolPreviewLines` rows. Streamed text fades in and every word keeps its own clock: a word enters near the terminal's background color and brightens to the color it settles in over `streamFadeSteps` levels of `streamFadeStepMs` each, so a fast stream leaves a longer trail of brightening words rather than a darker one. The same fade covers streamed reasoning, which settles at the dim foreground it is drawn in, and a tool card, whose header and call rows fade in when the call is logged and whose result rows fade in when the tool answers; a card redrawn from persisted history carries no fade, and text that has settled is never dimmed again. Below the transcript sit a spinner while the agent works, any open prompt, the editor, the subagent panel, and the footer: a status bar of segments over a line of key hints. The editor's caret is the terminal's own blinking bar: the app asks for that shape at start, gives your default back on quit, and draws no caret at all while the status bar or the panel holds the keyboard. Each segment appears only when its fact exists the model and reasoning effort, the permission preset, the running turn's elapsed time, cumulative token usage, the context window percentage, todo and goal and plan-mode markers from the projection seam, the workspace path (shortened with `~` and `…/` when long), and the count of pending attachments — and `Shift+Down` moves focus into the bar to read a segment's details. Compaction and model-request retries appear as notices, the same facts the browser's markers carry.
43
+ The header names the session by its title once one is generated or set, with the id beside it. The transcript grows in the terminal's own scrollback: your prompts start with `›` (attachments listed under them), assistant reasoning is dim above the Markdown reply, and each tool call is a card with a status glyph, the tool name, the presenter headline, and a body folded to `toolPreviewLines` rows. Streamed reply text fades in and every word keeps its own clock: a word enters near the terminal's background color and brightens to the color it settles in over `streamFadeSteps` ticks of `streamFadeStepMs` each, so a fast stream leaves a longer trail of brightening words rather than a darker one. Streamed reasoning and a tool card float out over the same duration: they appear at a lifted color and recede to the dim italic or palette colors they settle in; a card redrawn from persisted history carries no fade, and text that has settled is never dimmed again. Below the transcript sit a spinner while the agent works, any open prompt, the editor, the subagent panel, and the footer: a status bar that, unfocused, is one line of key facts. The editor's caret is the terminal's own blinking bar: the app asks for that shape at start, gives your default back on quit, and draws no caret at all while the status bar or the panel holds the keyboard. The model, effort (`effort default` when the selection leaves reasoning to the model), and workspace path (shortened with `~` and `…/` when long) are always present; every other segment appears only when its fact exists — the permission preset, the running turn's elapsed time, cumulative token usage, the context window percentage, todo and goal and plan-mode markers from the projection seam, and the count of pending attachments — and `Shift+Down` expands the bar around the selected segment so that segment stays on screen. Compaction and model-request retries appear as notices, the same facts the browser's markers carry.
44
44
 
45
45
  While the keyboard walks the conversation an inspector is docked directly above the editor. It names the focused section — its position among the navigable blocks, the turn it belongs to, and what the section is, such as `3/12 · turn 2 · bash git status · result` — draws the block's parts as a strip like `‹ reasoning · reply ›` when it has more than one, shows the section's own source rows cut to `focusPreviewLines` with `… <n> more rows · Enter opens the page` under them, and ends with a dim line of the keys it answers. The focused block is also marked where it stands, with a two-column gutter that is dim beside the block's other lines and accented beside the focused section's own, and its content wraps two columns narrower while the mark is drawn. That mark reaches only the lines pi-tui still repaints differentially — the last `rows` lines of the last frame it wrote, a boundary a taller frame raises and a shorter one never lowers — because changing anything above them would clear the terminal's scrollback; a block that has scrolled past that point keeps no gutter and its inspector heading reads `off screen` instead, and a fade whose rows reach that edge settles them to their final colors in the frame that writes them, so nothing stays dim in the scrollback.
46
46
 
47
- The subagent panel is drawn while a subagent session under the bound one is resident, or the listing carries a candidate it could not read. Its heading counts what it lists, and each row gives the child's depth indent, its label or id, its mode (`one-shot` or `continuable`), `resident`, whether its agent is `running` or `idle`, its elapsed time — the open turn's, else the total its settled turns took — and its token usage, as far as this process's own view of that child and the composed projections carry them. Six rows are drawn at most, with `+<n> more · /subagents lists them all` under them; a candidate the listing could not interpret draws as `unreadable: <reason>` and opens nothing, and a failed listing keeps the rows the last good one produced with `listing failed: <reason>` beneath. The panel disappears with its last row.
47
+ The subagent panel is drawn while a subagent session under the bound one is resident, or the listing carries a candidate it could not read. Unfocused it is one summary line with the listed count and the first child's key label; focused, its heading counts what it lists, and each row gives the child's depth indent, its label or id, its mode (`one-shot` or `continuable`), `resident`, whether its agent is `running` or `idle`, its elapsed time — the open turn's, else the total its settled turns took — and its token usage, as far as this process's own view of that child and the composed projections carry them. Six rows are drawn at most, with `+<n> more · /subagents lists them all` under them; a candidate the listing could not interpret draws as `unreadable: <reason>` and opens nothing, and a failed listing keeps the rows the last good one produced with `listing failed: <reason>` beneath. The panel disappears with its last row.
48
48
 
49
49
  ### Keys and commands
50
50
 
@@ -64,7 +64,7 @@ The subagent panel is drawn while a subagent session under the bound one is resi
64
64
 
65
65
  The conversation, the subagent panel, and the status bar stack in that order, and `Up` / `Down` walk the whole stack without wrapping at either end. While the conversation has focus, `Up` / `Down` move between blocks — your prompts, assistant messages, and tool cards, with notices and printed reports skipped — `Left` / `Right` move between a block's parts, which are the reasoning and the reply of a message and the call and the result of a tool, `Enter` opens the focused part as a read-only page carrying its full rows and comes back to the same part, and `Esc` returns focus to the editor. The rows a part carries are the block's own source text, so a reply reads as the Markdown the model wrote rather than the rendering drawn above. Every other key is consumed there, apart from `Ctrl+C` and `Ctrl+D`; a session switch drops the focus back to the editor, and a session with nothing to inspect yet answers `Shift+Up` with `nothing in the transcript to inspect yet` and leaves the keyboard in the editor.
66
66
 
67
- While the status bar has focus, `Left` / `Right` and `Tab` / `Shift+Tab` move between segments and wrap at both ends, `Up` leaves the bar for the subagent panel's last row while it is drawn and for the conversation otherwise, `Enter` opens the selected segment while the bar keeps focus, and `Esc` returns focus to the editor. No other key reaches the editor while the bar has focus; `Ctrl+C` and `Ctrl+D` keep their usual meaning and return focus to the editor.
67
+ While the status bar has focus, `Left` / `Right`, `Shift+Left` / `Shift+Right`, and `Tab` / `Shift+Tab` move between segments and wrap at both ends, `Up` leaves the bar for the subagent panel's last row while it is drawn and for the conversation otherwise, `Enter` opens the selected segment while the bar keeps focus, and `Esc` returns focus to the editor. No other key reaches the editor while the bar has focus; `Ctrl+C` and `Ctrl+D` keep their usual meaning and return focus to the editor.
68
68
 
69
69
  While the subagent panel has focus, `Up` / `Down` move the selection and continue into the neighboring regions at its ends — `Up` on the first row reaches the conversation and `Down` on the last row the status bar — `Enter` opens that child's session details as a read-only page and comes back to the panel on the same row, and `Esc` returns focus to the editor. Every other key is consumed there as well, apart from `Ctrl+C` and `Ctrl+D`; the panel also hands the keyboard back to the editor when its last row leaves.
70
70
 
@@ -124,8 +124,8 @@ An approval request draws `Allow <tool>?` with the asker's reason, the logged ca
124
124
  | `toolPreviewLines` | `8` | Collapsed tool-card body rows before `Ctrl+O` expands them |
125
125
  | `focusPreviewLines` | `12` | Rows of the focused section the docked inspector shows before `Enter` opens the whole of it |
126
126
  | `liveRefreshMs` | `1000` | Period of the redraw that advances the `turn` segment and the panel's elapsed values and re-reads a stale subagent listing |
127
- | `streamFadeSteps` | `8` | Brightness levels a streamed word, a reasoning word, or a tool card climbs before it draws in the color it settles in |
128
- | `streamFadeStepMs` | `33` | How long one brightness level lasts, so a word settles `streamFadeSteps × streamFadeStepMs` after it appeared; it is also the repaint period of the fade |
127
+ | `streamFadeSteps` | `8` | How many ticks a fade lasts: reply text fades in, reasoning and tool cards float out, over `streamFadeSteps × streamFadeStepMs` |
128
+ | `streamFadeStepMs` | `33` | One fade tick, and the repaint period while anything is still fading; duration is `streamFadeSteps × streamFadeStepMs` |
129
129
  | `reducedMotion` | `false` | Draw streamed text, streamed reasoning, and tool cards in their settled colors, with no fade and no repeating repaint |
130
130
  | `openBrowser` | `true` | Hand marked authorization pages to the local default browser |
131
131
 
@@ -167,7 +167,7 @@ The patch rides over `dsh-base`: it sets the coding persona prefix and cwd suffi
167
167
  | [`src/navigation.ts`](src/navigation.ts) | The transcript as sections, the cursor that walks them, and the inspector heading |
168
168
  | [`src/inspector.ts`](src/inspector.ts) | The docked inspector: the focused section's heading, parts strip, folded rows, and its mounted component |
169
169
  | [`src/screen.ts`](src/screen.ts) | The main screen with the settle passes between building a frame and writing it, the repaint window each pass is judged against, and the per-block repaint floor |
170
- | [`src/fade.ts`](src/fade.ts) | The streamed-text fade: the wall-clock tail tracker, the block-fade clock and registry, the perceptual ramp, and the recolor of rendered lines |
170
+ | [`src/fade.ts`](src/fade.ts) | The streamed-text fade: the wall-clock tail tracker, the block-fade clock and registry, the fade-in ramp, the float-out mix, and the recolor of rendered lines |
171
171
  | [`src/prompts.ts`](src/prompts.ts) | Approval, question, picker, and read-only detail prompts plus the modal queue |
172
172
  | [`src/transcript.ts`](src/transcript.ts) | Pure text folding of presentation views, usage, and turn-end reasons |
173
173
  | [`src/diff.ts`](src/diff.ts) | Line diff and hunk selection for diff cards |
@@ -175,8 +175,8 @@ The patch rides over `dsh-base`: it sets the coding persona prefix and cwd suffi
175
175
  | [`src/completion.ts`](src/completion.ts) | Slash-command and `@`-reference completion for the editor |
176
176
  | [`src/editor.ts`](src/editor.ts) | The prompt editor without pi-tui's drawn block cursor, and the DECSCUSR sequences for the terminal's own caret |
177
177
  | [`src/status.ts`](src/status.ts) | Projection-seam facts and the sections the `/status` report and the segment details share; compaction and retry notices |
178
- | [`src/footer.ts`](src/footer.ts) | The status bar: the ordered segments, each segment's detail rows, and the footer's two rendered lines |
179
- | [`src/subagent-panel.ts`](src/subagent-panel.ts) | The live subagent panel: one descendant listing plus sampled live facts become its rows, and the rows its text |
178
+ | [`src/footer.ts`](src/footer.ts) | The status bar: the ordered segments, each segment's detail rows, the one-line unfocused facts, and the focused sliding window |
179
+ | [`src/subagent-panel.ts`](src/subagent-panel.ts) | The live subagent panel: one descendant listing plus sampled live facts become its rows, the unfocused summary line, and the focused listing |
180
180
  | [`src/catalog.ts`](src/catalog.ts) | Rows for `/settings`, `/plugins`, `/subagents`, `/deliverables`, `/changes`, and `/outline`, and the `/plugins` management verbs |
181
181
  | [`src/todos.ts`](src/todos.ts) | The todo list: the status glyphs, the picker rows, and one item's detail rows |
182
182
  | [`cordis.patch.yml`](cordis.patch.yml) | The terminal patch over `dsh-base` |
@@ -40,11 +40,11 @@ dsh tui --no-open # print sign-in URLs without opening a
40
40
 
41
41
  ### 屏幕布局
42
42
 
43
- 页眉在标题生成或设置后以标题命名会话,并在旁边显示 id。对话记录在终端自身的回滚区中增长:你的提示以 `›` 开头(附件列在其下),assistant 的推理以暗色显示在 Markdown 回复上方,每次工具调用是一张卡片,含状态符号、工具名、呈现器标题,以及折叠到 `toolPreviewLines` 行的正文。流式文字会淡入,而且每个词各走各的时钟:一个词以接近终端背景色的亮度出现,并经 `streamFadeSteps` 级亮度、每级 `streamFadeStepMs` 提亮到它最终稳定的颜色,因此流得更快只会留下更长的一串正在提亮的词,而不会更暗。同一套淡入也覆盖流式推理(它稳定在自身绘制所用的暗色前景)与工具卡片:卡片的表头与调用行在调用被记录时淡入,结果行在工具应答时淡入;从持久化历史重绘的卡片不带淡入,已经稳定下来的文字也不会再被调暗。对话记录下方依次是 agent 工作时的旋转指示、任何打开的提示、编辑器、子 agent 面板,以及页脚:一行分段状态栏,其下是一行按键提示。编辑器的光标是终端自身的闪烁竖条:应用在启动时请求这一形状,退出时把你的默认形状还回去,而状态栏或面板持有键盘期间完全不绘制光标。每个分段只在其事实存在时出现——模型与推理强度、权限预设、正在进行轮次的已用时间、累计 token 用量、上下文窗口百分比、来自投影接缝的 todo、目标与计划模式标记、workspace 路径(过长时以 `~` 与 `…/` 缩短)、待发送附件数量——`Shift+Down` 把焦点移入状态栏以查看某个分段的详情。压缩与模型请求重试以通知形式出现,与浏览器标记承载的事实相同。
43
+ 页眉在标题生成或设置后以标题命名会话,并在旁边显示 id。对话记录在终端自身的回滚区中增长:你的提示以 `›` 开头(附件列在其下),assistant 的推理以暗色显示在 Markdown 回复上方,每次工具调用是一张卡片,含状态符号、工具名、呈现器标题,以及折叠到 `toolPreviewLines` 行的正文。流式回复文字会淡入,而且每个词各走各的时钟:一个词以接近终端背景色的亮度出现,并经 `streamFadeSteps` 次、每次 `streamFadeStepMs` 提亮到它最终稳定的颜色,因此流得更快只会留下更长的一串正在提亮的词,而不会更暗。流式推理与工具卡片在同一时长内浮出:它们以抬高的颜色出现,再退回到稳定时的暗色斜体或调色板颜色;从持久化历史重绘的卡片不带淡入,已经稳定下来的文字也不会再被调暗。对话记录下方依次是 agent 工作时的旋转指示、任何打开的提示、编辑器、子 agent 面板,以及页脚:未聚焦时是一行关键事实的状态栏。编辑器的光标是终端自身的闪烁竖条:应用在启动时请求这一形状,退出时把你的默认形状还回去,而状态栏或面板持有键盘期间完全不绘制光标。模型、推理强度(选择把推理交给模型时标为 `effort default`)以及 workspace 路径(过长时以 `~` 与 `…/` 缩短)始终出现;其余分段只在其事实存在时出现——权限预设、正在进行轮次的已用时间、累计 token 用量、上下文窗口百分比、来自投影接缝的 todo、目标与计划模式标记、待发送附件数量——`Shift+Down` 展开状态栏并使所选分段留在屏幕上。压缩与模型请求重试以通知形式出现,与浏览器标记承载的事实相同。
44
44
 
45
45
  键盘在对话记录中移动时,编辑器正上方停靠着一个检视面板。它写出当前聚焦小节的名称——它在可导航块中的位置、所属轮次,以及这是哪一种小节,例如 `3/12 · turn 2 · bash git status · result`——在块含多个部分时把这些部分绘制成形如 `‹ reasoning · reply ›` 的一条选择行,把该小节自身的源文本行截到 `focusPreviewLines` 行并在其下写出 `… <n> more rows · Enter opens the page`,最后以一行暗色文字列出它所应答的按键。被聚焦的块也会在原地被标记:一条两列宽的标记条,在块的其他行旁为暗色、在被聚焦小节自身的行旁为强调色,标记绘制期间该块的内容按窄两列换行。这个标记只能落在 pi-tui 仍以差分方式重绘的行上——也就是它写出的上一帧的最后 `rows` 行,这条边界只会被更高的帧抬高、不会被更矮的帧降低——因为改动其上方的任何一行都会清空终端的回滚区;已经滚过该位置的块不带标记条,其检视面板标题改为写出 `off screen`;淡入中的各行抵达这一边界时,会在写出该帧的同一帧内被结算为最终颜色,因此回滚区里不会留下暗色文字。
46
46
 
47
- 子 agent 面板在绑定会话之下有常驻的子 agent 会话、或列表中带有无法读取的候选者时绘制。其标题统计所列出的条目,每一行给出该子会话的层级缩进、其标签或 id、其模式(`one-shot` 或 `continuable`)、`resident`、其 agent 处于 `running` 还是 `idle`、其已用时间——进行中轮次的用时,否则是已结束轮次的合计——以及其 token 用量,以本进程对该子会话的可见程度与已组合的投影所能提供的为限。最多绘制六行,其下是 `+<n> more · /subagents lists them all`;列表无法解读的候选者绘制为 `unreadable: <reason>` 且打不开任何页面,读取失败的列表则保留上一次成功读取产生的各行,并在其下写出 `listing failed: <reason>`。面板随其最后一行一同消失。
47
+ 子 agent 面板在绑定会话之下有常驻的子 agent 会话、或列表中带有无法读取的候选者时绘制。未聚焦时它是一行摘要,含列出的数量与第一个子会话的关键标签;聚焦时其标题统计所列出的条目,每一行给出该子会话的层级缩进、其标签或 id、其模式(`one-shot` 或 `continuable`)、`resident`、其 agent 处于 `running` 还是 `idle`、其已用时间——进行中轮次的用时,否则是已结束轮次的合计——以及其 token 用量,以本进程对该子会话的可见程度与已组合的投影所能提供的为限。最多绘制六行,其下是 `+<n> more · /subagents lists them all`;列表无法解读的候选者绘制为 `unreadable: <reason>` 且打不开任何页面,读取失败的列表则保留上一次成功读取产生的各行,并在其下写出 `listing failed: <reason>`。面板随其最后一行一同消失。
48
48
 
49
49
  ### 按键与命令
50
50
 
@@ -64,7 +64,7 @@ dsh tui --no-open # print sign-in URLs without opening a
64
64
 
65
65
  对话记录、子 agent 面板与状态栏按此顺序自上而下排布,`Up` / `Down` 走遍整个序列且在两端都不环绕。对话记录持有焦点时,`Up` / `Down` 在块之间移动——你的提示、assistant 消息与工具卡片,通知与打印出的报告会被跳过——`Left` / `Right` 在块的各部分之间移动,也就是一条消息的推理与回复、一次工具调用的调用与结果,`Enter` 把当前聚焦的部分作为只读页面打开、其中带有该部分的完整各行,离开后回到同一部分,`Esc` 把焦点交还编辑器。各部分承载的是块自身的源文本,因此一条回复读到的是模型写下的 Markdown,而不是其上方绘制出的渲染结果。除 `Ctrl+C` 与 `Ctrl+D` 外,其他按键都在此被消费;切换会话会把焦点交回编辑器,尚无可查看内容的会话对 `Shift+Up` 回以 `nothing in the transcript to inspect yet` 并把键盘留在编辑器。
66
66
 
67
- 状态栏持有焦点时,`Left` / `Right` 与 `Tab` / `Shift+Tab` 在分段之间移动并在两端环绕,`Up` 在面板已绘制时离开状态栏前往面板的最后一行、否则前往对话记录,`Enter` 打开所选分段且状态栏保持焦点,`Esc` 把焦点交还编辑器。状态栏持有焦点期间其他按键不会到达编辑器;`Ctrl+C` 与 `Ctrl+D` 保持其一贯含义,并把焦点交还编辑器。
67
+ 状态栏持有焦点时,`Left` / `Right`、`Shift+Left` / `Shift+Right` 与 `Tab` / `Shift+Tab` 在分段之间移动并在两端环绕,`Up` 在面板已绘制时离开状态栏前往面板的最后一行、否则前往对话记录,`Enter` 打开所选分段且状态栏保持焦点,`Esc` 把焦点交还编辑器。状态栏持有焦点期间其他按键不会到达编辑器;`Ctrl+C` 与 `Ctrl+D` 保持其一贯含义,并把焦点交还编辑器。
68
68
 
69
69
  子 agent 面板持有焦点时,`Up` / `Down` 移动选择,并在其两端继续进入相邻区域——在第一行按 `Up` 到达对话记录,在最后一行按 `Down` 到达状态栏——`Enter` 把该子会话的详情作为只读页面打开、离开后回到面板的同一行,`Esc` 把焦点交还编辑器。除 `Ctrl+C` 与 `Ctrl+D` 外,其他按键同样在此被消费;面板的最后一行离开时,它也会把键盘交还编辑器。
70
70
 
@@ -124,8 +124,8 @@ dsh tui --no-open # print sign-in URLs without opening a
124
124
  | `toolPreviewLines` | `8` | `Ctrl+O` 展开前折叠的工具卡片正文行数 |
125
125
  | `focusPreviewLines` | `12` | `Enter` 完整打开之前,停靠的检视面板为聚焦小节显示的行数 |
126
126
  | `liveRefreshMs` | `1000` | 重绘周期:推进 `turn` 分段与面板中的已用时间,并重新读取已过期的子 agent 列表 |
127
- | `streamFadeSteps` | `8` | 一个流式词、一个推理词或一张工具卡片在以其稳定颜色绘制之前经过的亮度级数 |
128
- | `streamFadeStepMs` | `33` | 每级亮度持续多久,因此一个词在出现后 `streamFadeSteps × streamFadeStepMs` 稳定下来;这也是淡入的重绘周期 |
127
+ | `streamFadeSteps` | `8` | 一次淡入或浮出持续多少拍:回复文字淡入,推理与工具卡片浮出,时长为 `streamFadeSteps × streamFadeStepMs` |
128
+ | `streamFadeStepMs` | `33` | 一拍淡入或浮出的时长,也是仍在变化时的重绘周期;总时长为 `streamFadeSteps × streamFadeStepMs` |
129
129
  | `reducedMotion` | `false` | 以稳定颜色绘制流式文本、流式推理与工具卡片,不做淡入,也不重复重绘 |
130
130
  | `openBrowser` | `true` | 把被标记的授权页面交给本地默认浏览器 |
131
131
 
@@ -167,7 +167,7 @@ runner 等待完整应用就绪(`ctx.get('loader')?.await()`),并在核心
167
167
  | [`src/navigation.ts`](src/navigation.ts) | 把对话记录看作各个小节、走遍它们的光标,以及检视面板的标题 |
168
168
  | [`src/inspector.ts`](src/inspector.ts) | 停靠的检视面板:聚焦小节的标题、各部分选择行、折叠后的各行,及其挂载的组件 |
169
169
  | [`src/screen.ts`](src/screen.ts) | 在构建一帧与写出该帧之间带若干次结算的主屏幕、每次结算所依据的重绘窗口,以及每个块的重绘下界 |
170
- | [`src/fade.ts`](src/fade.ts) | 流式文本淡入:基于挂钟的尾部追踪器、块淡入时钟与注册表、感知均匀的亮度级别,以及对已渲染行的重新着色 |
170
+ | [`src/fade.ts`](src/fade.ts) | 流式文本淡入与浮出:基于挂钟的尾部追踪器、块时钟与注册表、淡入亮度级别、浮出混合,以及对已渲染行的重新着色 |
171
171
  | [`src/prompts.ts`](src/prompts.ts) | 审批、提问、选择器与只读详情提示以及模态队列 |
172
172
  | [`src/transcript.ts`](src/transcript.ts) | 呈现视图、用量与轮次结束原因的纯文本折叠 |
173
173
  | [`src/diff.ts`](src/diff.ts) | diff 卡片的行 diff 与 hunk 选择 |
@@ -175,8 +175,8 @@ runner 等待完整应用就绪(`ctx.get('loader')?.await()`),并在核心
175
175
  | [`src/completion.ts`](src/completion.ts) | 编辑器的斜杠命令与 `@` 引用补全 |
176
176
  | [`src/editor.ts`](src/editor.ts) | 去掉 pi-tui 自绘块状光标的提示编辑器,以及终端自身光标所用的 DECSCUSR 序列 |
177
177
  | [`src/status.ts`](src/status.ts) | 投影接缝的事实,以及 `/status` 报告与分段详情共享的小节;压缩与重试通知 |
178
- | [`src/footer.ts`](src/footer.ts) | 状态栏:有序的各分段、每个分段的详情行,以及页脚渲染出的两行 |
179
- | [`src/subagent-panel.ts`](src/subagent-panel.ts) | 实时子 agent 面板:一次后代列表加上采样到的实时事实构成其各行,各行再构成其文本 |
178
+ | [`src/footer.ts`](src/footer.ts) | 状态栏:有序的各分段、每个分段的详情行、未聚焦时的一行关键事实,以及聚焦时的滑动窗口 |
179
+ | [`src/subagent-panel.ts`](src/subagent-panel.ts) | 实时子 agent 面板:一次后代列表加上采样到的实时事实构成其各行、未聚焦时的摘要行,以及聚焦时的列表 |
180
180
  | [`src/catalog.ts`](src/catalog.ts) | `/settings`、`/plugins`、`/subagents`、`/deliverables`、`/changes` 与 `/outline` 的行,以及 `/plugins` 的管理动作 |
181
181
  | [`src/todos.ts`](src/todos.ts) | todo 列表:状态符号、选择器行与单个条目的详情行 |
182
182
  | [`cordis.patch.yml`](cordis.patch.yml) | 基于 `dsh-base` 的终端 patch |
@@ -1014,6 +1014,10 @@ const LUMINANCE = {
1014
1014
  };
1015
1015
  /** Largest value one color channel encodes. */
1016
1016
  const CHANNEL_MAX = 255;
1017
+ /** Channel scale SGR faint applies when a float-out reads a dim run. */
1018
+ const DIM_CHANNEL = .5;
1019
+ /** How far a float-out lifts a color that is already brighter than `fg` toward white. */
1020
+ const LIFT_TOWARD_WHITE = .5;
1017
1021
  /** Encoded sRGB value below which the transfer function is the linear segment. */
1018
1022
  const SRGB_LINEAR_CUT = .04045;
1019
1023
  /** Linear intensity below which the sRGB transfer function is the linear segment. */
@@ -1093,6 +1097,34 @@ function mix(from, to, ratio) {
1093
1097
  return toSrgb(linear + (toLinear(to) - linear) * ratio);
1094
1098
  }
1095
1099
  /**
1100
+ * Mix `from` toward `to` with the same smoothstep-in-linear-light interpolation
1101
+ * {@link buildFadeRamp} uses. Positions at or below 0 and at or above 1 return
1102
+ * that endpoint's bytes, so a float-out's last computed mix can match the
1103
+ * settled color it recedes toward.
1104
+ * @param from - the color at t = 0.
1105
+ * @param to - the color at t = 1.
1106
+ * @param t - position along the mix; values outside 0..1 clamp to an endpoint.
1107
+ * @returns the mixed sRGB color.
1108
+ */
1109
+ function mixFadeColor(from, to, t) {
1110
+ if (t <= 0) return {
1111
+ r: from.r,
1112
+ g: from.g,
1113
+ b: from.b
1114
+ };
1115
+ if (t >= 1) return {
1116
+ r: to.r,
1117
+ g: to.g,
1118
+ b: to.b
1119
+ };
1120
+ const eased = t * t * (3 - 2 * t);
1121
+ return {
1122
+ r: mix(from.r, to.r, eased),
1123
+ g: mix(from.g, to.g, eased),
1124
+ b: mix(from.b, to.b, eased)
1125
+ };
1126
+ }
1127
+ /**
1096
1128
  * Decode one sRGB channel to linear light, by the sRGB transfer function.
1097
1129
  * @param value - the channel byte.
1098
1130
  * @returns the linear intensity in 0..1.
@@ -1121,7 +1153,7 @@ function toSrgb(value) {
1121
1153
  function fadeSgr(style, age) {
1122
1154
  if (style.capability === "none") return "";
1123
1155
  if (style.capability === "dim") return age < DIM_AGES ? DIM : "";
1124
- const level = style.ramp[Math.max(0, Math.min(age, style.ramp.length - 1))];
1156
+ const level = style.ramp[Math.max(0, Math.min(Math.floor(age), style.ramp.length - 1))];
1125
1157
  if (level === void 0) return "";
1126
1158
  return style.capability === "truecolor" ? truecolorSgr(level) : grayscaleSgr(level);
1127
1159
  }
@@ -1163,31 +1195,340 @@ function restoreFor(capability) {
1163
1195
  /** Every SGR sequence, the sequences a block's own palette styling writes. */
1164
1196
  const SGR_SEQUENCE = /\u001b\[[0-9;]*m/g;
1165
1197
  /**
1166
- * Draw whole rendered lines at one brightness level, for a block that fades in
1167
- * as a unit rather than word by word.
1198
+ * Draw whole rendered lines as a float-out: each SGR run starts at a lifted
1199
+ * mix toward the assumed foreground and recedes to the run's own settled
1200
+ * color. At `age >= ramp.length` the lines come back byte-identical, so the
1201
+ * palette sequences win without a snap. The two-level mode overlays faint for
1202
+ * the whole flight; the caller stops invoking this once the clock settles.
1168
1203
  *
1169
- * The level is opened at the start of the line and reasserted after every SGR
1170
- * the line already carries, so the palette colors inside a card - the status
1171
- * glyph, the dim body rule, the bold tool name - are overridden while the card
1172
- * fades and come back on their own once it settles. Each line ends with the
1173
- * sequence that undoes what the level set - the terminal's own foreground, or
1174
- * its normal intensity in the two-level mode - so the level never leaks past
1175
- * the line it was applied to. Empty lines come back byte-identical, so
1176
- * the renderer leaves the blank rows around a card alone.
1204
+ * The mix is opened after every SGR the line already carries, so the status
1205
+ * glyph, the dim body rule, and the bold tool name each recede toward their
1206
+ * own color. Each line ends with the sequence that undoes the overlay, so the
1207
+ * mix never leaks past the line it was applied to. Empty lines come back
1208
+ * byte-identical, so the renderer leaves the blank rows around a card alone.
1177
1209
  * @param lines - the rendered lines of the block.
1178
- * @param age - the brightness level to draw them at.
1179
- * @param style - the capability and the ramp.
1180
- * @param from - first line the level is applied to; the lines before it come
1210
+ * @param age - elapsed time in units of `stepMs`; `ramp.length` is t = 1.
1211
+ * @param style - the capability and the ramp whose last level is the assumed foreground.
1212
+ * @param from - first line the mix is applied to; the lines before it come
1181
1213
  * back byte-identical, which is how a caller keeps rows the renderer can no
1182
1214
  * longer repaint out of the fade.
1183
- * @returns the lines at that level; copies when the style writes no sequence
1184
- * for this age, which is what the `none` capability and a settled age yield.
1215
+ * @returns the lines at that mix; copies when the style writes no sequence,
1216
+ * which is what the `none` capability and a settled age yield.
1185
1217
  */
1186
1218
  function recolorLines(lines, age, style, from = 0) {
1187
- const sgr = fadeSgr(style, age);
1188
- if (sgr === "") return [...lines];
1189
- const restore = restoreFor(style.capability);
1190
- return lines.map((line, index) => index < from || line === "" ? line : `${sgr}${line.replace(SGR_SEQUENCE, (match) => match + sgr)}${restore}`);
1219
+ const capability = style.capability;
1220
+ if (capability === "none") return [...lines];
1221
+ if (capability === "dim") return lines.map((line, index) => index < from || line === "" ? line : `${DIM}${line}${RESET_INTENSITY}`);
1222
+ const steps = style.ramp.length;
1223
+ const fg = style.ramp[steps - 1];
1224
+ if (fg === void 0 || age >= steps) return [...lines];
1225
+ const t = age / steps;
1226
+ return lines.map((line, index) => index < from || line === "" ? line : floatOutLine(line, t, capability, fg));
1227
+ }
1228
+ /** Assumed terminal white, used only to lift a settled color that is already brighter than `fg`. */
1229
+ const WHITE_RGB = {
1230
+ r: CHANNEL_MAX,
1231
+ g: CHANNEL_MAX,
1232
+ b: CHANNEL_MAX
1233
+ };
1234
+ /** xterm 16-color palette, indices 0..15. */
1235
+ const ANSI16 = [
1236
+ {
1237
+ r: 0,
1238
+ g: 0,
1239
+ b: 0
1240
+ },
1241
+ {
1242
+ r: 205,
1243
+ g: 0,
1244
+ b: 0
1245
+ },
1246
+ {
1247
+ r: 0,
1248
+ g: 205,
1249
+ b: 0
1250
+ },
1251
+ {
1252
+ r: 205,
1253
+ g: 205,
1254
+ b: 0
1255
+ },
1256
+ {
1257
+ r: 0,
1258
+ g: 0,
1259
+ b: 238
1260
+ },
1261
+ {
1262
+ r: 205,
1263
+ g: 0,
1264
+ b: 205
1265
+ },
1266
+ {
1267
+ r: 0,
1268
+ g: 205,
1269
+ b: 205
1270
+ },
1271
+ {
1272
+ r: 229,
1273
+ g: 229,
1274
+ b: 229
1275
+ },
1276
+ {
1277
+ r: 127,
1278
+ g: 127,
1279
+ b: 127
1280
+ },
1281
+ {
1282
+ r: 255,
1283
+ g: 0,
1284
+ b: 0
1285
+ },
1286
+ {
1287
+ r: 0,
1288
+ g: 255,
1289
+ b: 0
1290
+ },
1291
+ {
1292
+ r: 255,
1293
+ g: 255,
1294
+ b: 0
1295
+ },
1296
+ {
1297
+ r: 92,
1298
+ g: 92,
1299
+ b: 255
1300
+ },
1301
+ {
1302
+ r: 255,
1303
+ g: 0,
1304
+ b: 255
1305
+ },
1306
+ {
1307
+ r: 0,
1308
+ g: 255,
1309
+ b: 255
1310
+ },
1311
+ {
1312
+ r: 255,
1313
+ g: 255,
1314
+ b: 255
1315
+ }
1316
+ ];
1317
+ /**
1318
+ * Relative luminance of one sRGB color, using the same weights as the grayscale encoder.
1319
+ * @param color - the color.
1320
+ * @returns the weighted sum of the channels.
1321
+ */
1322
+ function colorLuminance(color) {
1323
+ return LUMINANCE.r * color.r + LUMINANCE.g * color.g + LUMINANCE.b * color.b;
1324
+ }
1325
+ /**
1326
+ * The color a float-out starts from: the assumed foreground when that is
1327
+ * brighter than the settle, otherwise a mix toward white.
1328
+ * @param settled - the run's color at t = 1.
1329
+ * @param fg - the assumed terminal foreground.
1330
+ * @returns the lifted color at t = 0.
1331
+ */
1332
+ function liftColor(settled, fg) {
1333
+ return colorLuminance(fg) >= colorLuminance(settled) ? fg : mixFadeColor(settled, WHITE_RGB, LIFT_TOWARD_WHITE);
1334
+ }
1335
+ /**
1336
+ * Apply SGR faint as a channel scale, matching what a dim palette run looks like.
1337
+ * @param color - the undimmed RGB.
1338
+ * @returns the dimmed RGB.
1339
+ */
1340
+ function dimColor(color) {
1341
+ return {
1342
+ r: Math.round(color.r * DIM_CHANNEL),
1343
+ g: Math.round(color.g * DIM_CHANNEL),
1344
+ b: Math.round(color.b * DIM_CHANNEL)
1345
+ };
1346
+ }
1347
+ /**
1348
+ * The RGB a run settles at, given the SGR in force and the assumed default foreground.
1349
+ * @param state - the SGR foreground and dim flag.
1350
+ * @param fg - the assumed default foreground.
1351
+ * @returns the settled color the mix recedes toward.
1352
+ */
1353
+ function settledRgb(state, fg) {
1354
+ const base = state.color ?? fg;
1355
+ return state.dim ? dimColor(base) : base;
1356
+ }
1357
+ /**
1358
+ * Encode one mixed color under a color capability.
1359
+ * @param capability - `truecolor` or `ansi256`.
1360
+ * @param color - the mixed RGB.
1361
+ * @returns the SGR sequence, or empty when the capability cannot encode a mix.
1362
+ */
1363
+ function encodeMix(capability, color) {
1364
+ return capability === "truecolor" ? truecolorSgr(color) : `${CSI}38;5;${String(nearestAnsi256(color))}m`;
1365
+ }
1366
+ /**
1367
+ * Rebuild one card row as a per-run float-out mix.
1368
+ * @param line - the rendered line.
1369
+ * @param t - 0 at lift, 1 at settle.
1370
+ * @param capability - how the mix is encoded.
1371
+ * @param fg - the assumed default foreground.
1372
+ * @returns the line with each run overlaid, closed by {@link RESET_FOREGROUND}.
1373
+ */
1374
+ function floatOutLine(line, t, capability, fg) {
1375
+ let out = "";
1376
+ let last = 0;
1377
+ const state = {
1378
+ color: void 0,
1379
+ dim: false
1380
+ };
1381
+ for (const match of line.matchAll(SGR_SEQUENCE)) {
1382
+ const start = match.index;
1383
+ if (start > last) out += paintSegment(line.slice(last, start), state, t, capability, fg);
1384
+ applySgr(state, match[0]);
1385
+ out += match[0];
1386
+ last = start + match[0].length;
1387
+ }
1388
+ if (last < line.length) out += paintSegment(line.slice(last), state, t, capability, fg);
1389
+ return `${out}${RESET_FOREGROUND}`;
1390
+ }
1391
+ /**
1392
+ * Overlay one text run with the float-out mix for the SGR in force.
1393
+ * @param text - the run's characters, with no SGR.
1394
+ * @param state - the SGR in force.
1395
+ * @param t - 0 at lift, 1 at settle.
1396
+ * @param capability - how the mix is encoded.
1397
+ * @param fg - the assumed default foreground.
1398
+ * @returns the run prefixed by intensity-reset and the mixed color.
1399
+ */
1400
+ function paintSegment(text, state, t, capability, fg) {
1401
+ const settled = settledRgb(state, fg);
1402
+ return `${RESET_INTENSITY}${encodeMix(capability, mixFadeColor(liftColor(settled, fg), settled, t))}${text}`;
1403
+ }
1404
+ /**
1405
+ * The float-out sequence one reasoning span draws under, toward dimmed `fg`.
1406
+ * @param style - the capability and the ramp.
1407
+ * @param age - elapsed time in units of `stepMs`.
1408
+ * @returns the sequence, or empty once `age` reaches the ramp length.
1409
+ */
1410
+ function floatOutSpanSgr(style, age) {
1411
+ if (style.capability === "dim") return DIM;
1412
+ if (style.capability !== "truecolor" && style.capability !== "ansi256") return "";
1413
+ const steps = style.ramp.length;
1414
+ const fg = style.ramp[steps - 1];
1415
+ if (fg === void 0 || age >= steps) return "";
1416
+ const settled = dimColor(fg);
1417
+ const mixed = mixFadeColor(liftColor(settled, fg), settled, age / steps);
1418
+ return `${RESET_INTENSITY}${encodeMix(style.capability, mixed)}`;
1419
+ }
1420
+ /**
1421
+ * Apply one SGR sequence to the float-out foreground state.
1422
+ * @param state - the state to update.
1423
+ * @param sequence - a CSI SGR including the trailing `m`.
1424
+ */
1425
+ function applySgr(state, sequence) {
1426
+ const body = sequence.slice(2, -1);
1427
+ const parts = body === "" ? [0] : body.split(";").map((part) => part === "" ? 0 : Number(part));
1428
+ for (let index = 0; index < parts.length; index += 1) {
1429
+ const code = colorParam(parts, index);
1430
+ if (code === 0) {
1431
+ state.color = void 0;
1432
+ state.dim = false;
1433
+ } else if (code === 2) state.dim = true;
1434
+ else if (code === 22) state.dim = false;
1435
+ else if (code === 39) state.color = void 0;
1436
+ else if (code >= 30 && code <= 37) state.color = ANSI16[code - 30];
1437
+ else if (code >= 90 && code <= 97) state.color = ANSI16[code - 90 + 8];
1438
+ else if (code === 38) {
1439
+ const read = readExtendedColor(parts, index);
1440
+ state.color = read.color;
1441
+ index += read.skip;
1442
+ } else if (code === 48) index += readExtendedColor(parts, index).skip;
1443
+ }
1444
+ }
1445
+ /**
1446
+ * Consume an ITU T.416 extended color (`38`/`48` plus `2;R;G;B` or `5;N`).
1447
+ * @param parts - the SGR parameter list.
1448
+ * @param index - index of the `38` or `48` code.
1449
+ * @returns the RGB for a foreground read, and how many following parameters were consumed.
1450
+ */
1451
+ function readExtendedColor(parts, index) {
1452
+ const mode = parts[index + 1];
1453
+ if (mode === 2) return {
1454
+ color: {
1455
+ r: channel(colorParam(parts, index + 2)),
1456
+ g: channel(colorParam(parts, index + 3)),
1457
+ b: channel(colorParam(parts, index + 4))
1458
+ },
1459
+ skip: 4
1460
+ };
1461
+ if (mode === 5) return {
1462
+ color: ansi256Rgb(colorParam(parts, index + 2)),
1463
+ skip: 2
1464
+ };
1465
+ return {
1466
+ color: void 0,
1467
+ skip: mode === void 0 ? 0 : 1
1468
+ };
1469
+ }
1470
+ /**
1471
+ * Read one SGR numeric parameter, defaulting a missing slot to 0.
1472
+ * @param parts - the SGR parameter list.
1473
+ * @param index - the slot to read.
1474
+ * @returns the parameter, or 0 when it is absent.
1475
+ */
1476
+ function colorParam(parts, index) {
1477
+ return parts[index] ?? 0;
1478
+ }
1479
+ /**
1480
+ * RGB for one xterm 256-color index.
1481
+ * @param index - 0..255.
1482
+ * @returns the palette color, clamped onto the table.
1483
+ */
1484
+ function ansi256Rgb(index) {
1485
+ const n = Math.max(0, Math.min(255, Math.floor(index)));
1486
+ if (n < 16) {
1487
+ const color = ANSI16[n];
1488
+ /* v8 ignore next -- ANSI16 has 16 entries and n is already clamped to 0..15 */
1489
+ if (color === void 0) return {
1490
+ r: 0,
1491
+ g: 0,
1492
+ b: 0
1493
+ };
1494
+ return color;
1495
+ }
1496
+ if (n >= GRAY_FIRST_INDEX) {
1497
+ const value = GRAY_FIRST_VALUE + (n - GRAY_FIRST_INDEX) * GRAY_VALUE_STEP;
1498
+ return {
1499
+ r: value,
1500
+ g: value,
1501
+ b: value
1502
+ };
1503
+ }
1504
+ const cube = n - 16;
1505
+ const r = Math.floor(cube / 36);
1506
+ const g = Math.floor(cube % 36 / 6);
1507
+ const b = cube % 6;
1508
+ const level = (step) => step === 0 ? 0 : 55 + step * 40;
1509
+ return {
1510
+ r: level(r),
1511
+ g: level(g),
1512
+ b: level(b)
1513
+ };
1514
+ }
1515
+ /**
1516
+ * Nearest xterm 256-color index to one RGB, by squared channel distance.
1517
+ * @param color - the mixed RGB.
1518
+ * @returns an index in 0..255.
1519
+ */
1520
+ function nearestAnsi256(color) {
1521
+ let best = 0;
1522
+ let bestDist = Infinity;
1523
+ for (let n = 0; n < 256; n += 1) {
1524
+ const candidate = ansi256Rgb(n);
1525
+ const dist = (candidate.r - color.r) ** 2 + (candidate.g - color.g) ** 2 + (candidate.b - color.b) ** 2;
1526
+ if (dist < bestDist) {
1527
+ bestDist = dist;
1528
+ best = n;
1529
+ }
1530
+ }
1531
+ return best;
1191
1532
  }
1192
1533
  /**
1193
1534
  * The tail of one streaming region - the visible text or the reasoning of one
@@ -1202,8 +1543,8 @@ function recolorLines(lines, age, style, from = 0) {
1202
1543
  * Ages are read, never counted. A chunk's age is the elapsed time since it
1203
1544
  * became visible divided by `stepMs`, computed at the moment a render asks for
1204
1545
  * it, so a render triggered by a delta between two fade periods draws every
1205
- * word at its own level and words do not move in lockstep. That is what makes
1206
- * the trailing edge continuous rather than banded.
1546
+ * word at its own mix and words do not move in lockstep. Fade-in floors that
1547
+ * age onto a ramp slot; float-out uses the fractional value.
1207
1548
  *
1208
1549
  * Arrival rate changes the tail's length, never its depth: whatever the rate,
1209
1550
  * a chunk is at the foreground `steps * stepMs` after it appeared, so a fast
@@ -1252,7 +1593,7 @@ var FadeTracker = class {
1252
1593
  * Drop the chunks that reached the step count, which the application runs
1253
1594
  * once per fade period so a settled chunk stops being matched against the
1254
1595
  * rendered lines.
1255
- * @returns whether a chunk still draws below the last brightness level, and
1596
+ * @returns whether a chunk is still younger than `steps * stepMs`, and
1256
1597
  * so whether the tail keeps moving after this period.
1257
1598
  */
1258
1599
  tick() {
@@ -1263,11 +1604,10 @@ var FadeTracker = class {
1263
1604
  }
1264
1605
  /**
1265
1606
  * Whether the current frame still differs from the settled rendering.
1266
- * @returns true while a tracked chunk draws below the last ramp level, which
1267
- * is the only reason to keep ticking.
1607
+ * @returns true while a tracked chunk is younger than `steps * stepMs`.
1268
1608
  */
1269
1609
  needsRepaint() {
1270
- return this.chunks.some((chunk) => this.ageOf(chunk) < this.steps - 1);
1610
+ return this.chunks.some((chunk) => this.ageOf(chunk) < this.steps);
1271
1611
  }
1272
1612
  /**
1273
1613
  * The tail {@link recolorTail} recolors.
@@ -1290,21 +1630,22 @@ var FadeTracker = class {
1290
1630
  this.openChunk = void 0;
1291
1631
  }
1292
1632
  /**
1293
- * The brightness level one chunk draws at right now.
1633
+ * The brightness age one chunk draws at right now.
1294
1634
  * @param chunk - the tracked chunk.
1295
- * @returns levels elapsed since it became visible, 0 for a chunk younger than one level.
1635
+ * @returns elapsed time since it became visible, in units of `stepMs`.
1296
1636
  */
1297
1637
  ageOf(chunk) {
1298
- return Math.floor((this.now() - chunk.bornAt) / this.stepMs);
1638
+ return (this.now() - chunk.bornAt) / this.stepMs;
1299
1639
  }
1300
1640
  };
1301
1641
  /**
1302
- * The age of one whole block that fades in as a unit - a tool card's rows,
1642
+ * The age of one whole block that floats out as a unit - a tool card's rows,
1303
1643
  * which arrive complete rather than word by word.
1304
1644
  *
1305
- * The last level is withheld the way the word tail withholds it: that level is
1306
- * an assumed foreground, so a block one level below the end is handed back to
1307
- * the terminal's own colors instead, and nothing jumps color as it settles.
1645
+ * Progress is `elapsed / (steps * stepMs)`, eased by the same smoothstep the
1646
+ * ramp uses. {@link BlockFadeClock.age} stays defined until that progress
1647
+ * reaches 1, so the last overlay frame can sit near the settled colors and
1648
+ * the next frame is the component's own bytes.
1308
1649
  */
1309
1650
  var BlockFadeClock = class {
1310
1651
  options;
@@ -1312,18 +1653,25 @@ var BlockFadeClock = class {
1312
1653
  this.options = options;
1313
1654
  }
1314
1655
  /**
1315
- * The brightness level the block draws at right now.
1316
- * @returns the level, or undefined once the block reached the last drawn
1317
- * level and renders in the colors the component itself produced.
1656
+ * How far through the float-out this block is.
1657
+ * @returns 0 at birth, 1 at or after `steps * stepMs`.
1318
1658
  */
1319
- age() {
1659
+ progress() {
1320
1660
  const { bornAt, stepMs, steps, now } = this.options;
1321
- const age = Math.floor((now() - bornAt) / stepMs);
1322
- return age < steps - 1 ? age : void 0;
1661
+ return Math.min(1, Math.max(0, (now() - bornAt) / (steps * stepMs)));
1662
+ }
1663
+ /**
1664
+ * The fractional age the block draws at right now, in units of `stepMs`.
1665
+ * @returns `progress * steps`, or undefined once progress has reached 1 and
1666
+ * the block renders in the colors the component itself produced.
1667
+ */
1668
+ age() {
1669
+ const t = this.progress();
1670
+ return t >= 1 ? void 0 : t * this.options.steps;
1323
1671
  }
1324
1672
  /**
1325
- * Whether this block still draws below the last brightness level.
1326
- * @returns true while {@link BlockFadeClock.age} yields a level.
1673
+ * Whether this block still draws below the settled colors.
1674
+ * @returns true while {@link BlockFadeClock.age} yields a value.
1327
1675
  */
1328
1676
  needsRepaint() {
1329
1677
  return this.age() !== void 0;
@@ -1376,27 +1724,32 @@ var FadeRegistry = class {
1376
1724
  *
1377
1725
  * Styling inside a recolored run survives. The run reasserts the sequences in
1378
1726
  * force at its start, so an enclosing bold or italic continues across it; an
1379
- * enclosing foreground color reasserted there wins over the ramp, and that run
1380
- * simply does not fade. Each recolored line ends with `ESC[39m` (or `ESC[22m`
1381
- * in the two-level mode, which also ends bold) after the line's own closing
1382
- * sequences, so the recolor never leaks past the line it was applied to.
1727
+ * enclosing foreground color reasserted there wins over the fade-in ramp, and
1728
+ * that run simply does not fade. Each recolored line ends with `ESC[39m` (or
1729
+ * `ESC[22m` in the two-level mode, which also ends bold) after the line's own
1730
+ * closing sequences, so the recolor never leaks past the line it was applied
1731
+ * to. Float-out resets intensity before the mixed color so a dim wrapper does
1732
+ * not stack on the overlay, and at t >= 1 the original lines come back
1733
+ * unchanged.
1383
1734
  * @param lines - the rendered lines of the streaming block, newest text last.
1384
1735
  * @param spans - the tail from {@link FadeTracker.spans}, oldest first.
1385
1736
  * @param style - the capability and the ramp.
1386
1737
  * @param from - first line the tail may recolor; the lines before it come back
1387
1738
  * byte-identical, which is how a caller keeps rows the renderer can no longer
1388
1739
  * repaint out of the fade.
1740
+ * @param mode - `in` climbs {@link buildFadeRamp}; `out` recedes toward the
1741
+ * dim foreground reasoning settles in. Defaults to `in`.
1389
1742
  * @returns the lines with the tail recolored; lines the tail does not cover
1390
1743
  * are returned byte-identical, so the renderer leaves them alone.
1391
1744
  */
1392
- function recolorTail(lines, spans, style, from = 0) {
1393
- if (style.capability === "none" || spans.length === 0 || lines.length === 0) return [...lines];
1745
+ function recolorTail(lines, spans, style, from = 0, mode = "in") {
1746
+ if (spans.length === 0 || lines.length === 0) return [...lines];
1394
1747
  const cells = cellsFromEnd(lines);
1395
1748
  const runs = [];
1396
1749
  for (const span of [...spans].reverse()) {
1397
1750
  const covered = consumeSpan(cells, span.text);
1398
1751
  if (covered === void 0) break;
1399
- const sgr = fadeSgr(style, span.age);
1752
+ const sgr = mode === "out" ? floatOutSpanSgr(style, span.age) : fadeSgr(style, span.age);
1400
1753
  if (sgr !== "") collectRuns(runs, covered, sgr);
1401
1754
  }
1402
1755
  const byLine = /* @__PURE__ */ new Map();
@@ -1700,8 +2053,9 @@ var NoticeBlock = class {
1700
2053
  * durable `assistant/message` replaces both with the committed content.
1701
2054
  *
1702
2055
  * A block that is streaming right now can carry a {@link FadeRender} for its
1703
- * text and another for its reasoning, each drawing that region's newest words
1704
- * dimmed and brightening. A block rebuilt from history carries neither, and
2056
+ * text and another for its reasoning. Visible text draws newest words dimmed
2057
+ * and brightening; reasoning floats out from a lifted color toward the dim
2058
+ * italic it settles in. A block rebuilt from history carries neither, and
1705
2059
  * {@link AssistantBlock.commit} drops the ones a streaming block had, so
1706
2060
  * settled text is never recolored.
1707
2061
  */
@@ -1783,7 +2137,7 @@ var AssistantBlock = class {
1783
2137
  setRepaintFloor(floor) {
1784
2138
  if (floor <= this.repaintFloor) return false;
1785
2139
  this.repaintFloor = floor;
1786
- return drawnSpans(this.fade).length > 0 || drawnSpans(this.reasoningFade).length > 0;
2140
+ return this.fade !== void 0 && fadingInSpans(this.fade).length > 0 || this.reasoningFade !== void 0 && floatingOutSpans(this.reasoningFade).length > 0;
1787
2141
  }
1788
2142
  /**
1789
2143
  * Append streamed visible text.
@@ -1849,13 +2203,11 @@ var AssistantBlock = class {
1849
2203
  return withGutter(this.theme.palette, lines, (index) => index >= section.from && index < section.to);
1850
2204
  }
1851
2205
  /**
1852
- * The reasoning lines, with the streaming tail recolored.
2206
+ * The reasoning lines, with the streaming tail floated out toward dim italic.
1853
2207
  *
1854
- * The ramp climbs towards the assumed terminal foreground, but the reasoning
1855
- * draws inside the faint and italic sequences `appendReasoning` wrapped it
1856
- * in, and a recolored run reasserts the styling in force at its start. The
1857
- * tail therefore arrives at the dim foreground the settled reasoning carries,
1858
- * not at the plain one.
2208
+ * The mix starts at a lifted color and recedes to the faint foreground the
2209
+ * settled reasoning already carries. At full age the original dim italic
2210
+ * bytes come back unchanged.
1859
2211
  * @param width - the width the reasoning lays out in.
1860
2212
  * @param at - index of the region's first line in this block's render.
1861
2213
  * @returns the lines to draw.
@@ -1866,7 +2218,7 @@ var AssistantBlock = class {
1866
2218
  if (fade === void 0) return lines;
1867
2219
  if (this.reasoningFadeWidth !== void 0 && this.reasoningFadeWidth !== width) fade.flush();
1868
2220
  this.reasoningFadeWidth = width;
1869
- return recolorTail(lines, drawnSpans(fade), fade.style(), this.repaintFloor - at);
2221
+ return recolorTail(lines, floatingOutSpans(fade), fade.style(), this.repaintFloor - at, "out");
1870
2222
  }
1871
2223
  /**
1872
2224
  * The Markdown lines, with the streaming tail recolored.
@@ -1885,14 +2237,14 @@ var AssistantBlock = class {
1885
2237
  if (fade === void 0) return lines;
1886
2238
  if (this.fadeWidth !== void 0 && this.fadeWidth !== width) fade.flush();
1887
2239
  this.fadeWidth = width;
1888
- return recolorTail(lines, drawnSpans(fade), fade.style(), this.repaintFloor - at);
2240
+ return recolorTail(lines, fadingInSpans(fade), fade.style(), this.repaintFloor - at);
1889
2241
  }
1890
2242
  };
1891
2243
  /**
1892
2244
  * A tool call card: status glyph, tool name, headline, then a foldable body.
1893
2245
  *
1894
- * The card arrives in two pieces, and each fades in on its own: the header and
1895
- * the call rows when the call is logged, the result rows when the tool
2246
+ * The card arrives in two pieces, and each floats out on its own: the header
2247
+ * and the call rows when the call is logged, the result rows when the tool
1896
2248
  * answers. A card rebuilt from history carries neither fade.
1897
2249
  */
1898
2250
  var ToolBlock = class {
@@ -1961,7 +2313,7 @@ var ToolBlock = class {
1961
2313
  }
1962
2314
  /**
1963
2315
  * Hand the rows the renderer can no longer repaint back to the colors this
1964
- * card drew them in, so a fade that is still climbing never rewrites them.
2316
+ * card drew them in, so a fade that is still moving never rewrites them.
1965
2317
  * @param floor - this card's own first repaintable line; the application
1966
2318
  * raises it as the frame grows and never lowers it.
1967
2319
  * @returns whether the floor took rows away from a fade that is drawing
@@ -2022,11 +2374,11 @@ var ToolBlock = class {
2022
2374
  }
2023
2375
  };
2024
2376
  /**
2025
- * Draw one group of a card's rows at the level its fade reports.
2377
+ * Draw one group of a card's rows at the mix its fade reports.
2026
2378
  * @param lines - the group's final rendered lines.
2027
2379
  * @param fade - the group's fade; absent for a card that never faded.
2028
2380
  * @param from - first line of the group the fade may recolor.
2029
- * @returns the lines at that level, or the lines themselves once the fade
2381
+ * @returns the lines at that mix, or the lines themselves once the fade
2030
2382
  * settled or was never attached.
2031
2383
  */
2032
2384
  function faded(lines, fade, from) {
@@ -2035,18 +2387,30 @@ function faded(lines, fade, from) {
2035
2387
  return age === void 0 ? lines : recolorLines(lines, age, fade.style(), from);
2036
2388
  }
2037
2389
  /**
2038
- * The tail chunks one streaming region still draws below the terminal's own
2390
+ * The tail chunks one streaming reply still draws below the terminal's own
2039
2391
  * foreground.
2040
2392
  *
2041
2393
  * The last ramp level is an assumed foreground - pi-tui reports the terminal
2042
2394
  * background but not its foreground - so a chunk that reached it is left to
2043
2395
  * draw in the terminal's own foreground, which is also what it draws in once it
2044
2396
  * leaves the tail. No chunk can therefore jump color as it settles.
2045
- * @param fade - the region's fade; absent for a region that never faded.
2397
+ * @param fade - the region's fade.
2046
2398
  * @returns the spans to recolor, oldest first.
2047
2399
  */
2048
- function drawnSpans(fade) {
2049
- return fade === void 0 ? [] : fade.spans().filter((span) => span.age < fade.steps - 1);
2400
+ function fadingInSpans(fade) {
2401
+ return fade.spans().filter((span) => span.age < fade.steps - 1);
2402
+ }
2403
+ /**
2404
+ * The tail chunks one streaming reasoning region still floats out.
2405
+ *
2406
+ * Overlay continues until `age >= steps`, which is `steps * stepMs` after the
2407
+ * word appeared. The last overlay sits near the dim settle; the next frame is
2408
+ * the original dim italic bytes.
2409
+ * @param fade - the region's fade.
2410
+ * @returns the spans to recolor, oldest first.
2411
+ */
2412
+ function floatingOutSpans(fade) {
2413
+ return fade.spans().filter((span) => span.age < fade.steps);
2050
2414
  }
2051
2415
  //#endregion
2052
2416
  //#region lib/types/completion.js
@@ -2304,7 +2668,7 @@ const OFF_SCREEN = " · off screen";
2304
2668
  /** The keys the inspector answers, drawn dim under its rows. */
2305
2669
  const HINT = "↑ ↓ blocks · ← → parts · Enter page · Esc back";
2306
2670
  /** What ends a line the width cut short; one column, so the mark itself fits. */
2307
- const ELLIPSIS = "…";
2671
+ const ELLIPSIS$1 = "…";
2308
2672
  /**
2309
2673
  * Draw the focused section.
2310
2674
  * @param view - the section to show.
@@ -2321,7 +2685,7 @@ function renderInspector(view, render) {
2321
2685
  const wrapped = view.rows.flatMap((row) => wrapTextWithAnsi(row, width));
2322
2686
  lines.push(...foldRows(wrapped, render.previewLines, (hidden) => palette.dim(`… ${String(hidden)} more row${hidden === 1 ? "" : "s"} · Enter opens the page`)));
2323
2687
  lines.push(palette.dim(HINT));
2324
- return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
2688
+ return lines.map((line) => truncateToWidth(line, width, ELLIPSIS$1));
2325
2689
  }
2326
2690
  /**
2327
2691
  * The heading line. A tool title can run past any terminal, so the subject is
@@ -2336,7 +2700,7 @@ function heading(view, palette, width) {
2336
2700
  const subject = palette.bold(palette.accent(view.heading));
2337
2701
  if (view.highlighted) return subject;
2338
2702
  const suffix = palette.dim(OFF_SCREEN);
2339
- return `${truncateToWidth(subject, Math.max(1, width - visibleWidth(suffix)), ELLIPSIS)}${suffix}`;
2703
+ return `${truncateToWidth(subject, Math.max(1, width - visibleWidth(suffix)), ELLIPSIS$1)}${suffix}`;
2340
2704
  }
2341
2705
  /**
2342
2706
  * Draw the parts of the focused block as one row of choices.
@@ -2943,10 +3307,11 @@ function plural(count, noun) {
2943
3307
  * The status bar under the editor: plain session facts become an ordered list
2944
3308
  * of segments, each with a stable id, the short label the bar draws, and what
2945
3309
  * `Enter` on it does — the rows the app prints, or the app's own navigable
2946
- * page; a second function renders the segments as the footer's two lines, dim
2947
- * while the editor holds focus and with the selected segment accented while
2948
- * the bar does. Everything here is pure — no Context, no services, no
2949
- * terminal, and no clock: elapsed values arrive already formatted.
3310
+ * page; a second function renders those segments as one unfocused line of key
3311
+ * facts, or as two focused lines whose first is a sliding window that always
3312
+ * includes the selected segment. Everything here is pure — no Context, no
3313
+ * services, no terminal, and no clock: elapsed values arrive already formatted,
3314
+ * and the terminal width arrives as an input so every line fits it.
2950
3315
  * @module @deepseek-ai/dsh-tui-app/footer
2951
3316
  */
2952
3317
  /** The segment focus enters the bar on; `buildFooterSegments` always emits it first. */
@@ -2962,10 +3327,20 @@ const SEPARATOR$1 = " · ";
2962
3327
  const WORKSPACE_LABEL_WIDTH = 24;
2963
3328
  /** The last two segments of a path, with the separators that precede them. */
2964
3329
  const PATH_TAIL = /[/\\][^/\\]+[/\\][^/\\]+$/u;
2965
- /** The keys the focused bar answers, replacing the usual hints. */
3330
+ /** The keys the focused bar answers, drawn on the expansion line. */
2966
3331
  const FOCUS_HINTS$1 = `← → select${SEPARATOR$1}↑ ↓ regions${SEPARATOR$1}Enter details${SEPARATOR$1}Esc back`;
2967
- /** What the unfocused hints advertise as the two ways out of the editor. */
2968
- const ENTRY_HINT = `Shift+↑ transcript${SEPARATOR$1}Shift+↓ status bar`;
3332
+ /** The one entry key the unfocused line keeps, trailing the facts. */
3333
+ const ENTRY_HINT = "Shift+↓";
3334
+ /** What ends a line the width cut short; one column, so the mark itself fits. */
3335
+ const ELLIPSIS = "…";
3336
+ /** Facts the unfocused line keeps; every other present segment folds into `+N`. */
3337
+ const KEY_SEGMENT_IDS = new Set([
3338
+ "model",
3339
+ "effort",
3340
+ "turn",
3341
+ "context",
3342
+ "workspace"
3343
+ ]);
2969
3344
  /** The command that prints every projection section at once. */
2970
3345
  const STATUS_COMMAND_ROW = "/status prints all of these sections";
2971
3346
  /**
@@ -3016,10 +3391,24 @@ function startsWithDirectory(path, directory) {
3016
3391
  return next === "" || next === "/" || next === "\\";
3017
3392
  }
3018
3393
  /**
3394
+ * The effort segment: always present, labelled `effort default` when the
3395
+ * selection leaves reasoning effort to the model.
3396
+ * @param effort - the selection's reasoning effort; absent is the model's own default.
3397
+ * @returns the segment.
3398
+ */
3399
+ function effortSegment(effort) {
3400
+ const unset = effort === void 0;
3401
+ return {
3402
+ id: "effort",
3403
+ label: unset ? "effort default" : `effort ${effort}`,
3404
+ detail: printed([unset ? "reasoning effort: the model's own default" : `reasoning effort: ${effort}`, `Shift+Tab cycles it${SEPARATOR$1}/effort picks one`])
3405
+ };
3406
+ }
3407
+ /**
3019
3408
  * Build the status bar's segments in the order it draws them: model, effort,
3020
- * permission, turn, usage, the projection facts, workspace, attachments. Only
3021
- * the model and workspace segments are always present; every other segment
3022
- * needs its fact.
3409
+ * permission, turn, usage, the projection facts, workspace, attachments. The
3410
+ * model, effort, and workspace segments are always present; every other
3411
+ * segment needs its fact.
3023
3412
  * @param inputs - the facts the app read for the bound session.
3024
3413
  * @returns the segments, the model segment first.
3025
3414
  */
@@ -3035,12 +3424,7 @@ function buildFooterSegments(inputs) {
3035
3424
  effort === void 0 ? "reasoning effort: the model's own default" : `reasoning effort: ${effort}`,
3036
3425
  "/model picks the provider and model for the next request"
3037
3426
  ])
3038
- }];
3039
- if (effort !== void 0) segments.push({
3040
- id: "effort",
3041
- label: `effort ${effort}`,
3042
- detail: printed([`reasoning effort: ${effort}`, `Shift+Tab cycles it${SEPARATOR$1}/effort picks one`])
3043
- });
3427
+ }, effortSegment(effort)];
3044
3428
  if (inputs.permission !== void 0) {
3045
3429
  const projected = permissionLines(facts);
3046
3430
  segments.push({
@@ -3099,20 +3483,142 @@ function footerSelectionIndex(segments, selected) {
3099
3483
  return index === -1 ? 0 : index;
3100
3484
  }
3101
3485
  /**
3102
- * Render the footer's two lines: the segment labels, then the key hints.
3103
- * Unfocused the whole bar is dim and the hints advertise the entry key;
3104
- * focused the selected segment is accented and the hints name the navigation
3105
- * keys instead.
3486
+ * Inclusive span of `widths` that contains `selected` and fits `budget`.
3487
+ * @param widths - visible width of each part, in order.
3488
+ * @param selected - the index that must stay in the span.
3489
+ * @param budget - maximum visible width of the joined span.
3490
+ * @param sep - visible width of the separator between two parts.
3491
+ * @returns start and end indices, inclusive.
3492
+ */
3493
+ function windowSpan(widths, selected, budget, sep) {
3494
+ let start = selected;
3495
+ let end = selected;
3496
+ let used = widths[selected];
3497
+ if (used >= budget) return {
3498
+ start,
3499
+ end
3500
+ };
3501
+ while (end + 1 < widths.length) {
3502
+ const next = used + sep + widths[end + 1];
3503
+ if (next > budget) break;
3504
+ end += 1;
3505
+ used = next;
3506
+ }
3507
+ while (start > 0) {
3508
+ const next = used + sep + widths[start - 1];
3509
+ if (next > budget) break;
3510
+ start -= 1;
3511
+ used = next;
3512
+ }
3513
+ return {
3514
+ start,
3515
+ end
3516
+ };
3517
+ }
3518
+ /**
3519
+ * Cut `line` to `width` columns.
3520
+ * @param line - the already-styled line.
3521
+ * @param width - terminal columns the line must fit; at least 1.
3522
+ * @returns the line, ellipsized when it was wider.
3523
+ */
3524
+ function fitLine(line, width) {
3525
+ return truncateToWidth(line, Math.max(1, width), ELLIPSIS);
3526
+ }
3527
+ /**
3528
+ * The unfocused line: key facts, a `+N` token for the rest, and the entry key.
3529
+ * @param segments - the segments in bar order.
3530
+ * @param palette - the palette the line is dimmed with.
3531
+ * @param width - terminal columns the line must fit.
3532
+ * @returns one dim line.
3533
+ */
3534
+ function unfocusedLine(segments, palette, width) {
3535
+ const key = segments.filter((segment) => KEY_SEGMENT_IDS.has(segment.id)).map((segment) => segment.label);
3536
+ const folded = segments.length - key.length;
3537
+ if (folded > 0) key.push(`+${String(folded)}`);
3538
+ key.push(ENTRY_HINT);
3539
+ return fitLine(palette.dim(key.join(SEPARATOR$1)), width);
3540
+ }
3541
+ /**
3542
+ * The first detail row of a printed segment, or the label of a page segment.
3543
+ * @param segment - the selected segment; absent when the bar has none.
3544
+ * @returns the expansion body.
3545
+ */
3546
+ function expansionBody(segment) {
3547
+ if (segment === void 0) return "";
3548
+ if (segment.detail.kind === "page") return segment.label;
3549
+ return segment.detail.rows[0];
3550
+ }
3551
+ /**
3552
+ * The focused second line: the selected segment's summary plus the navigation keys.
3553
+ * @param segment - the selected segment.
3554
+ * @param palette - the palette the line is dimmed with.
3555
+ * @param width - terminal columns the line must fit.
3556
+ * @returns one dim line that keeps the hints when they fit.
3557
+ */
3558
+ function expansionLine(segment, palette, width) {
3559
+ const body = expansionBody(segment);
3560
+ const suffix = palette.dim(body === "" ? FOCUS_HINTS$1 : `${SEPARATOR$1}${FOCUS_HINTS$1}`);
3561
+ const prefix = palette.dim(body);
3562
+ const rest = width - visibleWidth(suffix);
3563
+ if (rest < 1) return fitLine(`${prefix}${suffix}`, width);
3564
+ return `${truncateToWidth(prefix, rest, ELLIPSIS)}${suffix}`;
3565
+ }
3566
+ /**
3567
+ * The focused bar: a sliding window of segment labels, then the expansion line.
3568
+ * @param segments - the segments in bar order.
3569
+ * @param selected - index of the held segment.
3570
+ * @param palette - the palette the labels and hints are styled with.
3571
+ * @param width - terminal columns each line must fit.
3572
+ * @returns two lines, the selected label always in the first.
3573
+ */
3574
+ function focusedLines(segments, selected, palette, width) {
3575
+ const held = Math.min(Math.max(0, selected), Math.max(0, segments.length - 1));
3576
+ const styled = segments.map((segment, index) => index === held ? palette.bold(palette.accent(segment.label)) : palette.dim(segment.label));
3577
+ const sep = palette.dim(SEPARATOR$1);
3578
+ const { start, end } = windowSpan(styled.map(visibleWidth), held, width, visibleWidth(sep));
3579
+ return [fitLine(styled.slice(start, end + 1).join(sep), width), expansionLine(segments[held], palette, width)];
3580
+ }
3581
+ /**
3582
+ * Render the footer. Unfocused it is one dim line of key facts with a trailing
3583
+ * `Shift+↓`; focused it is the navigable segments (windowed to `width` so the
3584
+ * selected one is never dropped) and an expansion of that segment plus the
3585
+ * navigation keys. Every returned line fits `width`.
3106
3586
  * @param segments - the segments in bar order.
3107
- * @param render - the palette, the selected index, and the unfocused hints.
3108
- * @returns the footer text, two lines separated by a newline.
3587
+ * @param render - the palette, optional selected index, and terminal width.
3588
+ * @returns the footer lines, one when unfocused and two when focused.
3109
3589
  */
3110
3590
  function renderFooter(segments, render) {
3111
- const { palette, selected, hints } = render;
3112
- const labels = segments.map((segment) => segment.label);
3113
- if (selected === void 0) return `${palette.dim(labels.join(SEPARATOR$1))}\n${palette.dim(`${hints}${SEPARATOR$1}${ENTRY_HINT}`)}`;
3114
- return `${labels.map((label, index) => index === selected ? palette.bold(palette.accent(label)) : palette.dim(label)).join(palette.dim(SEPARATOR$1))}\n${palette.dim(FOCUS_HINTS$1)}`;
3591
+ const width = Math.max(1, render.width);
3592
+ if (render.selected === void 0) return [unfocusedLine(segments, render.palette, width)];
3593
+ return focusedLines(segments, render.selected, render.palette, width);
3115
3594
  }
3595
+ /**
3596
+ * The status bar as a mounted component. The view is read once per render
3597
+ * rather than pushed in, so the terminal width of that frame windows and
3598
+ * truncates the lines.
3599
+ */
3600
+ var FooterBar = class {
3601
+ view;
3602
+ /**
3603
+ * @param view - reads the segments and selection the bar draws.
3604
+ */
3605
+ constructor(view) {
3606
+ this.view = view;
3607
+ }
3608
+ invalidate() {}
3609
+ /**
3610
+ * Draw the status bar at `width`.
3611
+ * @param width - the total width the bar lays out in.
3612
+ * @returns the footer lines, each no wider than `width`.
3613
+ */
3614
+ render(width) {
3615
+ const { segments, render } = this.view();
3616
+ return renderFooter(segments, {
3617
+ ...render,
3618
+ width
3619
+ });
3620
+ }
3621
+ };
3116
3622
  //#endregion
3117
3623
  //#region lib/types/prompts.js
3118
3624
  /**
@@ -3835,18 +4341,25 @@ function subagentPanelView(inputs) {
3835
4341
  };
3836
4342
  }
3837
4343
  /**
3838
- * Render the panel: a heading counting the listed children, one line per
3839
- * drawn row, the overflow count, and the last listing failure. The whole
3840
- * panel is dim while the keyboard is elsewhere; the selected row is accented
3841
- * while the panel holds it.
4344
+ * Render the panel. Unfocused it is one dim summary line — the listed count
4345
+ * and the first child's key label. Focused it is the heading with navigation
4346
+ * keys, one line per drawn row, the overflow count, and the last listing
4347
+ * failure. The selected row is accented while the panel holds the keyboard.
3842
4348
  * @param view - the rows one draw produced.
3843
4349
  * @param render - the palette, the selected row, and the listing failure.
3844
- * @returns the panel text, one line per row.
4350
+ * @returns the panel text, one line when unfocused and one line per row when focused.
3845
4351
  */
3846
4352
  function renderSubagentPanel(view, render) {
3847
4353
  const { palette, selected } = render;
3848
4354
  const total = view.rows.length + view.hidden;
3849
- const heading = `subagents${SEPARATOR}${String(total)} listed${selected === void 0 ? "" : `${SEPARATOR}${FOCUS_HINTS}`}`;
4355
+ if (selected === void 0) {
4356
+ const first = view.rows[0]?.text.trimStart().split(SEPARATOR)[0];
4357
+ const parts = [`subagents${SEPARATOR}${String(total)} listed`];
4358
+ if (first) parts.push(first);
4359
+ if (render.failure !== void 0) parts.push(`listing failed: ${render.failure}`);
4360
+ return palette.dim(parts.join(SEPARATOR));
4361
+ }
4362
+ const heading = `subagents${SEPARATOR}${String(total)} listed${SEPARATOR}${FOCUS_HINTS}`;
3850
4363
  const lines = [palette.dim(heading)];
3851
4364
  for (const [index, row] of view.rows.entries()) lines.push(index === selected ? palette.bold(palette.accent(row.text)) : palette.dim(row.text));
3852
4365
  if (view.hidden > 0) lines.push(palette.dim(`+${String(view.hidden)} more${SEPARATOR}/subagents lists them all`));
@@ -3863,8 +4376,8 @@ function renderSubagentPanel(view, render) {
3863
4376
  * the editor it keeps two docked regions the keyboard can take over — the
3864
4377
  * subagent panel and the status bar — and one repeating tick advances their
3865
4378
  * elapsed counters and re-reads a stale subagent listing. A second tick, at
3866
- * its own period, brightens the text of the message streaming right now and
3867
- * the tool cards that just landed, each only as far up the frame as the
4379
+ * its own period, brightens streamed reply text and floats out reasoning and
4380
+ * tool cards that just landed, each only as far up the frame as the
3868
4381
  * renderer repaints without discarding the terminal's scrollback
3869
4382
  * (`./screen.ts`).
3870
4383
  * @module @deepseek-ai/dsh-tui-app/app
@@ -4149,7 +4662,13 @@ var TuiApp = class {
4149
4662
  this.onSubmit(text);
4150
4663
  };
4151
4664
  this.panel = new Text("", 0, 0);
4152
- this.footer = new Text("", 0, 0);
4665
+ this.footer = new FooterBar(() => ({
4666
+ segments: this.segments,
4667
+ render: {
4668
+ palette: this.deps.palette,
4669
+ ...this.focus === "bar" ? { selected: footerSelectionIndex(this.segments, this.barSelection) } : {}
4670
+ }
4671
+ }));
4153
4672
  this.modals = new ModalQueue({
4154
4673
  tui: this.tui,
4155
4674
  slot: this.modalSlot,
@@ -4393,7 +4912,6 @@ var TuiApp = class {
4393
4912
  this.tui.requestRender();
4394
4913
  }
4395
4914
  refreshFooter() {
4396
- const palette = this.deps.palette;
4397
4915
  const permission = this.deps.ctx.get("permissionPresets")?.current(this.agent.session);
4398
4916
  const started = this.turnStartedAt;
4399
4917
  const inbox = this.agent.inbox;
@@ -4416,12 +4934,6 @@ var TuiApp = class {
4416
4934
  kind: attachment.block.type
4417
4935
  }))
4418
4936
  });
4419
- const hints = this.agent.status === "running" ? "Enter queues for the next turn · Ctrl+S steers this turn · Esc stops it · Ctrl+O tool output · Ctrl+C twice quits" : "Enter sends · Esc stops the turn · Ctrl+O tool output · Ctrl+C twice quits";
4420
- this.footer.setText(renderFooter(this.segments, {
4421
- palette,
4422
- ...this.focus === "bar" ? { selected: footerSelectionIndex(this.segments, this.barSelection) } : {},
4423
- hints
4424
- }));
4425
4937
  this.tui.requestRender();
4426
4938
  }
4427
4939
  /**
@@ -4886,7 +5398,8 @@ var TuiApp = class {
4886
5398
  }
4887
5399
  /**
4888
5400
  * Answer one key while the status bar holds focus. Every key is consumed
4889
- * here, so nothing typed at the bar reaches the editor.
5401
+ * here, so nothing typed at the bar reaches the editor. `Shift+Left` and
5402
+ * `Shift+Right` move between segments the same way as `Left` and `Right`.
4890
5403
  * @param data - the raw key bytes.
4891
5404
  * @returns the consume marker the input listener returns.
4892
5405
  */
@@ -4898,11 +5411,11 @@ var TuiApp = class {
4898
5411
  else this.focusTranscript();
4899
5412
  return { consume: true };
4900
5413
  }
4901
- if (matchesKey(data, "left") || matchesKey(data, "shift+tab")) {
5414
+ if (matchesKey(data, "left") || matchesKey(data, "shift+left") || matchesKey(data, "shift+tab")) {
4902
5415
  this.moveStatusBar(-1);
4903
5416
  return { consume: true };
4904
5417
  }
4905
- if (matchesKey(data, "right") || matchesKey(data, "tab")) {
5418
+ if (matchesKey(data, "right") || matchesKey(data, "shift+right") || matchesKey(data, "tab")) {
4906
5419
  this.moveStatusBar(1);
4907
5420
  return { consume: true };
4908
5421
  }
@@ -5297,7 +5810,7 @@ var TuiApp = class {
5297
5810
  "Esc stops the running turn · Ctrl+O expands or collapses tool output",
5298
5811
  "Shift+Tab cycles the current model's reasoning effort for the next request",
5299
5812
  "Shift+Up focuses the transcript, Shift+Down the subagent panel or the status bar",
5300
- "Then ↑ ↓ move between blocks, panel rows, and the bar; ← → move between a block's parts or the bar's segments",
5813
+ "Then ↑ ↓ move between blocks, panel rows, and the bar; ← → move between a block's parts or the bar's segments (Shift+← → also move the bar)",
5301
5814
  "Enter opens the focused section or segment, Esc returns to the input",
5302
5815
  "Ctrl+C clears the input (twice quits) · Ctrl+D on an empty input quits"
5303
5816
  ];
@@ -5762,9 +6275,9 @@ var TuiApp = class {
5762
6275
  this.updateFadeTicker();
5763
6276
  }
5764
6277
  /**
5765
- * Take one reasoning delta, which fades in on its own tail: the reasoning
5766
- * and the visible text of one message stream at different times and each
5767
- * brightens from the moment its own words appeared.
6278
+ * Take one reasoning delta, which floats out on its own tail: the reasoning
6279
+ * and the visible text of one message stream at different times, and each
6280
+ * ages from the moment its own words appeared.
5768
6281
  * @param delta - the streamed reasoning delta.
5769
6282
  */
5770
6283
  appendStreamedReasoning(delta) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@songtonyli/dsh-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "The dsh terminal agent: an interactive TUI over the DeepSeek Harness, with its whole Node runtime bundled in one install.",
5
5
  "license": "MIT",
6
6
  "type": "module",