Merge remote-tracking branch 'origin/master' into fix/worker-timer-clamp

This commit is contained in:
Chinesezjc
2026-07-28 00:08:32 +08:00
265 changed files with 10844 additions and 5029 deletions

View File

@@ -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/client/ui-conversation/README.md
README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6
README.zh.md: 88992176165ab11050a30c7df381479796908ba2
README.md: 32651291253077098bc43a930cf4ce11d29b1ed8
README.zh.md: ea8f398541d7af6136b29c3365a78c4ea3a1e85d

View File

@@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).

View File

@@ -8,7 +8,7 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。

View File

@@ -30,6 +30,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
return (
<ToolRow
variant={model.variant}
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}

View File

@@ -42,6 +42,21 @@
color: var(--dsw-alias-label-secondary);
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
.root[data-tool^='cordis_'] .title {
color: var(--dsw-alias-state-business-primary);
}
.root[data-tool^='cordis_'] .title {
font-weight: 500;
}
.root[data-tool^='cordis_'] .sep {
background: var(--dsw-alias-state-business-primary);
}
button.leading {
cursor: pointer;
}

View File

@@ -13,6 +13,8 @@ import css from './ToolRow.module.css'
export interface ToolRowProps {
variant: ToolRowVariant
/** Wire tool name for tool-owned styling layered over the generic variant. */
toolName?: string | undefined
/** Leading 16px tool icon, shown while collapsed and not running/failed. */
icon: ReactNode
title: string
@@ -39,6 +41,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
export function ToolRow({
variant,
toolName,
icon,
title,
summary,
@@ -64,7 +67,7 @@ export function ToolRow({
toggleExpand()
}
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}

View File

@@ -36,6 +36,16 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
write: 'write',
edit: 'edit',
run_code: 'code',
cordis_inspect: 'read',
cordis_mount: 'code',
cordis_unmount: 'others',
}
/** Tool-owned titles that refine a generic row variant without replacing it. */
const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect',
cordis_mount: 'Mount temporary Plugin',
cordis_unmount: 'Unmount temporary Plugin',
}
/**
@@ -130,12 +140,15 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
: block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok'
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
const toolTitle = TOOL_TITLES[toolName]
// Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot so no information is lost.
const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base
// name rides the mutable summary slot unless the tool owns a specific title.
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
? `${toolName} · ${base}`
: base
return {
variant,
title: VARIANT_TITLES[variant],
title: toolTitle ?? VARIANT_TITLES[variant],
summary,
body: deriveBody(variant, argsRaw),
state,

View File

@@ -167,6 +167,28 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(view.getByText('Tool call')).toBeTruthy()
})
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
const parent = 'call-cordis'
const code = 'return { name: "audit", apply(ctx) {} }'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')!
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = nest.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))

View File

@@ -31,6 +31,9 @@ describe('tool-call-model', () => {
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('cordis_inspect')).toBe('read')
expect(classifyTool('cordis_mount')).toBe('code')
expect(classifyTool('cordis_unmount')).toBe('others')
expect(classifyTool('todo_write')).toBe('others')
})
@@ -67,6 +70,33 @@ describe('tool-call-model', () => {
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
})
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({
name: 'cordis_inspect',
argsRaw: '{"what":"api","name":"tools"}',
}))).toMatchObject({
variant: 'read',
title: 'Inspect',
summary: 'api',
})
expect(toolRowModel('cordis_mount', running({
name: 'cordis_mount',
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
}))).toMatchObject({
variant: 'code',
title: 'Mount temporary Plugin',
summary: 'return { name: "audit", apply(ctx) {} }',
body: 'return { name: "audit", apply(ctx) {} }',
})
expect(toolRowModel('cordis_unmount', result({
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
}))).toMatchObject({
variant: 'others',
title: 'Unmount temporary Plugin',
summary: 'dyn-2',
})
})
})
describe('ToolRow', () => {

View File

@@ -12,7 +12,7 @@
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
@@ -166,6 +166,25 @@ describe('keyed toolview hole through the real machinery', () => {
expect(view.getByText('Tool call')).toBeTruthy()
})
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
const code = 'return { name: "audit", apply(ctx) {} }'
const b = await bench([
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
])
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = view.container.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)

View File

@@ -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: b3a70b07c57ae1a2e3dd975a64c840d90c5a84ad
README.zh.md: 5832310cfe14a299ac5998a28bcbbb500caf86b5
# pnpm run verify-translation-pairing --write packages/cordis/README.md
README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365
README.zh.md: cc91e68f0dfa9fb343c332eba2579c2a077beb64

View File

@@ -6,4 +6,4 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in
| Package | Role | ctx key |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the runtime, evaluate model-written plugin code in a `node:vm` sandbox, and manage the dynamic mounts under one group fiber | registers on `ctx.tools` |
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` |

View File

@@ -2,8 +2,8 @@
[English](README.md) | 中文
面向模型、作用于 agent智能体自身所在实时 Cordis 运行时的工具:检查已加载插件与服务接口、挂载模型编写的插件,以及再次释放这些插件。设计归档见[工具集 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
面向模型、作用于 agent智能体所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并挂载或卸载仅存于内存的临时 Plugin。设计归档见[工具集 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
| 包package | 角色 | ctx 键 |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取运行时、在 `node:vm` 沙箱中求值模型编写的插件代码,并在一个分组 fiber 下管理动态挂载 | 注册到 `ctx.tools` |
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` |

View File

@@ -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: 022e25decad650e03aa621cfa2b3f33ccc8d9743
README.zh.md: 99a79209a2a895ee8e11e3b7345a305da514ac1e
# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md
README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97
README.zh.md: 11e5be11dca84247b19888fefa7d70e5d74da174

View File

@@ -2,17 +2,19 @@
English | [中文](README.zh.md)
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The self-referential Cordis toolset: three model-facing tools over the live runtime in the current DSH process. Design home — sandbox semantics, temporary-plugin lifecycle and composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## What it does
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-<n>`.
- `cordis_unmount`disposes one mount by id, returning only after quiescence.
- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_mount` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
- `cordis_mount` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-<n>`.
- `cordis_unmount`unmounts one `dyn-<n>` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins.
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`.
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed.
Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow.
## Trust stance
@@ -22,7 +24,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
| Field | Default | Meaning |
|---|---|---|
| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of mount-code evaluation; an async body escapes it |
| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of temporary-Plugin code evaluation; an async body escapes it |
## The generated API catalog
@@ -30,7 +32,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
## Rendering
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the mount code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
## Export shape
@@ -56,7 +58,7 @@ Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle cha
#### What the model sees
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until unmounted or DSH restart. The empty state explains that `cordis_mount` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `Temporary Plugin <id> is running (...)` or `Temporary Plugin <id> is pending (...)`; unmount returns `Temporary Plugin <id> was unmounted and removed.` The submitted program remains in assistant tool-call history.
#### Token effect
@@ -66,19 +68,19 @@ Inspect output and mount code are data-dependent and resent until compaction; li
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Later requests after a mount
### Later requests after cordis_mount
#### What the model sees
A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_unmount` removes those contributions after quiescence.
#### Token effect
Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
Indirect token impact equals the temporary Plugin's contributions and lasts only for its process-local lifetime.
#### KV Cache effect
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable.
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable.
## Known Limitations and Deferred Work

View File

@@ -2,17 +2,19 @@
[English](README.md) | 中文
自引用 cordis 工具集:三个面向模型的工具,操作 agent 所处的存活运行时。设计归属(沙箱语义、挂载生命周期、跨挂载组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的存活运行时。设计归属(沙箱语义、临时 Plugin 生命周期与组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
## 功能
- `cordis_inspect`:运行时的只读报告,包括服务、已加载插件列表、已注册工具、动态挂载表,以及目录支持的 `api``events` 参考。精确的 `name` 配合 `what: "api"``what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。
- `cordis_mount``node:vm` 沙箱中求值模型编写的 JavaScript(一个 async 函数的主体);代码必须 `return` 一个 cordis 插件,系统将其挂载在 `cordis-dynamic` 分组 fiber 下,并以 `dyn-<n>` 跟踪
- `cordis_unmount`按 id 释放一项挂载,只在完全停稳后返回
- `cordis_inspect`当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_mount` 临时 Plugin 子集,以及目录支持的 `api``events` 参考。精确的 `name` 配合 `what: "api"``what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。
- `cordis_mount`立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-<n>` 跟踪、仅存于内存的临时 Plugin
- `cordis_unmount`卸载一个 `dyn-<n>` 临时 Plugin并只在其自有效果完全停稳后返回它不能删除 Loader、配置或已安装的 Plugin
精确的面向模型 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。
规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 保留现有文本,因此程序可以使用 `mounted.id`,普通 Function Calling 仍会看到 `mounted dyn-1 (...)`
规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除
临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。
## 信任立场
@@ -22,7 +24,7 @@
| 字段 | 默认值 | 含义 |
|---|---|---|
| `vmTimeoutMs` | `5000` | 挂载代码求值中同步部分的边界async 主体可逃出该边界 |
| `vmTimeoutMs` | `5000` | 临时 Plugin 代码求值中同步部分的边界async 主体可逃出该边界 |
## 生成的 API 目录
@@ -30,7 +32,7 @@
## 渲染
三个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_mount``rawInput` 携带挂载代码。presenter 是 args 的纯函数;结果保留默认文本渲染。
三个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_mount``rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。
## 导出形状
@@ -56,7 +58,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### 模型看到的内容
检查会精确地用 `## <section>` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行。宽泛的 API事件报告省略 JSDoc`name` 配合 `what: "api"``what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `mounted <id> (plugin "<name>", state: <state>)`,并可在右括号前插入 ` — waiting for service(s): <names> (activates when provided)`卸载返回 `unmounted <id> (plugin "<name>")`;未知 id 会变成 `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`。提交的挂载程序保留在 assistant 工具调用历史中。
检查会精确地用 `## <section>` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 runningpending 状态、提供与等待的服务,以及持续至卸载或 DSH 重启的生命周期;空状态说明 `cordis_mount` Plugin 会在重启时消失。宽泛的 API事件报告省略 JSDoc`name` 配合 `what: "api"``what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `Temporary Plugin <id> is running (...)``Temporary Plugin <id> is pending (...)`卸载返回 `Temporary Plugin <id> was unmounted and removed.`。提交的程序保留在 assistant 工具调用历史中。
#### Token 影响
@@ -66,19 +68,19 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
### 挂载后的后续请求
### cordis_mount 后的后续请求
#### 模型看到的内容
已挂载插件可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;卸载会在完全停稳后移除这些贡献。
临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_unmount` 会在完全停稳后移除这些贡献。
#### Token 影响
间接 token 影响等于已挂载插件的贡献,且只在挂载生命周期内持续。
间接 token 影响等于临时 Plugin 的贡献,且只在其进程内生命周期内持续。
#### KV Cache 影响
挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;挂载集合不变时,前缀保持稳定。
挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。
## 已知限制与暂缓事项

View File

@@ -2219,7 +2219,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SkillSource',
declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | (string & {});',
declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | \'bundled\' | (string & {});',
},
{
name: 'SkillSummary',
@@ -2505,58 +2505,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ToolSchema',
declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
},
{
name: 'TuiComponent',
declaration: 'export interface TuiComponent {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}',
},
{
name: 'TuiFocusable',
declaration: 'export interface TuiFocusable {\n focused: boolean;\n}',
},
{
name: 'TuiOverlayAnchor',
declaration: 'export type TuiOverlayAnchor = \'center\' | \'top-left\' | \'top-right\' | \'bottom-left\' | \'bottom-right\' | \'top-center\' | \'bottom-center\' | \'left-center\' | \'right-center\';',
},
{
name: 'TuiOverlayCloseReason',
declaration: 'export type TuiOverlayCloseReason = \'closed\' | \'aborted\' | \'owner-disposed\' | \'tui-disposed\' | \'error\';',
},
{
name: 'TuiOverlayHost',
declaration: 'export interface TuiOverlayHost {\n readonly signal: AbortSignal;\n readonly viewport: TuiViewport;\n readonly theme: TuiTheme;\n display(value: string): string;\n invalidate(): void;\n close(): void;\n}',
},
{
name: 'TuiOverlayMargin',
declaration: 'export interface TuiOverlayMargin {\n readonly top?: number;\n readonly right?: number;\n readonly bottom?: number;\n readonly left?: number;\n}',
},
{
name: 'TuiOverlayOptions',
declaration: 'export interface TuiOverlayOptions {\n readonly width?: number | `${number}%`;\n readonly minWidth?: number;\n readonly maxHeight?: number | `${number}%`;\n readonly anchor?: TuiOverlayAnchor;\n readonly margin?: number | TuiOverlayMargin;\n}',
},
{
name: 'TuiOverlayOutcome',
declaration: 'export type TuiOverlayOutcome = {\n readonly reason: Exclude<TuiOverlayCloseReason, \'error\'>;\n} | {\n readonly reason: \'error\';\n readonly error: unknown;\n};',
},
{
name: 'TuiOverlayRequest',
declaration: 'export interface TuiOverlayRequest {\n readonly create: (host: TuiOverlayHost) => TuiComponent & Partial<TuiFocusable>;\n readonly options?: TuiOverlayOptions;\n readonly signal?: AbortSignal;\n}',
},
{
name: 'TuiOverlaySession',
declaration: 'export interface TuiOverlaySession {\n readonly state: TuiOverlayState;\n readonly closed: Promise<TuiOverlayOutcome>;\n close(): Promise<TuiOverlayOutcome>;\n}',
},
{
name: 'TuiOverlayState',
declaration: 'export type TuiOverlayState = \'queued\' | \'active\' | \'closed\';',
},
{
name: 'TuiTheme',
declaration: 'export interface TuiTheme {\n readonly text: (value: string) => string;\n readonly muted: (value: string) => string;\n readonly dim: (value: string) => string;\n readonly accent: (value: string) => string;\n readonly success: (value: string) => string;\n readonly warning: (value: string) => string;\n readonly error: (value: string) => string;\n readonly bold: (value: string) => string;\n}',
},
{
name: 'TuiViewport',
declaration: 'export interface TuiViewport {\n readonly columns: number;\n readonly rows: number;\n}',
},
{
name: 'TurnEndReason',
declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];',

View File

@@ -684,7 +684,7 @@ function sandboxContext(ctx: Context): Context {
if (ctx.get(prop) !== undefined) {
throw new Error(
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+ 'so cordis parks this mount if the provider is later unmounted.',
+ 'so cordis parks this temporary Plugin if the provider is later unmounted.',
)
}
throw new Error(

View File

@@ -1,6 +1,6 @@
/**
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned plugin
* under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary
* plugin under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
* so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent
* accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real
* runtime. Named exports preserve loader injection metadata.
@@ -40,8 +40,8 @@ export const Config: z<Config> = z.object({
type ResolvedConfig = Required<Config>
/**
* Mount the three cordis tools on `ctx.tools` and create the `cordis-dynamic`
* group fiber every dynamic mount hangs under.
* Register the three cordis tools and own every temporary plugin under one
* `cordis-dynamic` group fiber.
* @param ctx - the plugin context (`tools` injected).
* @param config - the schemastery-resolved {@link Config}.
*/
@@ -56,19 +56,21 @@ export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'cordis_inspect',
description:
'Inspect the live cordis runtime that is running THIS agent. Read-only. '
'Inspect the live Cordis runtime in the current DSH process. Read-only. '
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
+ '`plugins` (a flat list of the loaded plugins with their lifecycle states), '
+ '`plugins` (all live plugin fibers with their lifecycle states), '
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
+ '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
+ '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), '
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
+ 'Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` '
+ 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. '
+ 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. '
+ 'With `what:"api"` or `what:"events"`, pass an exact `name` '
+ 'to narrow to one service/event and include its original source JSDoc.',
parameters: {
what: {
type: 'string',
enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'],
enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'],
description: 'Limit the report to one section. Omit for all sections.',
},
name: {
@@ -84,19 +86,19 @@ export function apply(ctx: Context, config: Config): void {
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
throw new Error('name is valid only with what:"api" or what:"events"')
}
const sections: [heading: string, body: () => string[]][] = [
['services', () => describeServices(ctx)],
['plugins', () => describePlugins(ctx)],
const sections: [key: string, heading: string, body: () => string[]][] = [
['services', 'services', () => describeServices(ctx)],
['plugins', 'plugins', () => describePlugins(ctx)],
// The calling agent's view: scoped/shadowed tools included, restricted
// globals absent — "what you can call", not the global registry.
['tools', () => describeTools(ctx, exec.agent)],
['dynamic', () => describeDynamic(ctx, mounts)],
['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', () => describeEvents(EVENT_API, args.name)],
['tools', 'tools', () => describeTools(ctx, exec.agent)],
['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)],
['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', 'events', () => describeEvents(EVENT_API, args.name)],
]
const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading)
const selected = sections.filter(([key]) => args.what === undefined || args.what === key)
const text = selected
.map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
.map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`)
.join('\n\n')
return Promise.resolve(text)
},
@@ -106,8 +108,13 @@ export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'cordis_mount',
description:
'Mount a NEW cordis plugin into the live runtime that is running THIS agent '
+ '(self-modification). `code` runs as the body of an async JavaScript function '
'Mount a temporary Cordis Plugin in the current DSH process. '
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. '
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. '
+ 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. '
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. '
+ '`code` runs now as the body of an async JavaScript function '
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
+ 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
@@ -131,10 +138,10 @@ export function apply(ctx: Context, config: Config): void {
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
+ '`output.render(args, value)` separately returns Native/model content blocks. '
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
+ 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and '
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+ 'until the provider exists and returns to pending when the provider is unmounted. '
+ 'Everything registered inside `apply` is cleaned up automatically on unmount. '
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_unmount. '
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
@@ -143,7 +150,7 @@ export function apply(ctx: Context, config: Config): void {
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, '
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
+ 'auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
+ 'trailing `next` callback which MUST be called — returning without `next()` '
@@ -159,7 +166,7 @@ export function apply(ctx: Context, config: Config): void {
code: {
type: 'string',
required: true,
description: 'Body of an async JS function; must `return` the plugin to mount.',
description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.',
},
},
output: {
@@ -179,12 +186,12 @@ export function apply(ctx: Context, config: Config): void {
},
},
render: (_args, value) => {
const note = value.waitingFor.length > 0
? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)`
: ''
const status = value.waitingFor.length > 0
? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}`
: `is running (plugin "${value.pluginName}"`
return [{
type: 'text',
text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`,
text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`,
}]
},
},
@@ -195,13 +202,13 @@ export function apply(ctx: Context, config: Config): void {
if (!isPlugin(evaluated)) {
if (evaluated === undefined) {
throw new Error(
'mount code returned `undefined` — did you forget `return`?\n'
'temporary Plugin code returned `undefined` — did you forget `return`?\n'
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
)
}
throw new Error(
'mount code must `return` a plugin: a function, or an object with an `apply(ctx)` method',
'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method',
)
}
const fiber = await mountDynamic(group, evaluated)
@@ -225,15 +232,13 @@ export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'cordis_unmount',
description:
'Dispose a plugin previously mounted with cordis_mount, by id. All its '
+ 'registrations (event listeners, tools, services) are cleaned up through '
+ 'the cordis effect lifecycle. Returns only after disposal has fully '
+ 'completed (quiescence, not just a request to stop).',
'Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. '
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.',
parameters: {
id: {
type: 'string',
required: true,
description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
description: 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.',
},
},
output: {
@@ -245,12 +250,12 @@ export function apply(ctx: Context, config: Config): void {
pluginName: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }],
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }],
},
async execute(args) {
const mount = mounts.get(args.id)
if (!mount) {
throw new Error(`no dynamic plugin with id "${args.id}" (list mounts with cordis_inspect what:"dynamic")`)
throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`)
}
await mount.fiber.dispose()
mounts.delete(args.id)

View File

@@ -1,6 +1,6 @@
/**
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
* plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits),
* plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits),
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
* original source JSDoc without inflating the default reports.
* @module @deepseek-ai/dsh-tool-cordis/inspect
@@ -63,8 +63,8 @@ export function describeServices(ctx: Context): string[] {
/**
* The `plugins` section: a flat list of every fiber the registry knows, one
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
* mounted more than once repeats — one line per instance). Dynamic mounts are
* listed like any other plugin; their ids live in the `dynamic` section.
* mounted more than once repeats — one line per instance). Temporary plugins are
* listed like any other plugin; their ids live in the `temporary` section.
* @param ctx - the runtime whose registry is enumerated.
* @returns one line per loaded plugin fiber.
*/
@@ -91,7 +91,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
}
/**
* The `dynamic` section: one line per mount with id, plugin name, lifecycle
* The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle
* state, the services its subtree provides, and — for a pending mount — the
* services it waits for.
* @param ctx - the runtime the mounts live in.
@@ -99,13 +99,14 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
* @returns one line per mount, or a single placeholder line when none exist.
*/
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) return ['(no dynamic plugins mounted)']
if (mounts.size === 0) {
return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.']
}
return [...mounts].map(([id, mount]) => {
const provides = providedServices(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''
return `- ${id}: ${mount.pluginName} [${STATE_LABELS[mount.fiber.state]}]${providesNote}${waitingNote}`
const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state]
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts`
})
}

View File

@@ -39,8 +39,8 @@ export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber>
// while the old mount still holds the name — teach the replace recipe.
if (message.includes('already registered')) {
throw new Error(
`${message} — to REPLACE something an earlier mount registered, first cordis_unmount that mount's id `
+ '(find it with cordis_inspect what:"dynamic"), then mount the new version.',
`${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_unmount that Plugin's id `
+ '(find it with cordis_inspect what:"temporary"), then mount the new version.',
)
}
throw error instanceof Error ? error : new Error(message)

