Merge remote-tracking branch 'origin/master' into fix/worker-timer-clamp
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6
|
||||
README.zh.md: 88992176165ab11050a30c7df381479796908ba2
|
||||
README.md: 32651291253077098bc43a930cf4ce11d29b1ed8
|
||||
README.zh.md: ea8f398541d7af6136b29c3365a78c4ea3a1e85d
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session 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 scope;key 空间在运行时开放);其渲染点逐行通过 `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']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向模型、作用于 agent(智能体)自身所在实时 Cordis 运行时的工具:检查已加载插件与服务接口、挂载模型编写的插件,以及再次释放这些插件。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
面向模型、作用于 agent(智能体)所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并挂载或卸载仅存于内存的临时 Plugin。设计归档见[工具集 Agent Note(agent 决策记录)](../../.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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 行都会报告 running/pending 状态、提供与等待的服务,以及持续至卸载或 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 集合不变时,前缀保持稳定。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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];',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `)`.',
|
||||
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]')
|
||||
})
|
||||
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
## 颜色
|
||||
|
||||
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、工具卡片)使用彩色左侧 gutter bar,而非填充背景块;问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
95
packages/ui/tui/src/chat/autocomplete.ts
Normal file
95
packages/ui/tui/src/chat/autocomplete.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
31
packages/ui/tui/src/chat/channel.ts
Normal file
31
packages/ui/tui/src/chat/channel.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
137
packages/ui/tui/src/chat/helpers.ts
Normal file
137
packages/ui/tui/src/chat/helpers.ts
Normal 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
|
||||
191
packages/ui/tui/src/chat/model-command.ts
Normal file
191
packages/ui/tui/src/chat/model-command.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
168
packages/ui/tui/src/chat/questions.ts
Normal file
168
packages/ui/tui/src/chat/questions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
245
packages/ui/tui/src/chat/resume.ts
Normal file
245
packages/ui/tui/src/chat/resume.ts
Normal 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')
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
67
packages/ui/tui/src/chat/skill-invocation.ts
Normal file
67
packages/ui/tui/src/chat/skill-invocation.ts
Normal 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}`
|
||||
}
|
||||
347
packages/ui/tui/src/chat/timing.ts
Normal file
347
packages/ui/tui/src/chat/timing.ts
Normal 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}`
|
||||
}
|
||||
96
packages/ui/tui/src/chat/tokens.ts
Normal file
96
packages/ui/tui/src/chat/tokens.ts
Normal 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`
|
||||
}
|
||||
56
packages/ui/tui/src/components/content.ts
Normal file
56
packages/ui/tui/src/components/content.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
789
packages/ui/tui/src/components/dialogs.ts
Normal file
789
packages/ui/tui/src/components/dialogs.ts
Normal 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)))} `
|
||||
})
|
||||
}
|
||||
}
|
||||
49
packages/ui/tui/src/components/text.ts
Normal file
49
packages/ui/tui/src/components/text.ts
Normal 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, '')
|
||||
}
|
||||
184
packages/ui/tui/src/components/theme.ts
Normal file
184
packages/ui/tui/src/components/theme.ts
Normal 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 0–255.
|
||||
*/
|
||||
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)),
|
||||
}
|
||||
}
|
||||
529
packages/ui/tui/src/components/transcript.ts
Normal file
529
packages/ui/tui/src/components/transcript.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
142
packages/ui/tui/src/components/xml-tool-output.ts
Normal file
142
packages/ui/tui/src/components/xml-tool-output.ts
Normal 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(),
|
||||
]
|
||||
}
|
||||
213
packages/ui/tui/src/config.ts
Normal file
213
packages/ui/tui/src/config.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
217
packages/ui/tui/src/prompt.ts
Normal file
217
packages/ui/tui/src/prompt.ts
Normal 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
|
||||
45
packages/ui/tui/src/runtime.ts
Normal file
45
packages/ui/tui/src/runtime.ts
Normal 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']
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('dsh-tui plugin export shape', () => {
|
||||
'llm',
|
||||
'systemPrompt',
|
||||
'tokenMeter',
|
||||
'tuiPrompt',
|
||||
])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
|
||||
169
packages/ui/tui/tests/prompt.spec.ts
Normal file
169
packages/ui/tui/tests/prompt.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user