Merge remote-tracking branch 'upstream/master' into fix/hook-matcher-validation
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: f5420b6f2f30837b030a0e832a438c34674a6f23
|
||||
README.zh.md: 7beeaadf380a742cbdb6447553f42692a97fad10
|
||||
README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d
|
||||
README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc
|
||||
|
||||
@@ -44,6 +44,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46
|
||||
README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
|
||||
README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92
|
||||
README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b
|
||||
|
||||
@@ -57,7 +57,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef
|
||||
|
||||
## UI presentation
|
||||
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
@@ -153,6 +153,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
|
||||
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual.
|
||||
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
|
||||
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.
|
||||
|
||||
@@ -57,7 +57,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
|
||||
|
||||
## UI 展示
|
||||
|
||||
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
|
||||
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
@@ -153,6 +153,6 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。
|
||||
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill,并且该行会从卡片正文中丢失,因为解析会把它当作自己消耗的标记;这是仅影响展示的已知残留问题。
|
||||
- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。
|
||||
- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。
|
||||
|
||||
@@ -296,7 +296,9 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
|
||||
if (isBackground || result.isError) {
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
}
|
||||
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
|
||||
// The exit marker becomes the card's exit pill, so it leaves the output body.
|
||||
const { body, ...exit } = parseExitStatus(raw)
|
||||
return { card: 'terminal', output: body, ...exit }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,10 +95,23 @@ export function renderProcessRead(
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the structured exit status from a rendered {@link renderResult}
|
||||
* string — the inverse of the status markers it appends. A killed marker
|
||||
* yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
|
||||
* means a clean exit 0.
|
||||
* The exit status recovered from a rendered result, with the output body that
|
||||
* status was split off from.
|
||||
*/
|
||||
export type ParsedExitStatus =
|
||||
& { body: string }
|
||||
& ({ exitCode: number } | { signal: string })
|
||||
|
||||
/**
|
||||
* Split a rendered {@link renderResult} string into its output body and the
|
||||
* structured exit status — the inverse of the status markers it appends. A
|
||||
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
|
||||
* absent both means a clean exit 0.
|
||||
*
|
||||
* The consumed marker is removed from `body` because a terminal presentation
|
||||
* shows the exit status as its own pill: leaving the marker in the output would
|
||||
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
|
||||
* pill shows, so they stay in the body.
|
||||
*
|
||||
* Replay only retains the rendered content text, not the original
|
||||
* `BashRunResult`, so terminal presentation must recover the exit pill here.
|
||||
@@ -106,12 +119,12 @@ export function renderProcessRead(
|
||||
* that merely ends with marker-like text from matching unless the final line
|
||||
* is indistinguishable from a real marker.
|
||||
* @param text - rendered model-facing bash result.
|
||||
* @returns the recovered terminal exit code or signal.
|
||||
* @returns the marker-free body plus the recovered terminal exit code or signal.
|
||||
*/
|
||||
export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
|
||||
export function parseExitStatus(text: string): ParsedExitStatus {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { signal: signal[1] }
|
||||
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
|
||||
return { exitCode: 0 }
|
||||
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { body: text, exitCode: 0 }
|
||||
}
|
||||
|
||||
@@ -911,22 +911,32 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'echo hi', description: 'echo' },
|
||||
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
|
||||
{ command: 'printf "hi\\n\\n"', description: 'echo' },
|
||||
// A clean run renders no exit marker at all, so the body is the raw bytes.
|
||||
{ content: [{ type: 'text', text: 'hi\n\n' }], isError: false },
|
||||
)
|
||||
// A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
|
||||
// needs; the bridge derives the fenced fallback. exitCode is parsed back from
|
||||
// the [exit code: N] marker.
|
||||
expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 })
|
||||
// needs; the bridge derives the fenced fallback.
|
||||
expect(present).toEqual({ card: 'terminal', output: 'hi\n\n', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
|
||||
const ctx = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
|
||||
expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 })
|
||||
expect(nonzero).toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
|
||||
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
|
||||
expect(killed).toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('bash presentResult: markers a pill CANNOT show (timeout, sandbox denial) stay in the terminal output', async () => {
|
||||
const ctx = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
const timedOut = ctx.tools.get('bash')!.presentResult!(
|
||||
args,
|
||||
{ content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
|
||||
)
|
||||
expect(timedOut).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
|
||||
})
|
||||
|
||||
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
|
||||
@@ -952,8 +962,11 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
const rendered = renderResult(c.result)
|
||||
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
|
||||
// Drop card + output; the remaining fields are the parsed exit.
|
||||
const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
|
||||
const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
|
||||
expect(exit).toEqual(c.expect)
|
||||
// Whatever the parse consumed is gone from the body, so a card with an exit
|
||||
// pill never shows the same status twice.
|
||||
expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -964,6 +977,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
|
||||
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
|
||||
// Unparsed marker-like text is real output, so it is NOT stripped from the body.
|
||||
// Same for a fake signal marker with no leading newline.
|
||||
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
|
||||
6
packages/client/README.i18n.yaml
Normal file
6
packages/client/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
|
||||
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8
|
||||
34
packages/client/README.md
Normal file
34
packages/client/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# client/ — web-GUI browser half
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
|
||||
|
||||
| Package | Role | ctx key / slot |
|
||||
|---|---|---|
|
||||
| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
|
||||
| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
|
||||
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
|
||||
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
|
||||
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
|
||||
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
|
||||
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
|
||||
| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
|
||||
| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
|
||||
| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
|
||||
| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
|
||||
| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
|
||||
| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
|
||||
| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
|
||||
| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
|
||||
| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
|
||||
| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
|
||||
|
||||
Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.
|
||||
34
packages/client/README.zh.md
Normal file
34
packages/client/README.zh.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# client/ — web GUI 浏览器半侧
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`。
|
||||
|
||||
| 包 | 角色 | ctx 键/slot |
|
||||
|---|---|---|
|
||||
| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
|
||||
| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
|
||||
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
|
||||
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
|
||||
| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` |
|
||||
| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
|
||||
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
|
||||
| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) |
|
||||
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) |
|
||||
| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
|
||||
| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` |
|
||||
| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) |
|
||||
| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) |
|
||||
| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) |
|
||||
| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) |
|
||||
|
||||
特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
|
||||
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
|
||||
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4
|
||||
|
||||
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
129
packages/client/connection/src/api-request-trust.ts
Normal file
129
packages/client/connection/src/api-request-trust.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Browser-trust fence for every /api request. Defends the two confused-deputy
|
||||
* paths a browser opens against a local HTTP API — DNS rebinding (Host names
|
||||
* the attacker's domain while the socket reaches this server) and cross-site
|
||||
* requests fired from a malicious page. The Host fence binds every request,
|
||||
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
||||
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
|
||||
* headers go only to trustworthy destinations), so an unmarked request may
|
||||
* still be a rebound browser read and Host is the one header rebinding cannot
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback, the
|
||||
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
|
||||
* Network reachability and authentication stay out of scope: binding policy
|
||||
* belongs to the webserver config, and this fence is not an auth layer.
|
||||
*/
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
|
||||
function parseAuthority(authority: string): URL | undefined {
|
||||
try {
|
||||
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
|
||||
return new URL(`http://${authority}`)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
||||
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
|
||||
* (case aside). Anything parsing would silently rewrite is refused as a typo
|
||||
* that must fail the load loudly instead of being ignored until requests 403
|
||||
* or quietly changing the grant: URL parts beyond the authority
|
||||
* (`harness.internal/path`, `user@harness.internal` — which would authorize
|
||||
* the embedded hostname), stripped whitespace, a dangling colon or
|
||||
* zero-padded port (which would broaden an intended exact-port grant to every
|
||||
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
|
||||
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
|
||||
* carries).
|
||||
* @param entry - the configured value, verbatim.
|
||||
*/
|
||||
export function assertTrustedAuthority(entry: string): void {
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
|
||||
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of a parsed authority: `hostname` when no port was written,
|
||||
* else `hostname:port`. The port is judged from URL parses under both special
|
||||
* schemes (their default ports differ, so `:80` and `:443` still count as
|
||||
* explicit), never from the raw string, where WHATWG trimming would misread
|
||||
* shapes like `host:port ` as port-less.
|
||||
*/
|
||||
function canonicalAuthority(entry: string, entryUrl: URL): string {
|
||||
// An authority that parsed under http cannot fail under https.
|
||||
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
||||
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the request authority matches a `trustedHosts` entry. An entry with
|
||||
* an explicit port matches that exact authority; a port-less entry matches the
|
||||
* hostname on any port (the shape the CLI derives for IP-literal LAN serving,
|
||||
* where the bound port may be OS-assigned). Both sides compare through WHATWG
|
||||
* normalization, so case and a redundant `:80` never decide trust.
|
||||
*/
|
||||
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
|
||||
return trustedHosts.some((entry) => {
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl === undefined) return false
|
||||
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
||||
? entryUrl.hostname === hostUrl.hostname
|
||||
: entryUrl.host === hostUrl.host
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
||||
// Host fence (DNS-rebinding defense), applied to every request: the browser
|
||||
// fills Host from the URL it believes it is talking to, so a rebound page
|
||||
// carries the attacker's domain here even though the socket lands on this
|
||||
// server. There is no marker shortcut — a browser read over plain HTTP
|
||||
// (EventSource, images, navigations) arrives with neither Origin nor
|
||||
// Fetch-Metadata, indistinguishable from curl, and its response is readable
|
||||
// by the rebound page.
|
||||
const host = header(request.headers, 'host')
|
||||
if (host === undefined) return false
|
||||
const hostUrl = parseAuthority(host)
|
||||
if (hostUrl === undefined) return false
|
||||
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
||||
// Cross-site fence: modern browsers label the initiator relationship on
|
||||
// every fetch; an explicit cross-site marker is refused regardless of Origin.
|
||||
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
|
||||
// Origin fence: when a browser attaches an Origin it must be exactly this
|
||||
// authority (compared through the same normalization as the Host). Absent
|
||||
// Origin is fine — the Host fence above already bound the request. The
|
||||
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
|
||||
const origin = header(request.headers, 'origin')
|
||||
if (origin === undefined) return true
|
||||
try {
|
||||
return new URL(origin).host === hostUrl.host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
@@ -15,20 +16,42 @@ export const name = 'client-connection'
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
/**
|
||||
* Authorities this deployment serves beyond loopback: exact `host:port`, or
|
||||
* port-less `host` matching any port. The /api trust fence refuses any
|
||||
* request whose Host is neither loopback nor listed here, so a
|
||||
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
|
||||
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
|
||||
* that is not a bare, canonical authority fails the plugin load.
|
||||
*/
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
export const Config: z<ConnectionConfig> = z.object({
|
||||
trustedHosts: z.array(String).default([]),
|
||||
})
|
||||
|
||||
/**
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* Mounts the API gateway under the browser transport prefix. Every request on
|
||||
* the prefix passes the browser-trust fence first (DNS-rebinding and
|
||||
* cross-site defense — [api-request-trust](./api-request-trust.ts)).
|
||||
* @param ctx - Host plugin context.
|
||||
* @param config - resolved plugin config (schema defaults applied).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// The Loader resolves schema defaults; hand-built test contexts may pass none.
|
||||
const trustedHosts = config?.trustedHosts ?? []
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
if ((pathname === `${API_PATH}/host.pickDirectory`
|
||||
|| pathname === `${API_PATH}/host.openPath`)
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/** Trust check for browser requests that can invoke privileged native host actions. */
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
interface NativeDialogRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
socket: { remoteAddress?: string | undefined }
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopback(address: string | undefined): boolean {
|
||||
if (address === undefined) return false
|
||||
if (address === '::1') return true
|
||||
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
|
||||
const first = ipv4.split('.')[0]
|
||||
return first === '127'
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a local socket plus browser-controlled same-origin metadata.
|
||||
* @param request - the node HTTP request facts used by the carrier guard.
|
||||
* @returns true only for a same-origin browser request whose peer and URL are loopback.
|
||||
*/
|
||||
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
|
||||
if (!isLoopback(request.socket.remoteAddress)) return false
|
||||
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
|
||||
const origin = header(request.headers, 'origin')
|
||||
const host = header(request.headers, 'host')
|
||||
if (origin === undefined || host === undefined) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
const hostUrl = new URL(`http://${host}`)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
||||
&& parsed.host === host
|
||||
&& isLoopbackHostname(parsed.hostname)
|
||||
&& isLoopbackHostname(hostUrl.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
108
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
108
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
|
||||
|
||||
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
|
||||
return { headers }
|
||||
}
|
||||
|
||||
describe('isTrustedApiRequest', () => {
|
||||
it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
|
||||
// Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
|
||||
// reads (EventSource, images, navigations), so a rebound-origin GET is
|
||||
// markerless and its response readable: no marker shortcut may exist.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({}), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
|
||||
for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
|
||||
expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
|
||||
expect(isTrustedApiRequest(request({
|
||||
host: 'evil.example:3080',
|
||||
origin: 'http://evil.example:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
}), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
|
||||
const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
|
||||
expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
|
||||
expect(isTrustedApiRequest(request(headers), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
|
||||
expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
|
||||
// An unparsable entry never matches; it must not poison the rest of the list.
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses cross-origin browser markers even on a loopback Host', () => {
|
||||
// Origin present and different → cross-site request that survived preflight rules.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
|
||||
// Explicit cross-site label → refused regardless of Origin.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
|
||||
// Opaque origin (sandboxed iframe, file: page) parses to no authority.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a same-origin browser request, with or without an Origin header', () => {
|
||||
expect(isTrustedApiRequest(request({
|
||||
host: 'localhost:3080',
|
||||
origin: 'http://localhost:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
}), [])).toBe(true)
|
||||
// Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
|
||||
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
|
||||
})
|
||||
|
||||
it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
|
||||
for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
|
||||
}
|
||||
// WHATWG parsing would quietly read a hostname out of each of these; the
|
||||
// config boundary must refuse them instead of authorizing the prefix.
|
||||
for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
// WHATWG trimming would silently strip these; the entry must fail instead.
|
||||
for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
// WHATWG parsing would silently rewrite these — a dangling colon or
|
||||
// zero-padded port would broaden an intended exact-port grant to every
|
||||
// port, and non-canonical host spellings would not read back as written.
|
||||
for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
})
|
||||
|
||||
it('never lets stray whitespace broaden an exact-port entry to every port', () => {
|
||||
// Defense in depth below the load-time assert: the explicit-port judgment
|
||||
// reads the parsed URL, so a trimmed `host:port ` entry stays exact.
|
||||
const trusted = ['harness.internal:3080 ']
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses malformed or untrusted authorities on browser requests', () => {
|
||||
const markers = { 'sec-fetch-site': 'same-origin' }
|
||||
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
|
||||
|
||||
function request(
|
||||
remoteAddress: string | undefined,
|
||||
headers: IncomingHttpHeaders = {
|
||||
host: '127.0.0.1:3080',
|
||||
origin: 'http://127.0.0.1:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
) {
|
||||
return { socket: { remoteAddress }, headers }
|
||||
}
|
||||
|
||||
describe('native dialog request trust', () => {
|
||||
it('accepts loopback same-origin browser requests', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::1', {
|
||||
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
|
||||
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects remote sockets and requests without matching browser metadata', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
@@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
// Structural fake: the plugin only touches register(); the service class
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
/** Structural httpServer fake: the plugin only touches register(). */
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
|
||||
function fakeRequest(headers: Record<string, string>): IncomingMessage {
|
||||
const request = Readable.from([]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
}) as unknown as ServerResponse
|
||||
return { response, state }
|
||||
}
|
||||
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
// The apply throw also escapes cordis as a late rejection — the shape the
|
||||
// boot's installFailLoud is contracted to catch. Capture it so the run
|
||||
// stays clean, same pattern as the webserver bind-failure test.
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||
expect(routes).toHaveLength(0)
|
||||
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url,
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
const { response, state } = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
}), response)
|
||||
expect(state.status).toBe(403)
|
||||
expect(state.body).toBe('forbidden')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('passes loopback and declared-authority requests through to the bridge', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
|
||||
// Loopback, no browser markers (curl shape): the fence passes; the carrier
|
||||
// answers 404 for a GET unary path — proof the bridge ran.
|
||||
const loopback = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
|
||||
expect(loopback.state.status).toBe(404)
|
||||
// LAN authority declared as a port-less IP literal — the shape the CLI
|
||||
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
|
||||
const lan = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
|
||||
expect(lan.state.status).toBe(404)
|
||||
// Declared public authority, same-origin browser shape.
|
||||
const declared = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({
|
||||
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}), declared.response)
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
6
packages/host/README.i18n.yaml
Normal file
6
packages/host/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/README.md
|
||||
README.md: 61e50cb64b95932085342b01a22d029cf8d5a228
|
||||
README.zh.md: 3109eccf89ee4c2d4d01be546e3ee9ead9084edc
|
||||
12
packages/host/README.md
Normal file
12
packages/host/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# host/ — web-GUI host half
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` |
|
||||
| `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` |
|
||||
|
||||
`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.
|
||||
12
packages/host/README.zh.md
Normal file
12
packages/host/README.zh.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# host/ — web GUI 宿主半侧
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` |
|
||||
| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` |
|
||||
|
||||
`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: c20887b73b9b9deb278db30d34d84df07257d664
|
||||
README.zh.md: 18a2f97477e5f57127371429b0dd59ba01f04341
|
||||
README.md: 3639722ab25826af8f7a0721f22d244f78b4210b
|
||||
README.zh.md: 3ac81fb412d4e4caf192953bc7a7c846a1d29965
|
||||
|
||||
@@ -6,7 +6,7 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/<method>` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload<K>`/`ResponseValue<K>`. Zod schemas anchor `satisfies z.ZodType<Wire<T>>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier.
|
||||
Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/<method>` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload<K>`/`ResponseValue<K>`. Zod schemas anchor `satisfies z.ZodType<Wire<T>>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. Every `/api` POST must declare the `application/json` media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind.
|
||||
|
||||
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
|
||||
|
||||
@@ -18,7 +18,7 @@ Session model routing is a session-domain contract. `session.models` returns the
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests.
|
||||
`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers this method like every other `/api` request.
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 契约层(`/api`)
|
||||
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。
|
||||
`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖该方法。
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Server side of the fetch carrier: maps an ApiProxy onto a pure
|
||||
* WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
|
||||
* path==method) -> payload dispatched per method. HTTP status expresses only the carrier
|
||||
* (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always
|
||||
* 200 + ServerResponse.
|
||||
* (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash);
|
||||
* business errors are always 200 + ServerResponse.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -205,6 +205,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Cross-site write fence: browsers send "simple" POSTs (text/plain,
|
||||
// form encodings) without a CORS preflight, so a malicious page could
|
||||
// otherwise execute side-effectful RPCs blind — the response stays
|
||||
// unreadable cross-origin, but session.prompt would still run. Only the
|
||||
// JSON media type is accepted; anything else is forced into a preflight
|
||||
// this server never answers. 415 = carrier layer, like the 400 below.
|
||||
const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (mediaType !== 'application/json') {
|
||||
return new Response('content type must be application/json', { status: 415 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('unary round trip', () => {
|
||||
it('rejects a method/path mismatch as bad-request', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
|
||||
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) })
|
||||
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
expect(response.status).toBe(200)
|
||||
const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
|
||||
expect(parsed.result.ok).toBe(false)
|
||||
@@ -162,13 +162,13 @@ describe('unary round trip', () => {
|
||||
it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
// No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
|
||||
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) })
|
||||
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
|
||||
expect(noId.status).toBe(200)
|
||||
const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
|
||||
expect(noIdParsed.result.ok).toBe(false)
|
||||
expect(noIdParsed.rpcId).toBe('invalid-request')
|
||||
// A string rpcId in the otherwise-bad body is salvaged for correlation.
|
||||
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
|
||||
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
|
||||
const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
|
||||
expect(withIdParsed.result.ok).toBe(false)
|
||||
expect(withIdParsed.rpcId).toBe('salvage-me')
|
||||
@@ -177,16 +177,34 @@ describe('unary round trip', () => {
|
||||
it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
// Unknown method → 404.
|
||||
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' })
|
||||
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
||||
expect(notFound.status).toBe(404)
|
||||
// Non-JSON body → 400.
|
||||
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' })
|
||||
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
|
||||
expect(badBody.status).toBe(400)
|
||||
// Impl crash → 500, and through the client that is a throw, not an err result.
|
||||
const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
|
||||
await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
|
||||
})
|
||||
|
||||
it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
|
||||
const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] }))
|
||||
const handler = toFetchHandler(scriptedApi({ sessions: { list } }))
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} })
|
||||
// A "simple" browser POST (text/plain — sent with no CORS preflight) is
|
||||
// refused at the carrier before the impl runs.
|
||||
const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
|
||||
expect(plain.status).toBe(415)
|
||||
// A string body with no explicit header defaults to text/plain — same fence.
|
||||
const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body })
|
||||
expect(unlabelled.status).toBe(415)
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
// Media-type parameters pass: the fence checks the type, not the exact string.
|
||||
const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
|
||||
expect(charset.status).toBe(200)
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects when the transport never resolves within timeoutMs', async () => {
|
||||
// AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
|
||||
const never = new InProcessApiClient({
|
||||
@@ -495,7 +513,7 @@ describe('respond path', () => {
|
||||
it('returns bad-response for a malformed client-response without reaching the impl', async () => {
|
||||
const respond = vi.fn()
|
||||
const handler = toFetchHandler(scriptedApi({ respond }))
|
||||
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) })
|
||||
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) })
|
||||
expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
|
||||
// The fake's /hang settles only when the invoke-level signal aborts: a
|
||||
// completed response with the cancelled error proves req.signal reached it.
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal }))
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
@@ -288,7 +288,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
|
||||
const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
|
||||
method: 'POST', body, signal: controller.signal,
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal,
|
||||
}))
|
||||
controller.abort()
|
||||
const parsed = await (await pending).json() as { result: { error?: { code: string } } }
|
||||
@@ -300,18 +300,18 @@ describe('handler carrier-layer statuses', () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
|
||||
it('404s unknown paths and non-POST non-stream methods', async () => {
|
||||
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
|
||||
})
|
||||
|
||||
it('400s a non-JSON body', async () => {
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
expect(body.rpcId).toBe('invalid-request')
|
||||
@@ -320,7 +320,7 @@ describe('handler carrier-layer statuses', () => {
|
||||
|
||||
it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} })
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
||||
const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
|
||||
expect(parsed.rpcId).toBe('r-9')
|
||||
expect(parsed.result.error?.message).toContain('does not match path')
|
||||
@@ -328,7 +328,7 @@ describe('handler carrier-layer statuses', () => {
|
||||
|
||||
it('rejects an invalid payload with the zod issues attached', async () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} })
|
||||
const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
||||
const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
|
||||
expect(parsed.result.error?.code).toBe('bad-request')
|
||||
expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
|
||||
@@ -337,23 +337,23 @@ describe('handler carrier-layer statuses', () => {
|
||||
it('500s when the impl itself throws', async () => {
|
||||
const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' }))
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} })
|
||||
const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
|
||||
const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.text()).toContain('impl crashed')
|
||||
})
|
||||
|
||||
it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => {
|
||||
const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } })
|
||||
const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json()
|
||||
const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json()
|
||||
expect(goodReceipt).toEqual({ accepted: true })
|
||||
const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} })
|
||||
const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json()
|
||||
const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json()
|
||||
expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' })
|
||||
})
|
||||
|
||||
it('accepts (url, init) form fetch invocation', async () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} })
|
||||
const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body })
|
||||
const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: c589c32c4e641e188f19ac6c5ad2e88e3eb79be3
|
||||
README.zh.md: 767195086b90a76160d87865caebf514ca75b0e3
|
||||
# pnpm run verify-translation-pairing --write packages/host/webserver/README.md
|
||||
README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0
|
||||
README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
|
||||
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
|
||||
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
朴素的 HTTP 路由注册插件(默认导出 `WebServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
|
||||
朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
|
||||
|
||||
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
|
||||
5
packages/ui/tui/AGENTS.md
Normal file
5
packages/ui/tui/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# AGENTS.md — TUI package
|
||||
|
||||
These rules supplement the package conventions in [packages/AGENTS.md](../../AGENTS.md).
|
||||
|
||||
- **Present TUI designs in tmux, not in the session transcript.** When tmux is available, run the assembled TUI in a pane of the same window the session runs in and point the user at it; print a rendering into the transcript only as a fallback.
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: 5aafd6f5207320bf273c96a04f2d606577ca2da0
|
||||
README.zh.md: 1901faeb26c65126bc5475a991fedecd39a88ba5
|
||||
README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d
|
||||
README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b
|
||||
|
||||
@@ -22,9 +22,9 @@ Typing `@` at a token boundary searches files and directories under the session
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
|
||||
|
||||
@@ -32,9 +32,15 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
|
||||
|
||||
`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text.
|
||||
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
|
||||
|
||||
Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
|
||||
|
||||
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -57,7 +63,6 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
|
||||
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
|
||||
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
|
||||
| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
@@ -74,7 +79,11 @@ Startup fails before mounting when either process stream is not a TTY. The compo
|
||||
|
||||
## Color
|
||||
|
||||
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade.
|
||||
|
||||
There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
|
||||
|
||||
Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -156,7 +165,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI.
|
||||
- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. The all-workspaces scope makes this reachable in one step, since a session another host is driving in a different directory is now selectable. Deployments that can run concurrent hosts must coordinate ownership outside the TUI.
|
||||
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
|
||||
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
|
||||
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback.
|
||||
|
||||
@@ -22,9 +22,9 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso
|
||||
|
||||
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
|
||||
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览;Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。
|
||||
|
||||
@@ -32,9 +32,15 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
|
||||
|
||||
`/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose(资源释放)并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
|
||||
|
||||
`resumeCommand` 仍是部署持有的回退行为:只有当前会话已持久化后,退出才会打印它;不支持原地 handoff 的宿主会显示所选会话的命令。`{session}` 展开为会话 id。TUI 代码绝不会执行模板或任意 shell 文本。
|
||||
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
|
||||
|
||||
选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
|
||||
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
|
||||
|
||||
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -57,7 +63,6 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
| `showHardwareCursor` | `false` | 在 pi-tui 的 IME marker 处显示硬件 cursor |
|
||||
| `color` | `true` | 应用内置 ANSI palette(参见[颜色](#color)) |
|
||||
| `title` | `DeepSeek Harness` | 终端窗口标题的产品后缀。 |
|
||||
| `resumeCommand` | 未设置 | 供退出提示和不支持原地 handoff 的宿主使用的 shell 命令模板,其中 `{session}` 会展开为会话 id |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
@@ -70,11 +75,15 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose 会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
|
||||
|
||||
## 颜色
|
||||
|
||||
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。
|
||||
|
||||
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
|
||||
|
||||
成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -156,7 +165,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。能够运行并发宿主的部署必须在 TUI 外协调所有权。
|
||||
- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。所有工作区作用域让这一情形一步即可触及,因为另一个宿主正在其他目录驱动的会话现在也可被选中。能够运行并发宿主的部署必须在 TUI 外协调所有权。
|
||||
- **一个已配置会话持有 transcript 和编辑器**:其他 agent 的问题仍可使用共享 overlay 提供方,但会话渲染与提示词输入仍绑定到 `sessionId`。
|
||||
- **工具卡片是文本终端展示**:终端、diff 与通用卡片使用工具持有的标题/内容,但会话内容目前没有用于内联图像渲染的图像块。
|
||||
- **有意不支持非 TTY 运行**:需要自动化的 app bundle 必须组合单次执行或服务器入口(`dsh-cli-demo`、`dsh-acp`),而不能依赖内部回退。
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
/**
|
||||
* Session-resume sub-controller for the interactive chat channel: the
|
||||
* `/resume` selector, per-candidate summary reads that tolerate a corrupt
|
||||
* neighbor, the pre-handoff preflight, the terminal handoff itself, and the
|
||||
* durable resume-hint command printed on exit.
|
||||
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
|
||||
* @module @deepseek-ai/dsh-tui/chat/resume
|
||||
*/
|
||||
|
||||
import type { TUI } from '@earendil-works/pi-tui'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionLogSnapshot,
|
||||
SessionQueryService,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { HintEditor } from './helpers.ts'
|
||||
import { formatCwd } from './helpers.ts'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import type { TuiRuntime } from '../runtime.ts'
|
||||
import type { Config } from '../config.ts'
|
||||
import {
|
||||
ResumePicker,
|
||||
summarizeResumeCandidate,
|
||||
@@ -31,9 +28,7 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
/** Collaborators the resume controller needs from the chat channel. */
|
||||
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
readonly agent: Agent
|
||||
readonly config: Config
|
||||
readonly runtime: TuiRuntime
|
||||
readonly persistence: SessionPersistence | undefined
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
readonly ui: TUI
|
||||
readonly editor: HintEditor
|
||||
@@ -43,47 +38,27 @@ export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
|
||||
/** Session-resume controller for one chat channel. */
|
||||
export interface ResumeController {
|
||||
/** Open the current-workspace searchable session selector. */
|
||||
/** Open the searchable session selector, scoped to this workspace until the user widens it. */
|
||||
showResume(): void
|
||||
/**
|
||||
* The resume command for the current session — the configured template with
|
||||
* every `{session}` filled — but only once the session is durably persisted;
|
||||
* `undefined` otherwise.
|
||||
*/
|
||||
currentResumeCommand(): Promise<string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session-resume controller for one chat channel.
|
||||
* @param deps - channel collaborators, terminal handles, and optional services.
|
||||
* @returns the controller wired to the `/resume` command and exit hint.
|
||||
* @returns the controller wired to the `/resume` command.
|
||||
*/
|
||||
export function createResumeController(deps: ResumeControllerDeps): ResumeController {
|
||||
const {
|
||||
ctx, agent, config, runtime, resolved, palette, overlayManager,
|
||||
persistence, sessionQuery, ui, editor,
|
||||
ctx, agent, runtime, resolved, palette, overlayManager,
|
||||
sessionQuery, ui, editor,
|
||||
} = deps
|
||||
let resumeOverlay: TuiOverlaySession | undefined
|
||||
let resumeInFlight = false
|
||||
let resumeScan = 0
|
||||
|
||||
/**
|
||||
* Persisted sessions for this workspace, newest first. Empty when no
|
||||
* persistence backend is mounted or a listing failure would otherwise block
|
||||
* exit or crash `/resume`; the resume hint is best-effort convenience.
|
||||
*/
|
||||
const listWorkspaceSessions = async (): Promise<SessionHeader[]> => {
|
||||
if (persistence === undefined) return []
|
||||
let all: readonly SessionHeader[]
|
||||
try {
|
||||
all = await persistence.list()
|
||||
} catch {
|
||||
// A listing failure must never block terminal exit or crash `/resume`.
|
||||
return []
|
||||
}
|
||||
return all
|
||||
.filter(header => header.cwd === agent.session.header.cwd)
|
||||
}
|
||||
/** Label any session's own workspace the way the prompt labels the current one. */
|
||||
const workspaceLabel = (cwd: string | undefined): string =>
|
||||
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
|
||||
|
||||
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
|
||||
const readResumeCandidate = async (
|
||||
@@ -109,6 +84,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
providers,
|
||||
workspaceLabel,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
@@ -116,13 +92,18 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
currentWorkspace: record.header.cwd === agent.session.header.cwd,
|
||||
workspaceLabel: workspaceLabel(record.header.cwd),
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read every mutable precondition immediately before terminal handoff. */
|
||||
const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
|
||||
/**
|
||||
* Re-read every mutable precondition immediately before terminal handoff and
|
||||
* resolve the exact identity and workspace the host will re-exec into.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
|
||||
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
const initialStatus = deps.agentStatus()
|
||||
@@ -134,9 +115,12 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
new Set(ctx.llm.listProviders().map(provider => provider.id)),
|
||||
)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
const cwd = candidate.record.header.cwd
|
||||
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
|
||||
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return candidate
|
||||
return { id: candidate.record.header.id, cwd }
|
||||
}
|
||||
|
||||
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
|
||||
@@ -147,13 +131,9 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
const checked = await preflightResume(candidate.record.header.id)
|
||||
const hostHandoff = runtime.handoffResume
|
||||
if (hostHandoff === undefined) {
|
||||
const template = config.resumeCommand
|
||||
const fallback = template?.replaceAll('{session}', checked.record.header.id)
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
deps.appendNotice(fallback === undefined
|
||||
? 'Session is resumable, but this host cannot hand it off in place.'
|
||||
: `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
|
||||
deps.appendNotice('Session is resumable, but this host cannot hand it off in place.', 'warning')
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
|
||||
@@ -169,7 +149,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
if (deps.isDisposed()) return
|
||||
ui.stop()
|
||||
terminalReleased = true
|
||||
await hostHandoff(checked.record.header.id)
|
||||
// The host re-execs into the session's own workspace: process cwd, not the
|
||||
// restored session header, is what the filesystem and shell tools resolve
|
||||
// against.
|
||||
await hostHandoff(checked.id, checked.cwd)
|
||||
throw new Error('resume host returned without replacing the process')
|
||||
} catch (error: unknown) {
|
||||
if (!deps.isDisposed()) {
|
||||
@@ -189,12 +172,6 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
}
|
||||
|
||||
return {
|
||||
currentResumeCommand: async (): Promise<string | undefined> => {
|
||||
if (config.resumeCommand === undefined) return undefined
|
||||
const sessions = await listWorkspaceSessions()
|
||||
if (!sessions.some(header => header.id === agent.session.id)) return undefined
|
||||
return config.resumeCommand.replaceAll('{session}', agent.session.id)
|
||||
},
|
||||
showResume(): void {
|
||||
if (agent.status !== 'idle') {
|
||||
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
|
||||
@@ -208,9 +185,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
void resumeOverlay?.close()
|
||||
void sessionQuery.listSessions().then(async (records) => {
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd)
|
||||
// Every workspace in the store is summarized; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
|
||||
const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers)))
|
||||
const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers)))
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
@@ -218,7 +196,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
create: host => new ResumePicker(
|
||||
candidates,
|
||||
resolved.maxResumeOptions,
|
||||
runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd),
|
||||
workspaceLabel(agent.session.header.cwd),
|
||||
() => host.viewport.rows,
|
||||
palette,
|
||||
(candidate) => { void handoffResume(candidate, session) },
|
||||
|
||||
@@ -290,7 +290,7 @@ export function fadeGlyph(
|
||||
return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m`
|
||||
}
|
||||
if (!visible) return ' '
|
||||
return colorEnabled ? palette.muted(glyph) : glyph
|
||||
return colorEnabled ? palette.dim(glyph) : glyph
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -205,7 +205,7 @@ export class StatusCardComponent implements Component {
|
||||
if (groupIndex > 0) body.push('')
|
||||
for (const [label, value] of group) {
|
||||
const plainLabel = truncateToWidth(`${label}:`, labelWidth, '')
|
||||
const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} `
|
||||
const prefix = ` ${this.palette.dim(plainLabel.padEnd(labelWidth))} `
|
||||
const continuation = ' '.repeat(1 + labelWidth + 2)
|
||||
const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix))
|
||||
const wrapped = wrapTextWithAnsi(value, valueWidth)
|
||||
@@ -281,9 +281,10 @@ export function renderDialog(
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */
|
||||
/** Keyboard model selector rendered as a bordered overlay, with a filter box and per-model reasoning-effort cycling. */
|
||||
export class ModelDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
private list: SelectList
|
||||
private readonly filter = new Input()
|
||||
private readonly items: Map<string, SelectItem>
|
||||
private readonly choices: Map<string, ModelChoice>
|
||||
private readonly efforts: Map<string, ReasoningEffortId | undefined>
|
||||
@@ -292,10 +293,10 @@ export class ModelDialog implements Component {
|
||||
constructor(
|
||||
choices: readonly ModelChoice[],
|
||||
current: AgentLlmTarget | undefined,
|
||||
maxVisible: number,
|
||||
private readonly maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
done: (selection: ModelDialogSelection) => void,
|
||||
cancel: () => void,
|
||||
private readonly done: (selection: ModelDialogSelection) => void,
|
||||
private readonly cancel: () => void,
|
||||
) {
|
||||
this.items = new Map()
|
||||
this.choices = new Map()
|
||||
@@ -317,18 +318,38 @@ export class ModelDialog implements Component {
|
||||
description: this.describeChoice(choice, isCurrent),
|
||||
})
|
||||
}
|
||||
this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette))
|
||||
const currentIndex = current === undefined
|
||||
? 0
|
||||
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
|
||||
this.list.setSelectedIndex(currentIndex)
|
||||
this.list.onSelect = (item) => {
|
||||
const selected = choices.find(choice => targetLabel(choice) === item.value)
|
||||
/* v8 ignore next -- SelectList only returns values built from `choices`. */
|
||||
if (selected === undefined) return
|
||||
done({ choice: selected, reasoningEffort: this.efforts.get(item.value) })
|
||||
}
|
||||
this.list.onCancel = cancel
|
||||
this.list = this.buildList(this.currentValue)
|
||||
}
|
||||
|
||||
/** Build a SelectList over the currently filtered items, selecting `selectValue` when present. */
|
||||
private buildList(selectValue: string | undefined): SelectList {
|
||||
const items = this.filteredItems()
|
||||
const list = new SelectList(items, this.maxVisible, dialogSelectTheme(this.palette))
|
||||
const index = selectValue === undefined ? 0 : items.findIndex(item => item.value === selectValue)
|
||||
list.setSelectedIndex(Math.max(0, index))
|
||||
list.onSelect = (item) => { this.confirm(item) }
|
||||
list.onCancel = this.cancel
|
||||
return list
|
||||
}
|
||||
|
||||
/** Items matching the filter box, as a case-insensitive substring over the label, model name, and description. */
|
||||
private filteredItems(): SelectItem[] {
|
||||
const query = this.filter.getValue().trim().toLocaleLowerCase()
|
||||
if (query === '') return [...this.items.values()]
|
||||
return [...this.items.values()].filter((item) => {
|
||||
const choice = this.choices.get(item.value)
|
||||
/* v8 ignore next -- items and choices share the same keys. */
|
||||
if (choice === undefined) return false
|
||||
return [item.value, choice.modelName, choice.description ?? '']
|
||||
.some(field => field.toLocaleLowerCase().includes(query))
|
||||
})
|
||||
}
|
||||
|
||||
private confirm(item: SelectItem): void {
|
||||
const selected = this.choices.get(item.value)
|
||||
/* v8 ignore next -- SelectList only returns values built from `choices`. */
|
||||
if (selected === undefined) return
|
||||
this.done({ choice: selected, reasoningEffort: this.efforts.get(item.value) })
|
||||
}
|
||||
|
||||
private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
|
||||
@@ -362,24 +383,50 @@ export class ModelDialog implements Component {
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.filter.invalidate()
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.shift(Key.tab))) {
|
||||
this.cycleReasoningEffort()
|
||||
} else {
|
||||
} else if (matchesKey(data, Key.escape)) {
|
||||
if (this.filter.getValue() === '') this.cancel()
|
||||
else {
|
||||
this.filter.setValue('')
|
||||
this.list = this.buildList(undefined)
|
||||
}
|
||||
} else if (
|
||||
matchesKey(data, Key.up)
|
||||
|| matchesKey(data, Key.down)
|
||||
|| matchesKey(data, Key.enter)
|
||||
) {
|
||||
this.list.handleInput(data)
|
||||
} else {
|
||||
const previous = this.filter.getValue()
|
||||
this.filter.focused = true
|
||||
this.filter.handleInput(data)
|
||||
if (this.filter.getValue() !== previous) {
|
||||
const selected = this.list.getSelectedItem()
|
||||
this.list = this.buildList(selected?.value)
|
||||
}
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
this.filter.focused = true
|
||||
const results = this.filteredItems()
|
||||
const filterContent = truncateToWidth(this.filter.render(innerWidth).join(''), innerWidth, '')
|
||||
return renderDialog('Select model', [
|
||||
...this.list.render(innerWidth),
|
||||
filterContent,
|
||||
'',
|
||||
this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
|
||||
...results.length === 0
|
||||
? [this.palette.dim(' No models match the filter')]
|
||||
: this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
@@ -396,6 +443,10 @@ export interface ResumeCandidate {
|
||||
title: string
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
|
||||
currentWorkspace: boolean
|
||||
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
|
||||
workspaceLabel: string
|
||||
route?: ResumeRoute
|
||||
goalPhase?: GoalPhase
|
||||
disabledReason?: string
|
||||
@@ -429,12 +480,15 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
|
||||
|
||||
/**
|
||||
* Build one resume selector row from a record and its log snapshot, deriving the
|
||||
* title, route, goal phase, and any reason the session cannot be resumed here.
|
||||
* title, route, goal phase, workspace scope, and any reason the session cannot
|
||||
* be resumed here. A workspace other than the current one is a scope, not a
|
||||
* disabled reason: resuming it hands the process off into that directory.
|
||||
* @param record - The session record.
|
||||
* @param snapshot - The session's log snapshot.
|
||||
* @param currentId - The current session id.
|
||||
* @param cwd - The current workspace directory.
|
||||
* @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in.
|
||||
* @param availableProviders - Providers registered in this runtime.
|
||||
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
|
||||
* @returns The summarized resume candidate.
|
||||
*/
|
||||
export function summarizeResumeCandidate(
|
||||
@@ -443,6 +497,7 @@ export function summarizeResumeCandidate(
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
formatWorkspace: (cwd: string | undefined) => string,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
|
||||
const route = resumeRoute(snapshot)
|
||||
@@ -450,7 +505,7 @@ export function summarizeResumeCandidate(
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
|
||||
else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
@@ -459,6 +514,8 @@ export function summarizeResumeCandidate(
|
||||
title,
|
||||
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
currentWorkspace: record.header.cwd === cwd,
|
||||
workspaceLabel: formatWorkspace(record.header.cwd),
|
||||
...route === undefined ? {} : { route },
|
||||
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
|
||||
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
|
||||
@@ -466,12 +523,23 @@ export function summarizeResumeCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
|
||||
/** Which workspaces the resume picker currently lists. */
|
||||
export type ResumeScope = 'workspace' | 'all'
|
||||
|
||||
/**
|
||||
* Full-viewport keyboard selector over detached, preflighted resume summaries.
|
||||
*
|
||||
* Two scopes over one candidate set: `workspace` (the default) lists only the
|
||||
* current session's workspace, `all` lists every workspace and labels each row
|
||||
* with its own. Tab toggles between them; the search query and selection reset
|
||||
* on a scope change so the highlighted row always belongs to the visible list.
|
||||
*/
|
||||
export class ResumePicker implements Component, Focusable {
|
||||
private readonly search = new Input()
|
||||
private pasteBuffer: string | undefined
|
||||
private selectedIndex = 0
|
||||
private error = ''
|
||||
private scope: ResumeScope = 'workspace'
|
||||
focused = false
|
||||
|
||||
constructor(
|
||||
@@ -488,15 +556,29 @@ export class ResumePicker implements Component, Focusable {
|
||||
this.search.invalidate()
|
||||
}
|
||||
|
||||
/** Candidates in the active scope, before the search query narrows them. */
|
||||
private scoped(): ResumeCandidate[] {
|
||||
return this.scope === 'all'
|
||||
? [...this.candidates]
|
||||
: this.candidates.filter(candidate => candidate.currentWorkspace)
|
||||
}
|
||||
|
||||
private filtered(): ResumeCandidate[] {
|
||||
const query = this.search.getValue().trim().toLocaleLowerCase()
|
||||
if (query === '') return [...this.candidates]
|
||||
return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|
||||
|| candidate.record.header.id.toLocaleLowerCase().includes(query))
|
||||
const scoped = this.scoped()
|
||||
if (query === '') return scoped
|
||||
// The workspace label only distinguishes rows once it is on screen, so it
|
||||
// joins the searchable text exactly in the scope that shows it.
|
||||
return scoped.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|
||||
|| candidate.record.header.id.toLocaleLowerCase().includes(query)
|
||||
|| (this.scope === 'all' && candidate.workspaceLabel.toLocaleLowerCase().includes(query)))
|
||||
}
|
||||
|
||||
private visibleCandidateCount(): number {
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
|
||||
// The all-workspaces scope adds a per-row workspace line, so a row costs
|
||||
// one more terminal row there than in the single-workspace scope.
|
||||
const rowHeight = this.scope === 'all' ? 5 : 4
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
|
||||
return Math.min(this.maxVisible, candidateBudget)
|
||||
}
|
||||
|
||||
@@ -553,6 +635,11 @@ export class ResumePicker implements Component, Focusable {
|
||||
Math.max(0, filtered.length - 1),
|
||||
this.selectedIndex + this.visibleCandidateCount(),
|
||||
)
|
||||
} else if (matchesKey(data, Key.tab)) {
|
||||
this.scope = this.scope === 'workspace' ? 'all' : 'workspace'
|
||||
this.search.setValue('')
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const selected = filtered[this.selectedIndex]
|
||||
if (selected === undefined) this.error = 'No session matches this search.'
|
||||
@@ -570,6 +657,21 @@ export class ResumePicker implements Component, Focusable {
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope line under the search box: the active scope with the current
|
||||
* workspace it means, and the inactive scope with the count Tab would reveal.
|
||||
*/
|
||||
private renderScopeLine(): string {
|
||||
const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length
|
||||
const active = this.scope === 'workspace'
|
||||
? `this workspace ${displayText(this.workspaceLabel)}`
|
||||
: `all workspaces (${this.candidates.length})`
|
||||
const other = this.scope === 'workspace'
|
||||
? `all workspaces (${this.candidates.length})`
|
||||
: `this workspace (${inWorkspace})`
|
||||
return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}`
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
this.search.focused = this.focused
|
||||
const height = Math.max(1, this.viewportRows())
|
||||
@@ -594,7 +696,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
`${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`,
|
||||
`${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`,
|
||||
'',
|
||||
`${indent}${this.palette.muted(displayText(this.workspaceLabel))}`,
|
||||
`${indent}${this.renderScopeLine()}`,
|
||||
'',
|
||||
)
|
||||
|
||||
@@ -620,8 +722,13 @@ export class ResumePicker implements Component, Focusable {
|
||||
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
|
||||
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
|
||||
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
|
||||
push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
// Only the all-workspaces scope mixes directories, so the per-row
|
||||
// workspace is redundant in the scope that already names one.
|
||||
if (this.scope === 'all') {
|
||||
push(this.palette.dim(` workspace ${displayText(candidate.workspaceLabel)}`))
|
||||
}
|
||||
if (candidate.disabledReason !== undefined) {
|
||||
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
|
||||
}
|
||||
@@ -632,7 +739,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
push(this.palette.error(displayText(this.error)))
|
||||
}
|
||||
|
||||
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}`
|
||||
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel')}`
|
||||
while (lines.length < height - 2) lines.push('')
|
||||
lines.push(footer, '')
|
||||
return lines.slice(0, height)
|
||||
@@ -720,7 +827,7 @@ export class QuestionDialog implements Component, Focusable {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
|
||||
const lines = [
|
||||
this.palette.muted(header),
|
||||
this.palette.dim(header),
|
||||
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
|
||||
]
|
||||
const push = (line: string): void => { lines.push(line) }
|
||||
@@ -764,7 +871,7 @@ export class QuestionDialog implements Component, Focusable {
|
||||
: left
|
||||
const description = option.description === undefined
|
||||
? ''
|
||||
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
|
||||
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}`
|
||||
push(`${leftStyled}${description}`)
|
||||
}
|
||||
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
|
||||
|
||||
@@ -11,67 +11,149 @@ import type {
|
||||
TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
|
||||
/** Theme-agnostic role colors and SGR attribute wrappers. */
|
||||
/**
|
||||
* Text carrying exactly one palette color. Branded so the compiler rejects
|
||||
* wrapping it in a second color: SGR has no color stack, so an inner span's
|
||||
* close reverts to the default foreground rather than the outer color, which
|
||||
* silently drops the outer color for the remainder of the line.
|
||||
*/
|
||||
export type Colored = string & { readonly __coloredBy: unique symbol }
|
||||
|
||||
/**
|
||||
* Text a color may still be applied to: a bare string, or one already carrying
|
||||
* SGR attributes. Attributes (bold, italic, underline, strike, reverse) occupy
|
||||
* independent SGR groups from the foreground color, so they compose in either
|
||||
* order without either side clobbering the other.
|
||||
*/
|
||||
export type Colorable = string & { readonly __coloredBy?: undefined }
|
||||
|
||||
/** Applies one color role; rejects input that already carries a color. */
|
||||
export type ColorRole = (text: Colorable) => Colored
|
||||
|
||||
/** Applies one SGR attribute; accepts colored or uncolored text and preserves its color. */
|
||||
export type AttributeRole = <T extends string>(text: T) => T
|
||||
|
||||
/**
|
||||
* Theme-agnostic role colors and SGR attribute wrappers.
|
||||
*
|
||||
* One role per visual meaning: `dim` is the single recessed tone, `accent` the
|
||||
* single emphasis color, and `success`/`error` double as a diff's added/removed
|
||||
* pair. Roles that resolved to the same escape were merged rather than kept as
|
||||
* aliases, so a reader cannot pick a name that silently renders as another.
|
||||
*
|
||||
* Colors and attributes are separately typed: `bold(accent(x))` and
|
||||
* `accent(bold(x))` both compile, while `accent(error(x))` does not.
|
||||
*/
|
||||
export interface Palette {
|
||||
accent: (text: string) => string
|
||||
accent2: (text: string) => string
|
||||
text: (text: string) => string
|
||||
muted: (text: string) => string
|
||||
dim: (text: string) => string
|
||||
success: (text: string) => string
|
||||
warning: (text: string) => string
|
||||
error: (text: string) => string
|
||||
code: (text: string) => string
|
||||
added: (text: string) => string
|
||||
removed: (text: string) => string
|
||||
bold: (text: string) => string
|
||||
italic: (text: string) => string
|
||||
underline: (text: string) => string
|
||||
strike: (text: string) => string
|
||||
accent: ColorRole
|
||||
/** The terminal's own default foreground; still a color, so it does not stack. */
|
||||
text: ColorRole
|
||||
/** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */
|
||||
dim: ColorRole
|
||||
success: ColorRole
|
||||
warning: ColorRole
|
||||
error: ColorRole
|
||||
code: ColorRole
|
||||
bold: AttributeRole
|
||||
italic: AttributeRole
|
||||
underline: AttributeRole
|
||||
strike: AttributeRole
|
||||
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
|
||||
selected: (text: string) => string
|
||||
selected: AttributeRole
|
||||
}
|
||||
|
||||
function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
|
||||
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
|
||||
/** Names of the palette's color roles, in the order `/palette` prints them. */
|
||||
export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const
|
||||
|
||||
/** Names of the palette's attribute roles, in the order `/palette` prints them. */
|
||||
export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const
|
||||
|
||||
/** One role's SGR parameters and the reason it carries them. */
|
||||
export interface RoleSpec {
|
||||
/** SGR parameters that open the span, without the `ESC [` prefix or `m` suffix. */
|
||||
readonly open: string
|
||||
/** SGR parameters that close it; MUST reset every group `open` sets. */
|
||||
readonly close: string
|
||||
/** What the role means, shown by `/palette`. */
|
||||
readonly purpose: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
|
||||
* attributes, which every terminal remaps to its active color scheme. Body
|
||||
* `text` stays the terminal's default foreground so it reads on light and dark
|
||||
* backgrounds alike; grouping uses foreground-only bold, underlined role
|
||||
* headers and reverse video rather than fixed background fills or per-line
|
||||
* prefixes, so a transcript drag-select copies message text without stray
|
||||
* glyphs.
|
||||
* Every SGR code the TUI is allowed to emit, keyed by role. This table is the
|
||||
* single source: {@link createPalette} derives the wrappers from it and
|
||||
* `/palette` prints it, so a role cannot exist in one and not the other, and no
|
||||
* component hand-writes an escape.
|
||||
*
|
||||
* Only the standard 16-color set and SGR attributes appear here. Terminals remap
|
||||
* those to the user's active theme, so the TUI stays legible on any background;
|
||||
* a fixed 24-bit color would not. The brand gradient is the one deliberate
|
||||
* exception ({@link gradientText}).
|
||||
*
|
||||
* @param scheme - Active terminal color scheme; only `code` differs between them.
|
||||
* @returns The SGR spec for every color and attribute role.
|
||||
*/
|
||||
export function paletteSpec(scheme: TerminalColorScheme): {
|
||||
readonly colors: Readonly<Record<typeof COLOR_ROLES[number], RoleSpec>>
|
||||
readonly attributes: Readonly<Record<typeof ATTRIBUTE_ROLES[number], RoleSpec>>
|
||||
} {
|
||||
return {
|
||||
colors: {
|
||||
// The terminal's own foreground, emitted as no escape at all: ordinary body
|
||||
// text must inherit whatever the user's theme uses.
|
||||
text: { open: '', close: '', purpose: 'Body text, the terminal default foreground' },
|
||||
// SGR 2 over an explicit default foreground, closing both groups it sets.
|
||||
// The attribute fades relative to whatever the terminal's own foreground is,
|
||||
// which is the only way to land *below* `text` on both schemes: ANSI 90
|
||||
// (bright black) is a fixed hue that many light themes render heavier than
|
||||
// their default foreground, which made every "dim" surface the most
|
||||
// prominent text on screen.
|
||||
dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' },
|
||||
accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' },
|
||||
// ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34
|
||||
// (blue) which is legible on both light and dark schemes.
|
||||
code: scheme === 'light'
|
||||
? { open: '34', close: '39', purpose: 'Inline code and code blocks in prose' }
|
||||
: { open: '36', close: '39', purpose: 'Inline code and code blocks in prose' },
|
||||
success: { open: '32', close: '39', purpose: 'Succeeded calls, and a diff\'s added lines' },
|
||||
warning: { open: '33', close: '39', purpose: 'Pending calls and warnings' },
|
||||
error: { open: '31', close: '39', purpose: 'Failures, signals, and a diff\'s removed lines' },
|
||||
},
|
||||
attributes: {
|
||||
bold: { open: '1', close: '22', purpose: 'Emphasis; composes with any color' },
|
||||
italic: { open: '3', close: '23', purpose: 'Reasoning text' },
|
||||
underline: { open: '4', close: '24', purpose: 'Role-header banding' },
|
||||
strike: { open: '9', close: '29', purpose: 'Struck-through Markdown' },
|
||||
selected: { open: '7', close: '27', purpose: 'Reverse video for the active selection' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text in an SGR pair, or pass it through when color is disabled.
|
||||
* An empty `open` emits nothing, so the `text` role costs no escape.
|
||||
*/
|
||||
function ansi(spec: RoleSpec, enabled: boolean): (text: string) => string {
|
||||
if (!enabled || spec.open === '') return text => text
|
||||
return text => `\x1b[${spec.open}m${text}\x1b[${spec.close}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette derived from {@link paletteSpec}. Body `text` stays the
|
||||
* terminal's default foreground so it reads on light and dark backgrounds alike;
|
||||
* grouping uses foreground-only bold, underlined role headers and reverse video
|
||||
* rather than fixed background fills or per-line prefixes, so a transcript
|
||||
* drag-select copies message text without stray glyphs.
|
||||
*
|
||||
* @param enabled - Whether ANSI is emitted at all.
|
||||
* @param scheme - Active terminal color scheme; adjusts dim and code roles.
|
||||
* @param scheme - Active terminal color scheme; adjusts the code role.
|
||||
* @returns The role palette for the given scheme.
|
||||
*/
|
||||
export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
|
||||
return {
|
||||
accent: ansi('94', '39', enabled),
|
||||
accent2: ansi('95', '39', enabled),
|
||||
text: text => text,
|
||||
muted: ansi('90', '39', enabled),
|
||||
// SGR 2 (dim) lightens text on a light background — substitute ANSI 90
|
||||
// (bright black / gray) which renders as a readable muted tone on any scheme.
|
||||
dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
|
||||
success: ansi('32', '39', enabled),
|
||||
warning: ansi('33', '39', enabled),
|
||||
error: ansi('31', '39', enabled),
|
||||
// ANSI 36 (cyan) is difficult to read on a light background — use
|
||||
// ANSI 34 (blue) which is legible on both light and dark schemes.
|
||||
code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
|
||||
added: ansi('32', '39', enabled),
|
||||
removed: ansi('31', '39', enabled),
|
||||
bold: ansi('1', '22', enabled),
|
||||
italic: ansi('3', '23', enabled),
|
||||
underline: ansi('4', '24', enabled),
|
||||
strike: ansi('9', '29', enabled),
|
||||
selected: ansi('7', '27', enabled),
|
||||
}
|
||||
const spec = paletteSpec(scheme)
|
||||
const roles = {} as Record<string, unknown>
|
||||
for (const name of COLOR_ROLES) roles[name] = ansi(spec.colors[name], enabled)
|
||||
for (const name of ATTRIBUTE_ROLES) roles[name] = ansi(spec.attributes[name], enabled)
|
||||
return roles as unknown as Palette
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,8 +227,8 @@ export function markdownTheme(palette: Palette): MarkdownTheme {
|
||||
// pi-tui presents both fence rows through this callback. Keep the opening
|
||||
// language label, but hide Markdown syntax and the otherwise-empty close.
|
||||
codeBlockBorder: text => palette.dim(text.slice(3)),
|
||||
quote: text => palette.muted(text),
|
||||
quoteBorder: text => palette.accent2(text),
|
||||
quote: text => palette.dim(text),
|
||||
quoteBorder: text => palette.accent(text),
|
||||
hr: text => palette.dim(text),
|
||||
listBullet: text => palette.accent(text),
|
||||
bold: text => palette.bold(text),
|
||||
@@ -165,7 +247,7 @@ export function selectTheme(palette: Palette): SelectListTheme {
|
||||
return {
|
||||
selectedPrefix: palette.accent,
|
||||
selectedText: palette.accent,
|
||||
description: palette.muted,
|
||||
description: palette.dim,
|
||||
scrollInfo: palette.dim,
|
||||
noMatch: palette.warning,
|
||||
}
|
||||
@@ -182,3 +264,49 @@ export function dialogSelectTheme(palette: Palette): SelectListTheme {
|
||||
selectedText: text => palette.selected(palette.accent(text)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample text every `/palette` row renders, long enough to judge a tone against its neighbours. */
|
||||
const PALETTE_SAMPLE = 'The quick brown fox 0123'
|
||||
|
||||
/**
|
||||
* Render every palette role as a labelled sample row, each painted by the role
|
||||
* it names, so a reader compares the actual tones their terminal produces rather
|
||||
* than reading SGR numbers. Colors print first and attributes second because the
|
||||
* two groups compose in that order; every row shows its SGR pair so a mismatch
|
||||
* between the table and the screen is visible.
|
||||
*
|
||||
* @param palette - Active role palette, used to paint each sample.
|
||||
* @param scheme - Active color scheme, reported in the heading and selecting the spec.
|
||||
* @param colorEnabled - Whether ANSI is emitted; reported so an unstyled listing is not confusing.
|
||||
* @returns The rendered rows, without a trailing blank.
|
||||
*/
|
||||
export function renderPalette(
|
||||
palette: Palette,
|
||||
scheme: TerminalColorScheme,
|
||||
colorEnabled: boolean,
|
||||
): string[] {
|
||||
const spec = paletteSpec(scheme)
|
||||
const width = Math.max(...[...COLOR_ROLES, ...ATTRIBUTE_ROLES].map(name => name.length))
|
||||
// Two rows per role: the painted sample beside its name and SGR pair, then the
|
||||
// purpose indented under it. Splitting the purpose onto its own row keeps every
|
||||
// sample on one visual line at the narrow widths a side-by-side pane gives.
|
||||
const head = (name: string, role: RoleSpec, sample: string): string => {
|
||||
const pair = role.open === '' ? 'no escape' : `ESC[${role.open}m ESC[${role.close}m`
|
||||
return ` ${sample} ${palette.dim(`${name.padEnd(width)} ${pair}`)}`
|
||||
}
|
||||
const purpose = (role: RoleSpec): string => ` ${palette.dim(` ${role.purpose}`)}`
|
||||
const rows = [
|
||||
palette.bold(palette.accent('Palette')),
|
||||
palette.dim(`${scheme} scheme · color ${colorEnabled ? 'on' : 'off'}`),
|
||||
'',
|
||||
palette.dim('Colors — exactly one per span; they never nest inside each other.'),
|
||||
]
|
||||
for (const name of COLOR_ROLES) {
|
||||
rows.push(head(name, spec.colors[name], palette[name](PALETTE_SAMPLE)), purpose(spec.colors[name]))
|
||||
}
|
||||
rows.push('', palette.dim('Attributes — compose with any color, in either order.'))
|
||||
for (const name of ATTRIBUTE_ROLES) {
|
||||
rows.push(head(name, spec.attributes[name], palette[name](PALETTE_SAMPLE)), purpose(spec.attributes[name]))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
ToolResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { FileDiff } from '@deepseek-ai/dsh-tools'
|
||||
import { renderUnknownXml } from './xml-tool-output.ts'
|
||||
import { preview, renderUnknownXml } from './xml-tool-output.ts'
|
||||
import { displayInlineText, displayText } from './text.ts'
|
||||
import { gradientText, type Palette } from './theme.ts'
|
||||
import { contentText, type ParsedArguments } from './content.ts'
|
||||
@@ -58,9 +58,9 @@ function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
// each hunk always carries its own path header (no redundancy to suppress).
|
||||
const lines = [palette.bold(displayText(diff.path))]
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`))
|
||||
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`))
|
||||
}
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export class HeaderComponent implements Component {
|
||||
const subtitle = this.subtitle()
|
||||
const lines = [
|
||||
title,
|
||||
...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
|
||||
...subtitle === undefined ? [] : [this.palette.dim(displayText(subtitle))],
|
||||
this.palette.dim(detail),
|
||||
]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
@@ -147,12 +147,12 @@ function assistantMessageChildren(
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
const children: Component[] = [
|
||||
new Spacer(1),
|
||||
new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0),
|
||||
new Text(messageHeader('Assistant', palette.accent, palette), 0, 0),
|
||||
]
|
||||
if (reasoning && showReasoning) {
|
||||
children.push(
|
||||
new Text(palette.italic(palette.muted('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.muted(value), italic: true }),
|
||||
new Text(palette.italic(palette.dim('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }),
|
||||
)
|
||||
}
|
||||
if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }))
|
||||
@@ -301,10 +301,27 @@ export class StreamingAssistantComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool card's body split at the Markdown boundary. `prelude` rows are already
|
||||
* styled and render verbatim (a terminal `$` command, its cwd, a diff's hunks);
|
||||
* `lines` is the tool's own text. A generic card renders both as one Markdown
|
||||
* document under the dim body tone.
|
||||
*/
|
||||
interface CardBody {
|
||||
readonly prelude: readonly string[]
|
||||
readonly lines: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+O card-visibility cycle: `hidden` drops tool cards from the transcript,
|
||||
* `collapsed` previews the first body lines, `expanded` shows everything.
|
||||
*/
|
||||
export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded'
|
||||
|
||||
/** A tool call and its result, rendered as a collapsible status card. */
|
||||
export class ToolCardComponent implements Component {
|
||||
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
|
||||
private expanded = false
|
||||
private visibility: ToolCardVisibility = 'collapsed'
|
||||
private callView: ToolCallView
|
||||
private resultView: ToolResultView | undefined
|
||||
|
||||
@@ -353,16 +370,19 @@ export class ToolCardComponent implements Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card's body preview.
|
||||
* @param expanded - Whether the full body is shown.
|
||||
* Set the card's visibility state.
|
||||
* @param visibility - Hidden, collapsed preview, or full body.
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
setVisibility(visibility: ToolCardVisibility): void {
|
||||
this.visibility = visibility
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Hidden renders nothing — not even the leading gap — so the transcript
|
||||
// keeps only the conversation, the way Codex hides tool calls.
|
||||
if (this.visibility === 'hidden') return []
|
||||
const isError = this.result?.isError ?? false
|
||||
// A ring marker: hollow while the call is pending, filled once it settles;
|
||||
// the header color (warning/success/error) tells pending from ok from error.
|
||||
@@ -374,25 +394,23 @@ export class ToolCardComponent implements Component {
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(genericContent)),
|
||||
this.maxOutputLines,
|
||||
this.expanded,
|
||||
this.visibility === 'expanded',
|
||||
displayText,
|
||||
text => this.palette.muted(text),
|
||||
text => this.palette.dim(text),
|
||||
text => this.palette.dim(text),
|
||||
/* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */
|
||||
count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`),
|
||||
)
|
||||
: undefined
|
||||
const body = unknownXml ?? (genericContent !== undefined && rawBody.length > 0
|
||||
? new Markdown(rawBody.join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value) }).render(width)
|
||||
: rawBody)
|
||||
const headLines = Math.ceil(this.maxOutputLines / 2)
|
||||
const tailLines = this.maxOutputLines - headLines
|
||||
const visibleBody = unknownXml !== undefined || this.expanded || body.length <= this.maxOutputLines
|
||||
// A generic card renders title and result as one Markdown document, so the
|
||||
// document's own block spacing is preserved, then dims every row — the whole
|
||||
// card body reads as one dim block under the status-colored header.
|
||||
const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0
|
||||
? this.dimBody(rawBody, width)
|
||||
: [...rawBody.prelude, ...rawBody.lines])
|
||||
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
|
||||
? body
|
||||
: [
|
||||
...body.slice(0, headLines),
|
||||
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
|
||||
...body.slice(body.length - tailLines),
|
||||
]
|
||||
: preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`))
|
||||
// The header is a fixed `Tool / <name>` frame in the status color (warning
|
||||
// pending / success ok / error), flat — no bold or underline, so one color
|
||||
// reads consistently across the whole row. Every tool-specific detail (a
|
||||
@@ -409,7 +427,9 @@ export class ToolCardComponent implements Component {
|
||||
const desc = this.headerDescription()
|
||||
const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}`
|
||||
const header = truncateToWidth(headerText, Math.max(1, width - 2), '')
|
||||
const lines = [statusColor(header)]
|
||||
// The blank first row is the card's own paragraph gap (no external Spacer),
|
||||
// so the hidden state removes the gap together with the card.
|
||||
const lines: string[] = ['', statusColor(header)]
|
||||
if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width))
|
||||
return lines
|
||||
}
|
||||
@@ -438,10 +458,11 @@ export class ToolCardComponent implements Component {
|
||||
return this.resultView?.title ?? this.callView.title
|
||||
}
|
||||
|
||||
private renderBody(): string[] {
|
||||
private renderBody(): CardBody {
|
||||
const view = this.resultView ?? this.callView
|
||||
if (view.card === 'terminal') {
|
||||
const pending = this.terminalPending()
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The command shows as a $-line here whenever it is not the header: either a
|
||||
// description headlines the row (the command still belongs somewhere) or the row
|
||||
@@ -452,18 +473,18 @@ export class ToolCardComponent implements Component {
|
||||
// rows and collide with the output below.
|
||||
const headlined = pending?.description !== undefined && pending.description !== ''
|
||||
const commandInBody = pending !== undefined && (headlined || this.result === undefined)
|
||||
if (commandInBody) lines.push(this.palette.code(`$ ${displayInlineText(pending.title)}`))
|
||||
if (pending?.cwd) lines.push(this.palette.dim(displayInlineText(pending.cwd)))
|
||||
if (commandInBody) prelude.push(this.palette.dim(`$ ${displayInlineText(pending.title)}`))
|
||||
if (pending?.cwd) prelude.push(this.palette.dim(displayInlineText(pending.cwd)))
|
||||
if (this.resultView?.card === 'terminal') {
|
||||
if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
|
||||
if (this.resultView.output) lines.push(...this.dimOutput(this.resultView.output))
|
||||
if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
|
||||
if (this.resultView.signal !== undefined) {
|
||||
lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
|
||||
}
|
||||
} else if (this.result !== undefined) {
|
||||
lines.push(...displayText(contentText(this.result.content)).split('\n'))
|
||||
lines.push(...this.dimOutput(contentText(this.result.content)))
|
||||
}
|
||||
return lines.filter(Boolean)
|
||||
return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) }
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
// The header no longer names the file, so each diff keeps its own path
|
||||
@@ -477,22 +498,138 @@ export class ToolCardComponent implements Component {
|
||||
})
|
||||
const files = view.diffs.length
|
||||
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
|
||||
return [...hunks, footer]
|
||||
// A diff's own `+`/`-` colors carry its meaning, so it renders verbatim
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
// `Tool / <name>` frame (a terminal card keeps its command $-line instead).
|
||||
// Skip it when it only repeats the tool name (the fallback presenter for a
|
||||
// tool with no presentCall, or an unknown tool), which the header already shows.
|
||||
const bodyTitle = this.bodyTitle()
|
||||
if (bodyTitle !== displayText(this.name)) lines.push(displayInlineText(bodyTitle))
|
||||
if (bodyTitle !== displayText(this.name)) prelude.push(displayInlineText(bodyTitle))
|
||||
if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
|
||||
const rawInput = this.result === undefined && this.callView.card === 'generic'
|
||||
? this.callView.rawInput
|
||||
: undefined
|
||||
if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
|
||||
return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1))
|
||||
// Blank-line trimming spans the whole body, so the title counts as a row:
|
||||
// interior blanks (a result's own paragraph break) survive while the body's
|
||||
// leading and trailing ones are dropped.
|
||||
const total = prelude.length + lines.length
|
||||
return {
|
||||
prelude,
|
||||
lines: lines.filter((line, index) => {
|
||||
const row = prelude.length + index
|
||||
return line.length > 0 || (row > 0 && row < total - 1)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's own output text as dim rows — the card's result-output color, which
|
||||
* separates what the tool produced from the card's own framing. A blank row
|
||||
* stays the empty string so the terminal branch's blank-row filter still reads
|
||||
* it as blank instead of as an ANSI-wrapped value.
|
||||
*/
|
||||
private dimOutput(text: string): string[] {
|
||||
return displayText(text).split('\n').map(line => line === '' ? line : this.palette.dim(line))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a generic card's prelude and result as one Markdown document under the
|
||||
* dim body tone. Rendering both together preserves the document's own block
|
||||
* spacing (Markdown's blank row before a heading); dimming every row keeps the
|
||||
* card body one uniform tone, so only the status-colored header carries color.
|
||||
*/
|
||||
private dimBody(body: CardBody, width: number): string[] {
|
||||
const rows = new Markdown([...body.prelude, ...body.lines].join('\n'), 0, 0, this.mdTheme, {
|
||||
color: value => this.palette.text(value),
|
||||
}).render(width)
|
||||
// A whitespace-only row carries no output to dim; leaving it unwrapped keeps
|
||||
// Markdown's padding out of the styled ranges.
|
||||
return rows.map(row => row.trim() === '' ? row : this.palette.dim(row))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a lone reminder-frame tag on its own line, capturing the element name.
|
||||
* Producers emit the frame as whole lines (`workspace-context`, `dsh-tool-skill`),
|
||||
* so anchoring the whole line keeps a tag mentioned inside prose from matching.
|
||||
*/
|
||||
const REMINDER_FRAME_LINE = /^<(\/?)([a-zA-Z][\w:.-]*)>$/u
|
||||
|
||||
/**
|
||||
* Drop a producer's outer reminder frame, keeping the instruction body verbatim.
|
||||
* The card header already names the source, so the frame lines carry nothing.
|
||||
* Only a matched open/close pair on the first and last lines is removed, so a
|
||||
* body that merely starts with a tag-like line is left intact.
|
||||
* @param text - Complete model-facing context text.
|
||||
* @returns The body without its outer frame lines, trimmed of the blank lines they leave.
|
||||
*/
|
||||
function stripReminderFrame(text: string): string {
|
||||
// A frame needs an open line and a distinct close line, so anything shorter than
|
||||
// two lines is already frameless.
|
||||
const [first = '', ...rest] = text.split('\n')
|
||||
const last = rest.at(-1)
|
||||
if (last === undefined) return text
|
||||
const open = REMINDER_FRAME_LINE.exec(first.trim())
|
||||
const close = REMINDER_FRAME_LINE.exec(last.trim())
|
||||
if (open?.[1] !== '' || close?.[1] !== '/' || open[2] !== close[2]) return text
|
||||
return rest.slice(0, -1).join('\n').replace(/^\n+|\n+$/gu, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected context (plugin/goal source, e.g. `workspace-context`), rendered as a
|
||||
* collapsible dim card that shares the tool-card `Ctrl+O` toggle. The header is
|
||||
* `Context · <label>`; the body is the message text as dim prose, one tone with
|
||||
* the header and the fold marker, folded to `maxOutputLines`, with a surrounding
|
||||
* reminder frame stripped because the source label already names the context.
|
||||
*
|
||||
* Injected context is prose, not markup, so this card does not parse it. The
|
||||
* `<system-reminder>` frame is a prompting convention no model is trained on
|
||||
* ([envelope rationale](../../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)),
|
||||
* and instruction bodies legitimately contain a raw `&` or angle-bracket
|
||||
* placeholders (`packages/<group>/<pkg>/`, `-t <name>`) that are prose rather than
|
||||
* elements. Tree-rendering such a payload depended on whether it happened to be
|
||||
* well-formed XML, which made both the fold and the frame-line suppression
|
||||
* content-dependent.
|
||||
*/
|
||||
export class ContextCardComponent implements Component {
|
||||
private expanded = false
|
||||
|
||||
constructor(
|
||||
private readonly label: string,
|
||||
private readonly text: string,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly palette: Palette,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card body.
|
||||
* @param expanded - Whether the full body is shown.
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const header = this.palette.dim(`Context · ${displayText(this.label)}`)
|
||||
// Emptiness is decided on the stripped text: styling a blank body would yield
|
||||
// one escape-only row, which reads as a stray blank line under the header.
|
||||
const stripped = stripReminderFrame(this.text)
|
||||
if (stripped === '') return [header]
|
||||
const body = stripped.split('\n')
|
||||
.map(line => line === '' ? line : this.palette.dim(displayText(line)))
|
||||
const visibleBody = this.expanded
|
||||
? body
|
||||
: preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`))
|
||||
return [header, ...new Text(visibleBody.join('\n'), 0, 0).render(width)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +651,7 @@ export class TodoComponent implements Component {
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.todos.length === 0) return []
|
||||
const lines = [this.palette.bold(this.palette.accent('Plan'))]
|
||||
const lines: string[] = [this.palette.bold(this.palette.accent('Plan'))]
|
||||
for (const todo of this.todos) {
|
||||
const prefix = todo.status === 'completed'
|
||||
? this.palette.success('✓')
|
||||
@@ -522,7 +659,7 @@ export class TodoComponent implements Component {
|
||||
? this.palette.warning('●')
|
||||
: this.palette.dim('○')
|
||||
const content = displayText(todo.content)
|
||||
const text = todo.status === 'completed' ? this.palette.muted(content) : content
|
||||
const text: string = todo.status === 'completed' ? this.palette.dim(content) : content
|
||||
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
|
||||
}
|
||||
return ['', ...lines]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Conservative readable-tree rendering for model-facing text containing one XML
|
||||
* document, used by the transcript's tool and context cards.
|
||||
* document, used by the transcript's tool cards for unknown tool results. Injected
|
||||
* context is prose and is not parsed; only {@link preview} is shared with its card.
|
||||
* @module @deepseek-ai/dsh-tui/components/xml-tool-output
|
||||
*/
|
||||
|
||||
@@ -76,26 +77,42 @@ function meaningfulChildren(element: XmlElement): readonly XmlNode[] {
|
||||
return element.children.filter(child => typeof child !== 'string' || child.trim() !== '')
|
||||
}
|
||||
|
||||
function textBlock(text: string, depth: number): string[] {
|
||||
return text.replace(/^\n|\n$/gu, '').split('\n').map(line => `${' '.repeat(depth)}${line}`)
|
||||
function textBlock(text: string, depth: number, body: (text: string) => string): string[] {
|
||||
return text.replace(/^\n|\n$/gu, '').split('\n')
|
||||
.map(line => line === '' ? line : `${' '.repeat(depth)}${body(line)}`)
|
||||
}
|
||||
|
||||
function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] {
|
||||
function treeLines(
|
||||
element: XmlElement,
|
||||
depth: number,
|
||||
label: (text: string) => string,
|
||||
body: (text: string) => string,
|
||||
): string[] {
|
||||
const indent = ' '.repeat(depth)
|
||||
const children = meaningfulChildren(element)
|
||||
if (children.length === 0) return [`${indent}${label(elementLabel(element))}`]
|
||||
if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) {
|
||||
return [`${indent}${label(`${elementLabel(element)}:`)} ${children[0].trim()}`]
|
||||
return [`${indent}${label(`${elementLabel(element)}:`)} ${body(children[0].trim())}`]
|
||||
}
|
||||
const lines = [`${indent}${label(elementLabel(element))}`]
|
||||
for (const child of children) {
|
||||
if (typeof child === 'string') lines.push(...textBlock(child, depth + 1))
|
||||
else lines.push(...treeLines(child, depth + 1, label))
|
||||
if (typeof child === 'string') lines.push(...textBlock(child, depth + 1, body))
|
||||
else lines.push(...treeLines(child, depth + 1, label, body))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] {
|
||||
/**
|
||||
* Collapse `lines` to a head/tail preview around one omitted-count marker.
|
||||
* The single fold rule for every transcript card, so a card's fold never depends
|
||||
* on how its body was rendered: tool cards share it with their tree output and
|
||||
* context cards apply it to prose rows.
|
||||
* @param lines - Fully rendered body rows.
|
||||
* @param limit - Maximum retained rows, excluding the marker.
|
||||
* @param omitted - Renders the marker for the omitted row count.
|
||||
* @returns `lines` unchanged when within `limit`, else head rows, the marker, and tail rows.
|
||||
*/
|
||||
export function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] {
|
||||
if (lines.length <= limit) return [...lines]
|
||||
const head = Math.ceil(limit / 2)
|
||||
const tail = limit - head
|
||||
@@ -104,13 +121,15 @@ function preview(lines: readonly string[], limit: number, omitted: (count: numbe
|
||||
|
||||
/**
|
||||
* Render a complete XML document as an indented tree, or decline without changing partial/mixed text.
|
||||
* @param source - Raw model-facing text from a context message or unknown tool result.
|
||||
* @param source - Raw model-facing text from an unknown tool result.
|
||||
* @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and
|
||||
* to the number of top-level children, so many siblings cannot grow the collapsed card without bound.
|
||||
* @param expanded - Whether to retain every rendered child line.
|
||||
* @param display - Escapes parsed text and attribute values for terminal output; character references
|
||||
* can expand to control characters that pre-parse escaping never saw.
|
||||
* @param label - Styles element names and attributes.
|
||||
* @param body - Styles the text content under those elements; the card's body tone, so tree
|
||||
* content matches the surrounding card rows instead of falling back to the default foreground.
|
||||
* @param omitted - Renders the omitted-line marker for a collapsed child or child range.
|
||||
* @returns Tree rows, or `undefined` when `source` is not one supported complete XML document.
|
||||
*/
|
||||
@@ -120,12 +139,13 @@ export function renderUnknownXml(
|
||||
expanded: boolean,
|
||||
display: (text: string) => string,
|
||||
label: (text: string) => string,
|
||||
body: (text: string) => string,
|
||||
omitted: (count: number) => string,
|
||||
): string[] | undefined {
|
||||
const root = parseXml(source, display)
|
||||
if (root === undefined) return undefined
|
||||
const blocks = meaningfulChildren(root).map(child =>
|
||||
typeof child === 'string' ? textBlock(child, 1) : treeLines(child, 1, label))
|
||||
typeof child === 'string' ? textBlock(child, 1, body) : treeLines(child, 1, label, body))
|
||||
const rootLine = label(elementLabel(root))
|
||||
if (expanded) return [rootLine, ...blocks.flat()]
|
||||
const previewed = blocks.map(block => preview(block, maxChildLines, omitted))
|
||||
|
||||
@@ -79,7 +79,7 @@ const colorSchema = z.boolean().default(true)
|
||||
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
|
||||
const truecolorSchema = z.boolean()
|
||||
const DEFAULT_LEFT_PROMPT = '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}'
|
||||
const DEFAULT_RIGHT_PROMPT = '${timing}'
|
||||
const DEFAULT_RIGHT_PROMPT = '${queued}'
|
||||
const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}'
|
||||
const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel'
|
||||
const TuiThemeConfigSchema: z<TuiThemeConfig> = z.object({
|
||||
@@ -120,19 +120,19 @@ export interface Config extends TuiConfig {
|
||||
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
|
||||
sessionId?: string
|
||||
/**
|
||||
* Shell command fallback printed on exit or after selecting a session when
|
||||
* the host cannot hand off in place. Every `{session}` becomes the selected
|
||||
* id; the TUI never executes this text. Absent disables only the fallback,
|
||||
* not the interactive selector.
|
||||
* Skill name auto-invoked as this session's first user turn, exactly as if
|
||||
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
|
||||
* turn to the user.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
initialSkill?: string
|
||||
}
|
||||
|
||||
/** Schemastery schema for the full plugin configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
welcome: z.string(),
|
||||
sessionId: z.string().default('main'),
|
||||
resumeCommand: z.string(),
|
||||
initialSkill: z.string(),
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
|
||||
@@ -37,9 +37,7 @@ export interface TuiFocusable {
|
||||
export interface TuiTheme {
|
||||
/** Render ordinary foreground text. */
|
||||
readonly text: (value: string) => string
|
||||
/** Render secondary information. */
|
||||
readonly muted: (value: string) => string
|
||||
/** Render low-emphasis hints. */
|
||||
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
|
||||
readonly dim: (value: string) => string
|
||||
/** Render the active accent role. */
|
||||
readonly accent: (value: string) => string
|
||||
|
||||
@@ -32,7 +32,6 @@ import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { renderUnknownXml } from './components/xml-tool-output.ts'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
@@ -67,7 +66,7 @@ import type {
|
||||
TuiTheme,
|
||||
} from './extension/types.ts'
|
||||
import { displayInlineText, displayText } from './components/text.ts'
|
||||
import { createPalette, markdownTheme, selectTheme } from './components/theme.ts'
|
||||
import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
|
||||
import { contentText, parseArguments } from './components/content.ts'
|
||||
import {
|
||||
cacheHitRate,
|
||||
@@ -92,6 +91,8 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import {
|
||||
ContextCardComponent,
|
||||
type ToolCardVisibility,
|
||||
HeaderComponent,
|
||||
StreamingAssistantComponent,
|
||||
ToolCardComponent,
|
||||
@@ -175,9 +176,68 @@ declare module 'cordis' {
|
||||
tui: TuiExtensionService
|
||||
/** Optional process host that can replace this TUI with a resumed session. */
|
||||
tuiResumeHost: TuiResumeHost
|
||||
/** Launcher-owned `main` session identity; absent lets the app mint one. */
|
||||
mainSessionId: MainSessionIdentity | undefined
|
||||
/** Line the launcher wants printed on exit; absent prints nothing. */
|
||||
tuiGoodbyeMessage: string | undefined
|
||||
/** Skill the launcher wants auto-invoked as the fresh session's first turn; absent leaves it to the user. */
|
||||
tuiInitialSkill: string | undefined
|
||||
/** Launcher-owned session-store root the app bundle defaults to; absent keeps the bundle's project-local default. */
|
||||
launcherSessionsRoot: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Launcher-chosen identity for the app's `main` session. */
|
||||
export interface MainSessionIdentity {
|
||||
/** Exact session id `main` binds to. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Whether that session already has persisted history to load. `true` requires
|
||||
* an existing log and fails loud when absent; `false` creates it fresh.
|
||||
*/
|
||||
readonly resume: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(MAIN_SESSION_ID_KEY, identity)`) to fix the `main` agent's
|
||||
* session identity, so an app bundle mounted from a `cordis.yml` binds a
|
||||
* launcher-selected session without a config key. `ctx.provide` is the only
|
||||
* channel from launcher argv into a Loader-mounted plugin, because config
|
||||
* `!!js` expressions evaluate against the entry's context. Absent leaves the
|
||||
* choice to the app.
|
||||
*/
|
||||
export const MAIN_SESSION_ID_KEY = 'mainSessionId'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(TUI_GOODBYE_MESSAGE_KEY, line)`) to supply the line the TUI
|
||||
* prints once the terminal is released on exit — for the shipped CLI, the
|
||||
* command that resumes this session. The launcher owns the wording because only
|
||||
* it knows how it was invoked; the TUI escapes terminal controls before
|
||||
* rendering. Absent prints nothing.
|
||||
*/
|
||||
export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade` guided-session
|
||||
* entry. The launcher sets it only when minting a fresh session, so it never
|
||||
* re-fires on a resumed one. Absent leaves the first turn to the user.
|
||||
*/
|
||||
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(SESSIONS_ROOT_KEY, root)`) to supply its session-store root as
|
||||
* the app bundle's default persistence root. Shared-store policy (one store
|
||||
* across every cwd) belongs to the launcher — the dsh CLI resolves it under the
|
||||
* Harness home — never to a plugin; a bundle without this slot keeps its own
|
||||
* project-local default, and an explicit `persistenceRoot` config still wins.
|
||||
*/
|
||||
export const SESSIONS_ROOT_KEY = 'launcherSessionsRoot'
|
||||
|
||||
/**
|
||||
* Optional terminal-local interaction service provided by one mounted TUI.
|
||||
*
|
||||
@@ -246,7 +306,6 @@ export function createTuiChat(
|
||||
const sessionId = SessionId(config.sessionId ?? 'main')
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
const sessionQuery = ctx.get('sessionQuery')
|
||||
const resolved = resolveTuiConfig(config)
|
||||
const palette = createPalette(resolved.theme.color)
|
||||
@@ -271,7 +330,9 @@ export function createTuiChat(
|
||||
editor.hintPrefix = initialInputPrompt
|
||||
const todo = new TodoComponent(palette)
|
||||
let showReasoning = resolved.showReasoning
|
||||
let toolsExpanded = false
|
||||
// Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops
|
||||
// tool cards entirely, collapsed previews, expanded shows full bodies.
|
||||
let toolsVisibility: ToolCardVisibility = 'collapsed'
|
||||
let streaming: StreamingAssistantComponent | undefined
|
||||
let completedStreaming: StreamingAssistantComponent | undefined
|
||||
let runningStatus: RunningStatus | undefined
|
||||
@@ -295,6 +356,7 @@ export function createTuiChat(
|
||||
const tokens = sessionTokens(agent.session)
|
||||
const toolCards = new Map<string, ToolCardComponent>()
|
||||
const allToolCards = new Set<ToolCardComponent>()
|
||||
const contextCards = new Set<ContextCardComponent>()
|
||||
const liveErrors = new Set<string>()
|
||||
const commandControllers = new Set<AbortController>()
|
||||
const referenceControllers = new Set<AbortController>()
|
||||
@@ -323,33 +385,33 @@ export function createTuiChat(
|
||||
const branch = runtime.gitBranch?.(cwd) ?? gitBranch(cwd)
|
||||
const promptValues: TuiPromptValueHandle[] = [
|
||||
ctx.tuiPrompt.register('cwd', palette.bold(palette.accent(formattedCwd))),
|
||||
ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)),
|
||||
ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`)),
|
||||
ctx.tuiPrompt.register('token_meter/cache_hit_rate'),
|
||||
ctx.tuiPrompt.register('model'),
|
||||
ctx.tuiPrompt.register('context'),
|
||||
ctx.tuiPrompt.register('timing'),
|
||||
ctx.tuiPrompt.register('queued'),
|
||||
ctx.tuiPrompt.register('symbol', palette.bold(palette.accent('dsh'))),
|
||||
ctx.tuiPrompt.register('indicator', palette.muted('> ')),
|
||||
ctx.tuiPrompt.register('indicator', palette.dim('> ')),
|
||||
]
|
||||
const [cwdValue, gitValue, tokenValue, modelValue, contextValue, timingValue, symbolValue, indicatorValue] = promptValues
|
||||
const [cwdValue, gitValue, tokenValue, modelValue, contextValue, queuedValue, symbolValue, indicatorValue] = promptValues
|
||||
/* v8 ignore next -- the fixed built-in registration list always supplies each handle. */
|
||||
if (cwdValue === undefined || gitValue === undefined || tokenValue === undefined || modelValue === undefined
|
||||
|| contextValue === undefined || timingValue === undefined || symbolValue === undefined || indicatorValue === undefined) {
|
||||
|| contextValue === undefined || queuedValue === undefined || symbolValue === undefined || indicatorValue === undefined) {
|
||||
throw new Error('TUI prompt built-ins failed to initialize')
|
||||
}
|
||||
const updatePromptValues = (): void => {
|
||||
cwdValue.set(palette.bold(palette.accent(formattedCwd)))
|
||||
gitValue.set(branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`))
|
||||
gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`))
|
||||
const rate = cacheHitRate(tokens)
|
||||
const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}`
|
||||
modelValue.set(` ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`)
|
||||
tokenValue.set(` ${palette.muted(rate === undefined ? usage : `${usage} cache ${rate}%`)}`)
|
||||
modelValue.set(` ${palette.dim(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`)
|
||||
tokenValue.set(` ${palette.dim(rate === undefined ? usage : `${usage} cache ${rate}%`)}`)
|
||||
const contextWindow = modelController.contextWindow()
|
||||
contextValue.set(contextWindow === undefined ? undefined : ` ${palette.muted(
|
||||
contextValue.set(contextWindow === undefined ? undefined : ` ${palette.dim(
|
||||
`${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`,
|
||||
)}`)
|
||||
const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size)
|
||||
timingValue.set(queued === undefined ? undefined : palette.dim(queued))
|
||||
queuedValue.set(queued === undefined ? undefined : palette.dim(queued))
|
||||
symbolValue.set(palette.bold(palette.accent('dsh')))
|
||||
// `${indicator}` owns the caret column and its trailing gap before the
|
||||
// cursor. The phase glyph replaces the `>` caret in place — same width
|
||||
@@ -370,7 +432,7 @@ export function createTuiChat(
|
||||
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) }
|
||||
: undefined
|
||||
const caret = envelope === undefined
|
||||
? palette.muted('>')
|
||||
? palette.dim('>')
|
||||
: fadeGlyph(
|
||||
envelope.glyph,
|
||||
palette,
|
||||
@@ -379,7 +441,7 @@ export function createTuiChat(
|
||||
envelope.level * pulseLevel(now()),
|
||||
envelope.level >= 0.5,
|
||||
)
|
||||
indicatorValue.set(`${caret}${palette.muted(' ')}`)
|
||||
indicatorValue.set(`${caret}${palette.dim(' ')}`)
|
||||
}
|
||||
const promptContext = new PromptContextComponent(
|
||||
parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)),
|
||||
@@ -416,7 +478,7 @@ export function createTuiChat(
|
||||
const disposePromptChanges = ctx.tuiPrompt.subscribe(requestRender)
|
||||
|
||||
const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => {
|
||||
const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted
|
||||
const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.dim
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(color(displayText(message)), 0, 0))
|
||||
requestRender()
|
||||
@@ -424,7 +486,6 @@ export function createTuiChat(
|
||||
|
||||
const extensionTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => palette.text(value),
|
||||
muted: (value: string) => palette.muted(value),
|
||||
dim: (value: string) => palette.dim(value),
|
||||
accent: (value: string) => palette.accent(value),
|
||||
success: (value: string) => palette.success(value),
|
||||
@@ -547,7 +608,7 @@ export function createTuiChat(
|
||||
palette,
|
||||
mdTheme,
|
||||
)
|
||||
card.setExpanded(toolsExpanded)
|
||||
card.setVisibility(toolsVisibility)
|
||||
toolCards.set(event.data.callId, card)
|
||||
allToolCards.add(card)
|
||||
return card
|
||||
@@ -626,22 +687,18 @@ export function createTuiChat(
|
||||
/* v8 ignore next -- context events with empty content are rejected by their owning producers. */
|
||||
if (text) {
|
||||
// The tui type view lacks plugin-augmented source kinds (e.g. goal),
|
||||
// so read the display label without narrowing on `kind`.
|
||||
const labelled = source as { kind: string; plugin?: string }
|
||||
/* v8 ignore next -- current plugin-augmented context sources always carry their display label. */
|
||||
const label = labelled.plugin ?? labelled.kind
|
||||
const xml = renderUnknownXml(
|
||||
text,
|
||||
resolved.maxToolOutputLines,
|
||||
true,
|
||||
displayText,
|
||||
value => palette.muted(value),
|
||||
/* v8 ignore next -- expanded context XML never asks renderUnknownXml for a collapsed summary. */
|
||||
() => '',
|
||||
)
|
||||
// so read the display label without narrowing on `kind`. The session
|
||||
// log is a durable/replay boundary: a corrupt or foreign injected
|
||||
// source may not match the typed shape, so fall back to `context`.
|
||||
const labelled = source as { kind?: unknown; plugin?: unknown }
|
||||
const label = typeof labelled.plugin === 'string' ? labelled.plugin
|
||||
: typeof labelled.kind === 'string' ? labelled.kind
|
||||
: 'context'
|
||||
const card = new ContextCardComponent(label, text, resolved.maxToolOutputLines, palette)
|
||||
card.setExpanded(toolsVisibility === 'expanded')
|
||||
contextCards.add(card)
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 0, 0))
|
||||
chat.addChild(new Text(xml?.join('\n') ?? palette.muted(displayText(text)), 0, 0))
|
||||
chat.addChild(card)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -681,8 +738,9 @@ export function createTuiChat(
|
||||
)
|
||||
break
|
||||
}
|
||||
// No external Spacer for tool cards: the card renders its own leading
|
||||
// gap, so the hidden state removes the row and the gap together.
|
||||
case 'tool/call':
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(parsedTool(event))
|
||||
trailStreamingTiming()
|
||||
break
|
||||
@@ -691,7 +749,7 @@ export function createTuiChat(
|
||||
let card = toolCards.get(callId)
|
||||
if (card === undefined) {
|
||||
card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme)
|
||||
chat.addChild(new Spacer(1))
|
||||
card.setVisibility(toolsVisibility)
|
||||
chat.addChild(card)
|
||||
allToolCards.add(card)
|
||||
}
|
||||
@@ -762,6 +820,7 @@ export function createTuiChat(
|
||||
chat.clear()
|
||||
toolCards.clear()
|
||||
allToolCards.clear()
|
||||
contextCards.clear()
|
||||
streaming = undefined
|
||||
todo.update([])
|
||||
const active = activeSurfaceSeqs(agent.session)
|
||||
@@ -790,12 +849,10 @@ export function createTuiChat(
|
||||
const resume = createResumeController({
|
||||
ctx,
|
||||
agent,
|
||||
config,
|
||||
runtime,
|
||||
resolved,
|
||||
palette,
|
||||
overlayManager,
|
||||
persistence,
|
||||
sessionQuery,
|
||||
ui,
|
||||
editor,
|
||||
@@ -824,9 +881,8 @@ export function createTuiChat(
|
||||
await runtime.terminal.drainInput(100, 20)
|
||||
ui.stop()
|
||||
if (exitProcess) {
|
||||
const command = await resume.currentResumeCommand()
|
||||
if (command !== undefined) {
|
||||
runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`)
|
||||
if (runtime.goodbyeMessage !== undefined) {
|
||||
runtime.terminal.write(`${palette.dim(displayText(runtime.goodbyeMessage))}\n`)
|
||||
}
|
||||
runtime.exit(0)
|
||||
}
|
||||
@@ -870,9 +926,15 @@ export function createTuiChat(
|
||||
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
|
||||
|
||||
const toggleTools = (): void => {
|
||||
toolsExpanded = !toolsExpanded
|
||||
for (const card of allToolCards) card.setExpanded(toolsExpanded)
|
||||
appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`)
|
||||
// The cycle order puts the two common reading modes adjacent: preview ->
|
||||
// full detail -> conversation-only, then back to the preview default.
|
||||
toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded'
|
||||
: toolsVisibility === 'expanded' ? 'hidden' : 'collapsed'
|
||||
for (const card of allToolCards) card.setVisibility(toolsVisibility)
|
||||
// Context cards carry injected instructions rather than tool traffic, so
|
||||
// they never hide: the hidden phase reads as their collapsed preview.
|
||||
for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded')
|
||||
appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`)
|
||||
}
|
||||
|
||||
const toggleReasoning = (): void => {
|
||||
@@ -898,12 +960,20 @@ export function createTuiChat(
|
||||
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 0, 0))
|
||||
chat.addChild(new Text([
|
||||
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
|
||||
'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning',
|
||||
'Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L redraw',
|
||||
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
|
||||
'',
|
||||
...commandLines,
|
||||
'/skill:<name> [instructions] — load a skill into the conversation',
|
||||
].map(line => palette.muted(line)).join('\n'), 0, 0))
|
||||
].map(line => palette.dim(line)).join('\n'), 0, 0))
|
||||
requestRender()
|
||||
}
|
||||
|
||||
const showPalette = (): void => {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(
|
||||
renderPalette(palette, currentScheme, resolved.theme.color).join('\n'), 0, 0,
|
||||
))
|
||||
requestRender()
|
||||
}
|
||||
|
||||
@@ -1050,19 +1120,9 @@ export function createTuiChat(
|
||||
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'reasoning',
|
||||
description: 'Toggle reasoning blocks',
|
||||
handler: () => { toggleReasoning(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'tools',
|
||||
description: 'Expand or collapse all tool cards',
|
||||
handler: () => { toggleTools(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'redraw',
|
||||
description: 'Invalidate components and redraw the terminal',
|
||||
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
|
||||
name: 'palette',
|
||||
description: 'Show every color and attribute role this terminal renders',
|
||||
handler: () => { showPalette(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'reload',
|
||||
@@ -1504,6 +1564,13 @@ export function createTuiChat(
|
||||
})
|
||||
startBannerReveal()
|
||||
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): invoke the
|
||||
// named skill exactly as a typed `/skill:<name>` would, once the chat is live
|
||||
// and the agent is idle. The launcher sets this only for a fresh session, so
|
||||
// there is no prior turn to collide with; invokeSkill reports an unknown skill
|
||||
// as a notice.
|
||||
if (config.initialSkill !== undefined) invokeSkill(config.initialSkill, '')
|
||||
|
||||
return {
|
||||
async dispose(): Promise<void> {
|
||||
detachListeners()
|
||||
@@ -1567,10 +1634,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// boundary from COLORTERM; an explicit theme value still wins.
|
||||
const truecolor = config.theme?.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '')
|
||||
const resumeHost = ctx.get('tuiResumeHost')
|
||||
mountTui(ctx, Object.assign({}, config, { theme: Object.assign({}, config.theme, { truecolor }) }), {
|
||||
const goodbyeMessage = ctx.get('tuiGoodbyeMessage')
|
||||
// The launcher seeds a guided fresh session's first turn through this key; a
|
||||
// config value still wins. Consumed in createTuiChat via config.initialSkill.
|
||||
const initialSkill = config.initialSkill ?? ctx.get('tuiInitialSkill')
|
||||
mountTui(ctx, Object.assign(
|
||||
{},
|
||||
config,
|
||||
{ theme: Object.assign({}, config.theme, { truecolor }) },
|
||||
initialSkill === undefined ? {} : { initialSkill },
|
||||
), {
|
||||
terminal: new ProcessTerminal(),
|
||||
exit: code => process.exit(code),
|
||||
...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) },
|
||||
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
|
||||
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
|
||||
})
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -12,12 +12,17 @@ import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */
|
||||
export interface TuiResumeHost {
|
||||
/**
|
||||
* Dispose the current app and replace it with a runtime for `sessionId`.
|
||||
* Success does not return. A host may reject before it commits teardown;
|
||||
* after commit it owns fatal reporting and process exit.
|
||||
* Dispose the current app and replace it with a runtime for `sessionId` in
|
||||
* `cwd`. Success does not return. A host may reject before it commits
|
||||
* teardown; after commit it owns fatal reporting and process exit.
|
||||
* @param sessionId - validated persisted session selected by the user.
|
||||
* @param cwd - the selected session's own workspace, which the replacement
|
||||
* process must run in: process cwd, not the restored session header, is what
|
||||
* filesystem and shell tools resolve against. It may differ from the current
|
||||
* workspace, so a host that cannot enter it must reject before committing
|
||||
* teardown.
|
||||
*/
|
||||
handoff(sessionId: SessionId): Promise<never>
|
||||
handoff(sessionId: SessionId, cwd: string): Promise<never>
|
||||
}
|
||||
|
||||
/** Runtime boundary used by the interactive TUI. */
|
||||
@@ -40,6 +45,13 @@ export interface TuiRuntime {
|
||||
gitBranch?: (cwd: string) => string | undefined
|
||||
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
|
||||
now?(): number
|
||||
/** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */
|
||||
/** Host-owned process handoff; absent leaves the session selectable but not resumable in place. */
|
||||
handoffResume?: TuiResumeHost['handoff']
|
||||
/**
|
||||
* Line the host wants printed once the terminal is released on exit, such as
|
||||
* the command that resumes this session. Absent prints nothing. The host owns
|
||||
* the wording; the TUI owns rendering and escapes terminal controls, so
|
||||
* embedded ANSI is shown literally rather than applied.
|
||||
*/
|
||||
goodbyeMessage?: string
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface TuiHarnessOptions {
|
||||
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
|
||||
}
|
||||
handoffResume?: TuiRuntime['handoffResume']
|
||||
/** Host-supplied exit line; absent exercises the no-message path. */
|
||||
goodbyeMessage?: TuiRuntime['goodbyeMessage']
|
||||
/** Set false to exercise the optional session-query degradation path. */
|
||||
mountSessionQuery?: boolean
|
||||
}
|
||||
@@ -253,6 +255,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
|
||||
...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
|
||||
...(options.goodbyeMessage === undefined ? {} : { goodbyeMessage: options.goodbyeMessage }),
|
||||
gitBranch: options.gitBranch ?? (() => 'tui-staging'),
|
||||
})
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=34 bufferRow=34
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,7 +17,7 @@ buffer
|
||||
6| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
7| "$ pnpm run test:coverage "
|
||||
style 0-23 fg=cyan
|
||||
style 0-23 dim
|
||||
8| "/workspace/project "
|
||||
style 0-17 dim
|
||||
9| "… +4 lines (Ctrl+O to expand) "
|
||||
@@ -39,11 +39,14 @@ buffer
|
||||
18| "● Tool / subagent"
|
||||
style 0-16 fg=green
|
||||
19| "Delegate renderer audit "
|
||||
style 0-99 dim
|
||||
20| "The renderer has explicit lifecycle ownership. "
|
||||
style 0-99 dim
|
||||
21| <blank>
|
||||
22| "● Tool / task_output"
|
||||
style 0-19 fg=green
|
||||
23| "Read output from background task subagent-7 "
|
||||
style 0-99 dim
|
||||
24| " "
|
||||
25| "… +2 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
@@ -52,18 +55,20 @@ buffer
|
||||
28| "● Tool / skill"
|
||||
style 0-13 fg=green
|
||||
29| "Load skill dsh-code-review "
|
||||
style 0-99 dim
|
||||
30| "Loaded review instructions. "
|
||||
style 0-99 dim
|
||||
31| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
32| <blank>
|
||||
33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
34| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
35-39| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=39 bufferRow=42
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,13 +17,17 @@ buffer
|
||||
6| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
7| "$ pnpm run test:coverage "
|
||||
style 0-23 fg=cyan
|
||||
style 0-23 dim
|
||||
8| "/workspace/project "
|
||||
style 0-17 dim
|
||||
9| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
10| "4016 tests passed "
|
||||
style 0-16 dim
|
||||
11| "1 test skipped "
|
||||
style 0-13 dim
|
||||
12| "coverage complete "
|
||||
style 0-16 dim
|
||||
13| "[exit 0] "
|
||||
style 0-7 dim
|
||||
14| <blank>
|
||||
@@ -45,35 +49,42 @@ buffer
|
||||
23| "● Tool / subagent"
|
||||
style 0-16 fg=green
|
||||
24| "Delegate renderer audit "
|
||||
style 0-99 dim
|
||||
25| "The renderer has explicit lifecycle ownership. "
|
||||
style 0-99 dim
|
||||
26| <blank>
|
||||
27| "● Tool / task_output"
|
||||
style 0-19 fg=green
|
||||
28| "Read output from background task subagent-7 "
|
||||
style 0-99 dim
|
||||
29| " "
|
||||
30| "console "
|
||||
style 0-6 dim
|
||||
31| " started background task bash-5 "
|
||||
style 2-31 fg=cyan
|
||||
style 0-1 dim
|
||||
style 2-31 fg=cyan dim
|
||||
style 32-99 dim
|
||||
32| " "
|
||||
33| <blank>
|
||||
34| "● Tool / skill"
|
||||
style 0-13 fg=green
|
||||
35| "Load skill dsh-code-review "
|
||||
style 0-99 dim
|
||||
36| "Loaded review instructions. "
|
||||
style 0-99 dim
|
||||
37| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
38| <blank>
|
||||
39| "Tool cards expanded. "
|
||||
style 0-19 fg=bright-black
|
||||
39| "Tool and context cards expanded. "
|
||||
style 0-31 dim
|
||||
40| <blank>
|
||||
41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
42| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -14,7 +14,7 @@ viewport
|
||||
style 8-8 fg=#2498ff bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -24,13 +24,13 @@ viewport
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
9-35| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=15 bufferRow=15
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -25,13 +25,13 @@ buffer
|
||||
style 0-14 dim
|
||||
13| <blank>
|
||||
14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
15| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
16-35| <blank>
|
||||
|
||||
@@ -4,19 +4,19 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=18 bufferRow=18
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Reasoning "
|
||||
style 0-8 fg=bright-black italic
|
||||
style 0-8 dim italic
|
||||
6| "Inspecting width and styles. "
|
||||
style 0-27 fg=bright-black italic
|
||||
style 0-27 dim italic
|
||||
7| "Streaming visible state… "
|
||||
style 10-22 bold
|
||||
8| " "
|
||||
@@ -29,17 +29,16 @@ viewport
|
||||
style 0-30 dim
|
||||
13| <blank>
|
||||
14| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
15| "Show the live update. "
|
||||
16| <blank>
|
||||
17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
18| " dsh ● press enter to steer and esc to cancel "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-44 dim
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-44 dim
|
||||
19-35| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=21 bufferRow=21
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -33,13 +33,13 @@ buffer
|
||||
style 0-14 dim
|
||||
19| <blank>
|
||||
20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
21| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
22-35| <blank>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
terminal 92x32 buffer=normal length=38 base=6 viewport=6
|
||||
terminal 92x32 buffer=normal length=37 base=5 viewport=5
|
||||
lifecycle started=1 stopped=1 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor visible column=0 viewportRow=31 bufferRow=37
|
||||
cursor visible column=0 viewportRow=31 bufferRow=36
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,62 +17,60 @@ buffer
|
||||
style 0-46 dim
|
||||
6| <blank>
|
||||
7| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 0-17 fg=bright-magenta bold
|
||||
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 fg=bright-black
|
||||
9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
|
||||
style 0-74 fg=bright-black
|
||||
10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 fg=bright-black
|
||||
11| " "
|
||||
12| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 fg=bright-black
|
||||
13| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
14| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 fg=bright-black
|
||||
15| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 fg=bright-black
|
||||
16| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
17| "/reasoning — Toggle reasoning blocks "
|
||||
style 0-35 fg=bright-black
|
||||
18| "/redraw — Invalidate components and redraw the terminal "
|
||||
style 0-54 fg=bright-black
|
||||
style 0-60 dim
|
||||
9| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • "
|
||||
style 0-91 dim
|
||||
10| "Ctrl+L redraw "
|
||||
style 0-12 dim
|
||||
11| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 dim
|
||||
12| " "
|
||||
13| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
14| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
15| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
16| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
17| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
18| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 fg=bright-black
|
||||
style 0-87 dim
|
||||
20| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 fg=bright-black
|
||||
style 0-49 dim
|
||||
21| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 fg=bright-black
|
||||
22| "/tools — Expand or collapse all tool cards "
|
||||
style 0-41 fg=bright-black
|
||||
23| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 fg=bright-black
|
||||
24| <blank>
|
||||
25| "provider stream failed after partial output "
|
||||
style 0-70 dim
|
||||
22| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
23| <blank>
|
||||
24| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
26| <blank>
|
||||
27| "The previous process ended during this turn. "
|
||||
25| <blank>
|
||||
26| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
28| <blank>
|
||||
29| "Turn stopped: the agent was disposed. "
|
||||
27| <blank>
|
||||
28| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
30| <blank>
|
||||
31| "Turn ended: plugin-policy. "
|
||||
29| <blank>
|
||||
30| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
32| <blank>
|
||||
33| "Unknown command: /unknown-advanced-command "
|
||||
31| <blank>
|
||||
32| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
34| <blank>
|
||||
35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
36| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
33| <blank>
|
||||
34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
35| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
37| <blank>
|
||||
36| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=17 bufferRow=17
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -28,13 +28,13 @@ buffer
|
||||
style 0-14 dim
|
||||
15| <blank>
|
||||
16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
17| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
18-35| <blank>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
terminal 92x32 buffer=normal length=37 base=5 viewport=5
|
||||
terminal 92x32 buffer=normal length=36 base=4 viewport=4
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=31 bufferRow=36
|
||||
cursor hidden column=7 viewportRow=31 bufferRow=35
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,61 +17,59 @@ buffer
|
||||
style 0-46 dim
|
||||
6| <blank>
|
||||
7| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 0-17 fg=bright-magenta bold
|
||||
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 fg=bright-black
|
||||
9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
|
||||
style 0-74 fg=bright-black
|
||||
10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 fg=bright-black
|
||||
11| " "
|
||||
12| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 fg=bright-black
|
||||
13| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
14| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 fg=bright-black
|
||||
15| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 fg=bright-black
|
||||
16| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
17| "/reasoning — Toggle reasoning blocks "
|
||||
style 0-35 fg=bright-black
|
||||
18| "/redraw — Invalidate components and redraw the terminal "
|
||||
style 0-54 fg=bright-black
|
||||
style 0-60 dim
|
||||
9| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • "
|
||||
style 0-91 dim
|
||||
10| "Ctrl+L redraw "
|
||||
style 0-12 dim
|
||||
11| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 dim
|
||||
12| " "
|
||||
13| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
14| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
15| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
16| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
17| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
18| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 fg=bright-black
|
||||
style 0-87 dim
|
||||
20| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 fg=bright-black
|
||||
style 0-49 dim
|
||||
21| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 fg=bright-black
|
||||
22| "/tools — Expand or collapse all tool cards "
|
||||
style 0-41 fg=bright-black
|
||||
23| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 fg=bright-black
|
||||
24| <blank>
|
||||
25| "provider stream failed after partial output "
|
||||
style 0-70 dim
|
||||
22| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
23| <blank>
|
||||
24| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
26| <blank>
|
||||
27| "The previous process ended during this turn. "
|
||||
25| <blank>
|
||||
26| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
28| <blank>
|
||||
29| "Turn stopped: the agent was disposed. "
|
||||
27| <blank>
|
||||
28| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
30| <blank>
|
||||
31| "Turn ended: plugin-policy. "
|
||||
29| <blank>
|
||||
30| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
32| <blank>
|
||||
33| "Unknown command: /unknown-advanced-command "
|
||||
31| <blank>
|
||||
32| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
34| <blank>
|
||||
35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
36| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
33| <blank>
|
||||
34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
35| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=11 viewportRow=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,15 +17,15 @@ viewport
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > @tsc "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 11-11 inverse
|
||||
9| " → File · terminal-special-case.t src/terminal-special-case.ts "
|
||||
style 7-38 fg=bright-blue
|
||||
style 7-38 fg=bright-magenta
|
||||
10-35| <blank>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=15 viewportRow=13 bufferRow=13
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
9-11| <blank>
|
||||
12| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
|
||||
style 8-83 fg=bright-magenta
|
||||
13| " │ > pro │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 15-15 inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
14| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-58 fg=bright-magenta inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
16| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
17| " │ type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-77 dim
|
||||
style 83-83 fg=bright-magenta
|
||||
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 8-83 fg=bright-magenta
|
||||
19-31| <blank>
|
||||
@@ -1,13 +1,13 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=0 viewportRow=31 bufferRow=31
|
||||
cursor hidden column=12 viewportRow=13 bufferRow=13
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,33 +17,40 @@ buffer
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
9-12| <blank>
|
||||
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
|
||||
style 8-83 fg=bright-blue
|
||||
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
|
||||
style 8-8 fg=bright-blue
|
||||
style 10-70 fg=bright-blue inverse
|
||||
style 83-83 fg=bright-blue
|
||||
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
|
||||
style 8-8 fg=bright-blue
|
||||
style 36-58 fg=bright-black
|
||||
style 83-83 fg=bright-blue
|
||||
16| " │ │ "
|
||||
style 8-8 fg=bright-blue
|
||||
style 83-83 fg=bright-blue
|
||||
17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ "
|
||||
style 8-8 fg=bright-blue
|
||||
style 10-71 dim
|
||||
style 83-83 fg=bright-blue
|
||||
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 8-83 fg=bright-blue
|
||||
19-31| <blank>
|
||||
9-11| <blank>
|
||||
12| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
|
||||
style 8-83 fg=bright-magenta
|
||||
13| " │ > │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 12-12 inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
14| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
15| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-70 fg=bright-magenta inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
16| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 36-58 dim
|
||||
style 83-83 fg=bright-magenta
|
||||
17| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
18| " │ type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-77 dim
|
||||
style 83-83 fg=bright-magenta
|
||||
19| " ╰──────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 8-83 fg=bright-magenta
|
||||
20-31| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=10 bufferRow=10
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,16 +17,16 @@ buffer
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
|
||||
style 0-63 fg=bright-black
|
||||
style 0-63 dim
|
||||
8| <blank>
|
||||
9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-48 fg=bright-black
|
||||
style 51-55 fg=bright-black
|
||||
style 58-67 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-48 dim
|
||||
style 51-55 dim
|
||||
style 58-67 dim
|
||||
10| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
11-31| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=0 viewportRow=19 bufferRow=19
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,23 +17,23 @@ viewport
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 "
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-55 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-55 dim
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
9-11| <blank>
|
||||
12| " "
|
||||
13| " Question 1/1 (1 unanswered) · Confirm "
|
||||
style 2-38 fg=bright-black
|
||||
style 2-38 dim
|
||||
14| " Continue with this change? "
|
||||
15| " "
|
||||
16| " › 1. Proceed Apply the proposed change "
|
||||
style 2-13 fg=bright-blue bold
|
||||
style 16-40 fg=bright-black
|
||||
style 2-13 fg=bright-magenta bold
|
||||
style 16-40 dim
|
||||
17| " Tab custom answer • Enter submit • Esc interrupt "
|
||||
style 2-49 dim
|
||||
18| " "
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=17 bufferRow=17
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -15,17 +15,17 @@ viewport
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| " "
|
||||
6| " Question 1/3 (3 unanswered) · Coverage "
|
||||
style 2-39 fg=bright-black
|
||||
style 2-39 dim
|
||||
7| " Which advanced TUI states belong in the required "
|
||||
8| " matrix? "
|
||||
9| " "
|
||||
10| " › 1. [ ] Code Mode run_code programs and capture "
|
||||
style 2-19 fg=bright-blue bold
|
||||
style 25-53 fg=bright-black
|
||||
style 2-19 fg=bright-magenta bold
|
||||
style 25-53 dim
|
||||
11| " 2. [ ] Workflows phases and parallel agents "
|
||||
style 25-50 fg=bright-black
|
||||
style 25-50 dim
|
||||
12| " 3. [ ] Cordis tools inspect, mount, and unmount "
|
||||
style 25-51 fg=bright-black
|
||||
style 25-51 dim
|
||||
13| " 1/4 "
|
||||
style 2-4 dim
|
||||
14| " Tab custom answer • ↑/↓ navigate • Space toggle • "
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=0 viewportRow=19 bufferRow=19
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -18,17 +18,17 @@ viewport
|
||||
6| <blank>
|
||||
7| " "
|
||||
8| " Question 1/3 (3 unanswered) · Coverage "
|
||||
style 2-39 fg=bright-black
|
||||
style 2-39 dim
|
||||
9| " Which advanced TUI states belong in the required "
|
||||
10| " matrix? "
|
||||
11| " "
|
||||
12| " › 1. [ ] Code Mode run_code programs and capture "
|
||||
style 2-19 fg=bright-blue bold
|
||||
style 25-53 fg=bright-black
|
||||
style 2-19 fg=bright-magenta bold
|
||||
style 25-53 dim
|
||||
13| " 2. [ ] Workflows phases and parallel agents "
|
||||
style 25-50 fg=bright-black
|
||||
style 25-50 dim
|
||||
14| " 3. [ ] Cordis tools inspect, mount, and unmount "
|
||||
style 25-51 fg=bright-black
|
||||
style 25-51 dim
|
||||
15| " 1/4 "
|
||||
style 2-4 dim
|
||||
16| " Tab custom answer • ↑/↓ navigate • Space toggle • "
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=6 viewportRow=4 bufferRow=4
|
||||
buffer
|
||||
0| " "
|
||||
1| " Resume session (1 of 3) "
|
||||
style 2-24 fg=bright-magenta bold
|
||||
2| " "
|
||||
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 2-89 dim
|
||||
4| " │ ⌕ │ "
|
||||
style 2-2 dim
|
||||
style 6-6 inverse
|
||||
style 89-89 dim
|
||||
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 2-89 dim
|
||||
6| " "
|
||||
7| " all workspaces (3) ⇥ this workspace (2) "
|
||||
style 2-19 fg=bright-magenta
|
||||
style 20-41 dim
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-magenta bold
|
||||
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
|
||||
style 2-67 dim
|
||||
11| " current · live · main-session "
|
||||
style 2-32 dim
|
||||
12| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
13| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
14| " Other workspace work "
|
||||
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
|
||||
style 2-74 dim
|
||||
16| " persisted · elsewhere-session "
|
||||
style 2-32 dim
|
||||
17| " workspace /workspace/other "
|
||||
style 2-29 dim
|
||||
18| " Resume selector design "
|
||||
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
|
||||
style 2-74 dim
|
||||
20| " persisted · earlier-session "
|
||||
style 2-30 dim
|
||||
21| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
22| " "
|
||||
23| " "
|
||||
24| " "
|
||||
25| " "
|
||||
26| " "
|
||||
27| " "
|
||||
28| " "
|
||||
29| " "
|
||||
30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel "
|
||||
style 2-84 dim
|
||||
31| " "
|
||||
@@ -5,7 +5,7 @@ cursor hidden column=6 viewportRow=4 bufferRow=4
|
||||
buffer
|
||||
0| " "
|
||||
1| " Resume session (1 of 2) "
|
||||
style 2-24 fg=bright-blue bold
|
||||
style 2-24 fg=bright-magenta bold
|
||||
2| " "
|
||||
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 2-89 dim
|
||||
@@ -16,20 +16,21 @@ buffer
|
||||
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 2-89 dim
|
||||
6| " "
|
||||
7| " /workspace/project "
|
||||
style 2-19 fg=bright-black
|
||||
7| " this workspace /workspace/project ⇥ all workspaces (3) "
|
||||
style 2-34 fg=bright-magenta
|
||||
style 35-56 dim
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-blue bold
|
||||
style 2-19 fg=bright-magenta bold
|
||||
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
|
||||
style 2-67 fg=bright-black
|
||||
style 2-67 dim
|
||||
11| " current · live · main-session "
|
||||
style 2-32 dim
|
||||
12| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
13| " Resume selector design "
|
||||
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
|
||||
style 2-74 fg=bright-black
|
||||
style 2-74 dim
|
||||
15| " persisted · earlier-session "
|
||||
style 2-30 dim
|
||||
16| " "
|
||||
@@ -46,6 +47,6 @@ buffer
|
||||
27| " "
|
||||
28| " "
|
||||
29| " "
|
||||
30| " Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel "
|
||||
style 2-70 dim
|
||||
30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel "
|
||||
style 2-84 dim
|
||||
31| " "
|
||||
|
||||
@@ -4,15 +4,15 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=12 bufferRow=12
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Start then cancel. "
|
||||
6| <blank>
|
||||
7| "Retrying model request (1/∞) in 1000ms: temporary transport failure "
|
||||
@@ -22,13 +22,13 @@ buffer
|
||||
style 0-14 fg=yellow
|
||||
10| <blank>
|
||||
11| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
12| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
13-35| <blank>
|
||||
|
||||
@@ -4,28 +4,28 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=10 bufferRow=10
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Let the bounded policy exhaust. "
|
||||
6| <blank>
|
||||
7| "provider still unavailable "
|
||||
style 0-25 fg=red
|
||||
8| <blank>
|
||||
9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
10| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
11-35| <blank>
|
||||
|
||||
@@ -4,28 +4,28 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=10 bufferRow=10
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Recover this request. "
|
||||
6| <blank>
|
||||
7| "Retrying model request (1/2) in 500ms: provider rate limit "
|
||||
style 0-57 fg=yellow
|
||||
8| <blank>
|
||||
9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
10| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
11-35| <blank>
|
||||
|
||||
@@ -4,28 +4,28 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=10 bufferRow=10
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Recover this request. "
|
||||
6| <blank>
|
||||
7| "Retrying model request (1/2) in 500ms: provider rate limit "
|
||||
style 0-57 fg=yellow
|
||||
8| <blank>
|
||||
9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
10| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
11-35| <blank>
|
||||
|
||||
@@ -4,15 +4,15 @@ title "DSH session reference"
|
||||
cursor hidden column=7 viewportRow=14 bufferRow=14
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Session reference snapshot."
|
||||
style 1-27 fg=bright-black
|
||||
style 1-27 dim
|
||||
2| " target-session"
|
||||
style 1-14 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Use @Source session "
|
||||
6| <blank>
|
||||
7| "Referenced sessions · Source session (source-session) "
|
||||
@@ -25,11 +25,11 @@ buffer
|
||||
style 0-46 dim
|
||||
12| <blank>
|
||||
13| "/workspace/project mock ↑0 ↓0"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 20-23 fg=bright-black
|
||||
style 26-30 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 20-23 dim
|
||||
style 26-30 dim
|
||||
14| " dsh ◍ "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
15-23| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=14 viewportRow=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,15 +17,15 @@ viewport
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > @design "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 14-14 inverse
|
||||
9| " → Session · Searchable design re opaque-source-id · /workspace/project · 1970-01-01T0 "
|
||||
style 7-38 fg=bright-blue
|
||||
style 7-38 fg=bright-magenta
|
||||
10-35| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=38 viewportRow=13 bufferRow=13
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -17,9 +17,9 @@ viewport
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-43 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-43 dim
|
||||
8| " ↑ 1 more "
|
||||
style 1-14 dim
|
||||
9| " enough detail to wrap across multiple "
|
||||
|
||||
@@ -4,10 +4,10 @@ title "Inspect session diagnostics — DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=35 bufferRow=43
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Inspect session diagnostics"
|
||||
style 1-27 fg=bright-black
|
||||
style 1-27 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -18,28 +18,28 @@ buffer
|
||||
style 0-14 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
9| "inspect this session "
|
||||
10| <blank>
|
||||
11| "╭─ Session status ─────────────────────────────────────╮"
|
||||
style 0-2 dim
|
||||
style 3-16 fg=bright-blue bold
|
||||
style 3-16 fg=bright-magenta bold
|
||||
style 17-55 dim
|
||||
12| "│ Session: main-session │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 55-55 dim
|
||||
13| "│ Title: Inspect session diagnostics │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 55-55 dim
|
||||
14| "│ Directory: /workspace/project │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 55-55 dim
|
||||
15| "│ Model: deepseek/deepseek-v4-pro (effort │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 40-55 dim
|
||||
16| "│ default; reasoning blocks shown) │"
|
||||
style 0-0 dim
|
||||
@@ -50,7 +50,7 @@ buffer
|
||||
style 55-55 dim
|
||||
18| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 55-55 dim
|
||||
19| "│ tool call │"
|
||||
style 0-0 dim
|
||||
@@ -60,13 +60,13 @@ buffer
|
||||
style 55-55 dim
|
||||
21| "│ Tokens: 1,250 input + 340 output │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 55-55 dim
|
||||
22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 15-15 dim
|
||||
style 16-26 fg=bright-blue
|
||||
style 16-26 fg=bright-magenta
|
||||
style 27-32 dim
|
||||
style 55-55 dim
|
||||
23| "│ + 250 write) │"
|
||||
@@ -74,9 +74,9 @@ buffer
|
||||
style 55-55 dim
|
||||
24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 15-15 dim
|
||||
style 16-20 fg=bright-blue
|
||||
style 16-20 fg=bright-magenta
|
||||
style 21-32 dim
|
||||
style 55-55 dim
|
||||
25| "│ 128,000) │"
|
||||
@@ -87,17 +87,17 @@ buffer
|
||||
style 55-55 dim
|
||||
27| "│ Created: 2026-07-22 09:10:11 UTC │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 55-55 dim
|
||||
28| "│ Active: 2026-07-22 09:10:11 UTC │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 55-55 dim
|
||||
29| "╰──────────────────────────────────────────────────────╯"
|
||||
style 0-55 dim
|
||||
30| <blank>
|
||||
31| "System prompt "
|
||||
style 0-12 fg=bright-blue bold
|
||||
style 0-12 fg=bright-magenta bold
|
||||
32| "You are an AI agent powered by the DeepSeek Harness SDK."
|
||||
33| " "
|
||||
34| "Paths prefixed with @ are files explicitly referenced by"
|
||||
@@ -106,15 +106,15 @@ buffer
|
||||
37| "reading it. "
|
||||
38| <blank>
|
||||
39| "Registered tools "
|
||||
style 0-15 fg=bright-blue bold
|
||||
style 0-15 fg=bright-magenta bold
|
||||
40| "read, write "
|
||||
41| <blank>
|
||||
42| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-48 fg=bright-black
|
||||
style 51-55 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-48 dim
|
||||
style 51-55 dim
|
||||
43| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -4,10 +4,10 @@ title "Inspect session diagnostics — DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=31 bufferRow=37
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Inspect session diagnostics"
|
||||
style 1-27 fg=bright-black
|
||||
style 1-27 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -18,28 +18,28 @@ buffer
|
||||
style 0-14 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
9| "inspect this session "
|
||||
10| <blank>
|
||||
11| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
|
||||
style 0-2 dim
|
||||
style 3-16 fg=bright-blue bold
|
||||
style 3-16 fg=bright-magenta bold
|
||||
style 17-81 dim
|
||||
12| "│ Session: main-session │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 81-81 dim
|
||||
13| "│ Title: Inspect session diagnostics │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 81-81 dim
|
||||
14| "│ Directory: /workspace/project │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 81-81 dim
|
||||
15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 40-79 dim
|
||||
style 81-81 dim
|
||||
16| "│ │"
|
||||
@@ -47,27 +47,27 @@ buffer
|
||||
style 81-81 dim
|
||||
17| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 tool call │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 81-81 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 81-81 dim
|
||||
19| "│ Tokens: 1,250 input + 340 output │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 81-81 dim
|
||||
20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 15-15 dim
|
||||
style 16-26 fg=bright-blue
|
||||
style 16-26 fg=bright-magenta
|
||||
style 27-32 dim
|
||||
style 81-81 dim
|
||||
21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 15-15 dim
|
||||
style 16-20 fg=bright-blue
|
||||
style 16-20 fg=bright-magenta
|
||||
style 21-32 dim
|
||||
style 81-81 dim
|
||||
22| "│ │"
|
||||
@@ -75,33 +75,33 @@ buffer
|
||||
style 81-81 dim
|
||||
23| "│ Created: 2026-07-22 09:10:11 UTC │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 81-81 dim
|
||||
24| "│ Active: 2026-07-22 09:10:11 UTC │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 3-12 dim
|
||||
style 81-81 dim
|
||||
25| "╰────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-81 dim
|
||||
26| <blank>
|
||||
27| "System prompt "
|
||||
style 0-12 fg=bright-blue bold
|
||||
style 0-12 fg=bright-magenta bold
|
||||
28| "You are an AI agent powered by the DeepSeek Harness SDK. "
|
||||
29| " "
|
||||
30| "Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when "
|
||||
31| "their contents are needed; do not claim to have inspected a file before reading it. "
|
||||
32| <blank>
|
||||
33| "Registered tools "
|
||||
style 0-15 fg=bright-blue bold
|
||||
style 0-15 fg=bright-magenta bold
|
||||
34| "read, write "
|
||||
35| <blank>
|
||||
36| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k ↓340 cache 67% 33% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-48 fg=bright-black
|
||||
style 51-71 fg=bright-black
|
||||
style 74-84 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-48 dim
|
||||
style 51-71 dim
|
||||
style 74-84 dim
|
||||
37| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -4,31 +4,31 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=11 bufferRow=11
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Reasoning "
|
||||
style 0-8 fg=bright-black italic
|
||||
style 0-8 dim italic
|
||||
6| "Checking the result. "
|
||||
style 0-19 fg=bright-black italic
|
||||
style 0-19 dim italic
|
||||
7| "The result is ready. "
|
||||
8| "Model wait 1.0s · Thinking 2.0s · Response 3.0s · Completed 2026-07-21 14:32:12 "
|
||||
style 0-78 dim
|
||||
9| <blank>
|
||||
10| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
11| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
12-35| <blank>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
terminal 44x18 buffer=normal length=18 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=15 bufferRow=15
|
||||
cursor hidden column=7 viewportRow=14 bufferRow=14
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -16,21 +16,22 @@ buffer
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Context · workspace-context "
|
||||
7| "Context · workspace-context"
|
||||
style 0-26 dim
|
||||
8| "system-reminder "
|
||||
style 0-14 fg=bright-black
|
||||
9| " Additional instructions from: "
|
||||
10| "nested/AGENTS.md "
|
||||
11| " "
|
||||
12| " Render workspace context XML clearly. "
|
||||
13| <blank>
|
||||
14| "/workspace/project (tui-staging) deepseek-v"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-43 fg=bright-black
|
||||
15| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
8| "Additional instructions from: "
|
||||
style 0-43 dim
|
||||
9| "nested/AGENTS.md "
|
||||
style 0-15 dim
|
||||
10| " "
|
||||
11| "Render workspace context XML clearly. "
|
||||
style 0-36 dim
|
||||
12| <blank>
|
||||
13| "/workspace/project (tui-staging) deepseek-v"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-43 dim
|
||||
14| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
16-17| <blank>
|
||||
15-17| <blank>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
terminal 104x30 buffer=normal length=30 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=14 bufferRow=14
|
||||
cursor hidden column=7 viewportRow=13 bufferRow=13
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -16,22 +16,22 @@ buffer
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Context · workspace-context "
|
||||
7| "Context · workspace-context"
|
||||
style 0-26 dim
|
||||
8| "system-reminder "
|
||||
style 0-14 fg=bright-black
|
||||
9| " Additional instructions from: nested/AGENTS.md "
|
||||
10| " "
|
||||
11| " Render workspace context XML clearly. "
|
||||
12| <blank>
|
||||
13| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
14| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
8| "Additional instructions from: nested/AGENTS.md "
|
||||
style 0-45 dim
|
||||
9| " "
|
||||
10| "Render workspace context XML clearly. "
|
||||
style 0-36 dim
|
||||
11| <blank>
|
||||
12| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
13| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
15-29| <blank>
|
||||
14-29| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=20 bufferRow=20
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -15,33 +15,36 @@ buffer
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises wrapping before compaction. "
|
||||
8| <blank>
|
||||
9| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
10| "$ pnpm run test:coverage "
|
||||
style 0-23 fg=cyan
|
||||
style 0-23 dim
|
||||
11| "/workspace/project "
|
||||
style 0-17 dim
|
||||
12| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
13| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
14| "1 test skipped "
|
||||
style 0-13 dim
|
||||
15| "coverage complete "
|
||||
style 0-16 dim
|
||||
16| "[exit 0] "
|
||||
style 0-7 dim
|
||||
17| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
18| <blank>
|
||||
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
20| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
21-23| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=15 bufferRow=15
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -18,21 +18,21 @@ viewport
|
||||
style 0-46 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
9| "Plan the work. "
|
||||
10| <blank>
|
||||
11| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
12| "Next question. "
|
||||
13| <blank>
|
||||
14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
15| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
16-35| <blank>
|
||||
|
||||
@@ -4,10 +4,10 @@ title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
cursor hidden column=0 viewportRow=33 bufferRow=33
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 1-60 fg=bright-black
|
||||
style 1-60 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
@@ -15,51 +15,52 @@ buffer
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
8| <blank>
|
||||
9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 0-81 fg=green
|
||||
10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-59 fg=cyan
|
||||
style 0-59 dim
|
||||
11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-52 dim
|
||||
12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-58 dim
|
||||
13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
|
||||
style 0-56 fg=red
|
||||
14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
|
||||
style 0-46 dim
|
||||
15| <blank>
|
||||
16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 0-61 dim
|
||||
17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-59 fg=bright-black
|
||||
style 0-59 dim
|
||||
18| <blank>
|
||||
19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-62 fg=red
|
||||
20-21| <blank>
|
||||
22| "Plan"
|
||||
style 0-3 fg=bright-blue bold
|
||||
style 0-3 fg=bright-magenta bold
|
||||
23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 2-2 fg=yellow
|
||||
24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
25| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
26| " "
|
||||
27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 2-90 fg=bright-black
|
||||
style 2-90 dim
|
||||
28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
29| " "
|
||||
30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
|
||||
style 2-65 fg=bright-blue bold
|
||||
style 67-97 fg=bright-black
|
||||
style 2-65 fg=bright-magenta bold
|
||||
style 67-97 dim
|
||||
31| " Tab custom answer • Enter submit • Esc interrupt "
|
||||
style 2-49 dim
|
||||
32| " "
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, type JsonValue, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
@@ -51,10 +51,12 @@ const CHECKPOINTS = [
|
||||
'surface-after-compaction-narrow',
|
||||
'surface-after-compaction-wide',
|
||||
'model-selector',
|
||||
'model-selector-filtered',
|
||||
'model-switching',
|
||||
'errors-and-help',
|
||||
'disposed-terminal',
|
||||
'resume-sessions',
|
||||
'resume-sessions-all-workspaces',
|
||||
'status-diagnostics',
|
||||
'status-diagnostics-narrow',
|
||||
'todo-plan-cleared',
|
||||
@@ -801,6 +803,10 @@ describe('TUI terminal-state snapshots', () => {
|
||||
harness.terminal.send('\r')
|
||||
})
|
||||
await checkpoint('model-selector', harness.terminal, { includeScrollback: true })
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('pro')
|
||||
})
|
||||
await checkpoint('model-selector-filtered', harness.terminal, { includeScrollback: true })
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('\x1b[B')
|
||||
harness.terminal.send('\r')
|
||||
@@ -812,35 +818,33 @@ describe('TUI terminal-state snapshots', () => {
|
||||
it('opens the searchable resume selector with log-backed session summaries', async () => {
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
|
||||
const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
|
||||
const elsewhere = { version: 0, id: SessionId('elsewhere-session'), createdAt: Date.parse('2024-02-02T00:00:00Z'), cwd: '/workspace/other' }
|
||||
const log = (meta: typeof earlier, title: string, day: string): { meta: typeof earlier; events: SessionEvent[] } => ({
|
||||
meta,
|
||||
events: [
|
||||
{ type: 'turn/start', seq: 0, time: Date.parse(`${day}T00:00:01Z`), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'user/message', seq: 1, time: Date.parse(`${day}T00:00:02Z`), data: createUserMessage({ content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }), surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: Date.parse(`${day}T00:00:03Z`), data: { turn: 1, step: 1 } },
|
||||
{ type: 'request/header', seq: 3, time: Date.parse(`${day}T00:00:04Z`), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
|
||||
{ type: 'assistant/message', seq: 4, time: Date.parse(`${day}T00:00:05Z`), data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ready' }],
|
||||
source: { kind: 'model', provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
],
|
||||
})
|
||||
const harness = await setupSnapshot({
|
||||
config: { resumeCommand: 'dsh --resume {session}' },
|
||||
sessionPersistence: {
|
||||
list: async () => [earlier],
|
||||
load: async () => ({
|
||||
meta: earlier,
|
||||
events: [
|
||||
{ type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } },
|
||||
{ type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
|
||||
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ready' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
],
|
||||
}),
|
||||
list: async () => [earlier, elsewhere],
|
||||
load: async id => id === elsewhere.id
|
||||
? log(elsewhere, 'Other workspace work', '2024-02-02')
|
||||
: log(earlier, 'Resume selector design', '2024-01-01'),
|
||||
},
|
||||
}, { columns: 92, rows: 32 })
|
||||
harness.terminal.send('/resume')
|
||||
@@ -850,6 +854,12 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
await harness.terminal.flush()
|
||||
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
|
||||
// Tab switches to the all-workspaces scope, which adds the other workspace's
|
||||
// session and labels every row with the directory it belongs to.
|
||||
harness.terminal.send('\t')
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
await harness.terminal.flush()
|
||||
await checkpoint('resume-sessions-all-workspaces', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
dateNow.mockRestore()
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
|
||||
import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
@@ -180,7 +181,7 @@ describe('TUI config', () => {
|
||||
color: true,
|
||||
truecolor: false,
|
||||
leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}',
|
||||
rightPrompt: '${timing}',
|
||||
rightPrompt: '${queued}',
|
||||
inputPrompt: '${symbol} ${indicator}',
|
||||
inputPlaceholder: 'press enter to steer and esc to cancel',
|
||||
},
|
||||
@@ -220,7 +221,7 @@ describe('TUI config', () => {
|
||||
color: false,
|
||||
truecolor: true,
|
||||
leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}',
|
||||
rightPrompt: '${timing}',
|
||||
rightPrompt: '${queued}',
|
||||
inputPrompt: '${symbol} ${indicator}',
|
||||
inputPlaceholder: 'press enter to steer and esc to cancel',
|
||||
},
|
||||
@@ -229,8 +230,7 @@ describe('TUI config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resume command and /resume', () => {
|
||||
const RESUME = 'dsh --resume {session}'
|
||||
describe('goodbye message and /resume', () => {
|
||||
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
|
||||
({ version: 0, id: SessionId(id), createdAt, cwd })
|
||||
const resumeEvents = (
|
||||
@@ -261,11 +261,10 @@ describe('resume command and /resume', () => {
|
||||
{ type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
]
|
||||
|
||||
it('prints the resume command on exit once the session is persisted', async () => {
|
||||
it('prints the host goodbye message on exit', async () => {
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
config: { resumeCommand: RESUME },
|
||||
sessionPersistence: { list: async () => [header('main-session', 1000, '/workspace')] },
|
||||
goodbyeMessage: 'To resume this session: dsh --resume main-session',
|
||||
})
|
||||
result.terminal.send('/exit')
|
||||
result.terminal.send('\r')
|
||||
@@ -275,8 +274,8 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('omits the exit hint when the session is not yet persisted', async () => {
|
||||
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
|
||||
it('prints nothing on exit when the host supplies no goodbye message', async () => {
|
||||
const result = await setup({ cwd: '/workspace' })
|
||||
result.terminal.send('/exit')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
@@ -285,17 +284,16 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('omits the exit hint when the session listing fails', async () => {
|
||||
it('escapes terminal controls in the host goodbye message', async () => {
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
config: { resumeCommand: RESUME },
|
||||
sessionPersistence: { list: () => Promise.reject(new Error('disk gone')) },
|
||||
goodbyeMessage: 'resume \u001b]2;hijacked\u0007now',
|
||||
})
|
||||
result.terminal.send('/exit')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('To resume this session')
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
expect(result.terminal.output).toContain('resume \\x1b]2;hijacked\\x07now')
|
||||
expect(result.terminal.output).not.toContain('\u001b]2;hijacked')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -305,7 +303,6 @@ describe('resume command and /resume', () => {
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
config: { resumeCommand: RESUME },
|
||||
handoffResume: handoff,
|
||||
sessionPersistence: {
|
||||
list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')],
|
||||
@@ -323,7 +320,10 @@ describe('resume command and /resume', () => {
|
||||
expect(output).toContain('Older investigation')
|
||||
expect(output).toContain('current · live')
|
||||
expect(output.indexOf('Newer product work')).toBeLessThan(output.indexOf('Older investigation'))
|
||||
// Default scope is the current workspace; one of the four records (three
|
||||
// listed plus the live current session) belongs to another.
|
||||
expect(output).not.toContain('foreign-session')
|
||||
expect(output).toContain('all workspaces (4)')
|
||||
result.terminal.send('Older')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('⌕ Older')
|
||||
@@ -351,7 +351,9 @@ describe('resume command and /resume', () => {
|
||||
await tick(); await tick()
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\t')
|
||||
// A key the search editor swallows without changing its value (backspace on
|
||||
// an empty box) keeps the selection and error untouched.
|
||||
result.terminal.send('\x7f')
|
||||
result.terminal.send('zz')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
@@ -602,7 +604,6 @@ describe('resume command and /resume', () => {
|
||||
const corrupt = header('corrupt', 30, '/workspace')
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
config: { resumeCommand: RESUME },
|
||||
sessionPersistence: {
|
||||
list: async () => [missing, corrupt],
|
||||
load: async (id) => {
|
||||
@@ -683,7 +684,7 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => {
|
||||
it('flushes, releases the terminal, and invokes one host handoff for the same SessionId and workspace', async () => {
|
||||
const target = header('target-session', 10, '/workspace')
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process')))
|
||||
const result = await setup({
|
||||
@@ -701,7 +702,7 @@ describe('resume command and /resume', () => {
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(handoff).toHaveBeenCalledTimes(1)
|
||||
expect(handoff).toHaveBeenCalledWith(target.id)
|
||||
expect(handoff).toHaveBeenCalledWith(target.id, '/workspace')
|
||||
expect(result.terminal.stopped).toBeGreaterThan(0)
|
||||
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
|
||||
await dispose(result)
|
||||
@@ -817,7 +818,7 @@ describe('resume command and /resume', () => {
|
||||
result.terminal.send('Query without persistence')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(handoff).toHaveBeenCalledWith(target.id)
|
||||
expect(handoff).toHaveBeenCalledWith(target.id, '/workspace')
|
||||
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
|
||||
await dispose(result)
|
||||
})
|
||||
@@ -902,16 +903,23 @@ describe('resume command and /resume', () => {
|
||||
expect(result.terminal.output).not.toContain('host rejected after disposal')
|
||||
})
|
||||
|
||||
it('rejects a candidate whose cwd changes between listing and preflight', async () => {
|
||||
// A cwd that changes between listing and preflight is no longer a rejection:
|
||||
// resume targets whatever workspace the record names at preflight, so the
|
||||
// handoff must carry the RE-READ cwd rather than the one the row displayed.
|
||||
it('hands off with the cwd re-read at preflight, not the one listed', async () => {
|
||||
const target = header('moving-workspace', 10, '/workspace')
|
||||
const moved = header('moving-workspace', 10, '/elsewhere')
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(
|
||||
() => Promise.reject(new Error('test host retained process')),
|
||||
)
|
||||
let listings = 0
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: vi.fn(),
|
||||
handoffResume: handoff,
|
||||
sessionPersistence: {
|
||||
list: async () => [++listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere')],
|
||||
list: async () => [++listings <= 2 ? target : moved],
|
||||
load: async () => ({
|
||||
meta: listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere'),
|
||||
meta: listings <= 2 ? target : moved,
|
||||
events: resumeEvents('Moving workspace'),
|
||||
}),
|
||||
},
|
||||
@@ -922,7 +930,101 @@ describe('resume command and /resume', () => {
|
||||
result.terminal.send('Moving workspace')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('different workspace')
|
||||
expect(handoff).toHaveBeenCalledWith(target.id, '/elsewhere')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps a session with no recorded workspace visible but disabled', async () => {
|
||||
// The key is omitted, not set to undefined: an explicit undefined is not
|
||||
// losslessly JSON-serializable and the header validator rejects it.
|
||||
const { cwd: _cwd, ...rootless } = header('no-workspace', 10, '/workspace')
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: handoff,
|
||||
sessionPersistence: {
|
||||
list: async () => [rootless],
|
||||
load: async () => ({ meta: rootless, events: resumeEvents('Rootless session') }),
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
// A cwd-less session lists under all workspaces, never the current one.
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('Rootless session')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('session has no recorded workspace')
|
||||
expect(handoff).not.toHaveBeenCalled()
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('lists other workspaces only in the all-workspaces scope and resumes into their cwd', async () => {
|
||||
const local = header('local-session', 2000, '/workspace')
|
||||
const foreign = header('foreign-session', 3000, '/elsewhere')
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(
|
||||
() => Promise.reject(new Error('test host retained process')),
|
||||
)
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: handoff,
|
||||
sessionPersistence: {
|
||||
list: async () => [local, foreign],
|
||||
load: async id => id === local.id
|
||||
? { meta: local, events: resumeEvents('Local product work') }
|
||||
: { meta: foreign, events: resumeEvents('Foreign investigation') },
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
// Three records: the two listed plus the live current session.
|
||||
expect(result.terminal.output).toContain('Local product work')
|
||||
expect(result.terminal.output).not.toContain('Foreign investigation')
|
||||
expect(result.terminal.output).toContain('all workspaces (3)')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
const scoped = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
|
||||
expect(scoped).toContain('Foreign investigation')
|
||||
expect(scoped).toContain('workspace /elsewhere')
|
||||
expect(scoped).toContain('this workspace (2)')
|
||||
// Searching the workspace label reaches a row the title would not match.
|
||||
result.terminal.send('elsewhere')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(handoff).toHaveBeenCalledWith(foreign.id, '/elsewhere')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('returns to the current workspace when Tab toggles back and clears the search', async () => {
|
||||
const local = header('scope-local', 2000, '/workspace')
|
||||
const foreign = header('scope-foreign', 3000, '/elsewhere')
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: vi.fn(),
|
||||
sessionPersistence: {
|
||||
list: async () => [local, foreign],
|
||||
load: async id => id === local.id
|
||||
? { meta: local, events: resumeEvents('Scope local') }
|
||||
: { meta: foreign, events: resumeEvents('Scope foreign') },
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('Scope foreign')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('⌕ Scope foreign')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
const back = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
|
||||
expect(back).not.toContain('⌕ Scope foreign')
|
||||
expect(back).toContain('Scope local')
|
||||
expect(back).not.toContain('Scope foreign')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -1018,30 +1120,7 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps resumeCommand as a displayed fallback when the host cannot hand off', async () => {
|
||||
const target = header('fallback-session', 10, '/workspace')
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
config: { resumeCommand: RESUME },
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents('Fallback target') }),
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('Fallback target')
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
|
||||
})
|
||||
expect(result.terminal.output).toContain('dsh --resume fallback-session')
|
||||
expect(result.terminal.stopped).toBe(0)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the selector independent from an absent command fallback', async () => {
|
||||
it('warns without releasing the terminal when the host cannot hand off', async () => {
|
||||
const target = header('no-fallback-session', 10, '/workspace')
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
@@ -1058,6 +1137,7 @@ describe('resume command and /resume', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place')
|
||||
})
|
||||
expect(result.terminal.stopped).toBe(0)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -1330,10 +1410,16 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('Steering')
|
||||
expect(result.terminal.output).toContain('user context')
|
||||
expect(result.terminal.output).toContain('Context · workspace-context')
|
||||
expect(result.terminal.output).toContain('system-reminder')
|
||||
// The redundant `system-reminder` frame element is dropped: the source label
|
||||
// already names the context, so the card body starts at the instruction text.
|
||||
expect(result.terminal.output).toContain('Additional instructions from: nested/AGENTS.md')
|
||||
expect(result.terminal.output).not.toContain('<system-reminder>')
|
||||
expect(result.terminal.output).toContain('\\x9b')
|
||||
expect(result.terminal.output).toContain('Render XML context clearly.')
|
||||
// A one-line frame has no open/close line pair to strip, so its text renders as
|
||||
// the prose it is. The card no longer parses context, so a character reference
|
||||
// stays literal instead of expanding to the control character it names — which
|
||||
// is why the expanded-C1 escaping the parser needed is no longer reachable here.
|
||||
expect(result.terminal.output).toContain('›')
|
||||
expect(result.terminal.output).not.toContain('\u009b')
|
||||
expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind
|
||||
expect(result.terminal.output).toContain('Turn cancelled')
|
||||
expect(result.terminal.progress).toContain(true)
|
||||
@@ -1361,6 +1447,104 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20)
|
||||
})
|
||||
|
||||
it('folds an injected-context card by default and expands it with Ctrl+O', async () => {
|
||||
const result = await setup()
|
||||
// A reminder body past the default 6-line budget so the collapsed card shows
|
||||
// the expand marker and hides a middle line until Ctrl+O.
|
||||
const instructions = Array.from({ length: 10 }, (_, index) => `instruction line ${index}`).join('\n')
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `<system-reminder>\n${instructions}\n</system-reminder>` }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await tick()
|
||||
|
||||
// The redundant `system-reminder` frame element is dropped; the card is
|
||||
// collapsed by default with the shared Ctrl+O expand marker.
|
||||
expect(result.terminal.output).toContain('Context · workspace-context')
|
||||
expect(result.terminal.output).not.toContain('system-reminder')
|
||||
expect(result.terminal.output).toContain('lines (Ctrl+O to expand)')
|
||||
expect(result.terminal.output).toContain('instruction line 0')
|
||||
expect(result.terminal.output).not.toContain('instruction line 5')
|
||||
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Tool and context cards expanded.')
|
||||
expect(result.terminal.output).toContain('instruction line 5')
|
||||
|
||||
// Third state: tool cards hide; a context card is injected instructions,
|
||||
// not tool traffic, so it stays visible at its collapsed preview.
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Tool cards hidden.')
|
||||
// A repaint proves the context card SURVIVES the hidden phase: injected
|
||||
// instructions are not tool traffic, so they stay at the collapsed preview.
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hidden = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(hidden).toContain('Context · workspace-context')
|
||||
// Fourth press returns to the collapsed default, closing the cycle.
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Tool and context cards collapsed.')
|
||||
|
||||
// Context with no frame renders as muted prose under the header; a frame
|
||||
// wrapping nothing renders header-only.
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'plain reminder text, no tags' }],
|
||||
source: { kind: 'plugin', plugin: 'plain-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<system-reminder>\n</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'empty-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
// Only a matched open/close pair is a frame: an unpaired tag line is prose and
|
||||
// survives, so a body is never silently truncated by a tag-like first line.
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>\nkept prose line\n</other-tag>' }],
|
||||
source: { kind: 'plugin', plugin: 'unpaired-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Context · plain-context')
|
||||
expect(result.terminal.output).toContain('plain reminder text, no tags')
|
||||
expect(result.terminal.output).toContain('Context · empty-context')
|
||||
expect(result.terminal.output).toContain('<available_skills>')
|
||||
expect(result.terminal.output).toContain('kept prose line')
|
||||
// Ctrl+L invalidates the mounted tree, exercising the card's invalidate hook.
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('renders XML-hostile instruction prose verbatim, framed and folded', async () => {
|
||||
const result = await setup()
|
||||
// Real AGENTS.md prose carries a raw `&` (a badge URL's `&logo=`) and
|
||||
// angle-bracket placeholders that name nothing (`packages/<group>/<pkg>/`).
|
||||
// Both are prose the model must read literally, and neither may affect whether
|
||||
// the frame is stripped or the body folds.
|
||||
const lines = Array.from({ length: 10 }, (_, index) => `prose line ${index}`).join('\n')
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `<system-reminder>\nbadge: https://img.shields.io/badge/x?style=flat&logo=deepseek\npath: packages/<group>/<pkg>/\n${lines}\n</system-reminder>`,
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'prose-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Context · prose-context')
|
||||
expect(result.terminal.output).not.toContain('<system-reminder>')
|
||||
expect(result.terminal.output).toContain('lines (Ctrl+O to expand)')
|
||||
expect(result.terminal.output).not.toContain('prose line 5')
|
||||
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('prose line 5')
|
||||
// Characters that break a strict XML parse survive unescaped and unexpanded.
|
||||
expect(result.terminal.output).toContain('&logo=deepseek')
|
||||
expect(result.terminal.output).toContain('packages/<group>/<pkg>/')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('counts failed and recovered request usage once per step', async () => {
|
||||
const result = await setup()
|
||||
result.session.append('assistant/chunk', {
|
||||
@@ -1445,10 +1629,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('badges queued steering on the prompt context timing and clears it as each drains', async () => {
|
||||
it('badges queued steering on the prompt context and clears it as each drains', async () => {
|
||||
// Pin a cwd free of the substring under test; the prompt context renders the path.
|
||||
const result = await setup({ status: 'running', cwd: '/workspace' })
|
||||
// Running with nothing queued: timing appears once in the prompt context and the editor keeps its hint.
|
||||
// Running with nothing queued: the badge is absent and the editor keeps its hint.
|
||||
expect(result.terminal.output).toContain('Assistant')
|
||||
expect(result.terminal.output).toContain('Model wait 0.0s')
|
||||
expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
|
||||
@@ -1854,13 +2038,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
}
|
||||
|
||||
// Without truecolor there is no per-frame gray: below the fade midpoint the
|
||||
// glyph slot is blank; past it the glyph shows in the palette muted role
|
||||
// (ANSI 90), never the accent (SGR 94).
|
||||
// glyph slot is blank; past it the glyph shows in the palette dim role,
|
||||
// never the accent (ANSI 95).
|
||||
const early = await frameAt(60)
|
||||
expect(early).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u)
|
||||
const shown = await frameAt(300)
|
||||
expect(shown).toMatch(/\x1b\[90m●/u)
|
||||
expect(shown).not.toMatch(/\x1b\[94m●/u)
|
||||
expect(shown).toMatch(/\x1b\[2;39m●/u)
|
||||
expect(shown).not.toMatch(/\x1b\[95m●/u)
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
@@ -2065,8 +2249,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
})
|
||||
|
||||
expect(result.terminal.output).not.toContain('stale partial response')
|
||||
result.terminal.send('/reasoning')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('\x12')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[A')
|
||||
@@ -2231,8 +2414,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007')
|
||||
|
||||
result.terminal.resize(56)
|
||||
result.terminal.send('/redraw')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
|
||||
await dispose(result)
|
||||
@@ -2304,7 +2486,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
||||
|
||||
result.agent.status = 'idle'
|
||||
for (const command of ['/help', '/reasoning', '/tools', '/redraw', '/reload']) {
|
||||
for (const command of ['/help', '/palette', '/reload']) {
|
||||
result.terminal.send(command)
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
@@ -2321,7 +2503,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
|
||||
expect(result.terminal.output).toContain('Keyboard shortcuts')
|
||||
expect(result.terminal.output).toContain('Reasoning blocks')
|
||||
expect(result.terminal.output).toContain('Tool cards')
|
||||
expect(result.terminal.output).toContain('Tool and context cards')
|
||||
expect(result.terminal.output).toContain('Unknown command')
|
||||
// /reload without a Loader in the context degrades to a warning.
|
||||
expect(result.terminal.output).toContain('/reload needs the cordis Loader')
|
||||
@@ -3133,8 +3315,25 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model')
|
||||
})
|
||||
const filterOpenOutput = result.terminal.output.length
|
||||
result.terminal.send('beta')
|
||||
await tick()
|
||||
expect(result.terminal.output.slice(filterOpenOutput)).toContain('> beta')
|
||||
expect(result.terminal.output.slice(filterOpenOutput)).toContain('beta/b1')
|
||||
expect(result.terminal.output.slice(filterOpenOutput)).not.toContain('alpha/a1')
|
||||
const noMatchOutput = result.terminal.output.length
|
||||
result.terminal.send('zzz')
|
||||
await tick()
|
||||
expect(result.terminal.output.slice(noMatchOutput)).toContain('No models match the filter')
|
||||
result.terminal.send('\x1b[D')
|
||||
await tick()
|
||||
expect(result.terminal.output.slice(noMatchOutput)).toContain('No models match the filter')
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
const filterClearedOutput = result.terminal.output.length
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
expect(result.terminal.output.slice(filterClearedOutput)).not.toContain('Select model')
|
||||
|
||||
const providerDefaultOutput = result.terminal.output.length
|
||||
result.terminal.send('/model alpha/shared')
|
||||
@@ -3669,6 +3868,23 @@ describe('skill slash command', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('auto-invokes a launcher-seeded initial skill as the first turn', async () => {
|
||||
const result = await setup({ config: { initialSkill: 'demo-skill' }, configureContext: withSkills })
|
||||
// The seed rides the same path as a typed `/skill:demo-skill`, delivered
|
||||
// once the chat is live; no user input is required.
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>' }]])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('reports an unknown initial skill as a notice without sending', async () => {
|
||||
const result = await setup({ config: { initialSkill: 'nope' }, configureContext: withSkills })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Unknown skill: nope')
|
||||
expect(result.agent.sent).toEqual([])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('reports an unknown skill and an empty skill name without sending', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill:does-not-exist')
|
||||
@@ -3895,8 +4111,7 @@ describe('tool cards and surface replay', () => {
|
||||
}
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('$ raw command')
|
||||
result.terminal.send('/reasoning')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('\x12')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('call presenter boom')
|
||||
expect(result.terminal.output).toContain('Symbol(input)')
|
||||
@@ -4030,8 +4245,7 @@ describe('tool cards and surface replay', () => {
|
||||
expect(output).toContain('line (number="1"): hello')
|
||||
expect(output).not.toContain('<result>')
|
||||
|
||||
result.terminal.send('/redraw')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(collapsed).toContain('Run command')
|
||||
@@ -4041,7 +4255,7 @@ describe('tool cards and surface replay', () => {
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('world')
|
||||
expect(result.terminal.output).toContain('Tool cards expanded.')
|
||||
expect(result.terminal.output).toContain('Tool and context cards expanded.')
|
||||
expect(result.terminal.output).not.toContain('tools:expanded')
|
||||
expect(result.terminal.output).toContain('+ created')
|
||||
expect(result.terminal.output).toContain('console')
|
||||
@@ -4049,6 +4263,42 @@ describe('tool cards and surface replay', () => {
|
||||
// expanded (`+ after` is b.txt's new text; the footer counts both files).
|
||||
expect(result.terminal.output).toContain('+ after')
|
||||
expect(result.terminal.output).toContain('· 2 files')
|
||||
|
||||
// Third Ctrl+O phase hides every tool card: after a redraw the repainted
|
||||
// frame carries no tool header at all, only the conversation.
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Tool cards hidden.')
|
||||
// A result for an untracked call mints a fallback card mid-hidden: it must
|
||||
// adopt the current visibility instead of rendering collapsed.
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'untracked' as never,
|
||||
content: [{ type: 'text', text: 'fallback result body' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hiddenFrame = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(hiddenFrame).not.toContain('Tool / bash')
|
||||
expect(hiddenFrame).not.toContain('Run command')
|
||||
expect(hiddenFrame).not.toContain('fallback result body')
|
||||
// The dozen hidden cards leave no per-card blank rows behind: each card owns
|
||||
// its leading gap, so the trimmed frame has no long blank run where the
|
||||
// cards used to be.
|
||||
const frameRows = hiddenFrame.split('\n').map(row => row.replaceAll(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim())
|
||||
const first = frameRows.findIndex(row => row.includes('Calling tools'))
|
||||
expect(first).toBeGreaterThan(-1)
|
||||
let blankRun = 0
|
||||
let longestRun = 0
|
||||
for (const row of frameRows.slice(first)) {
|
||||
blankRun = row === '' ? blankRun + 1 : 0
|
||||
longestRun = Math.max(longestRun, blankRun)
|
||||
}
|
||||
expect(longestRun).toBeLessThanOrEqual(2)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -4075,6 +4325,46 @@ describe('tool cards and surface replay', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('drops blank rows from a terminal card result that the dim styling wraps', async () => {
|
||||
const blankRowTools: Record<string, ToolDefinition> = {
|
||||
trailing: {
|
||||
name: 'trailing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'terminal', title: 'printf out', description: 'Run it', cwd: '/tmp' }),
|
||||
// Real command output ends in a newline, so the split yields a trailing blank row.
|
||||
presentResult: () => ({ card: 'terminal', output: 'first\n\nlast\n', exitCode: 0 }),
|
||||
},
|
||||
}
|
||||
// Color on: the dim wrapper is what makes a blank row non-empty, so the
|
||||
// guard against wrapping one is only observable with ANSI enabled.
|
||||
const result = await setup({ tools: blankRowTools, config: { theme: { color: true } } })
|
||||
appendUser(result.session, 'run it')
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'tool-call', id: 'blank' as never, name: 'trailing', arguments: '{}' },
|
||||
])
|
||||
result.session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: 'blank' as never, name: 'trailing', arguments: '{}',
|
||||
})
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'blank' as never, content: [{ type: 'text', text: 'ignored' }], isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
// Each output row is dim-wrapped, and the result's blank rows are dropped
|
||||
// rather than kept as dim-wrapped empty strings: `last` is the row right
|
||||
// after `first`, and no empty dim pair reaches the terminal.
|
||||
expect(result.terminal.output).toContain('\x1b[2;39mfirst\x1b[22;39m')
|
||||
expect(result.terminal.output).toContain('\x1b[2;39mlast\x1b[22;39m')
|
||||
expect(result.terminal.output).not.toContain('\x1b[2;39m\x1b[22;39m')
|
||||
const rows = result.terminal.output.split('\n')
|
||||
const firstRow = rows.findIndex(row => row.includes('first'))
|
||||
expect(firstRow).toBeGreaterThan(-1)
|
||||
expect(rows[firstRow + 1]).toContain('last')
|
||||
expect(rows[firstRow + 2]).toContain('[exit 0]')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
|
||||
const result = await setup({ tools })
|
||||
appendUser(result.session, 'old prompt')
|
||||
@@ -4311,7 +4601,6 @@ describe('TUI extension service', () => {
|
||||
host.theme.accent(`${label} plugin overlay`),
|
||||
[
|
||||
host.theme.text('text'),
|
||||
host.theme.muted('muted'),
|
||||
host.theme.dim('dim'),
|
||||
host.theme.success('success'),
|
||||
host.theme.warning('warning'),
|
||||
@@ -4605,10 +4894,42 @@ describe('terminal mounting', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => {
|
||||
it('prints every palette role through /palette, each painted by the code it reports', async () => {
|
||||
const result = await setup({ config: { theme: { color: true } } })
|
||||
// Initial render uses dark-optimised palette: SGR 2 (dim) for dim text.
|
||||
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
|
||||
const before = result.terminal.output.length
|
||||
result.terminal.send('/palette')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
const printed = result.terminal.output.slice(before)
|
||||
|
||||
// Every declared role appears with its purpose, so a role added to the spec
|
||||
// without a listing entry (or the reverse) fails here rather than silently
|
||||
// going unlisted.
|
||||
const spec = paletteSpec('dark')
|
||||
for (const name of COLOR_ROLES) {
|
||||
expect(printed).toContain(name)
|
||||
expect(printed).toContain(spec.colors[name].purpose)
|
||||
}
|
||||
for (const name of ATTRIBUTE_ROLES) {
|
||||
expect(printed).toContain(name)
|
||||
expect(printed).toContain(spec.attributes[name].purpose)
|
||||
}
|
||||
|
||||
// Each row is painted by the role it names, so the listing cannot report one
|
||||
// code while rendering another: the sample carries the spec's own open code.
|
||||
expect(printed).toContain(`\x1b[${spec.colors.accent.open}m`)
|
||||
expect(printed).toContain(`\x1b[${spec.colors.dim.open}m`)
|
||||
expect(printed).toContain(`\x1b[${spec.attributes.selected.open}m`)
|
||||
// `text` is the terminal default, emitted as no escape at all.
|
||||
expect(printed).toContain('no escape')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => {
|
||||
const result = await setup({ config: { theme: { color: true } } })
|
||||
// `dim` is scheme-independent (SGR 2 over the default foreground), so the
|
||||
// startup render carries it on both schemes; `code` is the role that varies.
|
||||
expect(result.terminal.output).toContain('\x1b[2;39mdeepseek-v4-flash')
|
||||
|
||||
// A report matching the current scheme is a no-op: no palette rebuild or
|
||||
// re-render (ESC [?997;1n = dark, the startup default).
|
||||
@@ -4622,20 +4943,14 @@ describe('terminal mounting', () => {
|
||||
result.terminal.send('\x1b[?997;2n')
|
||||
await tick()
|
||||
await tick()
|
||||
// The rebuild re-renders under the light palette, where `code` is ANSI 34
|
||||
// (blue) rather than the dark scheme's ANSI 36 (cyan); `dim` is unchanged.
|
||||
expect(result.terminal.output).toContain('\x1b[2;39mdeepseek-v4-flash')
|
||||
|
||||
// After switching to light-optimised palette: palette.dim uses ANSI 90
|
||||
// (gray) instead of SGR 2. The header now uses \x1b[90m for the detail
|
||||
// line. The cumulative output still contains the initial SGR 2 render,
|
||||
// so we assert that a LATER write (appended after the scheme switch)
|
||||
// uses ANSI 90 for the same header text.
|
||||
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
|
||||
|
||||
// Switch back to dark scheme.
|
||||
result.terminal.send('\x1b[?997;1n')
|
||||
await tick()
|
||||
await tick()
|
||||
// After switching back, a new write uses SGR 2 for the header detail.
|
||||
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
|
||||
expect(result.terminal.output).toContain('\x1b[2;39mdeepseek-v4-flash')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -4656,8 +4971,8 @@ describe('terminal mounting', () => {
|
||||
cwd: join(homedir(), 'projects', 'dsh-tui'),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.output).toContain('\x1b[94m~/')
|
||||
expect(terminal.output).toContain('\x1b[90m (tui-staging)')
|
||||
expect(terminal.output).toContain('\x1b[95m~/')
|
||||
expect(terminal.output).toContain('\x1b[2;39m (tui-staging)')
|
||||
await disposeTuiTestHarness(result)
|
||||
})
|
||||
it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ const render = (source: string, limit = 4, expanded = false): string[] | undefin
|
||||
text => text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu, control =>
|
||||
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`),
|
||||
text => `[label]${text}[/label]`,
|
||||
text => `[body]${text}[/body]`,
|
||||
count => ` … +${count} lines`,
|
||||
)
|
||||
|
||||
@@ -22,30 +23,38 @@ describe('unknown-tool XML rendering', () => {
|
||||
</content>
|
||||
</result>`)).toEqual([
|
||||
'[label]result[/label]',
|
||||
' [label]path:[/label] /tmp/a.txt',
|
||||
' [label]type:[/label] file',
|
||||
' [label]path:[/label] [body]/tmp/a.txt[/body]',
|
||||
' [label]type:[/label] [body]file[/body]',
|
||||
' [label]content[/label]',
|
||||
' [label]line (number="1"):[/label] hello',
|
||||
' [label]line (number="2"):[/label] world',
|
||||
' [label]line (number="1"):[/label] [body]hello[/body]',
|
||||
' [label]line (number="2"):[/label] [body]world[/body]',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders root text, CDATA, empty elements, and multiline nested text', () => {
|
||||
expect(render(' <result>\nfirst\nsecond\n</result> ')).toEqual([
|
||||
'[label]result[/label]',
|
||||
' first',
|
||||
' second',
|
||||
' [body]first[/body]',
|
||||
' [body]second[/body]',
|
||||
])
|
||||
expect(render('<result>\nfirst\nsecond\n</result>', 1, true)).toEqual([
|
||||
'[label]result[/label]',
|
||||
' first',
|
||||
' second',
|
||||
' [body]first[/body]',
|
||||
' [body]second[/body]',
|
||||
])
|
||||
expect(render('<result><value><![CDATA[literal <xml>]]></value><empty /></result>')).toEqual([
|
||||
'[label]result[/label]',
|
||||
' [label]value:[/label] literal <xml>',
|
||||
' [label]value:[/label] [body]literal <xml>[/body]',
|
||||
' [label]empty[/label]',
|
||||
])
|
||||
// An interior blank line stays the empty string: styling it would emit an
|
||||
// escape-only row, which reads as a stray indented blank rather than a gap.
|
||||
expect(render('<result>\nfirst\n\nsecond\n</result>', 4, true)).toEqual([
|
||||
'[label]result[/label]',
|
||||
' [body]first[/body]',
|
||||
'',
|
||||
' [body]second[/body]',
|
||||
])
|
||||
})
|
||||
|
||||
it('previews each top-level child independently and expands all rows', () => {
|
||||
@@ -53,13 +62,13 @@ describe('unknown-tool XML rendering', () => {
|
||||
expect(render(xml, 3)).toEqual([
|
||||
'[label]result[/label]',
|
||||
' [label]first[/label]',
|
||||
' a',
|
||||
' [body]a[/body]',
|
||||
' … +4 lines',
|
||||
' f',
|
||||
' [body]f[/body]',
|
||||
' [label]second[/label]',
|
||||
' g',
|
||||
' [body]g[/body]',
|
||||
' … +4 lines',
|
||||
' l',
|
||||
' [body]l[/body]',
|
||||
])
|
||||
expect(render(xml, 3, true)).toHaveLength(15)
|
||||
})
|
||||
@@ -68,10 +77,10 @@ describe('unknown-tool XML rendering', () => {
|
||||
const xml = `<result>${Array.from({ length: 8 }, (_, index) => `<item>${index}</item>`).join('')}</result>`
|
||||
expect(render(xml, 3)).toEqual([
|
||||
'[label]result[/label]',
|
||||
' [label]item:[/label] 0',
|
||||
' [label]item:[/label] 1',
|
||||
' [label]item:[/label] [body]0[/body]',
|
||||
' [label]item:[/label] [body]1[/body]',
|
||||
' … +5 lines',
|
||||
' [label]item:[/label] 7',
|
||||
' [label]item:[/label] [body]7[/body]',
|
||||
])
|
||||
expect(render(xml, 3, true)).toHaveLength(9)
|
||||
})
|
||||
@@ -79,11 +88,11 @@ describe('unknown-tool XML rendering', () => {
|
||||
it('escapes control characters expanded from character references', () => {
|
||||
expect(render('<result attr="a›b">tab	csi›</result>')).toEqual([
|
||||
'[label]result (attr="a\\\\x9bb")[/label]',
|
||||
' tab\\x09csi\\x9b',
|
||||
' [body]tab\\x09csi\\x9b[/body]',
|
||||
])
|
||||
expect(render('<result><value><![CDATA[del\u007f]]></value></result>')).toEqual([
|
||||
'[label]result[/label]',
|
||||
' [label]value:[/label] del\\x7f',
|
||||
' [label]value:[/label] [body]del\\x7f[/body]',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user