View File

@@ -25,7 +25,7 @@ export function presentInspectCall(args: { what?: string; name?: string }): Gene
}
/**
* The `cordis_mount` call card: an execute carrying the mount code as raw input.
* The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
@@ -33,13 +33,13 @@ export function presentMountCall(args: { code: string }): GenericCallView {
return {
card: 'generic',
kind: 'execute',
title: 'Mount plugin into live cordis runtime',
title: 'Mount temporary Cordis Plugin',
rawInput: { code: args.code },
}
}
/**
* The `cordis_unmount` call card: a delete, titled with the mount id.
* The `cordis_unmount` call card: a delete, titled with the temporary-plugin id.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
@@ -47,6 +47,6 @@ export function presentUnmountCall(args: { id: string }): GenericCallView {
return {
card: 'generic',
kind: 'delete',
title: `Unmount ${args.id}`,
title: `Unmount temporary Cordis Plugin ${args.id}`,
}
}

View File

@@ -52,7 +52,7 @@ function patchDualRealmInstanceof(sandbox: object): void {
const TIMER_REDIRECT
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.'
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when unmounted.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
@@ -80,7 +80,7 @@ function nodeApiTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
traps[name] = () => {
throw new Error(`${name} is not available in the mount sandbox — ${redirect}`)
throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`)
}
}
return traps
@@ -163,14 +163,14 @@ export async function evaluateMountCode(sandbox: object, code: string, id: strin
const offendingLine = context.split('\n')[1] ?? ''
if (/\bas\b/.test(offendingLine)) {
throw new Error(
`mount code failed to parse:\n${context}\n`
`temporary Plugin code failed to parse:\n${context}\n`
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
+ ' ✗ { type: \'text\' as const, text: x }\n'
+ ' ✓ { type: \'text\', text: x }',
)
}
throw new Error(
`mount code failed to parse:\n${context}\n`
`temporary Plugin code failed to parse:\n${context}\n`
+ 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',

View File

@@ -12,11 +12,11 @@ describe('cross-mount provide/inject', () => {
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
const ctx = await setup()
const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(text(provider)).toContain('state: active')
expect(text(provider)).toContain('is running')
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: active')
expect(text(consumer)).toContain('is running')
// The vm-realm service value is callable across mounts, and the result
// normalizes into the host realm like any dynamic tool result.
@@ -29,9 +29,9 @@ describe('cross-mount provide/inject', () => {
const ctx = await setup()
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: pending')
expect(text(consumer)).toContain('waiting for service(s): greeter')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('waiting for: greeter')
expect(text(consumer)).toContain('is pending')
expect(text(consumer)).toContain('missing services: greeter')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('waiting for: greeter')
expect(ctx.tools.get('greet')).toBeUndefined()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
@@ -48,8 +48,8 @@ describe('cross-mount provide/inject', () => {
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
expect(ctx.tools.get('greet')).toBeUndefined()
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(report).toContain('dyn-2: greeter-consumer [pending] — waiting for: greeter')
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until unmounted or DSH restarts')
})
it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => {
@@ -62,7 +62,7 @@ describe('cross-mount provide/inject', () => {
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3
expect(ctx.tools.get('greet')).toBeDefined()
expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-2: greeter-consumer [active]')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]')
})
it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => {
@@ -71,8 +71,8 @@ describe('cross-mount provide/inject', () => {
const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(duplicate.isError).toBe(true)
expect(text(duplicate)).toContain('has been registered')
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(report).toContain('dyn-1: greeter-provider')
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(report).toContain('Temporary Plugin dyn-1: greeter-provider')
expect(report).not.toContain('dyn-2')
})
@@ -81,8 +81,8 @@ describe('cross-mount provide/inject', () => {
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(dynamic).toContain('dyn-1: greeter-provider [active] — provides: greeter')
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until unmounted or DSH restarts')
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
expect(services).toContain('- greeter (provided by greeter-provider)')
@@ -126,7 +126,7 @@ describe('cross-mount provide/inject', () => {
`,
})
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: active')
expect(text(consumer)).toContain('is running')
expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null')
})
@@ -139,6 +139,6 @@ describe('cross-mount provide/inject', () => {
expect(ctx.tools.get('greet')).toBeUndefined()
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
expect(services).toContain('- greeter (provided by greeter-provider)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-1: greeter-provider [active]')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-1: greeter-provider [running]')
})
})

View File

@@ -18,7 +18,7 @@ describe('cordis_inspect', () => {
const report = text(result)
if (result.isError) throw new Error('expected cordis_inspect success')
expect(result.value).toBe(report)
for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
for (const heading of ['services', 'plugins', 'tools', 'Temporary Plugins', 'api', 'events']) {
expect(report).toContain(`## ${heading}`)
}
// The services section sees the real providers; the plugins list shows
@@ -28,7 +28,7 @@ describe('cordis_inspect', () => {
expect(report).toContain('- tool-cordis [active]')
expect(report).toContain('- cordis-dynamic [active]')
expect(report).toContain('- cordis_mount')
expect(report).toContain('(no dynamic plugins mounted)')
expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.')
})
it('limits the report to one section via `what`', async () => {
@@ -40,11 +40,12 @@ describe('cordis_inspect', () => {
expect(report).not.toContain('## plugins')
})
it('shows a mount in the dynamic section and in the flat plugins list', async () => {
it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
const report = text(await call(ctx, 'cordis_inspect', {}))
expect(report).toContain('- dyn-1: change-logger [active]')
expect(report).toContain('## Temporary Plugins')
expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts')
expect(report).toContain('- change-logger [active]')
})

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { REVERSE_TOOL_CODE } from './helpers.ts'
import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* Full-loop integration: a scripted mock model mounts a plugin that registers
@@ -65,4 +66,36 @@ describe('cordis tools through the agent loop', () => {
// After the unmount the self-made tool is gone from the registry.
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
it('keeps a temporary Plugin across turns, unmounts it, and does not restore it in a new runtime', async () => {
const adapter = new MockAdapter([
toolCallResponse('mount-1', 'cordis_mount', { code: 'return { name: \'turn-marker\', apply() {} }' }),
toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }),
textResponse('Turn one complete.'),
toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }),
toolCallResponse('unmount-1', 'cordis_unmount', { id: 'dyn-1' }),
toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }),
textResponse('Turn two complete.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'Mount the marker and inspect it.' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
agent.followup({ content: [{ type: 'text', text: 'On this later turn, inspect the marker, unmount it, then inspect again.' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const resultText = new Map(
agent.session.events
.filter(event => event.type === 'tool/result')
.map(event => [event.data.callId, event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')]),
)
expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
expect(resultText.get(CallId('unmount-1'))).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.')
const restarted = await setup()
expect(text(await call(restarted, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
})

View File

@@ -57,7 +57,7 @@ describe('cordis_mount', () => {
provides: [],
waitingFor: [],
})
expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until unmounted or DSH restarts).')
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
ctx.tools.register(dummyTool('trigger_a'))
@@ -665,8 +665,7 @@ describe('cordis_mount', () => {
provides: [],
waitingFor: ['no-such-service'],
})
expect(text(result)).toContain('state: pending')
expect(text(result)).toContain('waiting for service(s): no-such-service')
expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until unmounted or DSH restarts).')
// Unmounting a pending mount works like any other.
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
@@ -677,7 +676,7 @@ describe('cordis_mount', () => {
const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('boom in sandbox')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => {
@@ -693,7 +692,7 @@ describe('cordis_mount', () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'return 42' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('must `return` a plugin')
expect(text(result)).toContain('must `return` a Plugin')
})
it('answers a missing return with the two valid plugin forms', async () => {
@@ -710,7 +709,7 @@ describe('cordis_mount', () => {
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('apply exploded')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => {
@@ -754,16 +753,16 @@ describe('cordis_mount', () => {
})
it.each([
['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'],
['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'],
['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'],
['require(\'fs\')', 'require is not available in the temporary Plugin sandbox', 'inject: [\'fs\']'],
['setTimeout(() => {}, 5)', 'setTimeout is not available in the temporary Plugin sandbox', 'ctx.setTimeout'],
['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'],
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
expect(result.isError).toBe(true)
expect(text(result)).toContain(trapMessage)
expect(text(result)).toContain(redirect)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
@@ -781,7 +780,7 @@ describe('cordis_mount', () => {
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('state: active')
expect(text(result)).toContain('is running')
await new Promise(resolve => setTimeout(resolve, 50))
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick')
})
@@ -854,7 +853,7 @@ describe('cordis_mount', () => {
const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' })
expect(result.isError).toBe(true)
expect(text(result)).toMatch(/timed? ?out/i)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {

View File

@@ -22,13 +22,13 @@ describe('presenters', () => {
expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({
card: 'generic',
kind: 'execute',
title: 'Mount plugin into live cordis runtime',
title: 'Mount temporary Cordis Plugin',
rawInput: { code: 'return (ctx) => {}' },
})
})
it('cordis_unmount renders a generic delete card titled with the id', () => {
expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount dyn-1' })
expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount temporary Cordis Plugin dyn-1' })
})
it('is wired onto the registered definitions through the defineTool soft-validation path', async () => {
@@ -42,7 +42,7 @@ describe('presenters', () => {
title: 'Inspect cordis runtime: api: tools',
})
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' })
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount temporary Cordis Plugin dyn-2' })
// Soft validation: presenter args that fail the schema render as no card, never a throw.
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined()
})

View File

@@ -186,7 +186,7 @@ describe('sandbox context façade — inject gate on services', () => {
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('state: active')
expect(text(result)).toContain('is running')
})
it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {

View File

@@ -31,9 +31,10 @@ describe('tool registration', () => {
const ctx = await setup()
const names = ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
expect(names).not.toEqual(expect.arrayContaining(['cordis_try', 'cordis_stop']))
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events'])
expect(props.name?.type).toBe('string')
})
})

View File

@@ -28,13 +28,13 @@ describe('cordis_unmount', () => {
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_unmount success')
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
expect(text(result)).toContain('unmounted dyn-1')
expect(text(result)).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
// Immediately after the awaited unmount, the listener must be gone — no
// grace period, no eventual consistency.
ctx.tools.register(dummyTool('trigger_after'))
expect(log).toHaveBeenCalledTimes(1)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('unregisters a self-made tool on unmount', async () => {
@@ -50,7 +50,7 @@ describe('cordis_unmount', () => {
const ctx = await setup()
const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' })
expect(unknown.isError).toBe(true)
expect(text(unknown)).toContain('no dynamic plugin with id "dyn-99"')
expect(text(unknown)).toContain('no temporary Plugin with id "dyn-99"')
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })

View File

@@ -131,6 +131,7 @@ export function composeTuiApp(ctx: Context, config: Config): void {
ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') })
ctx.plugin(SessionReferenceService, config.sessionReferences ?? {})
ctx.plugin(UserInteractionService)
ctx.plugin(uiTui.TuiPromptService)
ctx.plugin(uiTui, {
...config.ui,
...config.welcome === undefined ? {} : { welcome: config.welcome },

View File

@@ -40,7 +40,7 @@ describe('dsh-tui-demo app', () => {
},
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
ui: { color: false, maxToolOutputLines: 3 },
ui: { theme: { color: false }, maxToolOutputLines: 3 },
skills: { tool: { catalogDescriptionMaxLength: 8 } },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
@@ -55,6 +55,7 @@ describe('dsh-tui-demo app', () => {
'SessionQuerySqlite',
'SessionReferenceService',
'UserInteractionService',
'TuiPromptService',
'ui-tui',
'agent-spine-demo',
'tool-ask-user',
@@ -67,15 +68,15 @@ describe('dsh-tui-demo app', () => {
candidateLimit: 7,
maxReferenceBytes: 1234,
})
const tuiConfig = calls[7]?.config as { sessionId: string }
const tuiConfig = calls[8]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
color: false,
theme: { color: false },
maxToolOutputLines: 3,
})
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[8]?.config as {
const spineConfig = calls[9]?.config as {
readonly agents: Array<Record<string, unknown>>
readonly goals: Record<string, never>
readonly maxParallelToolCalls: number
@@ -111,8 +112,8 @@ describe('dsh-tui-demo app', () => {
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[5]?.config).toEqual({})
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
expect(calls[7]?.config).toEqual({ sessionId: 'persisted-session' })
expect((calls[8]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' })
expect((calls[9]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',
})
@@ -128,12 +129,12 @@ describe('dsh-tui-demo app', () => {
workspaceContext: false,
})
const tuiConfig = calls[6]?.config as { sessionId: string }
const tuiConfig = calls[7]?.config as { sessionId: string }
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect((calls[7]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
expect((calls[8]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls[7]?.config).toMatchObject({ goals: false })
expect(calls[8]?.config).toMatchObject({ goals: false })
})
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {

View File

@@ -6,7 +6,7 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -154,6 +154,16 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
ctx.emit('fs/observed', target, info.version, exec)
return outcome
},
presentResult(_args, result: ToolResult): GenericResultView | undefined {
if (result.isError) return undefined
const only = result.content.length === 1 ? result.content[0] : undefined
const text = only?.type === 'text' ? only.text : undefined
if (text === undefined) return undefined
// Group 1 always captures (possibly empty) when the envelope matches.
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
if (body === undefined) return undefined
return { card: 'generic', content: [{ type: 'text', text: body }] }
},
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the
// read's offset (defaulting to 1). The window reflects raw args, so an omitted limit keeps

View File

@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type ToolResult } from '@deepseek-ai/dsh-tools'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
@@ -432,6 +432,11 @@ describe('tool-owned presentation (pure presentCall)', () => {
return ctx.tools.get(name)?.presentCall?.(args)
}
const presentResult = async (name: string, args: unknown, result: ToolResult) => {
const { ctx } = await setup()
return ctx.tools.get(name)?.presentResult?.(args, result)
}
it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => {
expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
@@ -445,6 +450,36 @@ describe('tool-owned presentation (pure presentCall)', () => {
})
})
it('read: completed presentation removes the model-facing XML envelope', async () => {
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
isError: false,
})).toEqual({
card: 'generic',
content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
})
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: 'malformed replay' }],
isError: false,
})).toBeUndefined()
})
it('read: completed presentation declines errors and non-single-text content', async () => {
const envelope = '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\nbody\n</content>'
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }],
isError: true,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
isError: false,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'reasoning', text: envelope }],
isError: false,
})).toBeUndefined()
})
it('read: "from line N" window when only offset is set', async () => {
expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({
card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }],

View File

@@ -28,6 +28,14 @@ export function mapUsage(usage: PiUsage): TokenUsage {
}
}
// XXX(pi-ai upstream): pi-ai flattens the caught error to `error.message`
// (api/anthropic-messages.js: `errorMessage = error instanceof Error ?
// error.message : JSON.stringify(error)`), discarding the original Error and its
// `cause` chain before it reaches us. undici carries the actionable transport
// detail on `cause` (e.g. `SocketError: other side closed`) but hands the fetch
// wrapper a bare `terminated`, so we are left pattern-matching terse words here.
// If pi-ai ever forwards the original Error (or a fetch/dispatcher hook that lets
// us capture the cause ourselves), classify on `code`/`cause` instead of text.
function classifyPiAiError(message: string): string {
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE
@@ -35,8 +43,19 @@ function classifyPiAiError(message: string): string {
if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST'
if (/\b5\d\d\b/.test(message)) return 'SERVER'
if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT'
// A stream truncated before the provider's terminal event: each pi-ai provider
// throws its own wording when the wire closes mid-response without a terminal
// event (`… stream ended before message_stop`, `… before a terminal response
// event`, `… ended without a terminal event`, `Stream ended without
// finish_reason`). The connection dropped mid-response, so this is a transport
// truncation, not a model-level error.
if (/stream ended (?:before|without)\b/i.test(message)) return 'TRANSPORT'
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) {
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)
// undici renders a mid-stream socket drop as a bare `terminated` (its
// `cause` — the real SocketError — was flattened away upstream); Node's
// stream layer says `Premature close`.
|| /\bterminated\b|premature close/i.test(message)) {
return 'TRANSPORT'
}
return 'PI_AI_ERROR'

View File

@@ -578,6 +578,15 @@ describe('mapStopReason / mapUsage', () => {
'other side closed',
'HTTP2 request did not get a response',
'WebSocket closed unexpectedly',
// undici flattens a mid-stream socket drop to this bare word (its SocketError
// cause is discarded upstream before it reaches us).
'terminated',
'Premature close',
// pi-ai's per-provider throws when the wire closes before the terminal event.
'Anthropic stream ended before message_stop',
'OpenAI Responses stream ended before a terminal response event',
'openrouter stream ended without a terminal event',
'Stream ended without finish_reason',
])('maps pi-ai transport wording %j', (errorMessage) => {
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage })))
.toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } })

View File

@@ -18,6 +18,7 @@ import {
} from '../feature.ts'
import { ProjectContribution, type ProjectResource } from '../resources.ts'
import {
cordisConfigEntry,
npmCordisConfigEntry,
optionalString,
ownedTextFile,
@@ -86,6 +87,10 @@ class AppOption extends FeatureOption {
id: 'user-interaction',
name: '@deepseek-ai/dsh-user-interaction',
}),
cordisConfigEntry(ID, {
id: 'tui-prompt',
name: '@deepseek-ai/dsh-tui/prompt',
}),
...npmCordisConfigEntry(ID, {
id: 'tui',
name: '@deepseek-ai/dsh-tui',

View File

@@ -187,6 +187,7 @@ describe('SdkProject and ProjectEditSession', () => {
expect(await readFile(join(project.root, 'cordis.yml'), 'utf8'))
.toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID')
expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model')
expect(project.cordis.entry('tui-prompt')?.name).toBe('@deepseek-ai/dsh-tui/prompt')
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')

View File

@@ -32,6 +32,7 @@ const PROJECT_AGENTS_RANK = 200
const CUSTOM_RANK = 300
const USER_DSH_RANK = 400
const USER_AGENTS_RANK = 500
const BUNDLED_RANK = 600
export const name = 'skill-local'
export const inject = ['skills']
@@ -44,12 +45,15 @@ export interface Config {
agentsHome?: string
/** Additional skill roots scanned after project roots and before user roots. */
customSkillDirs?: string[]
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
bundledSkillDir?: string
}
export const Config: Schema<Config> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
customSkillDirs: z.array(z.string()).default([]),
bundledSkillDir: z.string(),
})
interface SkillRoot {
@@ -57,6 +61,7 @@ interface SkillRoot {
source: SkillSource
rank: number
skipSystem?: boolean
trustedHost?: boolean
}
interface SkillRootEntry {
@@ -91,11 +96,14 @@ export class LocalSkillProvider implements SkillProvider {
private readonly dshHome: string
private readonly agentsHome: string
private readonly customSkillDirs: string[]
private readonly bundledSkillDir: string | undefined
constructor(private readonly ctx: Context, config: Config = {}) {
this.dshHome = resolveDshHome(config.dshHome)
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
}
/**
@@ -122,7 +130,7 @@ export class LocalSkillProvider implements SkillProvider {
*/
async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
const locator = candidate.locator as LocalLocator
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal)
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled')
if (parsed === undefined) return undefined
return {
name: parsed.name,
@@ -151,6 +159,9 @@ export class LocalSkillProvider implements SkillProvider {
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
...this.bundledSkillDir === undefined
? []
: [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }],
)
return roots
}
@@ -167,7 +178,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
? { path: entry.path, directory: root.path }
: undefined
if (locator === undefined) continue
const parsed = await parseSkillFile(locator.path, ctx)
const parsed = await parseSkillFile(locator.path, ctx, undefined, root.trustedHost === true)
if (parsed === undefined) continue
skills.push({
name: parsed.name,
@@ -188,7 +199,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs)
return await listSkillRootEntriesFromNode(root, ctx)
}
@@ -225,8 +236,8 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom
return result
}
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise<ParsedSkill | undefined> {
const raw = await readSkillText(ctx, path, signal)
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise<ParsedSkill | undefined> {
const raw = await readSkillText(ctx, path, signal, trustedHost)
signal?.throwIfAborted()
if (raw === undefined) {
return undefined
@@ -266,10 +277,10 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined {
return ctx.get('fs')
}
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise<string | undefined> {
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise<string | undefined> {
signal?.throwIfAborted()
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
if (fs !== undefined && !trustedHost) {
return await readSkillTextFromFileSystem(ctx, fs, path, signal)
}
try {

View File

@@ -149,15 +149,23 @@ describe('LocalSkillProvider', () => {
await writeSkill(custom, 'custom-only', 'custom only')
await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
const bundled = await tempDir('skill-bundled')
await writeSkill(bundled, 'bundled-only', 'bundled skill')
await writeSkill(bundled, 'same', 'bundled skill')
const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled })
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
['custom-only', 'custom only'],
['same', 'project dsh skill'],
expect(skills.map(skill => skill.name)).toEqual([
'bundled-only',
'custom-only',
'same',
])
expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only')
expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill')
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' })
expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.')
const noGit = await tempDir('skill-no-git')
await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
@@ -335,6 +343,20 @@ describe('LocalSkillProvider', () => {
])
expect(fs.listDirCalls).toBeGreaterThan(0)
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
const bundled = await tempDir('skill-backend-bundled')
await writeSkill(bundled, 'bundled-host', 'Bundled host skill')
const bundledCtx = new Context()
await bundledCtx.plugin(TestFileSystem)
const bundledFs = bundledCtx.fs as TestFileSystem
bundledFs.failResolvePaths.add(bundled)
await bundledCtx.plugin(SkillService)
await bundledCtx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
bundledSkillDir: bundled,
})
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
})
it('forwards cancellation to filesystem reads while loading a skill', async () => {
@@ -375,17 +397,22 @@ describe('LocalSkillProvider', () => {
it('uses default home root resolution without exposing builtin skills', async () => {
const previousDshHome = process.env.DSH_HOME
const previousAgentsHome = process.env.DSH_AGENTS_HOME
const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR
const envHome = await tempDir('skill-env-home')
try {
process.env.DSH_HOME = join(envHome, '.dsh')
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
const bundled = join(envHome, 'bundled-skills')
process.env.DSH_BUNDLED_SKILL_DIR = bundled
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill')
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
process.env.DSH_HOME = join(envHome, 'empty-dsh')
delete process.env.DSH_BUNDLED_SKILL_DIR
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
const empty = new Context()
await empty.plugin(SkillService)
@@ -405,6 +432,11 @@ describe('LocalSkillProvider', () => {
} else {
process.env.DSH_AGENTS_HOME = previousAgentsHome
}
if (previousBundledSkillDir === undefined) {
delete process.env.DSH_BUNDLED_SKILL_DIR
} else {
process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir
}
}
})
})

View File

@@ -28,7 +28,7 @@ export function isSkillName(name: string): boolean {
}
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
export type SkillResourceBase =

View File

@@ -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: 84ed3de78469ab778118ee5599acfd8476b0ecd1
README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16
README.md: 528daef773635451ceb198ab3231c2dd87cb9413
README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2

View File

@@ -74,7 +74,7 @@ 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, tool cards) use a colored left-gutter bar instead of a filled background block; 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.
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.
## Model Experience

View File

@@ -74,7 +74,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
## 颜色
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、工具卡片)使用彩色左侧 gutter bar而非填充背景块问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
## 模型体验

View File

@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./prompt": {
"types": "./lib/types/prompt.d.ts",
"default": "./lib/prompt.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/prompt.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -35,9 +40,9 @@
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -59,6 +64,7 @@
},
"dependencies": {
"@earendil-works/pi-tui": "0.80.7",
"saxes": "6.0.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
@@ -71,9 +77,9 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -0,0 +1,95 @@
/**
* Editor autocomplete provider merging path-only file candidates and optional
* session-reference snapshots with the base slash-command completions.
* @module @deepseek-ai/dsh-tui/chat/autocomplete
*/
import {
CombinedAutocompleteProvider,
type AutocompleteItem,
type AutocompleteProvider,
type AutocompleteSuggestions,
} from '@earendil-works/pi-tui'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
formatSessionReferenceMention,
type SessionReferenceService,
} from '@deepseek-ai/dsh-session-reference'
import { displayInlineText } from '../components/text.ts'
import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts'
/** Merge path-only file candidates and optional session snapshots with commands. */
export class ReferenceAutocompleteProvider implements AutocompleteProvider {
constructor(
private readonly base: CombinedAutocompleteProvider,
private readonly files: WorkspaceFileSearch,
private readonly sessions: SessionReferenceService | undefined,
private readonly agent: Agent,
) {}
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean },
): Promise<AutocompleteSuggestions | null> {
const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
const currentLine = lines[cursorLine]
/* v8 ignore next -- Editor always supplies its current state line. */
if (currentLine === undefined) return basePromise
const token = activeAtToken(currentLine, cursorCol)
if (token === undefined) {
this.files.invalidate()
return basePromise
}
const filePromise = this.files.list(token.query, options.signal).catch(() => [])
const sessionPromise = this.sessions === undefined || token.quoted
? Promise.resolve([])
: this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
const [base, fileCandidates, sessionCandidates] = await Promise.all([
basePromise,
filePromise,
sessionPromise,
])
if (options.signal.aborted) return base
const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
const value = formatFileMention(candidate, token.quoted)
if (value === undefined) return []
const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
const directory = candidate.kind === 'directory'
return [{
value,
label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
description: displayInlineText(candidate.path),
}]
})
const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
const mentionLabel = displayInlineText(candidate.label)
const sessionId = displayInlineText(candidate.sessionId)
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
return {
value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
label: `Session · ${mentionLabel}`,
description,
}
})
const items = [...fileItems, ...sessionItems]
if (items.length === 0) return base
return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
}
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
}
}

View File

@@ -0,0 +1,31 @@
/**
* Shared collaborator surface every chat-channel sub-controller receives from
* `createTuiChat`. Each controller's own `*Deps` extends {@link ChatChannelDeps}
* (and {@link ChannelNotice} when it reports outcomes) with the extra services
* it needs. Value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`)
* are stable for the channel's life; the callbacks stay on the object so a
* controller always calls the channel's current implementation.
* @module @deepseek-ai/dsh-tui/chat/channel
*/
import type { Context } from 'cordis'
import type { TuiOverlayManager } from '../extension/overlay-manager.ts'
import type { Palette } from '../components/theme.ts'
import type { ResolvedTuiConfig } from '../config.ts'
/** Collaborators shared by every chat-channel sub-controller. */
export interface ChatChannelDeps {
readonly ctx: Context
readonly resolved: ResolvedTuiConfig
readonly palette: Palette
readonly overlayManager: TuiOverlayManager
/** Redraw the channel. */
requestRender(): void
/** Whether the channel has begun shutting down. */
isDisposed(): boolean
}
/** Append a channel notice line; controllers that report outcomes mix this in. */
export interface ChannelNotice {
appendNotice(message: string, kind?: 'info' | 'warning' | 'error'): void
}

View File

@@ -3,7 +3,7 @@
* paths only: selected values remain ordinary prompt text and file contents
* stay behind the model-facing `read` tool.
*
* @module @deepseek-ai/dsh-tui/file-autocomplete
* @module @deepseek-ai/dsh-tui/chat/file-autocomplete
*/
import { lstat, readdir } from 'node:fs/promises'

View File

@@ -0,0 +1,137 @@
/**
* Zero-state helpers for the interactive chat channel: prompt-directory and
* Git-branch formatting, surface/tool-call derivations over the session log,
* session-reference context cards, the placeholder editor, and banner-reveal
* timing constants. None of these close over channel state.
* @module @deepseek-ai/dsh-tui/chat/helpers
*/
import { execFileSync } from 'node:child_process'
import { homedir } from 'node:os'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import {
CURSOR_MARKER,
Editor,
truncateToWidth,
visibleWidth,
} from '@earendil-works/pi-tui'
import type { Session } from '@deepseek-ai/dsh-session'
/** Editor that shows a placeholder without making it editable content. */
export class HintEditor extends Editor {
/** Placeholder shown in the empty input row; `undefined` hides it. */
hint: string | undefined
/** Prompt text rendered before the placeholder, matching the live prompt width. */
hintPrefix = ''
override render(width: number): string[] {
const lines = super.render(width)
if (this.hint === undefined || this.getText() !== '') return lines
const content = lines[0]
/* v8 ignore next -- Editor always renders one content row. */
if (content === undefined) return lines
const padding = ' '.repeat(this.getPaddingX())
/* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */
const marker = this.focused ? CURSOR_MARKER : ''
const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix))
const placeholder = truncateToWidth(this.hint, available, '')
const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder)
lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}`
return lines
}
}
/**
* Format the session working directory as a prompt label: `~` for home,
* `~/rel` for a home-relative path, the raw path otherwise.
* @param cwd - operational working directory from the session header.
* @returns unescaped prompt label.
*/
export function formatCwd(cwd: string | undefined): string {
if (cwd === undefined) return 'cwd unset'
const home = homedir()
const rel = relative(resolve(home), resolve(cwd))
if (rel === '') return '~'
/* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
if (isAbsolute(rel)) return cwd
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
return cwd
}
/**
* Resolve the current Git branch for the prompt context line.
* @param cwd - operational working directory to query.
* @returns branch name, or `undefined` outside a worktree or on any failure.
*/
export function gitBranch(cwd: string): string | undefined {
try {
const env = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)),
)
const branch = execFileSync('git', ['branch', '--show-current'], {
cwd,
encoding: 'utf8',
env,
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 1_000,
}).trim()
/* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */
return branch === '' ? undefined : branch
} catch (_gitUnavailableOrOutsideWorktree) {
return undefined
}
}
/**
* Sequence numbers currently visible on the session surface.
* @param session - session whose surface nodes to read.
* @returns the set of visible event sequence numbers.
*/
export function activeSurfaceSeqs(session: Session): Set<number> {
return new Set(session.surface.nodes)
}
/**
* Tool-call ids whose owning assistant message is on the active surface.
* @param session - session whose events to scan.
* @param active - sequence numbers currently on the surface.
* @returns the set of active tool-call ids.
*/
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
for (const block of event.data.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
}
return ids
}
/**
* Read a session-reference context card's display labels from an event source.
* @param source - event source to inspect.
* @returns per-reference labels, or `undefined` when the source is not a reference card.
*/
export function sessionReferenceCard(source: unknown): string[] | undefined {
if (typeof source !== 'object' || source === null) return undefined
const record = source as Record<string, unknown>
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
const references = record['references'] as unknown[]
const labels: string[] = []
for (const reference of references) {
if (typeof reference !== 'object' || reference === null) return undefined
const entry = reference as Record<string, unknown>
const sessionId = entry['sessionId']
const label = entry['label']
if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined
labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`)
}
return labels
}
/** Milliseconds between banner sweep-reveal frames (~60 fps). */
export const BANNER_REVEAL_INTERVAL_MS = 15
/** Number of sweep frames the banner reveal spreads the terminal width over. */
export const BANNER_REVEAL_STEPS = 24

View File

@@ -0,0 +1,191 @@
/**
* Model-selection sub-controller for the interactive chat channel: the queued
* `/model` command, the keyboard model selector overlay with reasoning-effort
* selection, and resolution of the selected model's context window. Owns the
* context-window cache the prompt and status views read; the caller owns the
* shared {@link AgentLlmTargetRef}.
* @module @deepseek-ai/dsh-tui/chat/model-command
*/
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { TuiOverlaySession } from '../extension/types.ts'
import { displayText } from '../components/text.ts'
import {
ModelDialog,
readModelChoices,
targetLabel,
targetReasoningLabel,
type ModelChoice,
type ModelDialogSelection,
} from '../components/dialogs.ts'
import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
/** Collaborators the model controller needs from the chat channel. */
export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice {
/** Shared selected-target handle owned by the channel. */
readonly target: AgentLlmTargetRef
}
/** Model-selection controller for one chat channel. */
export interface ModelController {
/** Resolved context window of the selected model, or `undefined` if unknown. */
contextWindow(): number | undefined
/** Queue a `/model` command; empty argument opens the selector. */
queueModelCommand(raw: string): void
/** Drop the pending context-window resolution (shutdown). */
resetContextResolution(): void
/** Forget the tracked selector overlay (shutdown). */
clearOverlay(): void
}
type ContextResolution =
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
| { readonly kind: 'error'; readonly error: unknown }
/**
* Build the model-selection controller for one chat channel.
* @param deps - channel collaborators and shared target handle.
* @returns the controller wired to the channel's overlay and prompt views.
*/
export function createModelController(deps: ModelControllerDeps): ModelController {
const { ctx, resolved, palette, overlayManager, target } = deps
let contextWindow: number | undefined
let contextResolution: Promise<ContextResolution> | undefined
let modelOverlay: TuiOverlaySession | undefined
let modelCommands = Promise.resolve()
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
const resolution: Promise<ContextResolution> = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const),
(error: unknown) => ({ kind: 'error', error } as const),
)
contextResolution = resolution
void resolution.then((result) => {
if (contextResolution !== resolution) return
if (result.kind === 'error') {
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
return
}
contextWindow = result.contextWindow
deps.requestRender()
})
}
resolveContextWindow(target.current)
const selectModel = (
selected: ModelChoice,
explicitReasoning?: { effort: ReasoningEffortId | undefined },
): void => {
const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model
const reasoningEffort = explicitReasoning === undefined
? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort)
: explicitReasoning.effort
if (sameRoute && target.current?.reasoningEffort === reasoningEffort) {
const reasoning = targetReasoningLabel(selected, reasoningEffort)
deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`)
return
}
target.current = {
provider: selected.provider,
model: selected.model,
...reasoningEffort === undefined ? {} : { reasoningEffort },
}
resolveContextWindow(target.current)
const reasoning = targetReasoningLabel(selected, reasoningEffort)
deps.appendNotice([
`Model selected: ${targetLabel(selected)}.`,
...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`],
'New steps will use it.',
].join(' '))
}
const showModelSelector = (choices: readonly ModelChoice[]): void => {
const current = target.current === undefined ? 'unset' : targetLabel(target.current)
if (choices.length === 0) {
deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
return
}
void modelOverlay?.close()
const session = overlayManager.open({
create: () => new ModelDialog(
choices,
target.current,
resolved.maxModelOptions,
palette,
(selection: ModelDialogSelection) => {
void session.close()
selectModel(selection.choice, { effort: selection.reasoningEffort })
},
() => { void session.close() },
),
options: {
width: resolved.modelDialogWidth,
maxHeight: resolved.modelDialogMaxHeight,
anchor: 'center',
margin: 1,
},
})
modelOverlay = session
void session.closed.then(() => {
if (modelOverlay === session) modelOverlay = undefined
})
deps.requestRender()
}
const handleModelCommand = async (raw: string): Promise<void> => {
const choices = await readModelChoices(ctx, target.current)
if (deps.isDisposed()) return
const argument = raw.trim()
if (argument === '') {
showModelSelector(choices)
return
}
const parts = argument.split(/\s+/u)
if (parts.length > 2) {
deps.appendNotice('Usage: /model [provider/]model', 'warning')
return
}
let matches: ModelChoice[]
if (parts.length === 2) {
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
} else {
const value = argument
const qualified = choices.filter(choice => targetLabel(choice) === value)
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
}
if (matches.length === 0) {
deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
return
}
if (matches.length > 1) {
deps.appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
return
}
const selected = matches[0]
/* v8 ignore next -- a non-empty matches array always has index zero. */
if (selected === undefined) return
selectModel(selected)
}
return {
contextWindow: () => contextWindow,
queueModelCommand(raw: string): void {
modelCommands = modelCommands.then(async () => {
await handleModelCommand(raw)
}).catch((error: unknown) => {
if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
})
},
resetContextResolution(): void {
contextResolution = undefined
},
clearOverlay(): void {
modelOverlay = undefined
},
}
}

View File

@@ -0,0 +1,168 @@
/**
* Ask-user-question sub-machine for the interactive chat channel. Registers the
* user-interaction provider, presents one question overlay at a time in FIFO
* order, and settles each request on answer, abort, overlay error, or channel
* shutdown.
* @module @deepseek-ai/dsh-tui/chat/questions
*/
import { errorChain } from '@deepseek-ai/dsh-llm'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import type { TuiOverlaySession } from '../extension/types.ts'
import { QuestionDialog } from '../components/dialogs.ts'
import type { ChatChannelDeps } from './channel.ts'
/** One queued or active ask-user-question request and its running answers. */
interface PendingQuestion {
request: AskUserQuestionRequest
index: number
answers: AskUserQuestionAnswerItem[]
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
overlay: TuiOverlaySession | undefined
}
/** Collaborators the question queue needs from the chat channel. */
export type QuestionQueueDeps = ChatChannelDeps
/** Ask-user-question controller for one chat channel. */
export interface QuestionQueue {
/** Reject the active and all queued questions (shutdown). */
rejectAll(): void
/** Remove the user-interaction provider registration. */
unregister(): void
}
/**
* Build the ask-user-question queue for one chat channel.
* @param deps - channel collaborators and overlay host.
* @returns the controller used at shutdown to drain and unregister.
*/
export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue {
const { ctx, resolved, palette, overlayManager } = deps
const questionQueue: PendingQuestion[] = []
let activeQuestion: PendingQuestion | undefined
const removeAbortListener = (pending: PendingQuestion): void => {
pending.request.signal?.removeEventListener('abort', pending.onAbort)
}
const rejectQuestion = (pending: PendingQuestion): void => {
void pending.overlay?.close()
pending.overlay = undefined
removeAbortListener(pending)
pending.reject(new UserInteractionError(
'ask_user_question was interrupted before the user answered',
'ASK_ABORTED',
))
}
const startNextQuestion = (): void => {
if (activeQuestion !== undefined || deps.isDisposed()) return
const pending = questionQueue.shift()
if (pending === undefined) return
activeQuestion = pending
const show = (): void => {
const question = pending.request.questions[pending.index]
if (question === undefined) {
activeQuestion = undefined
removeAbortListener(pending)
pending.resolve({ answers: pending.answers })
startNextQuestion()
return
}
const session = overlayManager.open({
...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
create: () => new QuestionDialog(
question,
pending.index + 1,
pending.request.questions.length,
pending.request.questions.length - pending.answers.length,
resolved.maxQuestionOptions,
palette,
(selection) => {
pending.overlay = undefined
void session.close()
pending.answers.push({ id: question.id, ...selection })
pending.index += 1
show()
},
() => {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
},
),
options: {
width: resolved.questionDialogWidth,
maxHeight: resolved.questionDialogMaxHeight,
anchor: 'bottom-left',
margin: { bottom: 1 },
},
})
pending.overlay = session
void session.closed.then((result) => {
if (pending.overlay !== session) return
pending.overlay = undefined
/* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
if (result.reason !== 'error') return
activeQuestion = undefined
removeAbortListener(pending)
pending.reject(new UserInteractionError(
`ask_user_question TUI failed: ${errorChain(result.error)}`,
'ASK_ABORTED',
))
startNextQuestion()
})
deps.requestRender()
}
show()
}
const unregister = ctx.userInteraction.registerProvider({
ask(request) {
return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
const pending: PendingQuestion = {
request,
index: 0,
answers: [],
resolve: resolveAnswer,
reject,
overlay: undefined,
onAbort: () => {
if (activeQuestion === pending) {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
return
}
// A non-active pending ask remains in the queue until this listener settles it.
questionQueue.splice(questionQueue.indexOf(pending), 1)
rejectQuestion(pending)
},
}
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
questionQueue.push(pending)
startNextQuestion()
})
},
})
return {
rejectAll(): void {
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
rejectQuestion(pending)
}
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
},
unregister,
}
}

View File

@@ -0,0 +1,245 @@
/**
* 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.
* @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 {
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,
type ResumeCandidate,
} from '../components/dialogs.ts'
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
/** Current agent status, re-read at each resume precondition point. */
agentStatus(): AgentStatus
}
/** Session-resume controller for one chat channel. */
export interface ResumeController {
/** Open the current-workspace searchable session selector. */
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.
*/
export function createResumeController(deps: ResumeControllerDeps): ResumeController {
const {
ctx, agent, config, runtime, resolved, palette, overlayManager,
persistence, 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)
}
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
const readResumeCandidate = async (
record: SessionRecord,
providers: ReadonlySet<string>,
): Promise<ResumeCandidate> => {
try {
let snapshot: SessionLogSnapshot
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) {
snapshot = {
session: structuredClone(live.header),
events: live.events.map(event => structuredClone(event)),
}
} else {
/* v8 ignore next -- caller checks the optional service before mapping records */
if (sessionQuery === undefined) throw new Error('session query is unavailable')
snapshot = await sessionQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
snapshot,
agent.session.id,
agent.session.header.cwd,
providers,
)
} catch (error: unknown) {
return {
record,
title: 'Unreadable session',
lastActivityAt: record.header.createdAt,
lastTurn: 'log unavailable',
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
}
}
}
/** Re-read every mutable precondition immediately before terminal handoff. */
const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
/* 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()
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
new Set(ctx.llm.listProviders().map(provider => provider.id)),
)
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
const finalStatus = deps.agentStatus()
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
return candidate
}
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
if (resumeInFlight) return
resumeInFlight = true
let terminalReleased = false
try {
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')
return
}
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
if (deps.isDisposed()) return
await ctx.sessions.flush(agent.session)
// Disposal can run while the flush promise is pending.
if (deps.isDisposed()) return
if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`)
await overlay.close()
resumeOverlay = undefined
await runtime.terminal.drainInput(100, 20)
// Disposal can run while terminal draining is pending.
if (deps.isDisposed()) return
ui.stop()
terminalReleased = true
await hostHandoff(checked.record.header.id)
throw new Error('resume host returned without replacing the process')
} catch (error: unknown) {
if (!deps.isDisposed()) {
if (terminalReleased) {
ui.start()
ui.setFocus(editor)
deps.appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
} else {
await overlay.close()
resumeOverlay = undefined
deps.appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
}
}
} finally {
resumeInFlight = false
}
}
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')
return
}
if (sessionQuery === undefined) {
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
return
}
const scan = ++resumeScan
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)
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
const candidates = await Promise.all(workspace.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
const session = overlayManager.open({
create: host => new ResumePicker(
candidates,
resolved.maxResumeOptions,
runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd),
() => host.viewport.rows,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
),
options: {
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session
void session.closed.then(() => {
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
})
deps.requestRender()
}, (error: unknown) => {
if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
})
},
}
}

View File

@@ -0,0 +1,67 @@
/**
* Manual `/skill:<name> [instructions]` parsing and model-visible rendering for
* the terminal front door.
* @module @deepseek-ai/dsh-tui/chat/skill-invocation
*/
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { SkillDefinition, SkillResourceBase } from '@deepseek-ai/dsh-skill'
/** Prefix that marks an editor submission as a manual skill invocation. */
export const SKILL_COMMAND_PREFIX = '/skill:'
/** Parsed `/skill:<name> [instructions]` submission; `name` is empty when the prefix carries no name. */
export interface ParsedSkillCommand {
/** Skill name typed after `/skill:`, up to the first space. */
name: string
/** Trimmed text after the name; empty when none was typed. */
instructions: string
}
/**
* Split a `/skill:<name> [instructions]` submission into its name and trailing instructions.
* @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}.
* @returns the skill name and any trailing instructions.
*/
export function parseSkillCommand(text: string): ParsedSkillCommand {
const rest = text.slice(SKILL_COMMAND_PREFIX.length)
const spaceIndex = rest.indexOf(' ')
if (spaceIndex === -1) return { name: rest, instructions: '' }
return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() }
}
/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */
function skillResourceReference(base: SkillResourceBase | undefined): string | undefined {
if (base === undefined) return undefined
switch (base.kind) {
case 'directory':
return `References in this skill are relative to ${base.path}.`
case 'url':
return `References in this skill are relative to ${base.url}.`
case 'opaque':
return base.description
default:
return assertNever(base, 'SkillResourceBase.kind')
}
}
/**
* Render a manually invoked skill into the model-visible user-message text. The
* `<skill>` block carries the body and, when the provider supplies one, its
* resource base; the trimmed `instructions` follow the block as the user's
* request for this turn. The name is registry-validated kebab-case
* (the skill registry rejects any other) and the resource base is trusted
* same-process provider prose, so — unlike the model-facing `dsh-tool-skill`
* result, which escapes for a tool channel — this user turn is assembled raw.
* @param skill - the loaded skill definition.
* @param instructions - trimmed text typed after `/skill:<name>`; empty when absent.
* @returns the user-message text delivered to the agent.
*/
export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string {
const lines = [`<skill name="${skill.name}">`]
const reference = skillResourceReference(skill.resourceBase)
if (reference !== undefined) lines.push(reference, '')
lines.push(skill.content, '</skill>')
const block = lines.join('\n')
return instructions === '' ? block : `${block}\n\n${instructions}`
}

View File

@@ -0,0 +1,347 @@
/**
* Per-step timing model and running-status glyph animation for the terminal
* front door. Timing buckets are replayed from the session event stream; the
* running glyph fades in on turn start, throbs while the turn runs, and fades
* out on turn end.
* @module @deepseek-ai/dsh-tui/chat/timing
*/
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Palette } from '../components/theme.ts'
/**
* Render cadence of the running prompt while active, and while the glyph fades
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
* changed terminal cells are re-emitted, so the faster tick stays cheap.
*/
export const STATUS_ANIMATION_INTERVAL_MS = 50
/**
* Milliseconds over which the running glyph fades in when a turn starts and
* fades out after it ends. The fade is an envelope over the running pulse:
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
*/
export const STATUS_FADE_MS = 300
/** Milliseconds for one full brightness throb of the running glyph. */
export const STATUS_PULSE_PERIOD_MS = 1400
/**
* Brightness floor of the running throb, as a fraction of the settled gray. At
* 0 the pulse swells from the near-background trough up to full and back. The
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
* cosine breathes symmetrically bold→dim→bold.
*/
export const STATUS_PULSE_FLOOR = 0
/**
* Muted-gray foreground the truecolor running glyph fades through, from the
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
* appearing rather than a colored indicator. Foreground-only, matching the
* brand gradient, so it stays legible on any terminal background.
*/
const STATUS_FADE_GRAY = {
trough: [43, 43, 43],
settled: [136, 136, 136],
} as const
/** The active phase of a running step, one bucket of accumulated wall time. */
export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools'
/** Turn/step coordinates of one assistant step. */
export type StepPosition = { turn: number; step: number }
/** Accumulated wall time per phase for one step or session slice. */
export interface TimingTotals {
ttft: number
thinking: number
responding: number
tools: number
}
interface TimingState {
totals: TimingTotals
active: { bucket: TimingBucket; since: number } | undefined
}
const TIMING_BUCKET_LABELS: Record<TimingBucket, string> = {
ttft: 'Model wait',
thinking: 'Thinking',
responding: 'Response',
tools: 'Tools',
}
const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools']
function emptyTimingTotals(): TimingTotals {
return { ttft: 0, thinking: 0, responding: 0, tools: 0 }
}
function timingState(startedAt?: number): TimingState {
return {
totals: emptyTimingTotals(),
/* v8 ignore next -- production timing state always begins at a logged step timestamp. */
active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt },
}
}
function sameStep(event: SessionEvent, position: StepPosition): boolean {
return typeof event.data === 'object'
&& 'turn' in event.data && 'step' in event.data
&& event.data.turn === position.turn && event.data.step === position.step
}
function closeTimingBucket(state: TimingState, at: number): void {
if (state.active === undefined) return
state.totals[state.active.bucket] += Math.max(0, at - state.active.since)
state.active = undefined
}
function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void {
if (state.active?.bucket === bucket) return
closeTimingBucket(state, at)
if (bucket !== undefined) state.active = { bucket, since: at }
}
function advanceStepTiming(
state: TimingState,
event: Extract<SessionEvent, { type: 'assistant/chunk' | 'tool/call' | 'step/end' }>,
): void {
if (event.type === 'assistant/chunk') {
const chunk = event.data.chunk
if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time)
if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) {
enterTimingBucket(state, 'thinking', event.time)
} else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) {
enterTimingBucket(state, 'responding', event.time)
}
} else if (event.type === 'tool/call') {
enterTimingBucket(state, 'tools', event.time)
} else {
closeTimingBucket(state, event.time)
}
}
function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
const totals = { ...state.totals }
if (state.active !== undefined && at !== undefined) {
totals[state.active.bucket] += Math.max(0, at - state.active.since)
}
return totals
}
/**
* Replay one step's accumulated per-phase timing up to clock `at`.
* @param events - Session events to replay.
* @param position - Turn/step coordinates of the step.
* @param at - Render clock to accumulate the open bucket up to.
* @returns The step's per-phase totals.
*/
export function stepTimingAt(
events: readonly SessionEvent[],
position: StepPosition,
at: number,
): TimingTotals {
const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
if (startIndex < 0) return emptyTimingTotals()
const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
const state = timingState(start.time)
for (let index = startIndex + 1; index < events.length; index += 1) {
const event = events[index] as SessionEvent
if (event.time > at) break
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
&& sameStep(event, position)) {
advanceStepTiming(state, event)
if (event.type === 'step/end') break
}
}
return timingTotalsAt(state, at)
}
/**
* The turn index of the currently open turn, or `undefined` when none is open.
* @param events - Session events to scan from the tail.
* @returns The open turn index, or `undefined`.
*/
export function openTurn(events: readonly SessionEvent[]): number | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'turn/end') return undefined
if (event.type === 'turn/start') return event.data.turn
}
return undefined
}
/**
* Phase-specific status glyph, keyed by the running step's active timing bucket.
* `ttft` is the pre-first-token wait a running turn falls back to between steps.
*/
export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
ttft: '◍',
thinking: '✻',
responding: '●',
tools: '⚙',
}
/**
* Derive the currently open step's active timing bucket, or `undefined` when no
* step is open. The open step is the last `step/start` with no later matching
* `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
* @param events - Session events to scan.
* @returns The open step's active bucket, or `undefined`.
*/
export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined {
let startIndex = -1
let start: Extract<SessionEvent, { type: 'step/start' }> | undefined
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'step/end') return undefined
if (event.type === 'step/start') {
startIndex = index
start = event
break
}
if (event.type === 'turn/end') return undefined
}
if (start === undefined) return undefined
const position = start.data
const state = timingState(start.time)
for (let index = startIndex + 1; index < events.length; index += 1) {
const event = events[index] as SessionEvent
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
&& sameStep(event, position)) {
advanceStepTiming(state, event)
}
}
return state.active?.bucket
}
/**
* The running agent's phase glyph, or `undefined` when idle. A running turn
* with no open step falls back to the pre-first-token wait so a glyph is always
* available while the agent works; it fades in on turn start, throbs while the
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
* @param events - Session events to derive the phase from.
* @param running - Whether the agent is currently running.
* @returns The phase glyph, or `undefined` when idle.
*/
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
if (!running) return undefined
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
}
/**
* The running throb's brightness at continuous clock `nowMs`: a cosine between
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
* fade envelope, which alone drives appear/disappear at turn boundaries.
*
* @param nowMs - Monotonic render clock in milliseconds.
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
*/
export function pulseLevel(nowMs: number): number {
const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS
const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase)
return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave
}
/**
* One frame of the running glyph at fade `opacity` (0 = near-background trough
* gray, 1 = settled dim gray). The character and its width never change — only
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
* the caret dimly breathing, never a colored indicator.
*
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
* and the running throb render as a smooth, symmetric brightness swing with no
* hard cutoff to clip the trough into a blank. Without truecolor there is no
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
* shows the glyph in the palette's muted role or leaves a blank column — a
* single dim appear/disappear at fixed width, still dim rather than accent, and
* no throb-driven blink. With color off entirely a visible glyph is bare,
* holding the caret column on a monochrome terminal.
*
* @param glyph - The phase glyph to paint.
* @param palette - Active palette supplying the muted (dim gray) role.
* @param colorEnabled - Whether ANSI is emitted at all.
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
* @param opacity - Brightness fraction in [0, 1] for the truecolor gray.
* @param visible - Whether the non-truecolor fallback shows the glyph at all.
* @returns The gray glyph at this opacity, or a single space when hidden.
*/
export function fadeGlyph(
glyph: string,
palette: Palette,
colorEnabled: boolean,
truecolor: boolean,
opacity: number,
visible: boolean,
): string {
if (truecolor && colorEnabled) {
const o = Math.min(Math.max(opacity, 0), 1)
const [tr, tg, tb] = STATUS_FADE_GRAY.trough
const [sr, sg, sb] = STATUS_FADE_GRAY.settled
const r = Math.round(tr + (sr - tr) * o)
const g = Math.round(tg + (sg - tg) * o)
const b = Math.round(tb + (sb - tb) * o)
return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m`
}
if (!visible) return ' '
return colorEnabled ? palette.muted(glyph) : glyph
}
/**
* Format a non-negative elapsed span at 100 ms resolution.
* @param elapsedMs - Elapsed milliseconds.
* @returns The formatted duration (e.g. `1.5s`, `2m03.4s`).
*/
export function formatStatusDuration(elapsedMs: number): string {
const tenths = Math.floor(Math.max(0, elapsedMs) / 100)
const seconds = tenths / 10
if (seconds < 60) return `${seconds.toFixed(1)}s`
const minutes = Math.floor(seconds / 60)
return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s`
}
/**
* Format the non-zero timing buckets of one step as a middot-joined summary.
* @param totals - Per-phase totals to format.
* @param includeModelWait - Whether to always include the model-wait bucket.
* @returns The formatted timing summary.
*/
export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string {
return TIMING_BUCKETS
.filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft'))
.map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`)
.join(' · ')
}
/**
* Format the queued-steering badge shown on the running status line.
* @param queued - Number of queued steering messages.
* @returns The badge text, or `undefined` when nothing is queued.
*/
export function formatQueuedStatus(queued: number): string | undefined {
return queued > 0 ? `${queued} queued` : undefined
}
/**
* Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time.
* @param time - Epoch milliseconds.
* @returns The formatted local timestamp.
*/
export function formatCompletionTime(time: number): string {
const date = new Date(time)
const parts = [
date.getFullYear().toString().padStart(4, '0'),
(date.getMonth() + 1).toString().padStart(2, '0'),
date.getDate().toString().padStart(2, '0'),
]
const clock = [date.getHours(), date.getMinutes(), date.getSeconds()]
.map(value => value.toString().padStart(2, '0'))
.join(':')
return `${parts.join('-')} ${clock}`
}

View File

@@ -0,0 +1,96 @@
/**
* Running token accounting for the terminal footer. Usage is keyed per
* turn/step so replayed or re-emitted usage replaces rather than double-counts.
* @module @deepseek-ai/dsh-tui/chat/tokens
*/
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Running token totals for the footer, keyed per turn/step so replayed or
* re-emitted usage replaces rather than double-counts; `input` is uncached
* input, cache buckets are disjoint.
*/
export interface SessionTokenTotals {
input: number
output: number
cacheRead: number
cacheWrite: number
readonly byStep: Map<string, TokenUsage>
}
/**
* Fold one step's usage into the running totals, replacing any prior usage
* logged for the same turn/step.
* @param totals - Running totals mutated in place.
* @param turn - Turn index of the usage.
* @param step - Step index of the usage.
* @param usage - The step's token usage.
*/
export function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void {
const key = `${turn}:${step}`
const previous = totals.byStep.get(key)
if (previous !== undefined) {
totals.input -= previous.inputTokens
totals.output -= previous.outputTokens
totals.cacheRead -= previous.cacheReadTokens ?? 0
totals.cacheWrite -= previous.cacheWriteTokens ?? 0
}
totals.byStep.set(key, usage)
totals.input += usage.inputTokens
totals.output += usage.outputTokens
totals.cacheRead += usage.cacheReadTokens ?? 0
totals.cacheWrite += usage.cacheWriteTokens ?? 0
}
/**
* Fold a usage-bearing session event into the running totals.
* @param totals - Running totals mutated in place.
* @param event - Session event; ignored when it carries no usage.
*/
export function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage)
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage)
}
}
/**
* Share of billed input (prompt) tokens served from the provider cache, as an
* integer percent, or `undefined` before any input is billed (avoids 0/0 and a
* meaningless rate on an empty session).
* @param totals - Running totals to measure.
* @returns The cache hit rate percent, or `undefined` when no input is billed.
*/
export function cacheHitRate(totals: SessionTokenTotals): number | undefined {
const billedInput = totals.input + totals.cacheRead + totals.cacheWrite
if (billedInput === 0) return undefined
return Math.round((totals.cacheRead / billedInput) * 100)
}
/**
* Fold every usage-bearing event in a session into fresh totals.
* @param session - Session whose events supply usage.
* @returns The accumulated token totals.
*/
export function sessionTokens(session: Session): SessionTokenTotals {
const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() }
for (const event of session.events) {
recordEventUsage(totals, event)
}
return totals
}
/**
* Format a token count with a compact k/m suffix for the footer.
* @param value - Token count.
* @returns The compact display string.
*/
export function formatTokens(value: number): string {
if (value < 1_000) return String(value)
if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`
if (value < 1_000_000) return `${Math.round(value / 1_000)}k`
return `${(value / 1_000_000).toFixed(1)}m`
}

View File

@@ -0,0 +1,56 @@
/**
* Content-block primitives shared across the terminal front door: flattening
* session content to display text and parsing tool-call arguments.
* @module @deepseek-ai/dsh-tui/components/content
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/**
* Flatten content blocks into a single display string, recursing into
* tool-result content and naming unknown block types.
* @param content - Content blocks to flatten.
* @returns The concatenated display text.
*/
export function contentText(content: readonly ContentBlock[]): string {
const parts: string[] = []
for (const block of content) {
switch (block.type) {
case 'text':
case 'reasoning':
parts.push(block.text)
break
case 'tool-call':
parts.push(`${block.name}(${block.arguments})`)
break
case 'tool-result':
parts.push(contentText(block.content))
break
default: {
const rawType = (block as { type?: unknown }).type
parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
break
}
}
}
return parts.join('')
}
/** A tool call's arguments parsed from their JSON source, with a validity flag. */
export interface ParsedArguments {
value: unknown
valid: boolean
}
/**
* Parse tool-call arguments from their JSON source.
* @param raw - Raw JSON arguments text.
* @returns The parsed value, or the raw text with `valid: false` on parse failure.
*/
export function parseArguments(raw: string): ParsedArguments {
try {
return { value: JSON.parse(raw), valid: true }
} catch {
return { value: raw, valid: false }
}
}

View File

@@ -0,0 +1,789 @@
/**
* pi-tui dialog and selector components for the terminal front door: the status
* card, prompt-context line, model selector, resume picker, and user-question
* dialog, plus the model-choice and resume-candidate data they present.
* @module @deepseek-ai/dsh-tui/components/dialogs
*/
import {
Input,
Key,
SelectList,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
type Focusable,
type SelectItem,
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import {
type Agent,
type AgentLlmTarget,
} from '@deepseek-ai/dsh-agent'
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
SessionLogSnapshot,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
import { dialogSelectTheme, type Palette } from './theme.ts'
import {
renderTuiPromptTemplate,
type TuiPromptTemplateToken,
} from '../prompt.ts'
/** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */
export interface ModelChoice extends AgentLlmTarget {
modelName: string
description?: string
reasoning?: LlmModelReasoningInfo
}
/**
* The provider/model route and selected reasoning effort resolved from a model dialog.
*/
export interface ModelDialogSelection {
choice: ModelChoice
reasoningEffort: ReasoningEffortId | undefined
}
/**
* Format a provider/model target as its `provider/model` label.
* @param target - The LLM target.
* @returns The `provider/model` label.
*/
export function targetLabel(target: AgentLlmTarget): string {
return `${target.provider}/${target.model}`
}
/**
* Format a target compactly as its model name with any selected reasoning effort appended.
* @param target - The LLM target.
* @returns The compact `model [effort]` label.
*/
export function compactTargetLabel(target: AgentLlmTarget): string {
return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}`
}
/**
* Resolve the display label for a choice's reasoning effort.
* @param choice - The model choice carrying advertised reasoning metadata.
* @param effort - The selected effort, or `undefined` for provider default.
* @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata.
*/
export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined {
if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default'
return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort
}
/**
* Derive the agent's initial LLM target from its logged request header or options.
* @param agent - The driven agent.
* @returns The initial target, or `undefined` when unset.
*/
export function initialTarget(agent: Agent): AgentLlmTarget | undefined {
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) {
if (logged.reasoningEffort === undefined) {
return { provider: logged.provider, model: logged.model }
}
return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort }
}
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
return { provider: agent.options.provider, model: agent.options.model }
}
/**
* List every advertised model across registered providers, appending the current
* target when a provider does not advertise it.
* @param ctx - Context supplying the LLM service.
* @param current - The current target, appended when unadvertised.
* @returns The model choices, flattened across providers.
*/
export async function readModelChoices(
ctx: Context,
current: AgentLlmTarget | undefined,
): Promise<ModelChoice[]> {
const providers = ctx.llm.listProviders()
const groups = await Promise.all(providers.map(async (provider) => {
const advertised = await ctx.llm.listModels(provider.id)
const models: LlmModelInfo[] = [...advertised]
if (
current?.provider === provider.id
&& !models.some(model => model.id === current.model)
) {
models.push({ provider: provider.id, id: current.model, name: current.model })
}
return Promise.all(models.map(async (model): Promise<ModelChoice> => {
const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning
return {
provider: provider.id,
model: model.id,
modelName: model.name,
...model.description === undefined ? {} : { description: model.description },
...reasoning === undefined ? {} : { reasoning },
}
}))
}))
return groups.flat()
}
/**
* Format a diagnostic integer with grouping separators.
* @param value - Integer to format.
* @returns The grouped decimal string.
*/
export function formatDiagnosticNumber(value: number): string {
return value.toLocaleString('en-US')
}
/**
* Format a diagnostic timestamp as an ISO date-time in UTC.
* @param value - Epoch milliseconds.
* @returns The formatted UTC timestamp.
*/
export function formatDiagnosticTime(value: number): string {
return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC')
}
/**
* Format a pluralized count for a diagnostic row.
* @param value - Count.
* @param singular - Singular noun; an `s` is appended for other counts.
* @returns The formatted count.
*/
export function formatDiagnosticCount(value: number, singular: string): string {
return `${String(value)} ${singular}${value === 1 ? '' : 's'}`
}
/**
* Render a fixed-width filled meter bar for a percentage.
* @param percent - Percentage in [0, 100].
* @param palette - Active role palette.
* @returns The rendered meter.
*/
export function diagnosticMeter(percent: number, palette: Palette): string {
const width = 16
const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width)
return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}`
}
/** One `label: value` row of a status card group. */
export type StatusCardRow = readonly [label: string, value: string]
/** Bordered, grouped field card for one point-in-time status snapshot. */
export class StatusCardComponent implements Component {
constructor(
private readonly groups: readonly (readonly StatusCardRow[])[],
private readonly palette: Palette,
) {}
invalidate(): void {}
render(width: number): string[] {
const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`))
const naturalLabelWidth = Math.max(...labels.map(label => label.length))
const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) =>
1 + naturalLabelWidth + 2 + visibleWidth(value))))
const cardWidth = Math.min(
Math.max(8, width),
Math.max('Session status'.length + 5, naturalBodyWidth + 4),
)
const innerWidth = Math.max(1, cardWidth - 4)
const labelWidth = Math.min(
naturalLabelWidth,
Math.max(1, Math.floor(innerWidth / 3)),
)
const body: string[] = []
for (const [groupIndex, group] of this.groups.entries()) {
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 continuation = ' '.repeat(1 + labelWidth + 2)
const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix))
const wrapped = wrapTextWithAnsi(value, valueWidth)
for (const [lineIndex, line] of wrapped.entries()) {
body.push(`${lineIndex === 0 ? prefix : continuation}${line}`)
}
}
}
const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '')
const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5))
const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}`)}`
const lines = [top]
for (const line of body) {
const clipped = truncateToWidth(line, innerWidth, '')
lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`)
}
lines.push(this.palette.dim(`${'─'.repeat(Math.max(0, cardWidth - 2))}`))
return lines
}
}
/** The left/right template line rendered above the editor. */
export class PromptContextComponent implements Component {
constructor(
private readonly leftTemplate: readonly TuiPromptTemplateToken[],
private readonly rightTemplate: readonly TuiPromptTemplateToken[],
private readonly resolve: (name: string) => string | undefined,
) {}
invalidate(): void {}
render(width: number): string[] {
const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '')
const rightWidth = visibleWidth(right)
const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2))
const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '')
if (rightWidth === 0) return [left]
const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth))
return [`${left}${gap}${right}`]
}
}
/** A user's answer to one question: chosen option labels and an optional custom answer. */
export interface QuestionSelection {
selected: string[]
custom?: string
}
/**
* Render a bordered dialog frame around body lines with a titled top edge.
* @param title - Dialog title shown in the top border.
* @param body - Body lines.
* @param width - Dialog width in columns.
* @param palette - Active role palette.
* @returns The framed dialog lines.
*/
export function renderDialog(
title: string,
body: readonly string[],
width: number,
palette: Palette,
): string[] {
const innerWidth = Math.max(1, width - 4)
const topLabel = ` ${displayText(title)} `
const top = `${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}`
const lines: string[] = [palette.accent(top)]
for (const line of body) {
const clipped = truncateToWidth(line, innerWidth, '')
lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
}
lines.push(palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`))
return lines
}
/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */
export class ModelDialog implements Component {
private readonly list: SelectList
private readonly items: Map<string, SelectItem>
private readonly choices: Map<string, ModelChoice>
private readonly efforts: Map<string, ReasoningEffortId | undefined>
private readonly currentValue: string | undefined
constructor(
choices: readonly ModelChoice[],
current: AgentLlmTarget | undefined,
maxVisible: number,
private readonly palette: Palette,
done: (selection: ModelDialogSelection) => void,
cancel: () => void,
) {
this.items = new Map()
this.choices = new Map()
this.efforts = new Map()
this.currentValue = current === undefined ? undefined : targetLabel(current)
for (const choice of choices) {
const value = targetLabel(choice)
const isCurrent = current?.provider === choice.provider && current.model === choice.model
this.choices.set(value, choice)
this.efforts.set(
value,
isCurrent
? current.reasoningEffort ?? choice.reasoning?.defaultEffort
: choice.reasoning?.defaultEffort,
)
this.items.set(value, {
value,
label: displayText(value),
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
}
private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice)))
return [
displayText(choice.modelName),
...choice.description === undefined ? [] : [displayText(choice.description)],
...effortLabel === undefined ? [] : [displayText(effortLabel)],
...isCurrent ? ['current'] : [],
].join(' — ')
}
private cycleReasoningEffort(): void {
const selectedItem = this.list.getSelectedItem()
/* v8 ignore next -- the dialog is opened only for a non-empty catalog. */
if (selectedItem === null) return
const choice = this.choices.get(selectedItem.value)
if (choice?.reasoning === undefined) return
const current = this.efforts.get(selectedItem.value)
const efforts: Array<ReasoningEffortId | undefined> = [
...choice.reasoning.defaultEffort === undefined ? [undefined] : [],
...choice.reasoning.efforts.map(effort => effort.id),
]
const currentIndex = efforts.indexOf(current)
const next = efforts[(currentIndex + 1) % efforts.length]
this.efforts.set(selectedItem.value, next)
const item = this.items.get(selectedItem.value)
/* v8 ignore next -- items and choices are constructed from the same values. */
if (item === undefined) return
item.description = this.describeChoice(choice, selectedItem.value === this.currentValue)
}
invalidate(): void {
this.list.invalidate()
}
handleInput(data: string): void {
if (matchesKey(data, Key.shift(Key.tab))) {
this.cycleReasoningEffort()
} else {
this.list.handleInput(data)
}
this.invalidate()
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 4)
return renderDialog('Select model', [
...this.list.render(innerWidth),
'',
this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
], width, this.palette)
}
}
/** The provider/model route recovered from a resume candidate's log. */
export interface ResumeRoute {
provider: string
model: string
}
/** A preflighted resume selector row summarizing one persisted session. */
export interface ResumeCandidate {
record: SessionRecord
title: string
lastActivityAt: number
lastTurn: string
route?: ResumeRoute
goalPhase?: GoalPhase
disabledReason?: string
}
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
const event = snapshot.events.findLast(item => item.type === 'turn/end')
if (event === undefined) return 'no completed turn'
const reason = event.data.reason
switch (reason.kind) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'error': return `turn ${event.data.turn}: error`
case 'disposed': return `turn ${event.data.turn}: disposed`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'interrupted': return `turn ${event.data.turn}: interrupted`
default: return `turn ${event.data.turn}: unknown result`
}
}
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
const header = snapshot.events.findLast(item => item.type === 'request/header')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
: 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.
* @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 availableProviders - Providers registered in this runtime.
* @returns The summarized resume candidate.
*/
export function summarizeResumeCandidate(
record: SessionRecord,
snapshot: SessionLogSnapshot,
currentId: SessionId,
cwd: string | undefined,
availableProviders: ReadonlySet<string>,
): ResumeCandidate {
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
const route = resumeRoute(snapshot)
const foldedGoal = foldGoal(snapshot.events).goal
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 (route !== undefined && !availableProviders.has(route.provider)) {
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
}
return {
record,
title,
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
lastTurn: resumeTurnLabel(snapshot),
...route === undefined ? {} : { route },
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
...disabledReason === undefined ? {} : { disabledReason },
}
}
/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
export class ResumePicker implements Component, Focusable {
private readonly search = new Input()
private pasteBuffer: string | undefined
private selectedIndex = 0
private error = ''
focused = false
constructor(
private readonly candidates: readonly ResumeCandidate[],
private readonly maxVisible: number,
private readonly workspaceLabel: string,
private readonly viewportRows: () => number,
private readonly palette: Palette,
private readonly done: (candidate: ResumeCandidate) => void,
private readonly cancel: () => void,
) {}
invalidate(): void {
this.search.invalidate()
}
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))
}
private visibleCandidateCount(): number {
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
return Math.min(this.maxVisible, candidateBudget)
}
private handleBracketedPaste(data: string): boolean {
const start = data.indexOf(BRACKETED_PASTE_START)
if (this.pasteBuffer === undefined && start < 0) return false
if (this.pasteBuffer === undefined) {
const prefix = data.slice(0, start)
if (prefix !== '') this.handleInput(prefix)
this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length)
} else {
this.pasteBuffer += data
}
const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END)
if (end < 0) return true
const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end))
const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length)
this.pasteBuffer = undefined
const previous = this.search.getValue()
this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`)
if (this.search.getValue() !== previous) {
this.selectedIndex = 0
this.error = ''
}
if (remaining !== '') this.handleInput(remaining)
this.invalidate()
return true
}
handleInput(data: string): void {
if (this.handleBracketedPaste(data)) return
const filtered = this.filtered()
if (matchesKey(data, Key.ctrl('c'))) {
this.cancel()
return
}
if (matchesKey(data, Key.escape)) {
if (this.search.getValue() === '') this.cancel()
else {
this.search.setValue('')
this.selectedIndex = 0
this.error = ''
}
} else if (matchesKey(data, Key.up)) {
this.selectedIndex = filtered.length === 0
? 0
: (this.selectedIndex + filtered.length - 1) % filtered.length
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
} else if (matchesKey(data, Key.pageUp)) {
this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount())
} else if (matchesKey(data, Key.pageDown)) {
this.selectedIndex = Math.min(
Math.max(0, filtered.length - 1),
this.selectedIndex + this.visibleCandidateCount(),
)
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
if (selected === undefined) this.error = 'No session matches this search.'
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
else this.done(selected)
} else {
const previous = this.search.getValue()
this.search.focused = this.focused
this.search.handleInput(data)
if (this.search.getValue() !== previous) {
this.selectedIndex = 0
this.error = ''
}
}
this.invalidate()
}
render(width: number): string[] {
this.search.focused = this.focused
const height = Math.max(1, this.viewportRows())
const horizontalPadding = width >= 12 ? 2 : 0
const contentWidth = Math.max(1, width - horizontalPadding * 2)
const indent = ' '.repeat(horizontalPadding)
const filtered = this.filtered()
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
const selected = filtered[this.selectedIndex]
const position = selected === undefined ? 0 : this.selectedIndex + 1
const lines: string[] = [
'',
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
'',
]
const searchInnerWidth = Math.max(1, contentWidth - 4)
lines.push(`${indent}${this.palette.dim(`${'─'.repeat(Math.max(0, contentWidth - 2))}`)}`)
const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, ' ')
const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '')
lines.push(
`${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))}`,
'',
)
const visibleCount = this.visibleCandidateCount()
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(visibleCount / 2),
filtered.length - visibleCount,
))
const end = Math.min(filtered.length, start + visibleCount)
const push = (line: string): void => {
lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`)
}
for (let index = start; index < end; index += 1) {
const candidate = filtered[index] as ResumeCandidate
const active = index === this.selectedIndex
const status = [
candidate.disabledReason === 'current session' ? 'current' : undefined,
candidate.record.live ? 'live' : undefined,
candidate.record.persisted ? 'persisted' : undefined,
].filter((value): value is string => value !== undefined).join(' · ')
const lead = `${active ? '' : ' '} ${displayText(candidate.title)}`
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
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(` ${status} · ${displayText(candidate.record.header.id)}`))
if (candidate.disabledReason !== undefined) {
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
}
}
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.error !== '') {
lines.push('')
push(this.palette.error(displayText(this.error)))
}
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}`
while (lines.length < height - 2) lines.push('')
lines.push(footer, '')
return lines.slice(0, height)
}
}
/** Bottom-anchored dialog for one user question with option or custom-answer modes. */
export class QuestionDialog implements Component, Focusable {
private selectedIndex = 0
private selected = new Set<number>()
private mode: 'options' | 'custom'
private error = ''
private readonly input = new Input()
private readonly options: NonNullable<AskUserQuestionItem['options']>
focused = false
constructor(
private readonly question: AskUserQuestionItem,
private readonly position: number,
private readonly total: number,
private readonly unanswered: number,
private readonly maxVisible: number,
private readonly palette: Palette,
private readonly done: (selection: QuestionSelection) => void,
private readonly cancel: () => void,
) {
this.options = question.options ?? []
this.mode = this.options.length > 0 ? 'options' : 'custom'
this.input.onSubmit = (value) => { this.submitCustom(value) }
this.input.onEscape = () => {
if (this.options.length > 0) {
this.mode = 'options'
this.error = ''
} else {
this.cancel()
}
}
}
invalidate(): void {
this.input.invalidate()
}
handleInput(data: string): void {
this.invalidate()
if (this.mode === 'custom') {
this.input.focused = this.focused
this.input.handleInput(data)
return
}
const options = this.options
if (matchesKey(data, Key.up)) {
this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
} else if (matchesKey(data, Key.space) && this.question.multiSelect) {
if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
else this.selected.add(this.selectedIndex)
} else if (matchesKey(data, Key.enter)) {
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
if (indices.length === 0) {
this.error = 'Select at least one option, or press Tab for a custom answer.'
return
}
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.error = ''
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
this.cancel()
}
}
private submitCustom(value: string): void {
const custom = value.trim()
if (custom === '') {
this.error = 'Enter an answer before submitting.'
return
}
this.done({ selected: [], custom })
}
render(width: number): string[] {
this.input.focused = this.focused
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),
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
]
const push = (line: string): void => { lines.push(line) }
// Supporting detail (e.g. the full plan under review) renders between the
// question and the answer surface, kept out of option labels.
if (this.question.detail !== undefined) {
push('')
for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
}
push('')
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
} else {
const options = this.options
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
options.length - this.maxVisible,
))
const end = Math.min(options.length, start + this.maxVisible)
const optionRows = options.slice(start, end).map((option, offset) => {
const index = start + offset
const mark = this.question.multiSelect
? this.selected.has(index) ? '[x] ' : '[ ] '
: ''
return `${index === this.selectedIndex ? '' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
})
const descriptionColumn = Math.min(
Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
Math.max(1, Math.floor(innerWidth * 0.55)),
)
for (let index = start; index < end; index += 1) {
// `index < end <= options.length`; the options array is borrowed immutably for this dialog.
const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
const mark = this.question.multiSelect
? this.selected.has(index) ? '[x] ' : '[ ] '
: ''
const left = `${index === this.selectedIndex ? '' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
const leftStyled = index === this.selectedIndex
? this.palette.bold(this.palette.accent(left))
: left
const description = option.description === undefined
? ''
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
push(`${leftStyled}${description}`)
}
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
const controls = [
'Tab custom answer',
...(options.length > 1 ? ['↑/↓ navigate'] : []),
...(this.question.multiSelect ? ['Space toggle'] : []),
'Enter submit',
'Esc interrupt',
]
const hint = this.palette.dim(controls.join(' • '))
for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
}
if (this.error) {
for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
}
return ['', ...lines, ''].map((line) => {
const clipped = truncateToWidth(line, innerWidth, '')
return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `
})
}
}

View File

@@ -0,0 +1,49 @@
/**
* Terminal text sanitization shared across the pi-tui front door. External text
* (model output, tool results, clipboard) is escaped or stripped of C0/C1
* controls before the TUI adds its own application-owned ANSI.
* @module @deepseek-ai/dsh-tui/components/text
*/
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu
/** Bracketed-paste start marker emitted by terminals around pasted content. */
export const BRACKETED_PASTE_START = '\u001B[200~'
/** Bracketed-paste end marker emitted by terminals around pasted content. */
export const BRACKETED_PASTE_END = '\u001B[201~'
/**
* Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
* Line feeds remain structural so transcript and tool output retain their layout.
* @param text - Untrusted text to render.
* @returns The text with control characters escaped as `\xNN`.
*/
export function displayText(text: string): string {
return text.replace(TERMINAL_CONTROL_PATTERN, control =>
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
}
/**
* Escape external controls for terminal fields that must remain on one line.
* @param text - Untrusted text to render inline.
* @returns The escaped text with newlines rendered as `\x0a`.
*/
export function displayInlineText(text: string): string {
return displayText(text).replaceAll('\n', '\\x0a')
}
/**
* Remove terminal controls from clipboard text before an editable field stores it.
* @param text - Raw pasted clipboard text.
* @returns The text stripped of OSC, CSI, escape, and control sequences.
*/
export function sanitizePastedText(text: string): string {
return text
.replace(TERMINAL_OSC_PATTERN, '')
.replace(TERMINAL_CSI_PATTERN, '')
.replace(TERMINAL_ESCAPE_PATTERN, '')
.replace(TERMINAL_CONTROL_PATTERN, '')
}

View File

@@ -0,0 +1,184 @@
/**
* Theme-agnostic ANSI palette and derived pi-tui themes for the terminal front
* door. The palette is built from the standard 16-color ANSI set plus SGR
* attributes so every terminal remaps it to its active color scheme.
* @module @deepseek-ai/dsh-tui/components/theme
*/
import type {
MarkdownTheme,
SelectListTheme,
TerminalColorScheme,
} from '@earendil-works/pi-tui'
/** Theme-agnostic role colors and SGR attribute wrappers. */
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
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
selected: (text: string) => string
}
function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
}
/**
* 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.
*
* @param enabled - Whether ANSI is emitted at all.
* @param scheme - Active terminal color scheme; adjusts dim and code roles.
* @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),
}
}
/**
* DeepSeek brand gradient stops (indigo → light blue) taken from the
* deepseek.com logo, painted across the startup banner's product name on
* truecolor terminals. Fixed brand identity, deliberately outside the
* theme-adaptive {@link Palette}.
*/
const BRAND_GRADIENT = [
[77, 107, 254], // #4D6BFE
[57, 130, 255], // #3982FF
[36, 152, 255], // #2498FF
] as const
/**
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
* interpolation across its stops.
*
* @param t - Position along the gradient; clamped to [0, 1].
* @returns The interpolated `[r, g, b]` channels, each rounded to 0255.
*/
function brandColorAt(t: number): readonly [number, number, number] {
const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1)
const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2)
const local = span - index
// `index` is clamped to a valid adjacent pair, so both lookups are in-bounds.
const from = BRAND_GRADIENT[index] as readonly [number, number, number]
const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number]
return [
Math.round(from[0] + (to[0] - from[0]) * local),
Math.round(from[1] + (to[1] - from[1]) * local),
Math.round(from[2] + (to[2] - from[2]) * local),
]
}
/**
* Paint `text` left-to-right in the DeepSeek brand gradient with per-character
* 24-bit foreground codes, resetting to the default foreground at the end.
* Foreground-only, so it stays legible on any terminal background; the caller
* gates it on truecolor support and wraps it in bold.
*
* @param text - Text to colorize; sampled once per character.
* @returns `text` wrapped in truecolor SGR foreground codes.
*/
export function gradientText(text: string): string {
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
// samples exactly one color per visible letter.
const last = Math.max(1, text.length - 1)
let painted = ''
for (let index = 0; index < text.length; index += 1) {
const [r, g, b] = brandColorAt(index / last)
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
}
return `${painted}\x1b[39m`
}
/**
* Derive the pi-tui Markdown theme from a role palette.
* @param palette - Active role palette.
* @returns The Markdown theme wired to palette roles.
*/
export function markdownTheme(palette: Palette): MarkdownTheme {
return {
heading: text => palette.accent(text),
link: text => palette.accent(text),
// pi-tui requires this URL slot but its current Markdown renderer does not invoke it.
/* v8 ignore next */
linkUrl: text => palette.dim(text),
code: text => palette.code(text),
codeBlock: text => palette.code(text),
// 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),
hr: text => palette.dim(text),
listBullet: text => palette.accent(text),
bold: text => palette.bold(text),
italic: text => palette.italic(text),
strikethrough: text => palette.strike(text),
underline: text => palette.underline(text),
}
}
/**
* Derive the pi-tui select-list theme from a role palette.
* @param palette - Active role palette.
* @returns The select-list theme wired to palette roles.
*/
export function selectTheme(palette: Palette): SelectListTheme {
return {
selectedPrefix: palette.accent,
selectedText: palette.accent,
description: palette.muted,
scrollInfo: palette.dim,
noMatch: palette.warning,
}
}
/**
* Derive the reverse-video dialog select-list theme from a role palette.
* @param palette - Active role palette.
* @returns The dialog select-list theme with a reverse-video selection.
*/
export function dialogSelectTheme(palette: Palette): SelectListTheme {
return {
...selectTheme(palette),
selectedText: text => palette.selected(palette.accent(text)),
}
}

View File

@@ -0,0 +1,529 @@
/**
* pi-tui transcript components: the startup banner, user/assistant messages,
* per-step timing footer, streaming assistant buffer, tool cards, and the todo
* panel. Each is a pure function of its inputs and the active palette.
* @module @deepseek-ai/dsh-tui/components/transcript
*/
import {
Container,
Markdown,
Spacer,
Text,
truncateToWidth,
wrapTextWithAnsi,
type Component,
type MarkdownTheme,
} from '@earendil-works/pi-tui'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
import type {
TerminalCallView,
ToolCallView,
ToolDefinition,
ToolResultView,
} from '@deepseek-ai/dsh-tools'
import type { FileDiff } from '@deepseek-ai/dsh-tools'
import { 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'
import {
formatCompletionTime,
formatTimingTotals,
stepTimingAt,
type StepPosition,
} from '../chat/timing.ts'
/** Concatenate the text of every block of one type, separated by blank lines. */
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
return content
.filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
.map(block => block.text)
.join('\n\n')
}
/** Render a value as terminal-safe text: strings escaped, other values as pretty JSON. */
function pretty(value: unknown): string {
if (typeof value === 'string') return displayText(value)
// JSON.stringify is typed to return string but yields undefined for e.g. symbols.
const serialized = JSON.stringify(value, null, 2) as string | undefined
return displayText(serialized ?? String(value))
}
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
function diffLines(diff: FileDiff, palette: Palette): string[] {
// The card header is a fixed `Tool / <name>` frame that never names a file, so
// 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.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
return lines
}
/**
* A message's bold, underlined role header in the role color. The underline
* bands each role without a background fill or per-line prefix, so it reads on
* any theme and a body drag-select copies the message text verbatim.
*/
function messageHeader(label: string, color: (text: string) => string, palette: Palette): string {
return palette.bold(palette.underline(color(displayText(label))))
}
/**
* Borderless startup banner: product title, an optional configured subtitle,
* and the session id. No box frame — each line renders as plain left-padded
* text (matching transcript notices) so it reads on any theme.
*/
export class HeaderComponent implements Component {
/** Columns of the banner currently revealed; `undefined` renders it whole. */
private revealWidth: number | undefined
constructor(
private readonly agent: Agent,
private readonly subtitle: () => string | undefined,
private readonly palette: Palette,
private readonly gradient: boolean,
) {}
/**
* Clip the banner to `width` columns (the sweep reveal); `undefined` restores it.
* @param width - Revealed banner width in columns, or `undefined` for the whole banner.
*/
setRevealWidth(width: number | undefined): void {
this.revealWidth = width
}
invalidate(): void {}
render(width: number): string[] {
const usable = Math.max(1, width - 2)
const name = this.gradient
? this.palette.bold(gradientText('DEEPSEEK'))
: this.palette.bold(this.palette.accent('DEEPSEEK'))
const title = `${name} ${this.palette.bold('HARNESS')}`
const detail = displayText(this.agent.session.id)
const subtitle = this.subtitle()
const lines = [
title,
...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
this.palette.dim(detail),
]
.flatMap(line => wrapTextWithAnsi(line, usable))
.map(line => ` ${truncateToWidth(line, usable, '')}`)
if (this.revealWidth === undefined) return lines
const revealed = this.revealWidth
return lines.map(line => truncateToWidth(line, revealed, ''))
}
}
/**
* A user or steering prompt in the transcript. An underlined accent role header
* plus blank-line spacing separate it from surrounding blocks; body lines carry
* no prefix or indent, so a terminal drag-select copies the prompt verbatim.
*/
export class UserMessageComponent extends Container {
constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
super()
this.addChild(new Text(messageHeader(label, palette.accent, palette), 0, 0))
this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, {
preserveOrderedListMarkers: true,
preserveBackslashEscapes: true,
}))
}
}
/** Children of a settled assistant message: optional reasoning block then the response text. */
function assistantMessageChildren(
content: readonly ContentBlock[],
showReasoning: boolean,
palette: Palette,
mdTheme: MarkdownTheme,
): Component[] {
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
const text = displayText(textBlocks(content, 'text').trim())
const children: Component[] = [
new Spacer(1),
new Text(messageHeader('Assistant', palette.accent2, 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 }),
)
}
if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }))
return children
}
/**
* A step's timing summary, rendered as a self-refreshing footer that stays at
* the tail of the step's output. Kept separate from the assistant message so
* the timing line trails any tool cards the step appends after its message.
*/
class StepTimingComponent extends Container {
private completionTime: number | undefined
constructor(
private readonly position: StepPosition,
private readonly events: () => readonly SessionEvent[],
private readonly now: () => number,
private readonly palette: Palette,
) {
super()
this.rebuild()
}
complete(time: number): void {
this.completionTime = time
this.rebuild()
}
override invalidate(): void {
this.rebuild()
super.invalidate()
}
private rebuild(): void {
this.clear()
const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now())
const timing = formatTimingTotals(totals, true)
const header = this.completionTime === undefined
? timing
: `${timing} · Completed ${formatCompletionTime(this.completionTime)}`
this.addChild(new Text(this.palette.dim(header), 0, 0))
}
}
interface StreamingBlock {
type: string
text: string
}
/** A live assistant step: streamed reasoning/text blocks until the message settles. */
export class StreamingAssistantComponent extends Container {
private readonly blocks = new Map<number, StreamingBlock>()
private settledContent: readonly ContentBlock[] | undefined
/**
* The step's timing footer. The renderer keeps it at the tail of the chat so
* it trails any tool cards the step appends after this assistant message; it
* is not a child of this component.
*/
readonly timing: StepTimingComponent
constructor(
position: StepPosition,
events: () => readonly SessionEvent[],
now: () => number,
private showReasoning: boolean,
private readonly palette: Palette,
private readonly mdTheme: MarkdownTheme,
) {
super()
this.timing = new StepTimingComponent(position, events, now, palette)
this.rebuild()
}
/**
* Replace the streamed blocks with the step's settled content.
* @param content - The settled assistant content blocks.
*/
settle(content: readonly ContentBlock[]): void {
this.settledContent = content
this.rebuild()
}
/**
* Whether this step's assistant message has settled.
* @returns `true` once {@link settle} has run.
*/
isSettled(): boolean {
return this.settledContent !== undefined
}
/**
* Pin the step's timing footer to its completion time.
* @param time - Step completion time in epoch milliseconds.
*/
complete(time: number): void {
this.timing.complete(time)
}
override invalidate(): void {
this.rebuild()
this.timing.invalidate()
super.invalidate()
}
/**
* Fold one streamed chunk into the live block buffer and re-render.
* @param chunk - The streamed assistant chunk.
*/
update(chunk: StreamChunk): void {
if (chunk.type === 'block-start') {
this.blocks.set(chunk.index, { type: chunk.blockType, text: '' })
} else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
const type = chunk.type === 'text-delta' ? 'text' : 'reasoning'
const block = this.blocks.get(chunk.index) ?? { type, text: '' }
block.text += chunk.text
this.blocks.set(chunk.index, block)
} else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
}
this.rebuild()
this.timing.invalidate()
}
/**
* Toggle whether reasoning blocks render, then re-render.
* @param show - Whether to show reasoning blocks.
*/
setShowReasoning(show: boolean): void {
this.showReasoning = show
this.rebuild()
}
private rebuild(): void {
this.clear()
const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()]
.sort(([left], [right]) => left - right)
.flatMap<ContentBlock>(([, block]) => {
if (block.type === 'text') return [{ type: 'text', text: block.text }]
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
return []
})
for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) {
this.addChild(child)
}
}
}
/** 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 callView: ToolCallView
private resultView: ToolResultView | undefined
constructor(
private readonly name: string,
private readonly parsed: ParsedArguments,
private readonly definition: ToolDefinition | undefined,
private readonly maxOutputLines: number,
private readonly palette: Palette,
private readonly mdTheme: MarkdownTheme,
) {
this.callView = this.presentCall()
}
private presentCall(): ToolCallView {
if (this.parsed.valid && this.definition?.presentCall) {
try {
const view = this.definition.presentCall(this.parsed.value)
if (view !== undefined) return view
} catch (error: unknown) {
return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` }
}
}
return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value }
}
/**
* Record the tool result and derive its result view.
* @param event - The `tool/result` event payload.
*/
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
this.result = {
content: [...event.content],
isError: event.isError,
...event.meta !== undefined ? { meta: event.meta } : {},
}
if (this.parsed.valid && this.definition?.presentResult) {
try {
const view = this.definition.presentResult(this.parsed.value, this.result)
if (view !== undefined) this.resultView = view
} catch (error: unknown) {
this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] }
}
}
}
/**
* Expand or collapse the card's body preview.
* @param expanded - Whether the full body is shown.
*/
setExpanded(expanded: boolean): void {
this.expanded = expanded
}
invalidate(): void {}
render(width: number): string[] {
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.
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined
const unknownXml = this.definition === undefined && genericContent !== undefined
? renderUnknownXml(
displayText(contentText(genericContent)),
this.maxOutputLines,
this.expanded,
displayText,
text => this.palette.muted(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
? body
: [
...body.slice(0, headLines),
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
...body.slice(body.length - tailLines),
]
// 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
// read's path, a diff, command output) lives in the body below; the sole
// header extra is a bash card's model-authored description, appended as a
// `/ <desc>` segment. The body stays unprefixed so a drag-select copies only
// the tool text; body lines pass through Text so overlong output wraps.
const statusColor = this.result === undefined
? this.palette.warning
: isError ? this.palette.error : this.palette.success
// The header is a single card row: collapse an embedded newline in the
// description to an inline escape so it cannot break onto extra rows and
// collide with the body lines that follow.
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)]
if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width))
return lines
}
/** The pending terminal call view, when this row is a terminal card. */
private terminalPending(): TerminalCallView | undefined {
return this.callView.card === 'terminal' ? this.callView : undefined
}
/**
* The optional header `/ <desc>` segment: a bash (terminal) card's
* model-authored description. Non-terminal tools contribute no header detail —
* their presenter title moves into the body instead.
*/
private headerDescription(): string | undefined {
const description = this.terminalPending()?.description
return description !== undefined && description !== '' ? description : undefined
}
/**
* The presenter's title for a non-terminal card, shown as the first body line
* (a read's `Read src/foo.ts`, a diff's `Edit files`) now that the header is a
* fixed `Tool / <name>` frame. The result-state title replaces the pending one.
*/
private bodyTitle(): string {
return this.resultView?.title ?? this.callView.title
}
private renderBody(): string[] {
const view = this.resultView ?? this.callView
if (view.card === 'terminal') {
const pending = this.terminalPending()
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
// is a pending undescribed call (the classic running-command echo). A completed
// undescribed row keeps the command only in the header.
// The command and cwd are each a single card row, so escape a multi-line
// command inline (displayInlineText) — a real newline would break onto extra
// 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 (this.resultView?.card === 'terminal') {
if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
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'))
}
return lines.filter(Boolean)
}
if (view.card === 'diff') {
// The header no longer names the file, so each diff keeps its own path
// header. A trailing footer summarizes the change (`+A -R · N file(s)`).
let added = 0
let removed = 0
const hunks = view.diffs.flatMap((diff, index) => {
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
added += displayText(diff.newText).split('\n').length
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
})
const files = view.diffs.length
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
return [...hunks, footer]
}
const content = view.content ?? this.result?.content
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 (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))
}
}
/** The plan/todo panel rendered above the prompt. */
export class TodoComponent implements Component {
private todos: readonly TodoItem[] = []
constructor(private readonly palette: Palette) {}
/**
* Replace the rendered plan items.
* @param todos - The current todo items.
*/
update(todos: readonly TodoItem[]): void {
this.todos = todos
}
invalidate(): void {}
render(width: number): string[] {
if (this.todos.length === 0) return []
const lines = [this.palette.bold(this.palette.accent('Plan'))]
for (const todo of this.todos) {
const prefix = todo.status === 'completed'
? this.palette.success('✓')
: todo.status === 'in_progress'
? this.palette.warning('●')
: this.palette.dim('○')
const content = displayText(todo.content)
const text = todo.status === 'completed' ? this.palette.muted(content) : content
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
}
return ['', ...lines]
}
}

View File

@@ -0,0 +1,142 @@
/**
* Conservative readable-tree rendering for model-facing text containing one XML
* document, used by the transcript's tool and context cards.
* @module @deepseek-ai/dsh-tui/components/xml-tool-output
*/
import { SaxesParser } from 'saxes'
interface XmlElement {
readonly name: string
readonly attributes: readonly XmlAttribute[]
readonly children: XmlNode[]
}
interface XmlAttribute {
readonly name: string
readonly value: string
}
type XmlNode = XmlElement | string
function parseXml(source: string, display: (text: string) => string): XmlElement | undefined {
const parser = new SaxesParser({ xmlns: false })
const stack: XmlElement[] = []
let root: XmlElement | undefined
const state = { invalid: false }
const reject = (): void => { state.invalid = true }
parser.on('opentag', (tag) => {
const element: XmlElement = {
name: tag.name,
// Attribute values and text pass through `display` because character references can
// expand to valid-XML control characters (tab, CR, DEL, C1) that pre-parse escaping
// of the raw source never saw. Element names cannot carry them: control characters
// are not XML name characters and character references do not apply inside names.
attributes: Object.entries(tag.attributes).map(([name, value]) => ({ name, value: display(value) })),
children: [],
}
const parent = stack.at(-1)
if (parent === undefined) {
if (root !== undefined) reject()
root = element
} else {
parent.children.push(element)
}
stack.push(element)
})
parser.on('text', (text) => {
const parent = stack.at(-1)
if (parent === undefined) {
if (text.trim() !== '') reject()
} else {
parent.children.push(display(text))
}
})
parser.on('cdata', (text) => {
const parent = stack.at(-1)
if (parent === undefined) reject()
else parent.children.push(display(text))
})
parser.on('closetag', () => { stack.pop() })
parser.on('xmldecl', reject)
parser.on('processinginstruction', reject)
parser.on('doctype', reject)
parser.on('comment', reject)
parser.on('error', reject)
parser.write(source).close()
return state.invalid ? undefined : root
}
function elementLabel(element: XmlElement): string {
const attributes = element.attributes.map(attribute => `${attribute.name}=${JSON.stringify(attribute.value)}`).join(' ')
return attributes === '' ? element.name : `${element.name} (${attributes})`
}
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 treeLines(element: XmlElement, depth: number, label: (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()}`]
}
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))
}
return lines
}
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
return [...lines.slice(0, head), omitted(lines.length - limit), ...lines.slice(lines.length - tail)]
}
/**
* 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 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 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.
*/
export function renderUnknownXml(
source: string,
maxChildLines: number,
expanded: boolean,
display: (text: string) => string,
label: (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))
const rootLine = label(elementLabel(root))
if (expanded) return [rootLine, ...blocks.flat()]
const previewed = blocks.map(block => preview(block, maxChildLines, omitted))
if (previewed.length <= maxChildLines) return [rootLine, ...previewed.flat()]
const head = Math.ceil(maxChildLines / 2)
const tail = maxChildLines - head
const hidden = blocks.slice(head, blocks.length - tail).reduce((total, block) => total + block.length, 0)
return [
rootLine,
...previewed.slice(0, head).flat(),
omitted(hidden),
...previewed.slice(previewed.length - tail).flat(),
]
}

View File

@@ -0,0 +1,213 @@
/**
* Serializable configuration and defaults for the pi-tui terminal mode. Loader
* schema validation normally fills defaults; {@link resolveTuiConfig} applies
* the same defaults for direct callers that bypass the Loader.
* @module @deepseek-ai/dsh-tui/config
*/
import z from 'schemastery'
import {
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
DEFAULT_FILE_SEARCH_MAX_RESULTS,
} from './chat/file-autocomplete.ts'
/** Theme and prompt-template settings for the pi-tui terminal mode. */
export interface TuiThemeConfig {
/** Apply the built-in ANSI color palette. */
color?: boolean
/** Paint the startup banner with the 24-bit DeepSeek brand gradient. */
truecolor?: boolean
/** Left-aligned template on the row above the editor. */
leftPrompt?: string
/** Right-aligned template on the row above the editor. */
rightPrompt?: string
/** Template used as the editor's first-line prefix. */
inputPrompt?: string
/** Static placeholder shown in an empty editor while the agent is running. */
inputPlaceholder?: string
}
/** Interaction and presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
/** Render model reasoning blocks. */
showReasoning?: boolean
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
maxToolOutputLines?: number
/** Maximum options visible at once in a user-question panel. */
maxQuestionOptions?: number
/** Maximum models visible at once in the model selector. */
maxModelOptions?: number
/** Maximum sessions visible at once in the resume selector. */
maxResumeOptions?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
questionDialogMaxHeight?: number
/** Model-selector width in terminal columns. */
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Maximum fuzzy file candidates displayed for one `@` query. */
fileSearchMaxResults?: number
/** Maximum paths retained in one `@` workspace index. */
fileSearchMaxEntries?: number
/** Directory basenames excluded from `@` traversal and completion. */
fileSearchExcludedDirectories?: string[]
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
showHardwareCursor?: boolean
/** Color and prompt-template settings. */
theme?: TuiThemeConfig
/** Terminal window title while the UI is mounted; a logged session title prefixes it. */
title?: string
}
const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
const showHardwareCursorSchema = z.boolean().default(false)
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_INPUT_PROMPT = '${symbol} ${indicator}'
const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel'
const TuiThemeConfigSchema: z<TuiThemeConfig> = z.object({
color: colorSchema,
truecolor: truecolorSchema,
leftPrompt: z.string().default(DEFAULT_LEFT_PROMPT),
rightPrompt: z.string().default(DEFAULT_RIGHT_PROMPT),
inputPrompt: z.string().default(DEFAULT_INPUT_PROMPT),
inputPlaceholder: z.string().default(DEFAULT_INPUT_PLACEHOLDER),
})
const titleSchema = z.string().default('DeepSeek Harness')
const tuiConfigSchemaFields = {
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
maxResumeOptions: maxResumeOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
fileSearchMaxResults: fileSearchMaxResultsSchema,
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
showHardwareCursor: showHardwareCursorSchema,
theme: TuiThemeConfigSchema,
title: titleSchema,
}
/** Schemastery schema for presentation settings embedded by app bundles. */
export const TuiConfigSchema: z<TuiConfig> = z.object(tuiConfigSchemaFields)
/** Serializable plugin configuration. */
export interface Config extends TuiConfig {
/** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */
welcome?: string
/** 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.
*/
resumeCommand?: 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(),
showReasoning: tuiConfigSchemaFields.showReasoning,
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor,
theme: tuiConfigSchemaFields.theme,
title: tuiConfigSchemaFields.title,
})
/** Fully defaulted TUI theme settings. */
export interface ResolvedTuiThemeConfig {
color: boolean
truecolor: boolean
leftPrompt: string
rightPrompt: string
inputPrompt: string
inputPlaceholder: string
}
/** Fully defaulted TUI presentation settings. */
export interface ResolvedTuiConfig {
showReasoning: boolean
maxToolOutputLines: number
maxQuestionOptions: number
maxModelOptions: number
maxResumeOptions: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
fileSearchMaxResults: number
fileSearchMaxEntries: number
fileSearchExcludedDirectories: string[]
showHardwareCursor: boolean
theme: ResolvedTuiThemeConfig
title: string
}
/**
* Apply direct-call defaults after Loader schema validation has normally run.
*
* @param config - Deployment-provided terminal presentation settings.
* @returns Complete settings consumed by the TUI renderer.
*/
export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
return {
showReasoning: config?.showReasoning ?? true,
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
maxResumeOptions: config?.maxResumeOptions ?? 8,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 76,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
showHardwareCursor: config?.showHardwareCursor ?? false,
theme: {
color: config?.theme?.color ?? true,
truecolor: config?.theme?.truecolor ?? false,
leftPrompt: config?.theme?.leftPrompt ?? DEFAULT_LEFT_PROMPT,
rightPrompt: config?.theme?.rightPrompt ?? DEFAULT_RIGHT_PROMPT,
inputPrompt: config?.theme?.inputPrompt ?? DEFAULT_INPUT_PROMPT,
inputPlaceholder: config?.theme?.inputPlaceholder ?? DEFAULT_INPUT_PLACEHOLDER,
},
title: config?.title ?? 'DeepSeek Harness',
}
}

View File

@@ -3,12 +3,12 @@
*
* The manager serializes modal ownership, guards extension callbacks, and
* settles every queued or active operation before terminal teardown.
* @module @deepseek-ai/dsh-tui/overlay-manager
* @module @deepseek-ai/dsh-tui/extension/overlay-manager
*/
import { Service, type Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TuiExtensionService } from './index.ts'
import type { TuiExtensionService } from '../index.ts'
import type {
Component,
Focusable,
@@ -26,7 +26,7 @@ import type {
TuiOverlayState,
TuiTheme,
TuiViewport,
} from './extension.ts'
} from './types.ts'
/** pi-tui operations retained by the front door instead of exposed to plugins. */
export interface TuiOverlayDriver {

View File

@@ -5,7 +5,7 @@
* the live pi-tui tree, focus controller, overlay handles, or terminal
* lifecycle. Registrations and open overlays remain owned by the calling
* Cordis fiber.
* @module @deepseek-ai/dsh-tui/extension
* @module @deepseek-ai/dsh-tui/extension/types
*/
/** Terminal component shape accepted from a trusted TUI extension. */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
/**
* Mutable terminal-prompt value registry consumed by the TUI template renderer.
* Values are trusted presentation fragments and may contain ANSI control sequences.
* @module @deepseek-ai/dsh-tui/prompt
*/
import { Context, Service } from 'cordis'
import { errorChain } from '@deepseek-ai/dsh-llm'
export const name = 'tui-prompt'
const VALUE_NAME = /^[a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)*$/u
/** Handle owned by one prompt-value registration. */
export interface TuiPromptValueHandle {
/**
* Replace the current fragment and schedule a coalesced change notification
* so the owning renderer redraws. Setting the current value again is a no-op.
* @param value - Trusted ANSI-capable fragment, or `undefined` while unavailable.
*/
set(value: string | undefined): void
/** Unregister this value; subsequent {@link TuiPromptValueHandle.set} calls fail. */
dispose(): void
}
interface RegisteredValue {
value: string | undefined
}
declare module 'cordis' {
interface Context {
tuiPrompt: TuiPromptService
}
}
/** Removes a change subscription registered with {@link TuiPromptService.subscribe}. */
export type TuiPromptUnsubscribe = () => void
/** One literal or variable token in a parsed TUI prompt template. */
export type TuiPromptTemplateToken =
| { readonly kind: 'literal'; readonly value: string }
| { readonly kind: 'value'; readonly name: string }
/**
* Parse a prompt template into immutable literal and value tokens.
* @param template - Text containing `${name}` references.
* @returns Tokens consumed by {@link renderTuiPromptTemplate}.
*/
export function parseTuiPromptTemplate(template: string): readonly TuiPromptTemplateToken[] {
const tokens: TuiPromptTemplateToken[] = []
const pattern = /\$\{([^}]*)\}/gu
let offset = 0
for (const match of template.matchAll(pattern)) {
const index = match.index
const name = match[1]
/* v8 ignore next -- the sole capture always exists when this pattern matches. */
if (name === undefined) continue
if (index > offset) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset, index) }))
tokens.push(Object.freeze({ kind: 'value', name }))
offset = index + match[0].length
}
if (offset < template.length) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset) }))
return Object.freeze(tokens)
}
/**
* Interpolate one parsed prompt while removing horizontal separators adjacent
* only to unavailable values.
* @param tokens - Parsed template tokens.
* @param resolve - Current value lookup.
* @returns ANSI-capable rendered prompt text.
*/
export function renderTuiPromptTemplate(
tokens: readonly TuiPromptTemplateToken[],
resolve: (name: string) => string | undefined,
): string {
const rendered: string[] = []
let omitLeadingWhitespace = false
for (const token of tokens) {
if (token.kind === 'value') {
const value = resolve(token.name)
if (value === undefined) {
omitLeadingWhitespace = true
} else {
rendered.push(value)
omitLeadingWhitespace = false
}
continue
}
rendered.push(omitLeadingWhitespace ? token.value.replace(/^[\t ]+/u, '') : token.value)
omitLeadingWhitespace = false
}
return rendered.join('')
}
/**
* Context-global mutable values interpolated by TUI theme prompt templates.
* A registration, mutation, or disposal schedules one coalesced notification to
* the renderer subscribed with {@link TuiPromptService.subscribe}, so a value
* that changes on its own schedule (not only in response to a UI event) still
* redraws. Notification is a direct in-service callback, not a Cordis event.
*/
export class TuiPromptService extends Service {
private readonly values = new Map<string, RegisteredValue>()
// Per-subscription record identity, not callback identity: two fibers may
// subscribe the same function, and disposing one must not remove the other's.
private readonly listeners = new Set<{ readonly listener: () => unknown }>()
private notificationQueued = false
constructor(ctx: Context) {
super(ctx, 'tuiPrompt')
}
/**
* Register one globally unique template value under the calling Cordis effect.
* @param name - Lowercase slash-separated template name.
* @param initialValue - Initial trusted ANSI-capable fragment.
* @returns A mutable handle whose disposal unregisters the name.
*/
register(name: string, initialValue?: string): TuiPromptValueHandle {
if (!VALUE_NAME.test(name)) {
throw new TypeError(`TUI prompt value name "${name}" must match ${String(VALUE_NAME)}`)
}
if (this.values.has(name)) throw new Error(`TUI prompt value "${name}" is already registered`)
const registered: RegisteredValue = { value: initialValue }
let active = true
const effectDisposer = this.ctx.effect(() => {
this.values.set(name, registered)
this.scheduleChange()
// Cordis runs this cleanup at most once per effect, and deleting an
// absent key is a no-op, so no re-entrancy guard is needed here; `active`
// exists only to reject a late {@link TuiPromptValueHandle.set}.
return () => {
active = false
this.values.delete(name)
this.scheduleChange()
}
}, `tuiPrompt.register(${name})`)
return Object.freeze({
set: (value: string | undefined): void => {
if (!active) throw new Error(`TUI prompt value "${name}" is disposed`)
if (registered.value === value) return
registered.value = value
this.scheduleChange()
},
dispose: (): void => { void effectDisposer() },
})
}
/**
* Read a registered fragment without evaluating plugin code.
* @param name - Exact registered template name.
* @returns The current fragment, or `undefined` when unknown or unavailable.
*/
get(name: string): string | undefined {
return this.values.get(name)?.value
}
/**
* Observe registration and value changes. The listener runs after a coalesced
* microtask following any burst of mutations; the renderer re-reads current
* values on that callback. The subscription is owned by the calling Cordis
* effect, so it is removed when the subscriber's fiber disposes; the returned
* disposer removes it early. Listener failures are contained — a synchronous
* throw or a rejected returned promise cannot starve the other observers.
* @param listener - Invoked once per coalesced change burst. Delivery does
* not wait on a returned promise; its rejection is only observed and logged,
* never left unhandled, so an async listener cannot order later observers.
* @returns A disposer that removes the subscription.
*/
subscribe(listener: () => unknown): TuiPromptUnsubscribe {
const record = { listener }
const disposeEffect = this.ctx.effect(() => {
this.listeners.add(record)
return () => { this.listeners.delete(record) }
}, 'tuiPrompt.subscribe')
return () => { void disposeEffect() }
}
/** Coalesce mutation bursts into one notification while containing each observer. */
private scheduleChange(): void {
if (this.notificationQueued) return
this.notificationQueued = true
queueMicrotask(() => {
this.notificationQueued = false
// Snapshot so a listener may subscribe/unsubscribe during delivery, but
// re-check liveness: a listener that synchronously unsubscribes another
// observer earlier in the same burst must silence it now, keeping the
// subscription set authoritative during reentrant notification.
for (const record of [...this.listeners]) {
if (this.listeners.has(record)) this.notifyOne(record.listener)
}
})
}
/** Deliver one change notification, containing a synchronous throw or a rejected promise. */
private notifyOne(listener: () => unknown): void {
let returned: unknown
try {
returned = listener()
} catch (error: unknown) {
// errorChain never throws, even on a hostile toString/getter, so the
// notification microtask can never escape to starve later observers.
this.ctx.logger.warn(`tui-prompt change listener threw: ${errorChain(error)}`)
return
}
// A listener may be async; contain a rejected promise the same as a throw.
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`tui-prompt change listener rejected: ${errorChain(error)}`)
})
}
}
export default TuiPromptService

View File

@@ -0,0 +1,45 @@
/**
* Host and process boundary the interactive TUI runs against: the resume-handoff
* host and the {@link TuiRuntime} the shipped CLI supplies (terminal, process
* exit, clock, and optional prompt/git overrides). These are plain interfaces so
* tests can drive the channel with a fake terminal.
* @module @deepseek-ai/dsh-tui/runtime
*/
import type { Terminal } from '@earendil-works/pi-tui'
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.
* @param sessionId - validated persisted session selected by the user.
*/
handoff(sessionId: SessionId): Promise<never>
}
/** Runtime boundary used by the interactive TUI. */
export interface TuiRuntime {
/** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
terminal: Terminal
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
exit(code: number): void
/**
* Override the prompt's logical working-directory label without changing the session directory used by tools.
* @param cwd - Operational working directory from the session header.
* @returns Unescaped label; the TUI makes terminal controls visible.
*/
formatCwd?: (cwd: string | undefined) => string
/**
* Override the Git branch shown in the prompt context line; production resolves it once at mount.
* @param cwd - Operational working directory from the session header.
* @returns Unescaped branch name, or `undefined` outside a Git worktree.
*/
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. */
handoffResume?: TuiResumeHost['handoff']
}

View File

@@ -11,12 +11,12 @@ import type {
TuiOverlayOptions,
TuiOverlaySession,
TuiTheme,
} from '../src/extension.ts'
} from '../src/extension/types.ts'
import {
TuiExtensionServiceImpl,
TuiOverlayManager,
type TuiOverlayDriver,
} from '../src/overlay-manager.ts'
} from '../src/extension/overlay-manager.ts'
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,

View File

@@ -6,7 +6,7 @@ import {
activeAtToken,
formatFileMention,
WorkspaceFileSearch,
} from '../src/file-autocomplete.ts'
} from '../src/chat/file-autocomplete.ts'
const searches: WorkspaceFileSearch[] = []
const roots: string[] = []

View File

@@ -17,10 +17,11 @@ import type {
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
import { TestSessionQueryService } from './session-query.ts'
import TuiPromptService from '../src/prompt.ts'
interface FakeAgent extends Agent {
status: AgentStatus
@@ -48,6 +49,7 @@ export interface TuiHarnessOptions {
beforeMount?: (session: Session) => void
cwd?: string | null
formatCwd?: TuiRuntime['formatCwd']
gitBranch?: TuiRuntime['gitBranch']
/** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */
agentOptions?: AgentOptions
contextWindow?: number
@@ -98,6 +100,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(TuiPromptService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
@@ -111,12 +114,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
},
} as never)
if (options.configureContext === undefined) {
const tools = options.tools ?? {}
ctx.provide('tools', {
get(name: string) {
return tools[name]
},
} as never)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
for (const tool of Object.values(options.tools ?? {})) ctx.tools.register(tool)
} else {
await options.configureContext(ctx)
}
@@ -238,7 +238,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const controller = createTuiChat(ctx, Object.assign({
...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' },
sessionId,
color: false,
theme: { color: false },
}, options.config), {
terminal,
exit,
@@ -248,6 +248,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 }),
gitBranch: options.gitBranch ?? (() => 'tui-staging'),
})
return { ctx, session, agent, terminal, exit, controller }
}

View File

@@ -21,6 +21,7 @@ describe('dsh-tui plugin export shape', () => {
'llm',
'systemPrompt',
'tokenMeter',
'tuiPrompt',
])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')

View File

@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import TuiPromptService, {
parseTuiPromptTemplate,
renderTuiPromptTemplate,
} from '../src/prompt.ts'
const tick = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
describe('TUI prompt values', () => {
it('registers, updates, and disposes mutable values', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const value = ctx.tuiPrompt.register('git/worktree', '\x1b[32m(main)\x1b[0m')
expect(ctx.tuiPrompt.get('git/worktree')).toBe('\x1b[32m(main)\x1b[0m')
value.set('next')
expect(ctx.tuiPrompt.get('git/worktree')).toBe('next')
value.set(undefined)
expect(ctx.tuiPrompt.get('git/worktree')).toBeUndefined()
value.dispose()
expect(() => { value.set('late') }).toThrow(/disposed/)
await ctx.fiber.dispose()
})
it('coalesces a change burst into one notification and contains each observer', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
// Capture the containment warnings so the rejected-promise and sync-throw
// paths are each pinned (removing either catch drops its warning).
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
// A synchronous thrower, an async rejecter, and a thrower whose error is
// hostile to string coercion all sit BEFORE the observed listener, so
// proving `after` still runs proves none of them starves it (a naive
// `String(error)` inside the containment would itself throw on the last).
const hostile = { toString() { throw new Error('hostile coercion') } }
const thrower = vi.fn(() => { throw new Error('sync observer boom') })
const rejecter = vi.fn(async () => { throw new Error('async observer boom') })
const hostileThrower = vi.fn(() => { throw hostile })
const after = vi.fn()
ctx.tuiPrompt.subscribe(thrower)
ctx.tuiPrompt.subscribe(rejecter)
ctx.tuiPrompt.subscribe(hostileThrower)
const unsubscribe = ctx.tuiPrompt.subscribe(after)
await tick() // drain the registration notifications
thrower.mockClear()
rejecter.mockClear()
hostileThrower.mockClear()
after.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
value.set('b') // unchanged: no additional schedule
value.set('c')
await tick()
await tick() // settle the contained rejected promise
// One coalesced callback for the whole burst; a throwing, rejecting, or
// hostile-to-render observer is contained and does not stop later observers.
expect(thrower).toHaveBeenCalledTimes(1)
expect(rejecter).toHaveBeenCalledTimes(1)
expect(hostileThrower).toHaveBeenCalledTimes(1)
expect(after).toHaveBeenCalledTimes(1)
// Each contained failure logged its own warning: the sync throw, the
// rejected promise, and the hostile-to-render throw (via non-throwing
// errorChain). Pinning the rejected-promise warning fails if its `.catch`
// containment is removed.
expect(warnings.some(w => w.includes('threw: sync observer boom'))).toBe(true)
expect(warnings.some(w => w.includes('rejected: async observer boom'))).toBe(true)
expect(warnings.some(w => w.includes('threw: <unrenderable value>'))).toBe(true)
// Unsubscribe stops further notifications for that listener.
unsubscribe()
value.set('d')
await tick()
expect(after).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})
it('removes a subscription when the subscriber fiber disposes', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const observed = vi.fn()
// Subscribe from a child plugin fiber that shares the service, then dispose
// only that fiber; the effect-owned subscription must go with it.
const child = ctx.plugin({
inject: ['tuiPrompt'],
apply: (childCtx) => { childCtx.tuiPrompt.subscribe(observed) },
})
await tick()
observed.mockClear()
await child.dispose()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
expect(observed).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('keeps one fiber\'s subscription when another disposes the same callback', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
// Both fibers subscribe the SAME function reference. Per-subscription record
// identity (not callback identity) keeps them independent, so disposing one
// must not silence the other.
const shared = vi.fn()
const first = ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
await tick()
await first.dispose()
shared.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
// The second fiber's subscription survives the first's disposal.
expect(shared).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})
it('does not notify a subscription unsubscribed earlier in the same burst', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const victim = vi.fn()
// This listener is delivered first (subscribed first) and synchronously
// unsubscribes the victim during the same notification. The snapshot must
// re-check liveness so the later victim record does not fire this burst.
ctx.tuiPrompt.subscribe(() => { unsubscribeVictim() })
const unsubscribeVictim = ctx.tuiPrompt.subscribe(victim)
await tick()
victim.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
expect(victim).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('rejects invalid and duplicate names', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
expect(() => ctx.tuiPrompt.register('Bad Name')).toThrow(/must match/)
ctx.tuiPrompt.register('status')
expect(() => ctx.tuiPrompt.register('status')).toThrow(/already registered/)
await ctx.fiber.dispose()
})
})
describe('TUI prompt templates', () => {
it('interpolates values and removes separators around unavailable values', () => {
const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}')
const values = new Map([['cwd', '/work'], ['model', 'deepseek']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek')
})
it('keeps a trailing literal after the last value', () => {
const tokens = parseTuiPromptTemplate('${symbol} ${indicator} > ')
const values = new Map([['symbol', 'dsh'], ['indicator', '●']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('dsh ● > ')
})
it('preserves trusted ANSI fragments', () => {
const powerline = '\x1b[44m work \x1b[34;46m\x1b[0m'
expect(renderTuiPromptTemplate(parseTuiPromptTemplate('${powerline}'), () => powerline)).toBe(powerline)
})
})

View File

@@ -1,7 +1,7 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -12,7 +12,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import CommandService from '@deepseek-ai/dsh-commands'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import { createTuiChat } from '../src/index.ts'
import { createTuiChat, TuiPromptService } from '../src/index.ts'
import { HeadlessTerminal } from './headless-terminal.ts'
import { TestSessionQueryService } from './session-query.ts'
@@ -51,6 +51,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise<void> {
describe('TUI session-reference snapshot', () => {
it('snapshots compacted current-surface context on send and displays only its reference card', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 30, 0).getTime())
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -59,6 +60,7 @@ describe('TUI session-reference snapshot', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(TuiPromptService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
@@ -97,7 +99,7 @@ describe('TUI session-reference snapshot', () => {
const controller = createTuiChat(ctx, {
sessionId: target.id,
welcome: 'Session reference snapshot.',
color: true,
theme: { color: true },
title: 'DSH session reference',
}, { terminal, exit: () => {} })
await terminal.waitForFrame(0)
@@ -138,5 +140,6 @@ describe('TUI session-reference snapshot', () => {
await controller.dispose()
await ctx.fiber.dispose()
await terminal.dispose()
clock.mockRestore()
})
})

View File

@@ -1,99 +1,69 @@
terminal 100x40 buffer=normal length=40 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=36 bufferRow=36
cursor hidden column=7 viewportRow=34 bufferRow=34
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=green
5| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
6| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
7| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
8| "▌ … +4 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
9| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
10| "▌ "
style 0-0 fg=green
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
7| "$ pnpm run test:coverage "
style 0-23 fg=cyan
8| "/workspace/project "
style 0-17 dim
9| "… +4 lines (Ctrl+O to expand) "
style 0-28 dim
10| "[exit 0] "
style 0-7 dim
11| <blank>
12| ""
style 0-0 fg=green
13| "▌ ✓ Edit renderer "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-16 bold
14| "▌ src/view.ts "
style 0-0 fg=green
style 2-12 bold
15| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
16| "▌ … +5 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
17| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-35 fg=green
18| " "
style 0-0 fg=green
19| <blank>
20| "▌ "
style 0-0 fg=green
21| "▌ ✓ Delegate renderer audit "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-26 bold
22| "▌ The renderer has explicit lifecycle ownership. "
style 0-0 fg=green
23| "▌ "
style 0-0 fg=green
24| <blank>
25| "▌ "
style 0-0 fg=green
26| "▌ ✓ Read output from background task subagent-7 "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-46 bold
27| "▌ audit complete "
style 0-0 fg=green
28| "▌ [status: completed] "
style 0-0 fg=green
29| "▌ "
style 0-0 fg=green
30| <blank>
31| "▌ "
style 0-0 fg=green
32| "▌ ✓ Load skill dsh-code-review "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-29 bold
33| "▌ Loaded review instructions. "
style 0-0 fg=green
34| "▌ "
style 0-0 fg=green
35| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
36| " "
style 1-1 inverse
37| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 73-99 dim
39| <blank>
12| "● Tool / edit"
style 0-12 fg=green
13| "src/view.ts "
style 0-10 bold
14| "- old line "
style 0-9 fg=red
15| "… +3 lines (Ctrl+O to expand) "
style 0-28 dim
16| "└ +2 -2 · 1 file "
style 0-15 dim
17| <blank>
18| "● Tool / subagent"
style 0-16 fg=green
19| "Delegate renderer audit "
20| "The renderer has explicit lifecycle ownership. "
21| <blank>
22| "● Tool / task_output"
style 0-19 fg=green
23| "Read output from background task subagent-7 "
24| " "
25| "… +2 lines (Ctrl+O to expand) "
style 0-28 dim
26| " "
27| <blank>
28| "● Tool / skill"
style 0-13 fg=green
29| "Load skill dsh-code-review "
30| "Loaded review instructions. "
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
34| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
35-39| <blank>

View File

@@ -1,117 +1,79 @@
terminal 100x40 buffer=normal length=48 base=8 viewport=8
terminal 100x40 buffer=normal length=43 base=3 viewport=3
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=37 bufferRow=45
cursor hidden column=7 viewportRow=39 bufferRow=42
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=green
5| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
6| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
7| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
8| "▌ packages/ui/tui 100% "
style 0-0 fg=green
9| "▌ 4016 tests passed "
style 0-0 fg=green
10| "▌ 1 test skipped "
style 0-0 fg=green
11| "▌ coverage complete "
style 0-0 fg=green
12| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
13| "▌ "
style 0-0 fg=green
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
7| "$ pnpm run test:coverage "
style 0-23 fg=cyan
8| "/workspace/project "
style 0-17 dim
9| "packages/ui/tui 100% "
10| "4016 tests passed "
11| "1 test skipped "
12| "coverage complete "
13| "[exit 0] "
style 0-7 dim
14| <blank>
15| ""
style 0-0 fg=green
16| "▌ ✓ Edit renderer "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-16 bold
17| "▌ src/view.ts "
style 0-0 fg=green
style 2-12 bold
18| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
19| "▌ - keep "
style 0-0 fg=green
style 2-7 fg=red
20| "▌ + new line "
style 0-0 fg=green
style 2-11 fg=green
21| "▌ + keep "
style 0-0 fg=green
style 2-7 fg=green
22| "▌ "
style 0-0 fg=green
23| "▌ tests/view.spec.ts "
style 0-0 fg=green
style 2-19 bold
24| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-35 fg=green
25| "▌ "
style 0-0 fg=green
15| "● Tool / edit"
style 0-12 fg=green
16| "src/view.ts "
style 0-10 bold
17| "- old line "
style 0-9 fg=red
18| "- keep "
style 0-5 fg=red
19| "+ new line "
style 0-9 fg=green
20| "+ keep "
style 0-5 fg=green
21| "└ +2 -2 · 1 file "
style 0-15 dim
22| <blank>
23| "● Tool / subagent"
style 0-16 fg=green
24| "Delegate renderer audit "
25| "The renderer has explicit lifecycle ownership. "
26| <blank>
27| ""
style 0-0 fg=green
28| "▌ ✓ Delegate renderer audit "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-26 bold
29| "▌ The renderer has explicit lifecycle ownership. "
style 0-0 fg=green
30| " "
style 0-0 fg=green
31| <blank>
32| "▌ "
style 0-0 fg=green
33| "▌ ✓ Read output from background task subagent-7 "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-46 bold
34| "▌ audit complete "
style 0-0 fg=green
35| "▌ [status: completed] "
style 0-0 fg=green
36| "▌ "
style 0-0 fg=green
37| <blank>
38| "▌ "
style 0-0 fg=green
39| "▌ ✓ Load skill dsh-code-review "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-29 bold
40| "▌ Loaded review instructions. "
style 0-0 fg=green
41| "▌ "
style 0-0 fg=green
42| <blank>
43| " Tool cards expanded. "
style 1-20 fg=bright-black
44| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
45| " "
style 1-1 inverse
46| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded"
style 0-43 dim
style 74-99 dim
27| "● Tool / task_output"
style 0-19 fg=green
28| "Read output from background task subagent-7 "
29| " "
30| "console "
style 0-6 dim
31| " started background task bash-5 "
style 2-31 fg=cyan
32| " "
33| <blank>
34| "● Tool / skill"
style 0-13 fg=green
35| "Load skill dsh-code-review "
36| "Loaded review instructions. "
37| "Model wait 0.0s "
style 0-14 dim
38| <blank>
39| "Tool cards expanded. "
style 0-19 fg=bright-black
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
42| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse

View File

@@ -1,7 +1,7 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=4 bufferRow=4
cursor hidden column=7 viewportRow=8 bufferRow=8
viewport
0| " DEEPSEEK HARNESS"
style 1-1 fg=#4d6bfe bold
@@ -15,15 +15,22 @@ viewport
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
7-35| <blank>
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-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
8| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
9-35| <blank>

View File

@@ -1,39 +1,37 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=12 bufferRow=12
cursor hidden column=7 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=yellow
5| "▌ ◌ Echo two markers and combine them "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-36 bold
6| "const first = await tools.bash({ command: 'echo CODE_ONE' }) "
style 0-0 fg=yellow
7| "const second = await tools.bash({ command: 'echo CODE_TWO' }) "
style 0-0 fg=yellow
8| "▌ console.log(first, second) "
style 0-0 fg=yellow
9| "▌ return `${first}+${second}` "
style 0-0 fg=yellow
10| "▌ "
style 0-0 fg=yellow
11| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
12| " "
style 1-1 inverse
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
14| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
15-35| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "○ Tool / run_code"
style 0-16 fg=yellow
7| "Echo two markers and combine them "
8| "const first = await tools.bash({ command: 'echo CODE_ONE' }) "
9| "const second = await tools.bash({ command: 'echo CODE_TWO' }) "
10| "console.log(first, second) "
11| "return `${first}+${second}` "
12| "Model wait 0.0s "
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
15| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
16-35| <blank>

View File

@@ -1,46 +1,45 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=active
title "DSH snapshot"
cursor hidden column=1 viewportRow=17 bufferRow=17
cursor hidden column=7 viewportRow=18 bufferRow=18
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Show the live update. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Reasoning "
style 1-9 fg=bright-black italic
10| " Inspecting width and styles. "
style 1-28 fg=bright-black italic
11| <blank>
12| " Assistant "
style 1-9 fg=bright-magenta bold
13| " Streaming visible state… "
style 11-23 bold
14| <blank>
15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels "
style 1-1 fg=bright-blue
style 3-62 fg=bright-black
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 fg=bright-blue
17| " "
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 fg=bright-blue
19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
20-35| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Reasoning "
style 0-8 fg=bright-black italic
6| "Inspecting width and styles. "
style 0-27 fg=bright-black italic
7| "Streaming visible state… "
style 10-22 bold
8| " "
9| "ts "
style 0-1 dim
10| " const visible = true "
style 2-21 fg=cyan
11| " "
12| "Model wait 1.0s · Thinking 2.0s "
style 0-30 dim
13| <blank>
14| "You "
style 0-2 fg=bright-blue 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
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
19-35| <blank>

View File

@@ -1,49 +1,45 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=16 bufferRow=16
cursor hidden column=7 viewportRow=21 bufferRow=21
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "▌ ◌ Inspect cordis runtime: tools "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-32 bold
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| ""
style 0-0 fg=yellow
7| "▌ ◌ Mount plugin into live cordis runtime "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-40 bold
8| "▌ { "
style 0-0 fg=yellow
9| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
style 0-0 fg=yellow
10| "▌ ready: true }) } }\" "
style 0-0 fg=yellow
11| "▌ } "
style 0-0 fg=yellow
12| " "
style 0-0 fg=yellow
13| <blank>
14| "▌ ◌ Unmount dyn-1 "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-16 bold
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
19-35| <blank>
6| "○ Tool / cordis_inspect"
style 0-22 fg=yellow
7| "Inspect cordis runtime: tools "
8| <blank>
9| "○ Tool / cordis_mount"
style 0-20 fg=yellow
10| "Mount temporary Cordis Plugin "
11| "{ "
12| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready:"
13| "true }) } }\" "
14| "} "
15| <blank>
16| "○ Tool / cordis_unmount"
style 0-22 fg=yellow
17| "Unmount temporary Cordis Plugin dyn-1 "
18| "Model wait 0.0s "
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
21| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
22-35| <blank>

View File

@@ -1,63 +1,78 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
terminal 92x32 buffer=normal length=38 base=6 viewport=6
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
cursor visible column=0 viewportRow=31 bufferRow=31
cursor visible column=0 viewportRow=31 bufferRow=37
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
style 0-46 dim
6| <blank>
7| "Keyboard shortcuts "
style 0-17 fg=bright-blue 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
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 0-87 fg=bright-black
20| "/resume — List this workspace's resumable sessions "
style 0-49 fg=bright-black
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| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "
style 1-1 inverse
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
30-31| <blank>
25| "provider stream failed after partial output "
style 0-42 fg=red
26| <blank>
27| "The previous process ended during this turn. "
style 0-43 fg=yellow
28| <blank>
29| "Turn stopped: the agent was disposed. "
style 0-36 fg=yellow
30| <blank>
31| "Turn ended: plugin-policy. "
style 0-25 fg=yellow
32| <blank>
33| "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
style 7-7 inverse
37| <blank>

View File

@@ -1,46 +1,40 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=15 bufferRow=15
cursor hidden column=7 viewportRow=17 bufferRow=17
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=yellow
5| "▌ ◌ workflow: tui-matrix "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-23 bold
6| "phase('Inspect') "
style 0-0 fg=yellow
7| "▌ const reports = await parallel([ "
style 0-0 fg=yellow
8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
style 0-0 fg=yellow
9| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=yellow
style 2-30 dim
10| "▌ ]) "
style 0-0 fg=yellow
11| "▌ phase('Verify') "
style 0-0 fg=yellow
12| "▌ return { reports, verdict: 'covered' } "
style 0-0 fg=yellow
13| "▌ "
style 0-0 fg=yellow
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| " "
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "○ Tool / workflow"
style 0-16 fg=yellow
7| "workflow: tui-matrix "
8| "phase('Inspect') "
9| "const reports = await parallel([ "
10| "… +2 lines (Ctrl+O to expand) "
style 0-28 dim
11| "]) "
12| "phase('Verify') "
13| "return { reports, verdict: 'covered' } "
14| "Model wait 0.0s "
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
17| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
18-35| <blank>

View File

@@ -1,63 +1,77 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
terminal 92x32 buffer=normal length=37 base=5 viewport=5
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=27 bufferRow=27
cursor hidden column=7 viewportRow=31 bufferRow=36
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
style 0-46 dim
6| <blank>
7| "Keyboard shortcuts "
style 0-17 fg=bright-blue 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
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 0-87 fg=bright-black
20| "/resume — List this workspace's resumable sessions "
style 0-49 fg=bright-black
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| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "
style 1-1 inverse
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
30-31| <blank>
25| "provider stream failed after partial output "
style 0-42 fg=red
26| <blank>
27| "The previous process ended during this turn. "
style 0-43 fg=yellow
28| <blank>
29| "Turn stopped: the agent was disposed. "
style 0-36 fg=yellow
30| <blank>
31| "Turn ended: plugin-policy. "
style 0-25 fg=yellow
32| <blank>
33| "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
style 7-7 inverse

View File

@@ -1,24 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=5 viewportRow=4 bufferRow=4
cursor hidden column=11 viewportRow=8 bufferRow=8
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
4| " @tsc "
style 5-5 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
6| " → File · terminal-special-case.t src/terminal-special-case.ts "
style 1-32 fg=bright-blue
7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
8-35| <blank>
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-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
8| " dsh > @tsc "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 11-11 inverse
9| " → File · terminal-special-case.t src/terminal-special-case.ts "
style 7-38 fg=bright-blue
10-35| <blank>

View File

@@ -1,42 +0,0 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=92 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ "
style 8-8 fg=bright-blue
style 38-77 fg=bright-black
style 83-83 fg=bright-blue
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
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>

View File

@@ -8,27 +8,34 @@ buffer
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-12| <blank>
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-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
8| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
9-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ "
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
style 10-70 fg=bright-blue inverse
style 83-83 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
style 8-8 fg=bright-blue
style 36-77 fg=bright-black
style 36-58 fg=bright-black
style 83-83 fg=bright-blue
16| " │ │ "
style 8-8 fg=bright-blue

View File

@@ -1,27 +1,32 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=7 bufferRow=7
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps "
style 1-91 fg=bright-black
5| " will use it. "
style 1-12 fg=bright-black
6| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
7| " "
style 1-1 inverse
8| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-41 dim
style 65-91 dim
10-31| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 0-63 fg=bright-black
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
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-31| <blank>

View File

@@ -0,0 +1,40 @@
terminal 56x20 buffer=normal length=20 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=19 bufferRow=19
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
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 "
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
8| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
9-11| <blank>
12| " "
13| " Question 1/1 (1 unanswered) · Confirm "
style 2-38 fg=bright-black
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
17| " Tab custom answer • Enter submit • Esc interrupt "
style 2-49 dim
18| " "
19| <blank>

View File

@@ -8,12 +8,11 @@ viewport
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────"
style 0-55 dim
4| " "
style 1-1 inverse
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| " "
6| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black

View File

@@ -8,17 +8,14 @@ viewport
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────"
style 0-55 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────"
style 0-55 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context"
style 0-43 dim
style 46-55 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| " "
8| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black

View File

@@ -1,38 +1,34 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=13 bufferRow=13
cursor hidden column=7 viewportRow=12 bufferRow=12
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Start then cancel. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Start then cancel. "
6| <blank>
7| "Retrying model request (1/∞) in 1000ms: temporary transport failure "
style 0-66 fg=yellow
8| <blank>
9| " Retrying model request (1/∞) in 1000ms: temporary transport failure "
style 1-67 fg=yellow
9| "Turn cancelled. "
style 0-14 fg=yellow
10| <blank>
11| " Turn cancelled. "
style 1-15 fg=yellow
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
16-35| <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
12| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
13-35| <blank>

View File

@@ -1,35 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=11 bufferRow=11
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Let the bounded policy exhaust. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Let the bounded policy exhaust. "
6| <blank>
7| "provider still unavailable "
style 0-25 fg=red
8| <blank>
9| " provider still unavailable "
style 1-26 fg=red
10| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
11| " "
style 1-1 inverse
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
14-35| <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
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-35| <blank>

View File

@@ -1,39 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=14 bufferRow=14
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Recover this request. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue 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| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
10| <blank>
11| " Assistant "
style 1-9 fg=bright-magenta bold
12| " Recovered on the next bounded attempt. "
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
14| " "
style 1-1 inverse
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
17-35| <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
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-35| <blank>

View File

@@ -1,35 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=11 bufferRow=11
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Recover this request. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue 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| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
10| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
11| " "
style 1-1 inverse
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
14-35| <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
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-35| <blank>

View File

@@ -1,39 +1,35 @@
terminal 96x24 buffer=normal length=24 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH session reference"
cursor hidden column=1 viewportRow=14 bufferRow=14
cursor hidden column=7 viewportRow=14 bufferRow=14
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Session reference snapshot."
style 1-27 fg=bright-black
2| " mock • target-session"
style 1-23 dim
2| " target-session"
style 1-14 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Use @Source session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Use @Source session "
6| <blank>
7| "Referenced sessions · Source session (source-session) "
style 0-52 dim
8| <blank>
9| " Referenced sessions · Source session (source-session) "
style 1-53 dim
10| <blank>
11| " Assistant "
style 1-9 fg=bright-magenta bold
12| " Combined reference request accepted. "
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
14| " "
style 1-1 inverse
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| "mock /workspace/project ↑0 ↓0 tools:collapsed"
style 0-30 dim
style 81-95 dim
17-23| <blank>
9| "Assistant "
style 0-8 fg=bright-magenta bold underline
10| "Combined reference request accepted. "
11| "Model wait 0.0s · Completed 2026-07-21 12:30:00 "
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
14| " dsh ◍ "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
15-23| <blank>

View File

@@ -0,0 +1,31 @@
terminal 44x18 buffer=normal length=18 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=38 viewportRow=13 bufferRow=13
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
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-v"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-43 fg=bright-black
8| " ↑ 1 more "
style 1-14 dim
9| " enough detail to wrap across multiple "
10| " full-width continuation rows without "
11| " leaving a prompt-sized gap at the right "
12| " edge. "
13| " Then suggest a simpler version. "
style 38-38 inverse
14-17| <blank>

View File

@@ -1,110 +1,120 @@
terminal 56x36 buffer=normal length=36 base=0 viewport=0
terminal 56x36 buffer=normal length=44 base=8 viewport=8
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=1 viewportRow=32 bufferRow=32
cursor hidden column=7 viewportRow=35 bufferRow=43
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Inspect session diagnostics"
style 1-27 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ inspect this session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Assistant "
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ─────────────────────────────────────╮"
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
style 0-2 fg=bright-blue bold underline
9| "inspect this session "
10| <blank>
11| "╭─ Session status ─────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-55 dim
13| "│ Session: main-session │"
12| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
14| "│ Title: Inspect session diagnostics │"
13| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
15| "│ Directory: /workspace/project │"
14| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
16| "│ Model: deepseek/deepseek-v4-pro (effort │"
15| "│ Model: deepseek/deepseek-v4-pro (effort │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-55 dim
17| "│ default; reasoning blocks shown) │"
16| "│ default; reasoning blocks shown) │"
style 0-0 dim
style 15-46 dim
style 55-55 dim
18| "│ │"
17| "│ │"
style 0-0 dim
style 55-55 dim
19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
20| "│ tool call │"
19| "│ tool call │"
style 0-0 dim
style 55-55 dim
21| "│ │"
20| "│ │"
style 0-0 dim
style 55-55 dim
22| "│ Tokens: 1,250 input + 340 output │"
21| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 55-55 dim
24| "│ + 250 write) │"
23| "│ + 250 write) │"
style 0-0 dim
style 55-55 dim
25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 55-55 dim
26| "│ 128,000) │"
25| "│ 128,000) │"
style 0-0 dim
style 55-55 dim
27| "│ │"
26| "│ │"
style 0-0 dim
style 55-55 dim
28| "│ Created: 2026-07-22 09:10:11 UTC │"
27| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
29| "│ Active: 2026-07-22 09:10:11 UTC │"
28| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
30| "╰──────────────────────────────────────────────────────╯"
29| "╰──────────────────────────────────────────────────────╯"
style 0-55 dim
31| "────────────────────────────────────────────────────────"
style 0-55 dim
32| " "
style 1-1 inverse
33| "────────────────────────────────────────────────────────"
style 0-55 dim
34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6"
style 0-55 dim
35| <blank>
30| <blank>
31| "System prompt "
style 0-12 fg=bright-blue bold
32| "You are an AI agent powered by the DeepSeek Harness SDK."
33| " "
34| "Paths prefixed with @ are files explicitly referenced by"
35| "the user. Use the read tool when their contents are "
36| "needed; do not claim to have inspected a file before "
37| "reading it. "
38| <blank>
39| "Registered tools "
style 0-15 fg=bright-blue 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
43| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse

View File

@@ -1,99 +1,107 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
terminal 92x32 buffer=normal length=38 base=6 viewport=6
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=1 viewportRow=28 bufferRow=28
cursor hidden column=7 viewportRow=31 bufferRow=37
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Inspect session diagnostics"
style 1-27 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ inspect this session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Assistant "
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
style 0-2 fg=bright-blue bold underline
9| "inspect this session "
10| <blank>
11| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-81 dim
13| "│ Session: main-session │"
12| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
14| "│ Title: Inspect session diagnostics │"
13| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
15| "│ Directory: /workspace/project │"
14| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-79 dim
style 81-81 dim
17| "│ │"
16| "│ │"
style 0-0 dim
style 81-81 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
17| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
19| "│ │"
18| "│ │"
style 0-0 dim
style 81-81 dim
20| "│ Tokens: 1,250 input + 340 output │"
19| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 81-81 dim
22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 81-81 dim
23| "│ │"
22| "│ │"
style 0-0 dim
style 81-81 dim
24| "│ Created: 2026-07-22 09:10:11 UTC │"
23| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
25| "│ Active: 2026-07-22 09:10:11 UTC │"
24| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
26| "╰────────────────────────────────────────────────────────────────────────────────╯"
25| "╰────────────────────────────────────────────────────────────────────────────────╯"
style 0-81 dim
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| " "
style 1-1 inverse
29| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed"
style 0-57 dim
style 64-91 dim
31| <blank>
26| <blank>
27| "System prompt "
style 0-12 fg=bright-blue 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
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
37| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse

View File

@@ -0,0 +1,34 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=11 bufferRow=11
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
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
6| "Checking the result. "
style 0-19 fg=bright-black 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
11| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
12-35| <blank>

View File

@@ -1,30 +1,36 @@
terminal 44x18 buffer=normal length=18 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=9 bufferRow=9
cursor hidden column=7 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Context · compact "
style 1-17 dim
5| " Compacted summary: the prior command "
style 1-43 fg=bright-black
6| " completed and its details were retired "
style 1-43 fg=bright-black
7| " from the active surface. "
style 1-24 fg=bright-black
8| "────────────────────────────────────────────"
style 0-43 dim
9| " "
style 1-1 inverse
10| "────────────────────────────────────────────"
style 0-43 dim
11| "deepseek-v4-flash /workspace/project ↑0 ↓0"
style 0-43 dim
12-17| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
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
style 7-7 inverse
16-17| <blank>

Some files were not shown because too many files have changed in this diff Show More