Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml # .agents/notes/archived/manifest.json # .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml # .agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml # apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt # apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt # apps/cli/tests/tui-keyless-smoke.e2e.ts # apps/cli/tests/tui.snapshot.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/cookbook/extension-cookbook.i18n.yaml # docs/event-producer-consumer.md # packages/ui/tui/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/session-reference.snapshot.ts # packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt # packages/ui/tui/tests/snapshots/details-command.expected.txt # packages/ui/tui/tests/snapshots/details-selector.expected.txt # packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt # packages/ui/tui/tests/snapshots/errors-and-help.expected.txt # packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt # packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt # packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt # packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt # packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt # packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt # packages/ui/tui/tests/snapshots/todo-plan-cleared.expected.txt # packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt # packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt # packages/ui/tui/tests/tui.snapshot.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863
|
||||
README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01
|
||||
README.md: 4832fffbc8963b8a7b1f8332e691083195bf94bc
|
||||
README.zh.md: 076b4f877070fcf0ee6b98d2310d1121cbbe63d6
|
||||
|
||||
@@ -46,11 +46,11 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC integration, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
|
||||
@@ -46,11 +46,11 @@
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
|
||||
@@ -1088,16 +1088,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tui',
|
||||
summary: 'Optional terminal-local interaction service provided by one mounted TUI.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession',
|
||||
jsDoc: '/**\n * Queue an interactive overlay owned by the calling plugin fiber.\n *\n * The TUI displays one overlay at a time in FIFO order. Disposing the caller\n * removes a queued overlay or closes an active one before plugin teardown\n * settles. This live presentation is neither logged nor replayed.\n *\n * @param request - component factory, layout constraints, and cancellation.\n * @returns the effect-owned overlay session.\n * @throws when the TUI has begun shutting down.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'typert',
|
||||
summary: 'Registry of generated schemas and package reflection.',
|
||||
|
||||
@@ -61,7 +61,7 @@ function createProgram(): Command {
|
||||
.option('--base-url <url>')
|
||||
.option('--api-key <key>')
|
||||
.option('--model <name>')
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'tui', 'embed']))
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'embed']))
|
||||
.addOption(new Option('--pm <name>').choices(['npm', 'pnpm', 'yarn']))
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
|
||||
@@ -169,10 +169,9 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep<ProjectAnswerState>[] = [
|
||||
message: 'Run interface',
|
||||
options: [
|
||||
{ value: 'acp', label: 'ACP automation server' },
|
||||
{ value: 'tui', label: 'Terminal TUI' },
|
||||
{ value: 'embed', label: 'Embedded context' },
|
||||
],
|
||||
initialValue: 'tui',
|
||||
initialValue: 'acp',
|
||||
}),
|
||||
prefilled: state => state.args.runInterface,
|
||||
apply: (state, value) => { state.runInterface = value },
|
||||
|
||||
@@ -6,7 +6,7 @@ Options:
|
||||
--base-url <url>
|
||||
--api-key <key>
|
||||
--model <name>
|
||||
--interface <acp|tui|embed>
|
||||
--interface <acp|embed>
|
||||
--pm <npm|pnpm|yarn>
|
||||
--install / --no-install
|
||||
--config <path>
|
||||
|
||||
@@ -179,12 +179,11 @@ describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', ()
|
||||
"message": "DeepSeek API key",
|
||||
},
|
||||
{
|
||||
"initialValue": "tui",
|
||||
"initialValue": "acp",
|
||||
"kind": "select",
|
||||
"message": "Run interface",
|
||||
"options": [
|
||||
"ACP automation server",
|
||||
"Terminal TUI",
|
||||
"Embedded context",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('create arguments', () => {
|
||||
expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
|
||||
expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
|
||||
expect(parseCreateArgs(['--help']).help).toBe(true)
|
||||
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, tui, embed')
|
||||
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, embed')
|
||||
expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
|
||||
expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
|
||||
})
|
||||
@@ -208,7 +208,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
'--provider=deepseek-official',
|
||||
'--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash',
|
||||
'--interface=tui',
|
||||
'--interface=acp',
|
||||
'--pm=npm',
|
||||
'--no-install',
|
||||
'--link-workspace',
|
||||
@@ -247,7 +247,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash', '--interface=tui', '--pm=npm', '--no-install',
|
||||
'--model=deepseek-v4-flash', '--interface=acp', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
@@ -275,7 +275,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
await expect(new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=k',
|
||||
'--model=m', '--interface=tui', '--pm=npm', '--no-install',
|
||||
'--model=m', '--interface=acp', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
|
||||
@@ -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/sdk/helper/README.md
|
||||
README.md: 8c6ed9e87be0a97af67849793edb7fa30ffb33ab
|
||||
README.zh.md: e8844aab14dd0494144251de5e0a9f5cfcaf37be
|
||||
README.md: 416fcab9815e50ca662333eb6925cc37eb0c41c4
|
||||
README.zh.md: c50ec7aafa03377bd11759c50eeb2422a12fab68
|
||||
|
||||
@@ -8,9 +8,9 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities,
|
||||
|
||||
All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts.
|
||||
|
||||
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to TUI or Web compositions.
|
||||
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, and timeout policy. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to host compositions.
|
||||
|
||||
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
|
||||
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`, but rejects a config that references the removed `@deepseek-ai/dsh-tui` root or a subpath. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
|
||||
|
||||
`.env.example` follows the currently selected features. `.env` is append-only: helper may add a missing differently named variable, but never updates or removes existing content.
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
所有业务验证与文档验证都会在提交写入任何受影响文件前完成。提交会检测编辑会话打开后发生的外部修改,但在开始写入后,有意不提供跨文件回滚。
|
||||
|
||||
内置功能包括提供方、bash、app、持久化、HMR(热模块替换)、filesystem、todo、skill(技能)、web、subagent、工作流、压缩(compaction)、钩子、repeat-tool guard、timeout policy 和 ask-user。目录负责功能选项、必需和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记;create 与 config 使用同一注册表和配置器。ACP(Agent Client Protocol)应用选项只贡献自动化桥;交互式服务属于 TUI 或 Web 组合。
|
||||
内置功能包括提供方、bash、app、持久化、HMR(热模块替换)、filesystem、todo、skill(技能)、web、subagent、工作流、压缩(compaction)、钩子、repeat-tool guard 和 timeout policy。目录负责功能选项、必需和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记;create 与 config 使用同一注册表和配置器。ACP(Agent Client Protocol)应用选项只贡献自动化桥;交互式服务属于宿主组合。
|
||||
|
||||
`SdkProject.open()` 只要求根目录下的 `package.json` 和 `cordis.yml` 可读。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源结构不完整就是 `inconsistent`,无法自动修改。
|
||||
`SdkProject.open()` 只要求根目录下的 `package.json` 和 `cordis.yml` 可读,但会拒绝引用已移除的 `@deepseek-ai/dsh-tui` 包根或其子路径的配置。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源结构不完整就是 `inconsistent`,无法自动修改。
|
||||
|
||||
`.env.example` 跟随当前所选功能。`.env` 仅追加:helper 可以补充缺失且名称不同的变量,但绝不会更新或删除现有内容。
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/app
|
||||
*/
|
||||
|
||||
import { JsExpression } from '../../documents/cordis-yaml-file.ts'
|
||||
import { featureId } from '../../ids.ts'
|
||||
import type { ProjectProfile } from '../../project/types.ts'
|
||||
import type { ProjectProfile, RunInterface } from '../../project/types.ts'
|
||||
import {
|
||||
createAppPackageScripts,
|
||||
createAppProjectArtifacts,
|
||||
@@ -18,9 +17,7 @@ import {
|
||||
} from '../feature.ts'
|
||||
import { ProjectContribution, type ProjectResource } from '../resources.ts'
|
||||
import {
|
||||
cordisConfigEntry,
|
||||
npmCordisConfigEntry,
|
||||
optionalString,
|
||||
ownedTextFile,
|
||||
packageScript,
|
||||
requiredString,
|
||||
@@ -30,10 +27,10 @@ const ID = featureId('app')
|
||||
|
||||
function appProjectResources(
|
||||
profile: ProjectProfile,
|
||||
runInterface: 'acp' | 'tui' | 'embed',
|
||||
runInterface: RunInterface,
|
||||
): readonly ProjectResource[] {
|
||||
const context = createProjectTemplateContext(profile, runInterface)
|
||||
const scripts = createAppPackageScripts(context)
|
||||
const scripts = createAppPackageScripts()
|
||||
return [
|
||||
...createAppProjectArtifacts(context).map(document => (
|
||||
ownedTextFile(ID, document.relativePath, document.serialize())
|
||||
@@ -44,10 +41,10 @@ function appProjectResources(
|
||||
}
|
||||
|
||||
class AppOption extends FeatureOption {
|
||||
override readonly id: 'acp' | 'tui' | 'embed'
|
||||
override readonly id: RunInterface
|
||||
override readonly label: string
|
||||
|
||||
constructor(id: 'acp' | 'tui' | 'embed', label: string) {
|
||||
constructor(id: RunInterface, label: string) {
|
||||
super()
|
||||
this.id = id
|
||||
this.label = label
|
||||
@@ -57,7 +54,6 @@ class AppOption extends FeatureOption {
|
||||
override markerConfigEntries(): readonly { id: string; name: string }[] {
|
||||
switch (this.id) {
|
||||
case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }]
|
||||
case 'tui': return [{ id: 'tui', name: '@deepseek-ai/dsh-tui' }]
|
||||
case 'embed': return []
|
||||
}
|
||||
}
|
||||
@@ -66,7 +62,7 @@ class AppOption extends FeatureOption {
|
||||
override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean {
|
||||
if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile)
|
||||
return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop')
|
||||
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-tui')
|
||||
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')
|
||||
}
|
||||
|
||||
override contribution(profile: ProjectProfile): ProjectContribution {
|
||||
@@ -80,36 +76,13 @@ class AppOption extends FeatureOption {
|
||||
config: { model: profile.runtime.model },
|
||||
}, ['model'], config => requiredString(config, 'model')),
|
||||
])
|
||||
case 'tui':
|
||||
return new ProjectContribution([
|
||||
...appProjectResources(profile, this.id),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
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',
|
||||
config: {
|
||||
welcome: 'TUI agent ready. Give it a coding task.',
|
||||
sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'),
|
||||
},
|
||||
}, ['welcome', 'sessionId'], config => [
|
||||
...optionalString(config, 'welcome'),
|
||||
...config.sessionId instanceof JsExpression ? [] : requiredString(config, 'sessionId'),
|
||||
]),
|
||||
])
|
||||
case 'embed':
|
||||
return new ProjectContribution(appProjectResources(profile, this.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Required app selection represented by ACP, TUI, or embed options. */
|
||||
/** Required app selection represented by ACP or embed options. */
|
||||
export class AppFeature extends ExclusiveOptionFeature {
|
||||
override readonly id = ID
|
||||
override readonly summary = 'Run interface'
|
||||
@@ -117,7 +90,6 @@ export class AppFeature extends ExclusiveOptionFeature {
|
||||
override readonly requires = [featureId('spine')]
|
||||
override readonly options = [
|
||||
new AppOption('acp', 'ACP automation server'),
|
||||
new AppOption('tui', 'Terminal TUI'),
|
||||
new AppOption('embed', 'Embedded context'),
|
||||
]
|
||||
|
||||
|
||||
@@ -357,21 +357,5 @@ config:
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ask-user',
|
||||
summary: 'Ask the user from the model loop',
|
||||
mode: 'single',
|
||||
supportedInterfaces: ['tui'],
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'ask_user_question tool',
|
||||
default: true,
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-ask-user',
|
||||
package: '@deepseek-ai/dsh-tool-ask-user',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
]), profile)
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ class DefinedFeature extends Feature {
|
||||
this.required = spec.required ?? false
|
||||
this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id))
|
||||
this.suggests = (spec.suggests ?? []).map(featureId)
|
||||
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'tui', 'embed']
|
||||
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'embed']
|
||||
}
|
||||
|
||||
override defaultOptions(): readonly string[] {
|
||||
|
||||
@@ -113,7 +113,7 @@ export abstract class Feature {
|
||||
/** Features recommended during creation. */
|
||||
readonly suggests: readonly FeatureId[] = []
|
||||
/** Front doors under which this feature is meaningful. */
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'tui', 'embed']
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed']
|
||||
|
||||
/**
|
||||
* Options selected when installation has no override.
|
||||
|
||||
@@ -90,6 +90,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
this.profile = source.profile
|
||||
this.documents = source.cloneDocuments()
|
||||
for (const feature of registry.all()) {
|
||||
/* v8 ignore next -- no current builtin is interface-specific after TUI removal */
|
||||
if (!feature.isApplicable(this.profile)) continue
|
||||
const installation = feature.inspect(this)
|
||||
this.states.set(feature.id, {
|
||||
@@ -507,6 +508,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
const view = this.projectView(profile)
|
||||
for (const feature of this.registry.all()) {
|
||||
const state = this.states.get(feature.id)
|
||||
/* v8 ignore next 5 -- no current builtin is interface-specific after TUI removal */
|
||||
if (!feature.isApplicable(profile)) {
|
||||
if (state?.state === 'enabled') {
|
||||
throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`)
|
||||
@@ -549,7 +551,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
|
||||
private finalProfile(): ProjectProfile {
|
||||
const runInterface = this.states.get(featureId('app'))?.selection?.options[0]
|
||||
if (runInterface !== 'acp' && runInterface !== 'tui' && runInterface !== 'embed') return this.profile
|
||||
if (runInterface !== 'acp' && runInterface !== 'embed') return this.profile
|
||||
return { ...this.profile, runInterface }
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,11 @@ const OPTIONAL_DOCUMENTS = [
|
||||
] as const
|
||||
|
||||
function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui'
|
||||
|| entry.name.startsWith('@deepseek-ai/dsh-tui/'))) {
|
||||
throw new Error('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
}
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp'
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui')) return 'tui'
|
||||
return 'embed'
|
||||
}
|
||||
|
||||
@@ -146,7 +149,7 @@ export class SdkProject {
|
||||
static create(root: string, request: ProjectCreationRequest): SdkProject {
|
||||
const app = request.features.find(selection => selection.id === 'app')
|
||||
const selectedInterface = app?.options[0]
|
||||
if (selectedInterface !== 'acp' && selectedInterface !== 'tui' && selectedInterface !== 'embed') {
|
||||
if (selectedInterface !== 'acp' && selectedInterface !== 'embed') {
|
||||
throw new Error('project creation requires one app feature option')
|
||||
}
|
||||
const profile: ProjectProfile = {
|
||||
@@ -178,6 +181,7 @@ export class SdkProject {
|
||||
* Load an existing project from required and SDK-managed optional files.
|
||||
* @param root - existing project directory.
|
||||
* @returns disk-backed project snapshot.
|
||||
* @throws When the config references the removed `@deepseek-ai/dsh-tui` root or a subpath.
|
||||
*/
|
||||
static async open(root: string): Promise<SdkProject> {
|
||||
const absolute = resolve(root)
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
||||
import type { FeatureId } from '../ids.ts'
|
||||
|
||||
/** Runtime front door selected for a generated project. */
|
||||
export type RunInterface = 'acp' | 'tui' | 'embed'
|
||||
export type RunInterface = 'acp' | 'embed'
|
||||
|
||||
/** Values shared by the required provider and app features. */
|
||||
interface ProjectRuntimeOptions {
|
||||
|
||||
@@ -9,16 +9,10 @@ Built with the DeepSeek Harness SDK using the {{model}} model.
|
||||
|
||||
Run `{{packageManager}} start` and configure a programmatic ACP client to launch this project. Standard output is reserved for ACP JSON-RPC.
|
||||
{{else}}
|
||||
{{#if isTui}}
|
||||
## Run in a terminal
|
||||
|
||||
Run `{{packageManager}} start` to start the interactive agent.
|
||||
{{else}}
|
||||
## Embed the harness
|
||||
|
||||
Import and call the exported `main()` from `index.ts` in your host application.
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -8,47 +8,13 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
|
||||
|
||||
/** Boot this project's cordis.yml when invoked by dsh-scripts. */
|
||||
export async function main(boot: SdkBootContext) {
|
||||
{{#if isTui}}
|
||||
const model = boot.args.model
|
||||
if (typeof model !== 'string' || model.length === 0) throw new Error('TUI startup requires --model=<name>')
|
||||
const resume = boot.args.resume
|
||||
if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) {
|
||||
throw new Error('TUI startup requires --resume=<session-id>')
|
||||
}
|
||||
const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)
|
||||
process.env.DSH_SDK_SESSION_ID = sessionId
|
||||
{{/if}}
|
||||
const ctx = await startSDK(new URL('./cordis.yml', import.meta.url))
|
||||
{{#if isTui}}
|
||||
try {
|
||||
if (resume === undefined) {
|
||||
await ctx.agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: boot.cwd },
|
||||
agentOptions: { model },
|
||||
})
|
||||
} else {
|
||||
await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model },
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await ctx.fiber.dispose()
|
||||
} catch (disposeError) {
|
||||
throw new AggregateError([error, disposeError], 'TUI startup and cleanup failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
{{else}}
|
||||
{{#if isEmbed}}
|
||||
await ctx.agents.create({
|
||||
sessionId: SessionId(`main-session-${randomUUID()}`),
|
||||
meta: { cwd: boot.cwd },
|
||||
agentOptions: { model: {{modelLiteral}} },
|
||||
})
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface ProjectTemplateContext {
|
||||
model: string
|
||||
modelLiteral: string
|
||||
isAcp: boolean
|
||||
isTui: boolean
|
||||
isEmbed: boolean
|
||||
packageManager: PackageManagerName
|
||||
installArgs: string
|
||||
@@ -60,7 +59,6 @@ export function createProjectTemplateContext(
|
||||
model: profile.runtime.model,
|
||||
modelLiteral: JSON.stringify(profile.runtime.model),
|
||||
isAcp: runInterface === 'acp',
|
||||
isTui: runInterface === 'tui',
|
||||
isEmbed: runInterface === 'embed',
|
||||
packageManager: profile.packageManager.name,
|
||||
installArgs: profile.packageManager.installCommand().join(' '),
|
||||
@@ -104,10 +102,9 @@ export function createAppProjectArtifacts(
|
||||
}
|
||||
|
||||
/** Build package scripts owned by the selected app feature option. */
|
||||
export function createAppPackageScripts(context: ProjectTemplateContext): Readonly<Record<'dev' | 'start', string>> {
|
||||
const modelArg = context.isTui ? ` -- --model=${JSON.stringify(context.model)}` : ''
|
||||
export function createAppPackageScripts(): Readonly<Record<'dev' | 'start', string>> {
|
||||
return {
|
||||
dev: `dsh-sdk dev index.ts${modelArg}`,
|
||||
start: `dsh-sdk start index.js${modelArg}`,
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
start: 'dsh-sdk start index.js',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ overrides:
|
||||
expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory')
|
||||
expect(createBaselineProjectArtifacts({
|
||||
name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn',
|
||||
isAcp: false, isTui: false, isEmbed: true,
|
||||
isAcp: false, isEmbed: true,
|
||||
installArgs: 'install', buildArgs: 'build',
|
||||
}).map(document => document.relativePath)).toContain('.yarnrc.yml')
|
||||
expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name')
|
||||
|
||||
@@ -51,7 +51,7 @@ function selection(id: string, options: readonly string[], secrets?: Record<stri
|
||||
function request(
|
||||
extra: readonly FeatureSelection[] = [],
|
||||
plugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'tui' | 'embed' = 'tui',
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
bash: 'local' | 'sandbox' = 'local',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
@@ -115,16 +115,23 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(acp.readEnvironment('.env', 'KEY')).toBe('value')
|
||||
expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow()
|
||||
expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile)
|
||||
const tui = await make('dsh-open-tui', {}, `- id: provider
|
||||
await expect(make('dsh-open-tui', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
await expect(make('dsh-open-tui-subpath', {}, `- id: tui-prompt
|
||||
name: '@deepseek-ai/dsh-tui/prompt'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
const embedded = await make('dsh-open-embed', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
`, { 'yarn.lock': '' })
|
||||
expect(tui.profile.runInterface).toBe('tui')
|
||||
expect(tui.profile.runtime.model).toBe('provider-model')
|
||||
expect(tui.profile.packageManager.name).toBe('yarn')
|
||||
expect(tui.profile.name).toBe(tui.root.split('/').at(-1))
|
||||
expect(embedded.profile.runInterface).toBe('embed')
|
||||
expect(embedded.profile.runtime.model).toBe('provider-model')
|
||||
expect(embedded.profile.packageManager.name).toBe('yarn')
|
||||
expect(embedded.profile.name).toBe(embedded.root.split('/').at(-1))
|
||||
const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' })
|
||||
expect(pnpm.profile.packageManager.name).toBe('pnpm')
|
||||
const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n')
|
||||
@@ -134,10 +141,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app')
|
||||
await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n'))
|
||||
.rejects.toThrow('invalid packageManager field')
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
config: { model: '' }
|
||||
- id: provider
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [fallback-model] }
|
||||
`)
|
||||
@@ -166,28 +170,17 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const index = await readFile(join(project.root, 'index.ts'), 'utf8')
|
||||
expect(index).toContain('SdkBootContext')
|
||||
expect(index).toContain('agents.create')
|
||||
expect(index).toContain('boot.args.resume')
|
||||
expect(index).not.toContain('boot.args.resume')
|
||||
expect(index).not.toContain('AgentId')
|
||||
expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)')
|
||||
expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId')
|
||||
expect(index).toContain('resumeSessionId: sessionId')
|
||||
expect(index).toContain('await ctx.fiber.dispose()')
|
||||
expect(index).toContain("new AggregateError([error, disposeError], 'TUI startup and cleanup failed')")
|
||||
expect(index).toContain('SessionId(`main-session-${randomUUID()}`)')
|
||||
expect(project.packageManifest().scripts).toEqual({
|
||||
dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"',
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
build: 'dsh-sdk build',
|
||||
typecheck: 'tsc -b',
|
||||
start: 'dsh-sdk start index.js -- --model="deepseek-v4-flash"',
|
||||
start: 'dsh-sdk start index.js',
|
||||
config: 'dsh-sdk config',
|
||||
})
|
||||
expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
|
||||
expect(project.cordis.entry('tui')?.config?.sessionId).toMatchObject({
|
||||
source: 'process.env.DSH_SDK_SESSION_ID',
|
||||
})
|
||||
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')
|
||||
@@ -229,13 +222,12 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(app.selection).toEqual(selection('app', ['embed']))
|
||||
expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(committed.cordis.entry('acp')).toBeUndefined()
|
||||
expect(committed.cordis.entry('tui')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits the sandbox workspace-write example as inactive Cordis config', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'tui', 'sandbox')
|
||||
const creation = request([], [], 'embed', 'sandbox')
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
@@ -326,7 +318,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const modifiedRegistry = createBuiltinRegistry(modified.profile)
|
||||
expect(() => { modified.edit(modifiedRegistry).configureFeature(
|
||||
modifiedRegistry.get(featureId('app')),
|
||||
selection('app', ['tui']),
|
||||
selection('app', ['acp']),
|
||||
) }).toThrow('feature-owned file was modified: README.md')
|
||||
|
||||
const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8'))
|
||||
@@ -337,18 +329,6 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
.toContain('missing package.json script dev')
|
||||
})
|
||||
|
||||
it('rejects ask-user on non-interactive app interfaces', async () => {
|
||||
const project = await createCommitted([selection('ask-user', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const embed = project.edit(registry)
|
||||
embed.configureFeature(registry.get(featureId('app')), selection('app', ['embed']))
|
||||
await expect(embed.commit()).rejects.toThrow('feature ask-user is not available for embed')
|
||||
|
||||
const acp = project.edit(registry)
|
||||
acp.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
|
||||
await expect(acp.commit()).rejects.toThrow('feature ask-user is not available for acp')
|
||||
})
|
||||
|
||||
it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
@@ -359,8 +339,8 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
edit.disableFeature(todo)
|
||||
edit.configureFeature(todo, selection('todo', ['default']))
|
||||
edit.enableFeature(todo)
|
||||
expect(() => { edit.enableFeature(registry.get(featureId('ask-user'))) }).toThrow('not installed')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('ask-user'))) }).toThrow('not installed')
|
||||
expect(() => { edit.enableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.setCustomPluginDisabled('missing', true) }).toThrow('does not exist')
|
||||
const committed = await edit.commit()
|
||||
expect(committed.changes.enabledFeatures).toContain('todo')
|
||||
@@ -373,7 +353,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const edit = project.edit(registry)
|
||||
edit.setCustomPluginDisabled('sample', true)
|
||||
expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true)
|
||||
expect(() => { edit.setCustomPluginDisabled('tui', true) }).toThrow('builtin feature')
|
||||
expect(() => { edit.setCustomPluginDisabled('agent-loop', true) }).toThrow('builtin feature')
|
||||
const next = (await edit.commit()).project
|
||||
const enable = next.edit(createBuiltinRegistry(next.profile))
|
||||
enable.setCustomPluginDisabled('sample', false)
|
||||
@@ -468,8 +448,8 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
}
|
||||
const internals = edit as unknown as Internals
|
||||
const collidingEntry: ProjectResource = {
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:tui'),
|
||||
entry: { id: 'tui', name: 'other-package' }, ownedConfigKeys: [],
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:agent-loop'),
|
||||
entry: { id: 'agent-loop', name: 'other-package' }, ownedConfigKeys: [],
|
||||
}
|
||||
expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by')
|
||||
const existingFile: ProjectResource = {
|
||||
@@ -821,7 +801,7 @@ describe('extension points', () => {
|
||||
})
|
||||
expect(exclusive.defaultOptions(profile)).toEqual(['one'])
|
||||
expect(exclusive.isApplicable(profile)).toBe(true)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'tui' })).toBe(false)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'acp' })).toBe(false)
|
||||
expect(exclusive.requirements(selection('defined', ['one']))).toEqual([
|
||||
{ id: 'base' }, { id: 'option', options: ['required'] },
|
||||
])
|
||||
@@ -835,7 +815,7 @@ describe('extension points', () => {
|
||||
expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([])
|
||||
expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3)
|
||||
expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'tui' }))
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'acp' }))
|
||||
.toThrow('not available')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one')
|
||||
@@ -843,7 +823,7 @@ describe('extension points', () => {
|
||||
id: 'fixed', summary: 'Fixed', mode: 'single', options: [option],
|
||||
}])).toHaveLength(2)
|
||||
expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature')
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'tui' }))
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'acp' }))
|
||||
.toBeUndefined()
|
||||
class Unsupported extends FixedFeature {
|
||||
override readonly id = featureId('unsupported')
|
||||
@@ -926,12 +906,6 @@ describe('extension points', () => {
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp')
|
||||
expect(acpEntry?.entry.id).toBe('acp')
|
||||
expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
|
||||
const tuiEntry = builtins.get(featureId('app')).contribution(selection('app', ['tui']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'tui')
|
||||
expect(tuiEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([
|
||||
'sessionId must be a non-empty string',
|
||||
])
|
||||
const embedOption = app.options.find(option => option.id === 'embed')
|
||||
expect(embedOption?.markerConfigEntries(profile)).toEqual([])
|
||||
expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([
|
||||
@@ -939,7 +913,7 @@ describe('extension points', () => {
|
||||
])
|
||||
expect(embedOption?.matchesConfigEntries([
|
||||
{ id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
|
||||
{ id: 'tui', name: '@deepseek-ai/dsh-tui' },
|
||||
{ id: 'acp', name: '@deepseek-ai/dsh-acp' },
|
||||
], profile)).toBe(false)
|
||||
const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('feature configurator', () => {
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
runInterface: 'tui',
|
||||
runInterface: 'embed',
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ function targetRunInterface(
|
||||
desired: ReadonlyMap<string, NestedMultiSelectValue<string, string>>,
|
||||
): RunInterface {
|
||||
const selected = desired.get('feature:app')?.choices[0]
|
||||
return selected === 'acp' || selected === 'tui' || selected === 'embed' ? selected : current
|
||||
return selected === 'acp' || selected === 'embed' ? selected : current
|
||||
}
|
||||
|
||||
/** Reconcile one tree selection into domain commands, then review and commit once. */
|
||||
@@ -135,6 +135,7 @@ export class ConfigWorkflow {
|
||||
runInterface: targetRunInterface(project.profile.runInterface, desiredByTarget),
|
||||
}
|
||||
for (const feature of features) {
|
||||
/* v8 ignore next -- no current builtin is interface-specific after TUI removal */
|
||||
if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature))
|
||||
}
|
||||
|
||||
|
||||
@@ -84,15 +84,10 @@ Change file: package.json
|
||||
"choiceMode": "exclusive",
|
||||
"choices": [
|
||||
{
|
||||
"default": false,
|
||||
"default": true,
|
||||
"label": "ACP automation server",
|
||||
"value": "acp",
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
"label": "Terminal TUI",
|
||||
"value": "tui",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Embedded context",
|
||||
@@ -280,16 +275,6 @@ Change file: package.json
|
||||
"value": "feature:timeout-policy",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Ask the user from the model loop",
|
||||
"required": false,
|
||||
"value": "feature:ask-user",
|
||||
"warning": undefined,
|
||||
},
|
||||
],
|
||||
"showChanges": true,
|
||||
},
|
||||
|
||||
@@ -94,7 +94,7 @@ async function baseProject(): Promise<SdkProject> {
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: ['tui'] },
|
||||
{ id: featureId('app'), options: ['acp'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
],
|
||||
localPlugins: [],
|
||||
|
||||
@@ -85,7 +85,7 @@ function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () =>
|
||||
function creation(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'tui' | 'embed' = 'embed',
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
name: 'config-agent',
|
||||
@@ -107,7 +107,7 @@ function creation(
|
||||
async function committedProject(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'tui' | 'embed' = 'embed',
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
): Promise<SdkProject> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-'))
|
||||
temporary.push(root)
|
||||
@@ -526,7 +526,7 @@ describe('ConfigWorkflow', () => {
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[
|
||||
{ value: 'feature:provider', choices: ['custom'] },
|
||||
{ value: 'feature:app', choices: ['tui'] },
|
||||
{ value: 'feature:app', choices: ['acp'] },
|
||||
{ value: 'feature:persistence', choices: ['jsonl'] },
|
||||
],
|
||||
'https://provider.example/v1',
|
||||
@@ -537,31 +537,11 @@ describe('ConfigWorkflow', () => {
|
||||
const provider = result.commit?.project.cordis.entry('llm-pi-ai')
|
||||
expect(provider?.config?.apiKey).toBeDefined()
|
||||
expect(provider?.config?.baseURL).toBe('https://provider.example/v1')
|
||||
expect(result.commit?.project.cordis.entry('tui')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('acp')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables ask-user when switching its app interface to ACP', async () => {
|
||||
const project = await committedProject([
|
||||
{ id: featureId('ask-user'), options: ['default'] },
|
||||
], [], 'tui')
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const output = outputBuffer()
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[
|
||||
{ value: 'feature:provider', choices: ['deepseek-official'] },
|
||||
{ value: 'feature:app', choices: ['acp'] },
|
||||
{ value: 'feature:persistence', choices: ['jsonl'] },
|
||||
{ value: 'feature:ask-user', choices: ['default'] },
|
||||
],
|
||||
true,
|
||||
]), output.stream, async () => {})
|
||||
const result = await workflow.run(project, registry)
|
||||
expect(result.commit?.project.profile.runInterface).toBe('acp')
|
||||
expect(result.commit?.project.cordis.entry('tool-ask-user')?.disabled).toBe(true)
|
||||
expect(output.read()).toContain('Disable feature: ask-user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-sdk create', () => {
|
||||
|
||||
@@ -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/todo/README.md
|
||||
README.md: e16d1a3ff413d13d47f9b08a3cfddfedb76b5254
|
||||
README.zh.md: e3307d4f0acdb3f50db3f1e010286ad11668906a
|
||||
README.md: da85a5573507cd8bb1ac52f9614225819a479baf
|
||||
README.zh.md: 5b83fe4d2d59ff6401b6a455d8d5977d6a5e5122
|
||||
|
||||
@@ -8,4 +8,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|
||||
|---|---|---|
|
||||
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../ui/tui) and the host/client runtime render the durable list from session events.
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. Host/client runtimes render the durable list from session events.
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|---|---|---|
|
||||
| `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) |
|
||||
|
||||
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。
|
||||
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。宿主/客户端运行时会根据会话事件渲染该持久化列表。
|
||||
|
||||
@@ -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/todo/tool-todo/README.md
|
||||
README.md: 91f4bc6abd0c08f44f0a0a50393e4cdd5ddada70
|
||||
README.zh.md: b9582307ff5590bf34be91b776fa06841a68b41d
|
||||
README.md: 456d4a08d88b145d574362ffa0874faef9167b22
|
||||
README.zh.md: ec37682773e50c3f153525f6c2b6b6cce583144f
|
||||
|
||||
@@ -20,7 +20,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
|
||||
|
||||
## Rendering
|
||||
|
||||
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../ui/tui) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
|
||||
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [web client](../../client/ui-conversation) shows a plan strip plus a dedicated tool row off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
|
||||
|
||||
## Session projection
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
## 渲染
|
||||
|
||||
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../ui/tui)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条(web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
|
||||
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条和专属工具行([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
|
||||
|
||||
## 会话投影
|
||||
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: f08157d411a018141cdc21c487f81ae198f4de56
|
||||
README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e
|
||||
README.md: 76ca80e5685f70e73a6c46fe8d980f951b965ed3
|
||||
README.zh.md: 3958b0bdfb5d8cbab82f9fecfe54d12d462738ea
|
||||
|
||||
@@ -11,12 +11,11 @@ Human-facing channels and the out-of-process SDK server. These are **product** p
|
||||
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, answers `ctx.userInteraction`, and hosts effect-owned plugin overlays | `ctx.tui` (drives `ctx.agents`) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; [`jsonrpc`](jsonrpc/README.md) serves out-of-process SDK clients, while non-interactive one-shot tasks use `cli-demo`. [`commands`](commands/README.md) is the human-only discovery and dispatch plane consumed by TUI; command input and output do not become model messages.
|
||||
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. [`jsonrpc`](jsonrpc/README.md) serves out-of-process SDK clients, while non-interactive one-shot tasks use `cli-demo`. [`commands`](commands/README.md) is the human-only discovery and dispatch plane for interactive adapters; command input and output do not become model messages.
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
|
||||
|
||||
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`cli-demo`, `acp-demo`, `jsonrpc-demo`), each with its own entry contract. The product [`dsh`](../../apps/cli/README.md) CLI uses no demo bundle. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
|
||||
@@ -11,12 +11,11 @@
|
||||
| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` |
|
||||
| `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) |
|
||||
| `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents`) |
|
||||
| `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) |
|
||||
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
|
||||
|
||||
UI 集成属于由客户端驱动的插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务则使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的仅面向用户的发现与分派通道;命令输入和输出不会成为模型消息。
|
||||
UI 集成属于由客户端驱动的插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务则使用 `cli-demo`。[`commands`](commands/README.md) 是面向交互式适配器的仅面向用户的发现与分派通道;命令输入和输出不会成为模型消息。
|
||||
|
||||
`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。
|
||||
|
||||
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
|
||||
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`),各自拥有入口契约。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)不使用 demo bundle。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
|
||||
|
||||
@@ -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/app-boot/README.md
|
||||
README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216
|
||||
README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab
|
||||
README.md: dfb8b45b1c2ea06683b44242f77637db9a1b783c
|
||||
README.zh.md: db69e7609b7a5458b862a26a70ea21651fc519b9
|
||||
|
||||
@@ -16,7 +16,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR |
|
||||
| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
@@ -25,18 +25,18 @@ Loader settlement rejects import and lifecycle failures with the failing entry a
|
||||
|
||||
The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
## Personal config
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's Web and headless modes ([`apps/cli`](../../../apps/cli/README.md)); raw config mode and the demo bins boot their named trees without this layer. Two optional files:
|
||||
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the Web settings page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
Web keeps `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
@@ -25,18 +25,18 @@ Loader 结算会在导入或生命周期失败时 reject,并携带失败的配
|
||||
|
||||
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个已交付的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
## 个人配置
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 Web 与 headless 模式([`apps/cli`](../../../apps/cli/README.md))使用;原始配置模式与 demo bin 会在不加该层的情况下启动指定的配置树。这里有两个可选文件:
|
||||
|
||||
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
|
||||
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
|
||||
TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# AGENTS.md — TUI package
|
||||
|
||||
These rules supplement the package conventions in [packages/AGENTS.md](../../AGENTS.md).
|
||||
|
||||
- **Present TUI designs in tmux, not in the session transcript.** When tmux is available, run the assembled TUI in a pane of the same window the session runs in and point the user at it; print a rendering into the transcript only as a fallback.
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: a84b9b5c75581ba2aff97a7e6085ecb697e911b0
|
||||
README.zh.md: 4c6bd757e9aed875a12ce925b3a0acef5d20d623
|
||||
@@ -1,177 +0,0 @@
|
||||
# @deepseek-ai/dsh-tui
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead.
|
||||
|
||||
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [file-reference autocomplete Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md) owns path-only `@file` behavior; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
|
||||
|
||||
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions inline between the transcript/status area and the editor. The question panel shows progress, numbered options, wrapped labels, and separately indented descriptions; it obeys both `maxQuestionOptions` and `questionDialogMaxHeight`, marks hidden options with `↑ N more` / `↓ N more`, and uses Page Up / Page Down to page long question/detail content before an individually oversized selected block while keeping the editor visible. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed.
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation; both enter the inbox and pass through `agent/pre-step` after the loop claims their batch.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it.
|
||||
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
|
||||
|
||||
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows read no whole logs: when the optional projection cache is mounted, titles come from the live projection registry or the durable checkpoint row, with a cold read folding only the log tail since the checkpoint (written back so the next scan is zero-I/O, bounded by `resumeScanConcurrency`); a composition without the cache falls back to one bounded batch title read over the logs. Candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
|
||||
|
||||
Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
|
||||
|
||||
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `welcome` | — | Banner subtitle line until the session has a logged title; unset, the banner sweeps in with no subtitle |
|
||||
| `sessionId` | `main` | Exact shared agent/session identity driven by the terminal |
|
||||
| `showReasoning` | `true` | Render reasoning blocks |
|
||||
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
|
||||
| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback |
|
||||
| `maxQuestionOptions` | `8` | Maximum option blocks visible at once; the row bound may reduce this further |
|
||||
| `maxModelOptions` | `8` | Visible models in the model selector |
|
||||
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
|
||||
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
|
||||
| `questionDialogMaxHeight` | `20` | Maximum question-panel rows, further bounded to retain the editor |
|
||||
| `modelDialogWidth` | `76` | Model-selector width in columns |
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `detailsDialogWidth` | `72` | Transcript-details selector width in columns |
|
||||
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
|
||||
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
|
||||
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion |
|
||||
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
|
||||
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
|
||||
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
config:
|
||||
welcome: 'Coding agent ready.'
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
maxDiffEditLength: 1000
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
|
||||
|
||||
## Color
|
||||
|
||||
Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike. The startup banner gradient and the official mark's exact `#4D6BFE` ink are the two deliberate truecolor brand exceptions. Body text keeps the terminal's default foreground rather than a fixed shade.
|
||||
|
||||
There is one role per visual meaning: `dim` is the single recessed tone, `accent` the single interaction emphasis, and `brand` the DeepSeek mark's standard-ANSI fallback, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
|
||||
|
||||
Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card with both sides available colors and counts exact added `+` and removed `-` lines, while unchanged context stays dim and uncounted. If exact comparison exceeds `maxDiffEditLength`, the card renders each old-side row as removed and each new-side row as added, marks the footer approximate, and caches that fallback for later redraws. When `oldText` is unavailable, including pending writes and replay fallbacks as well as creates, every non-empty new-side row is shown and counted as added; that count does not prove the rows were absent from an existing file. Empty new content produces no synthetic `+ ` row. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Interactive prompt input
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.followup()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, the logged title, cards, Markdown rendering, status lines, plans, and help text add no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### File-reference autocomplete
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A selected file remains ordinary user text such as `@src/index.ts` or `@"docs/design notes.md"`; autocomplete adds no content block, durable context, or special reference payload. When `read` is registered, every request from this TUI agent also contains the following fixed system-prompt section. The model decides whether the task requires the file contents and calls `read` through the normal tool loop when it does; a path alone is not evidence that the file was inspected.
|
||||
|
||||
##### Exact system-prompt text
|
||||
|
||||
```markdown
|
||||
Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Autocomplete itself adds no tokens. The selected path contributes only its ordinary user-text tokens; the fixed instruction contributes system-prompt tokens whenever `read` is available. File contents consume context only after a model-selected `read` call returns them.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The fixed instruction is part of the stable system-prompt prefix and is reusable across turns. Each selected path is append-only user text; a later `read` result appends the requested contents through the ordinary tool transcript.
|
||||
|
||||
### Session model selection
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model route in prompt variables and the selected provider/model/reasoning-effort target in request routing.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The selector adds no messages. A target change may alter interpolated system-prompt text and sends subsequent requests to the selected model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed.
|
||||
|
||||
### Manual skill invocation
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. User-disabled skills are omitted from autocomplete and rejected before exact-name loading; the loaded definition is rechecked for a policy race. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The rendered skill block and trailing instructions are retained as one user turn under the agent loop's normal session-history and compaction rules; a repeated invocation appends the body again.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Interactive user-question answers
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Waiting and terminal overlays add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. The all-workspaces scope makes this reachable in one step, since a session another host is driving in a different directory is now selectable. Deployments that can run concurrent hosts must coordinate ownership outside the TUI.
|
||||
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
|
||||
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
|
||||
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback.
|
||||
- **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again.
|
||||
- **File discovery is host-workspace discovery** — autocomplete reads the TUI process's session `cwd`, while the selected text is later interpreted by the configured `read` tool. Deployments that mount a remote or virtual filesystem must keep those namespaces aligned or provide another completion surface.
|
||||
- **File search uses explicit directory exclusions, not ignore files** — `.git` and `node_modules` are excluded by default and deployments may configure more basenames, but `.gitignore` and `.ignore` are not interpreted. Directory symlinks are not traversed.
|
||||
@@ -1,177 +0,0 @@
|
||||
# @deepseek-ai/dsh-tui
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) 构建。它要求 stdin 和 stdout 均为 TTY;脚本和 Loader pipe 应改用单次执行的 [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app。
|
||||
|
||||
已实现的 [TUI 功能 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)持有终端入口决策;[文件引用自动补全 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md)持有仅路径的 `@file` 行为;[终端状态快照 Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)持有其验证策略。
|
||||
|
||||
支持 macOS、Linux 和 Windows 上的交互式终端。Windows 使用 pi-tui 原生控制台 VT 输入处理;[Windows 支持 Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md)持有平台决策与 ConPTY 进程验证。
|
||||
|
||||
本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。
|
||||
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
|
||||
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在 transcript/状态区域与编辑器之间内联展示 `ctx.userInteraction` 问题。问题面板会显示进度、编号选项、换行标签和另行缩进的描述;它同时遵守 `maxQuestionOptions` 和 `questionDialogMaxHeight`,用 `↑ N more`/`↓ N more` 标记隐藏选项,并在保持编辑器可见的同时,通过 Page Up 和 Page Down 先分页浏览过长的问题/详情内容,再分页浏览单个超大的选中块。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
|
||||
|
||||
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。
|
||||
|
||||
在模型输出、会话事件、工具 presenter、问题、配置或诊断到达 pi-tui 的 ANSI 感知 renderer 或终端标题前,TUI 会把换行之外的 C0 和 C1 控制字符渲染为可见 `\xNN` 文本。这些来源无法添加终端控制序列;终端渲染与样式仍由 TUI 和 pi-tui 持有。
|
||||
|
||||
在 token 边界输入 `@` 会搜索会话工作目录下的文件和目录。没有路径的模糊查询使用可复用的有界工作区索引;包含 `/` 的查询直接列出该目录,选择文件夹后会保持补全开启以继续深入。含空白的路径会插入为 `@"path with spaces"`。选择文件只会插入其路径和一个尾随空格:TUI 不会读取文件、附加隐藏上下文,也不会把路径替换为引用对象。注册模型侧 `read` 工具后,TUI 会添加一条固定系统提示词指令,要求模型在需要显式路径内容时读取该路径。
|
||||
|
||||
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`;二者都会进入 inbox,并在循环领取相应批次后经过 `agent/pre-step`。
|
||||
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill,按精确名称调用时也会拒绝用户策略禁用的 skill。
|
||||
|
||||
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。
|
||||
|
||||
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
|
||||
|
||||
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
|
||||
|
||||
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据不读取任何完整日志:挂载可选的投影缓存时,标题来自实时投影注册表或持久化 checkpoint 行,冷读取只折叠 checkpoint 之后的日志尾部(并写回,使下次扫描零 I/O,受 `resumeScanConcurrency` 约束);未挂载缓存的组合回退到一次对日志的有界批量标题读取。候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
|
||||
|
||||
选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
|
||||
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
|
||||
|
||||
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `welcome` | 未设置 | 会话出现已记录标题前使用的 banner 副标题行;未设置时,banner 进入时没有副标题 |
|
||||
| `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 |
|
||||
| `showReasoning` | `true` | 渲染 reasoning 块 |
|
||||
| `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 |
|
||||
| `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 |
|
||||
| `maxQuestionOptions` | `8` | 一次最多可见的选项块数;行数边界可能进一步减少可见数量 |
|
||||
| `maxModelOptions` | `8` | 模型选择器中可见的模型数 |
|
||||
| `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 |
|
||||
| `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 |
|
||||
| `questionDialogMaxHeight` | `20` | 问题面板最大行数,会进一步受限以保留编辑器 |
|
||||
| `modelDialogWidth` | `76` | 模型选择器宽度(列数) |
|
||||
| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 |
|
||||
| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) |
|
||||
| `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 |
|
||||
| `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 |
|
||||
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename |
|
||||
| `showHardwareCursor` | `false` | 在 pi-tui 的 IME marker 处显示硬件 cursor |
|
||||
| `color` | `true` | 应用内置 ANSI palette(参见[颜色](#color)) |
|
||||
| `title` | `DeepSeek Harness` | 终端窗口标题的产品后缀。 |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
config:
|
||||
welcome: 'Coding agent ready.'
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
maxDiffEditLength: 1000
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。用户退出会先 dispose 应用根上下文以关闭同级资源,再退出进程;五秒兜底可避免某个卡住的 disposer 困住进程。
|
||||
|
||||
## 颜色
|
||||
|
||||
TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读。启动 banner 渐变与官方标志使用的精确 `#4D6BFE` 色值是两处有意保留的真彩色品牌例外。正文使用终端默认前景色,而非固定色调。
|
||||
|
||||
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
|
||||
|
||||
成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。当前后两侧文本均可用时,diff 卡片会为精确识别出的新增 `+` 行和删除 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。如果精确比较超出 `maxDiffEditLength`,卡片会把旧侧每一行渲染为删除行、把新侧每一行渲染为新增行,将页脚标记为近似结果,并缓存该回退结果供后续重绘使用。当 `oldText` 不可用时(包括待处理写入、回放回退以及文件创建),新侧的每个非空行都会显示并计作新增行;该计数不能证明这些行原先不存在于已有文件中。新内容为空时,不会补出虚构的 `+ ` 行。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 交互式提示词输入
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每次非空普通编辑器提交都会成为一个文本块;目标 agent 空闲时通过 `agent.followup()` 发送,运行时通过 `agent.steer()` 发送。会话 mention 会变为可读的 `@label` 文本,加上由 [`dsh-session-reference`](../../context/session-reference/README.md) 定义的持久不受信任上下文;其完整 JSON 隐藏在紧凑引用卡片之后。斜杠命令和按键绑定仅用于 TUI;命令结果仍是终端通知。命令生产方可以调度单独的 agent 输入,例如 `/plan [message]` 接受的可选消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
提交的文本会按 agent loop 的普通会话历史与压缩规则保留。Header、已记录标题、卡片、Markdown 渲染、状态行、计划和帮助文本不会增加 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 文件引用自动补全
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
所选文件仍是普通 user 文本,例如 `@src/index.ts` 或 `@"docs/design notes.md"`;自动补全不会添加内容块、持久上下文或特殊引用 payload。注册 `read` 后,此 TUI agent 的每个请求还会包含下方固定系统提示词段落。模型会判断任务是否需要文件内容,并在需要时通过普通工具循环调用 `read`;只有路径不能证明文件已经过检查。
|
||||
|
||||
##### 精确系统提示词文本
|
||||
|
||||
```markdown
|
||||
Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
自动补全本身不增加 token。所选路径只贡献普通 user 文本 token;`read` 可用时,固定指令会贡献系统提示词 token。只有模型选择的 `read` 调用返回文件内容后,这些内容才会占用上下文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
固定指令属于稳定系统提示词前缀,可以跨轮次复用。每个所选路径都是仅追加 user 文本;后续 `read` 结果通过普通工具 transcript 追加所请求内容。
|
||||
|
||||
### 会话模型选择
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量中收到所选提供方/模型路由,并在请求路由中收到所选提供方/模型/推理强度目标。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
选择器不会添加消息。更改目标可能改变插值后的系统提示词文本,并把后续请求发送给所选模型。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
更改提供方或模型会进入该目标的缓存域;不假定不同目标间可以复用缓存。
|
||||
|
||||
### 手动调用 skill
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable`,`invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 不出现在自动补全中,按精确名称调用时也会在加载前被拒绝;为防止策略竞态,加载后的定义还会再次接受检查。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。skill 服务是可选 peer;这项策略检查仅使用其类型契约,不引入运行时包依赖。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
渲染后的 skill 块与尾随指令会作为一个 user 轮次保留,并遵循 agent loop 的普通会话历史和压缩规则;重复调用会再次追加正文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 交互式用户问题回答
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
等待和终端 overlay 不增加 token;已解析回答或错误只会通过调用工具或插件的结果对模型可见。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。所有工作区作用域让这一情形一步即可触及,因为另一个宿主正在其他目录驱动的会话现在也可被选中。能够运行并发宿主的部署必须在 TUI 外协调所有权。
|
||||
- **一个已配置会话持有 transcript 和编辑器**:其他 agent 的问题仍可使用共享 overlay 提供方,但会话渲染与提示词输入仍绑定到 `sessionId`。
|
||||
- **工具卡片是文本终端展示**:终端、diff 与通用卡片使用工具持有的标题/内容,但会话内容目前没有用于内联图像渲染的图像块。
|
||||
- **有意不支持非 TTY 运行**:需要自动化的 app bundle 必须组合单次执行或服务器入口(`dsh-cli-demo`、`dsh-acp`),而不能依赖内部回退。
|
||||
- **手动 `/skill:` 调用总会重新加载完整 skill 正文**:TUI 不会检测会话中是否已存在某项 skill,因此重复调用会再次追加其指令。
|
||||
- **文件发现只发现宿主工作区**:自动补全读取 TUI 进程的会话 `cwd`,所选文本随后由已配置 `read` 工具解释。挂载远程或虚拟文件系统的部署必须对齐这些 namespace,或提供其他补全接口。
|
||||
- **文件搜索使用显式目录排除项,而非 ignore 文件**:默认排除 `.git` 和 `node_modules`,部署还可以配置更多 basename,但不会解释 `.gitignore` 和 `.ignore`。目录 symlink 不会遍历。
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tui",
|
||||
"description": "Interactive pi-tui terminal front door for DeepSeek Harness agents",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"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"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@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-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "^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-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-skill": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "0.80.7",
|
||||
"diff": "^9.0.0",
|
||||
"saxes": "6.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "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-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
/**
|
||||
* Host-workspace discovery for TUI `@file` completion. The index contains
|
||||
* paths only: selected values remain ordinary prompt text and file contents
|
||||
* stay behind the model-facing `read` tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tui/chat/file-autocomplete
|
||||
*/
|
||||
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** Default maximum file and directory candidates rendered for one query. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
|
||||
/** Default maximum entries retained in one workspace search index. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
|
||||
/** Directory basenames omitted from traversal unless the deployment overrides them. */
|
||||
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
|
||||
|
||||
/** Resolved limits and exclusions for one TUI workspace index. */
|
||||
export interface FileSearchConfig {
|
||||
/** Maximum ranked candidates returned for one query. */
|
||||
maxResults: number
|
||||
/** Maximum indexed files and directories. */
|
||||
maxEntries: number
|
||||
/** Directory basenames never traversed or offered. */
|
||||
excludedDirectories: readonly string[]
|
||||
}
|
||||
|
||||
/** One path-only completion candidate inside the session cwd. */
|
||||
export interface FileSearchCandidate {
|
||||
/** User-facing path accepted by the normal prompt and filesystem tools. */
|
||||
path: string
|
||||
/** Directories keep completion open; files finish the mention. */
|
||||
kind: 'file' | 'directory'
|
||||
}
|
||||
|
||||
/** Active `@` token ending at the editor cursor. */
|
||||
export interface ActiveAtToken {
|
||||
/** Complete token replaced when the user accepts a completion. */
|
||||
prefix: string
|
||||
/** Path query after `@` or `@"`. */
|
||||
query: string
|
||||
/** Whether the user opened a quoted path. */
|
||||
quoted: boolean
|
||||
}
|
||||
|
||||
interface IndexedPath extends FileSearchCandidate {}
|
||||
|
||||
interface RankedPath {
|
||||
candidate: FileSearchCandidate
|
||||
score: number
|
||||
}
|
||||
|
||||
interface IndexGeneration {
|
||||
controller: AbortController
|
||||
promise: Promise<IndexedPath[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
|
||||
* inside another token, such as an email address, is not a completion trigger.
|
||||
* @param line - current editor line.
|
||||
* @param cursorCol - cursor column within that line.
|
||||
* @returns the active token, or `undefined` outside an `@` token.
|
||||
*/
|
||||
export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
|
||||
const beforeCursor = line.slice(0, cursorCol)
|
||||
const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
|
||||
if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
|
||||
return { prefix: quoted[1], query: quoted[2], quoted: true }
|
||||
}
|
||||
const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
|
||||
if (plain?.[1] === undefined || plain[2] === undefined) return undefined
|
||||
return { prefix: plain[1], query: plain[2], quoted: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a selected path as prompt text. Whitespace uses Pi's quoted
|
||||
* `@"path"` grammar; directories retain a trailing slash so completion can
|
||||
* descend another level.
|
||||
* @param candidate - selected file or directory.
|
||||
* @param preserveQuote - retain an explicitly opened quote even when unnecessary.
|
||||
* @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
|
||||
*/
|
||||
export function formatFileMention(
|
||||
candidate: FileSearchCandidate,
|
||||
preserveQuote: boolean,
|
||||
): string | undefined {
|
||||
const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
|
||||
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
||||
const quoted = preserveQuote || /\s/u.test(path)
|
||||
if (!quoted) return `@${path}`
|
||||
return `@"${path}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
||||
* Directory-scoped queries list live state; bare fuzzy queries share one
|
||||
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
||||
*/
|
||||
export class WorkspaceFileSearch {
|
||||
private readonly excludedDirectories: ReadonlySet<string>
|
||||
private generation: IndexGeneration | undefined
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly config: FileSearchConfig,
|
||||
) {
|
||||
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
|
||||
throw new Error('file search maxResults must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
|
||||
throw new Error('file search maxEntries must be a positive safe integer')
|
||||
}
|
||||
if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
|
||||
throw new Error('file search excludedDirectories entries must be non-empty directory basenames')
|
||||
}
|
||||
this.excludedDirectories = new Set(config.excludedDirectories)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return ranked path candidates for the current token.
|
||||
* @param rawQuery - path text following `@` or `@"`.
|
||||
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
||||
* @returns at most `maxResults` deterministic candidates.
|
||||
*/
|
||||
async list(rawQuery: string, signal: AbortSignal): Promise<FileSearchCandidate[]> {
|
||||
signal.throwIfAborted()
|
||||
if (this.disposed) return []
|
||||
const query = rawQuery.replaceAll('\\', '/')
|
||||
const slash = query.lastIndexOf('/')
|
||||
if (query === '' || slash >= 0) {
|
||||
const directory = slash < 0 ? '' : query.slice(0, slash + 1)
|
||||
const fragment = slash < 0 ? '' : query.slice(slash + 1)
|
||||
return this.listDirectory(directory, fragment, signal)
|
||||
}
|
||||
const indexed = await waitForPromise(this.ensureIndex(), signal)
|
||||
return rankCandidates(
|
||||
indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)),
|
||||
query,
|
||||
this.config.maxResults,
|
||||
)
|
||||
}
|
||||
|
||||
/** Discard the current index so the next bare query observes a fresh tree. */
|
||||
invalidate(): void {
|
||||
this.generation?.controller.abort(new Error('file search index invalidated'))
|
||||
this.generation = undefined
|
||||
}
|
||||
|
||||
/** Abort traversal and make later queries return no candidates. */
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private ensureIndex(): Promise<IndexedPath[]> {
|
||||
if (this.generation !== undefined) return this.generation.promise
|
||||
const controller = new AbortController()
|
||||
const generation = {
|
||||
controller,
|
||||
promise: Promise.resolve([] as IndexedPath[]),
|
||||
} satisfies IndexGeneration
|
||||
generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => {
|
||||
/* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
|
||||
if (this.generation === generation) this.generation = undefined
|
||||
throw error
|
||||
})
|
||||
this.generation = generation
|
||||
return generation.promise
|
||||
}
|
||||
|
||||
private async scanWorkspace(signal: AbortSignal): Promise<IndexedPath[]> {
|
||||
const indexed: IndexedPath[] = []
|
||||
const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }]
|
||||
for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
|
||||
signal.throwIfAborted()
|
||||
const directory = directories[cursor]
|
||||
/* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
|
||||
if (directory === undefined) {
|
||||
throw new Error('file search selected a missing directory')
|
||||
}
|
||||
const entries = await readDirectory(directory.absolute, signal)
|
||||
for (const entry of entries) {
|
||||
signal.throwIfAborted()
|
||||
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
indexed.push({ path, kind: 'directory' })
|
||||
directories.push({ absolute: join(directory.absolute, entry.name), relative: path })
|
||||
} else if (entry.isFile()) {
|
||||
indexed.push({ path, kind: 'file' })
|
||||
}
|
||||
if (indexed.length >= this.config.maxEntries) break
|
||||
}
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
private async listDirectory(
|
||||
displayDirectory: string,
|
||||
fragment: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FileSearchCandidate[]> {
|
||||
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
|
||||
const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal)
|
||||
if (absolute === undefined) return []
|
||||
const entries = await readDirectory(absolute, signal)
|
||||
const candidates: FileSearchCandidate[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' })
|
||||
} else if (entry.isFile()) {
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' })
|
||||
}
|
||||
}
|
||||
return rankCandidates(candidates, fragment, this.config.maxResults)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDisplayDirectory(
|
||||
root: string,
|
||||
displayDirectory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
const resolvedRoot = resolve(root)
|
||||
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
|
||||
const fromRoot = relative(resolvedRoot, absolute)
|
||||
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
|
||||
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
||||
if (isAbsolute(fromRoot)) return undefined
|
||||
let current = resolvedRoot
|
||||
for (const segment of fromRoot.split(sep).filter(Boolean)) {
|
||||
signal.throwIfAborted()
|
||||
current = join(current, segment)
|
||||
try {
|
||||
const status = await lstat(current)
|
||||
signal.throwIfAborted()
|
||||
if (status.isSymbolicLink() || !status.isDirectory()) return undefined
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
async function readDirectory(absolute: string, signal: AbortSignal) {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const entries = await readdir(absolute, { withFileTypes: true })
|
||||
signal.throwIfAborted()
|
||||
return entries.sort((left, right) => compareText(left.name, right.name))
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
// An unreadable/missing subtree contributes no candidates; other readable
|
||||
// branches remain useful and autocomplete is advisory.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function visibleForGlobalQuery(path: string, query: string): boolean {
|
||||
if (query.startsWith('.') || query.includes('/.')) return true
|
||||
return !path.split('/').some(segment => segment.startsWith('.'))
|
||||
}
|
||||
|
||||
function rankCandidates(
|
||||
candidates: readonly FileSearchCandidate[],
|
||||
query: string,
|
||||
limit: number,
|
||||
): FileSearchCandidate[] {
|
||||
const ranked: RankedPath[] = []
|
||||
for (const candidate of candidates) {
|
||||
const score = scoreCandidate(candidate, query)
|
||||
if (score !== undefined) ranked.push({ candidate, score })
|
||||
}
|
||||
ranked.sort((left, right) =>
|
||||
right.score - left.score
|
||||
|| kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|
||||
|| (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|
||||
|| compareText(left.candidate.path, right.candidate.path))
|
||||
return ranked.slice(0, limit).map(entry => entry.candidate)
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined {
|
||||
if (query === '') return 0
|
||||
const path = candidate.path.toLowerCase()
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
const needle = query.toLowerCase()
|
||||
const directoryBonus = candidate.kind === 'directory' ? 25 : 0
|
||||
if (name === needle) return 1_000 + directoryBonus
|
||||
if (name.startsWith(needle)) return 900 + directoryBonus
|
||||
if (name.includes(needle)) return 700 + directoryBonus
|
||||
if (path.includes(needle)) return 500 + directoryBonus
|
||||
const subsequence = subsequenceScore(path, needle)
|
||||
return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
|
||||
}
|
||||
|
||||
function subsequenceScore(target: string, query: string): number | undefined {
|
||||
let targetIndex = 0
|
||||
let gap = 0
|
||||
for (const character of query) {
|
||||
const found = target.indexOf(character, targetIndex)
|
||||
if (found < 0) return undefined
|
||||
gap += found - targetIndex
|
||||
targetIndex = found + 1
|
||||
}
|
||||
return Math.max(0, 100 - gap)
|
||||
}
|
||||
|
||||
function kindRank(kind: FileSearchCandidate['kind']): number {
|
||||
return kind === 'directory' ? 0 : 1
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
/* v8 ignore next -- entries and candidates are unique; host enumeration
|
||||
* order determines which comparison direction sort requests. */
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function waitForPromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
/* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
|
||||
if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted'))
|
||||
return new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolvePromise(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
rejectPromise(errorReason(error, 'file search index failed'))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function errorReason(reason: unknown, fallback: string): Error {
|
||||
return reason instanceof Error ? reason : new Error(fallback, { cause: reason })
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* Zero-state helpers for the interactive chat channel: prompt-directory and
|
||||
* Git-branch formatting, transcript/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 { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
|
||||
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/** 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 branch = execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: scrubbedParentEnv(),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-call ids whose owning assistant message is append-origin, so its tool
|
||||
* cards stay paired in the transcript after a replacement shadowed the message
|
||||
* on the model surface.
|
||||
* @param session - session whose events to scan.
|
||||
* @returns the set of transcript tool-call ids.
|
||||
*/
|
||||
export function transcriptToolCallIds(session: Session): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') ids.add(block.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event is a landed compaction checkpoint. Recognition goes through
|
||||
* {@link isCompactCheckpointSource} — the compaction seam's backend-independent
|
||||
* contract for the source every backend stamps on its replacement user message —
|
||||
* rather than the shape of the replacement. Other replacements (a pruned
|
||||
* `tool/result`, a regenerated `assistant/message`) rewrite one node for the
|
||||
* model and mark no boundary in the conversation.
|
||||
*
|
||||
* Both current call sites already test the replacement themselves. The check
|
||||
* keeps the exported predicate true to its name for a third caller, rather than
|
||||
* making that caller repeat it.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event compacted a surface range.
|
||||
*/
|
||||
export function isCompactCheckpoint(event: SessionEvent): boolean {
|
||||
return event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source)
|
||||
&& isReplacementSurfaceEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* 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, LlmError, 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
|
||||
/** Remove the adapter-registration listener (channel detach). */
|
||||
detach(): 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()
|
||||
|
||||
// A route whose adapter has not registered yet. Loader activation order is
|
||||
// service-driven, so the TUI can mount before a configured adapter plugin
|
||||
// activates; that transient NO_ADAPTER is not an error — the resolution
|
||||
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
|
||||
let awaitingAdapter = false
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
awaitingAdapter = false
|
||||
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') {
|
||||
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
|
||||
awaitingAdapter = true
|
||||
return
|
||||
}
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
contextWindow = result.contextWindow
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
// The wait cannot go stale against `target.current`: every target change
|
||||
// re-enters resolveContextWindow, which clears it. A commit that still
|
||||
// lacks the route parks the resolution again rather than erroring, so
|
||||
// unrelated topology changes stay silent. The disposer rides the channel's
|
||||
// detachListeners() through detach(), matching the sibling listeners.
|
||||
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
|
||||
if (deps.isDisposed() || !awaitingAdapter) return
|
||||
resolveContextWindow(target.current)
|
||||
})
|
||||
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
|
||||
},
|
||||
detach(): void {
|
||||
disposeAdapterListener()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* 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 interface QuestionQueueDeps extends ChatChannelDeps {
|
||||
/** Current row budget after reserving the editor. */
|
||||
questionMaxHeight(): number
|
||||
}
|
||||
|
||||
/** 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,
|
||||
() => deps.questionMaxHeight(),
|
||||
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,
|
||||
},
|
||||
}, 'inline')
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
/**
|
||||
* Session-resume sub-controller for the interactive chat channel: the
|
||||
* `/resume` selector, one metadata-plus-title scan that tolerates a corrupt
|
||||
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
|
||||
* @module @deepseek-ai/dsh-tui/chat/resume
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
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 type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionQueryService,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
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 {
|
||||
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 runtime: TuiRuntime
|
||||
/**
|
||||
* The optional session-query service, re-read at each use. `sessionQuery` is
|
||||
* mounted by an independent plugin, and a flat config tree gives no ordering
|
||||
* guarantee between it and this front door, so a value captured once at
|
||||
* construction can be `undefined` even though the service arrives moments later.
|
||||
*/
|
||||
readonly sessionQuery: (this: void) => 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 searchable session selector, scoped to this workspace until the user widens it. */
|
||||
showResume(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function createResumeController(deps: ResumeControllerDeps): ResumeController {
|
||||
const {
|
||||
ctx, agent, runtime, resolved, palette, overlayManager,
|
||||
sessionQuery, ui, editor,
|
||||
} = deps
|
||||
let resumeOverlay: TuiOverlaySession | undefined
|
||||
let resumeInFlight = false
|
||||
let resumeScan = 0
|
||||
|
||||
/** Label any session's own workspace the way the prompt labels the current one. */
|
||||
const workspaceLabel = (cwd: string | undefined): string =>
|
||||
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
|
||||
|
||||
/** Summarize one record from metadata and its batch-folded title. */
|
||||
const summarize = (
|
||||
record: SessionRecord,
|
||||
title: string | undefined,
|
||||
lastActivityAt: number | undefined,
|
||||
): ResumeCandidate => summarizeResumeCandidate(
|
||||
record,
|
||||
title,
|
||||
lastActivityAt,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
workspaceLabel,
|
||||
)
|
||||
|
||||
/** The disabled fallback row for a session whose title read failed. */
|
||||
const unreadableCandidate = (
|
||||
record: SessionRecord,
|
||||
lastActivityAt: number | undefined,
|
||||
error: unknown,
|
||||
): ResumeCandidate => ({
|
||||
record,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: lastActivityAt ?? record.header.createdAt,
|
||||
currentWorkspace: record.header.cwd === agent.session.header.cwd,
|
||||
workspaceLabel: workspaceLabel(record.header.cwd),
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
})
|
||||
|
||||
/**
|
||||
* Metadata-only activity time: a live session's last in-memory event time,
|
||||
* otherwise the persisted artifact's mtime. Never reads a log, so browsing
|
||||
* cost stays independent of log size; any append (including bookkeeping)
|
||||
* moves it.
|
||||
*/
|
||||
const lastActivityAt = async (record: SessionRecord): Promise<number | undefined> => {
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) return live.events.at(-1)?.time
|
||||
const location = ctx.get('sessionPersistence')?.locate(record.header)
|
||||
if (location === undefined) return undefined
|
||||
try {
|
||||
return (await stat(location.path)).mtimeMs
|
||||
} catch {
|
||||
// Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One persisted row's title through the projection-cache ladder: the
|
||||
* zero-I/O checkpoint row when usable, otherwise a cold read that folds
|
||||
* only the log tail since the checkpoint and writes the refreshed row
|
||||
* back — so a store scanned once serves later scans without log reads.
|
||||
*/
|
||||
const projectedTitle = async (
|
||||
cache: SessionProjectionCache,
|
||||
record: SessionRecord,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | null | undefined> => {
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) return ctx.get('sessionProjections')?.snapshot(live).values.title
|
||||
const cached = cache.cachedSnapshot(record.header)
|
||||
if (cached !== undefined && 'title' in cached.values) return cached.values.title
|
||||
return (await cache.coldSnapshot(record.header.id, signal)).values.title
|
||||
}
|
||||
|
||||
/** One per-record title resolution: a title (absent for untitled) or an isolated failure. */
|
||||
type TitleResolution = { title?: string; failure?: unknown }
|
||||
|
||||
/**
|
||||
* Resolve every row's title without reading whole logs when the projection
|
||||
* cache is mounted (live registry snapshot / checkpoint row / tail-only
|
||||
* cold read, bounded by `resumeScanConcurrency`); a composition without
|
||||
* the cache falls back to one bounded raw-log title batch.
|
||||
*/
|
||||
const resolveTitles = async (
|
||||
listQuery: SessionQueryService,
|
||||
records: readonly SessionRecord[],
|
||||
signal: AbortSignal,
|
||||
): Promise<TitleResolution[]> => {
|
||||
const cache = ctx.get('sessionProjectionCache')
|
||||
if (cache === undefined) {
|
||||
const results = await listQuery.readTitleSnapshots(records.map(record => record.header.id), signal)
|
||||
return records.map((record, index): TitleResolution => {
|
||||
const result = results[index]
|
||||
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
|
||||
if (result === undefined || result.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
|
||||
if (result.status === 'rejected') return { failure: result.reason }
|
||||
const title = result.value.title?.title
|
||||
return title === undefined ? {} : { title }
|
||||
})
|
||||
}
|
||||
const resolutions = new Array<TitleResolution>(records.length)
|
||||
let cursor = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const index = cursor
|
||||
if (index >= records.length) return
|
||||
cursor += 1
|
||||
const record = records[index] as SessionRecord
|
||||
try {
|
||||
const value = await projectedTitle(cache, record, signal)
|
||||
resolutions[index] = typeof value === 'string' ? { title: value } : {}
|
||||
} catch (failure: unknown) {
|
||||
resolutions[index] = { failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from(
|
||||
{ length: Math.min(resolved.resumeScanConcurrency, records.length) },
|
||||
() => worker(),
|
||||
))
|
||||
return resolutions
|
||||
}
|
||||
|
||||
/** The latest logged provider/model route, for the preflight availability check. */
|
||||
const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => {
|
||||
const header = 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 = events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read every mutable precondition immediately before terminal handoff and
|
||||
* resolve the exact identity and workspace the host will re-exec into. This
|
||||
* is where the one chosen log is fully read, replay-validated, and checked
|
||||
* for a currently-available route — the listing never does any of that.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
const query = sessionQuery()
|
||||
/* v8 ignore start -- showResume alone calls this after proving the optional service exists */
|
||||
if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
/* v8 ignore stop */
|
||||
const initialStatus = deps.agentStatus()
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
|
||||
const candidate = summarize(record, undefined, undefined)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
let events: readonly SessionEvent[]
|
||||
try {
|
||||
events = (await query.readSession(record.header.id)).events
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`session cannot be loaded: ${errorChain(error)}`)
|
||||
}
|
||||
const route = resumeRoute(events)
|
||||
if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) {
|
||||
throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`)
|
||||
}
|
||||
const cwd = record.header.cwd
|
||||
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
|
||||
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return { id: record.header.id, cwd }
|
||||
}
|
||||
|
||||
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) {
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
deps.appendNotice('Session is resumable, but this host cannot hand it off in place.', 'warning')
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
|
||||
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
|
||||
// The host re-execs into the session's own workspace: process cwd, not the
|
||||
// restored session header, is what the filesystem and shell tools resolve
|
||||
// against.
|
||||
await hostHandoff(checked.id, checked.cwd)
|
||||
throw new Error('resume host returned without replacing the process')
|
||||
} catch (error: unknown) {
|
||||
if (!deps.isDisposed()) {
|
||||
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 {
|
||||
showResume(): void {
|
||||
if (agent.status !== 'idle') {
|
||||
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
|
||||
return
|
||||
}
|
||||
const listQuery = sessionQuery()
|
||||
if (listQuery === undefined) {
|
||||
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
|
||||
return
|
||||
}
|
||||
const scan = ++resumeScan
|
||||
void resumeOverlay?.close()
|
||||
// The picker opens before the scan settles so the terminal stops feeding
|
||||
// the editor immediately; a queued activation (the closing predecessor
|
||||
// still holds the slot) receives an already-scanned set through
|
||||
// `scanned` instead of a loading placeholder.
|
||||
let picker: ResumePicker | undefined
|
||||
let scanned: ResumeCandidate[] | undefined
|
||||
const session = overlayManager.open({
|
||||
create: (host) => {
|
||||
picker = new ResumePicker(
|
||||
scanned,
|
||||
resolved.maxResumeOptions,
|
||||
workspaceLabel(agent.session.header.cwd),
|
||||
() => host.viewport.rows,
|
||||
palette,
|
||||
(candidate) => { void handoffResume(candidate, session) },
|
||||
() => { void session.close() },
|
||||
)
|
||||
return picker
|
||||
},
|
||||
options: {
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
anchor: 'top-left',
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
resumeOverlay = session
|
||||
// Closing the picker — Escape, supersession, disposal — aborts the scan:
|
||||
// the borrowed-log pass over a large store must not outlive its overlay.
|
||||
const scanAbort = new AbortController()
|
||||
void session.closed.then(() => {
|
||||
scanAbort.abort()
|
||||
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
|
||||
if (resumeOverlay === session) resumeOverlay = undefined
|
||||
})
|
||||
deps.requestRender()
|
||||
/** Whether this scan's overlay, session generation, or TUI is gone. */
|
||||
const scanStale = (): boolean =>
|
||||
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
|
||||
const scanCandidates = async (): Promise<void> => {
|
||||
// Every workspace in the store is listed; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const records = await listQuery.listSessions(scanAbort.signal)
|
||||
if (scanStale()) return
|
||||
// Rows need only metadata, an mtime, and a title — resolved without
|
||||
// whole-log reads when the projection cache is mounted. A corrupt
|
||||
// neighbor degrades to one disabled row.
|
||||
const [titles, activity] = await Promise.all([
|
||||
resolveTitles(listQuery, records, scanAbort.signal),
|
||||
Promise.all(records.map(record => lastActivityAt(record))),
|
||||
])
|
||||
const candidates = records.map((record, index) => {
|
||||
const resolution = titles[index] as TitleResolution
|
||||
return 'failure' in resolution
|
||||
? unreadableCandidate(record, activity[index], resolution.failure)
|
||||
: summarize(record, resolution.title, activity[index])
|
||||
})
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (scanStale()) return
|
||||
scanned = candidates
|
||||
picker?.setCandidates(candidates)
|
||||
deps.requestRender()
|
||||
}
|
||||
// One catch covers listing, titles, and mtimes, so a scan failure
|
||||
// cannot strand the overlay on its loading placeholder; an aborted
|
||||
// scan's rejection stays silent because the user already dismissed the
|
||||
// picker.
|
||||
void scanCandidates().catch((error: unknown) => {
|
||||
if (scanStale()) return
|
||||
void session.close()
|
||||
deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 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}`
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* Per-step timing model and prompt-status glyph animation for the terminal
|
||||
* front door. Timing buckets are replayed from the session event stream; the
|
||||
* active glyph fades in when work starts, throbs while work runs, and fades out
|
||||
* when it ends.
|
||||
* @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 status prompt while active, and while the glyph fades
|
||||
* out after work 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 status glyph fades in when work starts and fades
|
||||
* out after it ends. The fade is an envelope over the active 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 active status glyph. */
|
||||
export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
|
||||
/**
|
||||
* Brightness floor of the status 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 status 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
|
||||
}
|
||||
|
||||
function stepKey(position: StepPosition): string {
|
||||
return `${position.turn}:${position.step}`
|
||||
}
|
||||
|
||||
interface TrackedStep extends TimingState {
|
||||
/** Set at the step's `step/end`; later same-coordinate events no longer advance the step. */
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental per-step timing accumulator shared by every step's timing footer
|
||||
* in one transcript. One forward pass over the append-only session log serves
|
||||
* all steps' totals: each query advances a cursor over the events appended
|
||||
* since the previous query, so a transcript of S steps costs O(events) in
|
||||
* total instead of the O(S × events) of replaying the whole log per footer
|
||||
* ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
|
||||
*
|
||||
* The log must be append-only with stable indices (the session `seq = log
|
||||
* length` contract). Event times are consumed as logged: a backward wall-clock
|
||||
* step clamps each bucket at zero rather than cutting the scan off at the
|
||||
* query clock. The open bucket is accumulated to the query clock at lookup,
|
||||
* never during the scan.
|
||||
*/
|
||||
export class StepTimingTracker {
|
||||
private scanned = 0
|
||||
private readonly steps = new Map<string, TrackedStep>()
|
||||
|
||||
/**
|
||||
* Advance over events appended since the previous query, then return one
|
||||
* step's accumulated per-phase timing up to clock `at`.
|
||||
* @param events - Current session event log (append-only).
|
||||
* @param position - Turn/step coordinates of the queried step.
|
||||
* @param at - Render clock to accumulate the open bucket up to.
|
||||
* @returns The step's per-phase totals; empty when the step never started.
|
||||
*/
|
||||
totalsAt(events: readonly SessionEvent[], position: StepPosition, at: number): TimingTotals {
|
||||
for (; this.scanned < events.length; this.scanned += 1) {
|
||||
const event = events[this.scanned] as SessionEvent
|
||||
if (event.type === 'step/start') {
|
||||
const key = stepKey(event.data)
|
||||
if (!this.steps.has(key)) this.steps.set(key, { ...timingState(event.time), closed: false })
|
||||
} else if (event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') {
|
||||
const state = this.steps.get(stepKey(event.data))
|
||||
if (state !== undefined && !state.closed) {
|
||||
advanceStepTiming(state, event)
|
||||
if (event.type === 'step/end') state.closed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const state = this.steps.get(stepKey(position))
|
||||
return state === undefined ? emptyTimingTotals() : 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: '⚙',
|
||||
}
|
||||
|
||||
/** Status glyph for a live standalone compaction bracket. */
|
||||
const COMPACTING_GLYPH = '⊙'
|
||||
|
||||
/**
|
||||
* 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 StepTimingTracker}.
|
||||
* @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 active status glyph, or `undefined` when idle. A running turn takes
|
||||
* precedence over standalone compaction and falls back to the pre-first-token
|
||||
* wait when no step is open. The caller applies the shared fade and throb
|
||||
* animation (see {@link fadeGlyph}).
|
||||
* @param events - Session events to derive the phase from.
|
||||
* @param running - Whether the agent is currently running.
|
||||
* @param compacting - Whether a live standalone compaction bracket is open.
|
||||
* @returns The active status glyph, or `undefined` when idle.
|
||||
*/
|
||||
export function runningPhaseGlyph(
|
||||
events: readonly SessionEvent[],
|
||||
running: boolean,
|
||||
compacting: boolean,
|
||||
): string | undefined {
|
||||
if (running) {
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
}
|
||||
return compacting ? COMPACTING_GLYPH : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The status 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 work 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 status 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 status 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 status 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.dim(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}`
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* 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`
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 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, '')
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* Text carrying exactly one palette color. Branded so the compiler rejects
|
||||
* wrapping it in a second color: SGR has no color stack, so an inner span's
|
||||
* close reverts to the default foreground rather than the outer color, which
|
||||
* silently drops the outer color for the remainder of the line.
|
||||
*/
|
||||
export type Colored = string & { readonly __coloredBy: unique symbol }
|
||||
|
||||
/**
|
||||
* Text a color may still be applied to: a bare string, or one already carrying
|
||||
* SGR attributes. Attributes (bold, italic, underline, strike, reverse) occupy
|
||||
* independent SGR groups from the foreground color, so they compose in either
|
||||
* order without either side clobbering the other.
|
||||
*/
|
||||
export type Colorable = string & { readonly __coloredBy?: undefined }
|
||||
|
||||
/** Applies one color role; rejects input that already carries a color. */
|
||||
export type ColorRole = (text: Colorable) => Colored
|
||||
|
||||
/** Applies one SGR attribute; accepts colored or uncolored text and preserves its color. */
|
||||
export type AttributeRole = <T extends string>(text: T) => T
|
||||
|
||||
/**
|
||||
* Theme-agnostic role colors and SGR attribute wrappers.
|
||||
*
|
||||
* One role per visual meaning: `dim` is the single recessed tone, `accent` the
|
||||
* single emphasis color, and `success`/`error` double as a diff's added/removed
|
||||
* pair. Roles that resolved to the same escape were merged rather than kept as
|
||||
* aliases, so a reader cannot pick a name that silently renders as another.
|
||||
*
|
||||
* Colors and attributes are separately typed: `bold(accent(x))` and
|
||||
* `accent(bold(x))` both compile, while `accent(error(x))` does not.
|
||||
*/
|
||||
export interface Palette {
|
||||
accent: ColorRole
|
||||
/** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */
|
||||
brand: ColorRole
|
||||
/** The terminal's own default foreground; still a color, so it does not stack. */
|
||||
text: ColorRole
|
||||
/** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */
|
||||
dim: ColorRole
|
||||
success: ColorRole
|
||||
warning: ColorRole
|
||||
error: ColorRole
|
||||
code: ColorRole
|
||||
bold: AttributeRole
|
||||
italic: AttributeRole
|
||||
underline: AttributeRole
|
||||
strike: AttributeRole
|
||||
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
|
||||
selected: AttributeRole
|
||||
}
|
||||
|
||||
/** Names of the palette's color roles, in the order `/palette` prints them. */
|
||||
export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', 'code', 'success', 'warning', 'error'] as const
|
||||
|
||||
/** Names of the palette's attribute roles, in the order `/palette` prints them. */
|
||||
export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const
|
||||
|
||||
/** One role's SGR parameters and the reason it carries them. */
|
||||
export interface RoleSpec {
|
||||
/** SGR parameters that open the span, without the `ESC [` prefix or `m` suffix. */
|
||||
readonly open: string
|
||||
/** SGR parameters that close it; MUST reset every group `open` sets. */
|
||||
readonly close: string
|
||||
/** What the role means, shown by `/palette`. */
|
||||
readonly purpose: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Every SGR code the TUI is allowed to emit, keyed by role. This table is the
|
||||
* single source: {@link createPalette} derives the wrappers from it and
|
||||
* `/palette` prints it, so a role cannot exist in one and not the other, and no
|
||||
* component hand-writes an escape.
|
||||
*
|
||||
* Only the standard 16-color set and SGR attributes appear here. Terminals remap
|
||||
* those to the user's active theme, so the TUI stays legible on any background;
|
||||
* a fixed 24-bit color would not. The startup gradient and exact official mark
|
||||
* color are the two deliberate brand exceptions ({@link gradientText},
|
||||
* {@link brandText}).
|
||||
*
|
||||
* @param scheme - Active terminal color scheme; only `code` differs between them.
|
||||
* @returns The SGR spec for every color and attribute role.
|
||||
*/
|
||||
export function paletteSpec(scheme: TerminalColorScheme): {
|
||||
readonly colors: Readonly<Record<typeof COLOR_ROLES[number], RoleSpec>>
|
||||
readonly attributes: Readonly<Record<typeof ATTRIBUTE_ROLES[number], RoleSpec>>
|
||||
} {
|
||||
return {
|
||||
colors: {
|
||||
// The terminal's own foreground, emitted as no escape at all: ordinary body
|
||||
// text must inherit whatever the user's theme uses.
|
||||
text: { open: '', close: '', purpose: 'Body text, the terminal default foreground' },
|
||||
// SGR 2 over an explicit default foreground, closing both groups it sets.
|
||||
// The attribute fades relative to whatever the terminal's own foreground is,
|
||||
// which is the only way to land *below* `text` on both schemes: ANSI 90
|
||||
// (bright black) is a fixed hue that many light themes render heavier than
|
||||
// their default foreground, which made every "dim" surface the most
|
||||
// prominent text on screen.
|
||||
dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' },
|
||||
accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' },
|
||||
brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' },
|
||||
// ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34
|
||||
// (blue) which is legible on both light and dark schemes.
|
||||
code: scheme === 'light'
|
||||
? { open: '34', close: '39', purpose: 'Inline code and code blocks in prose' }
|
||||
: { open: '36', close: '39', purpose: 'Inline code and code blocks in prose' },
|
||||
success: { open: '32', close: '39', purpose: 'Succeeded calls, and a diff\'s added lines' },
|
||||
warning: { open: '33', close: '39', purpose: 'Pending calls and warnings' },
|
||||
error: { open: '31', close: '39', purpose: 'Failures, signals, and a diff\'s removed lines' },
|
||||
},
|
||||
attributes: {
|
||||
bold: { open: '1', close: '22', purpose: 'Emphasis; composes with any color' },
|
||||
italic: { open: '3', close: '23', purpose: 'Reasoning text' },
|
||||
underline: { open: '4', close: '24', purpose: 'Role-header banding' },
|
||||
strike: { open: '9', close: '29', purpose: 'Struck-through Markdown' },
|
||||
selected: { open: '7', close: '27', purpose: 'Reverse video for the active selection' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text in an SGR pair, or pass it through when color is disabled.
|
||||
* An empty `open` emits nothing, so the `text` role costs no escape.
|
||||
*/
|
||||
function ansi(spec: RoleSpec, enabled: boolean): (text: string) => string {
|
||||
if (!enabled || spec.open === '') return text => text
|
||||
return text => `\x1b[${spec.open}m${text}\x1b[${spec.close}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette derived from {@link paletteSpec}. Body `text` stays the
|
||||
* terminal's default foreground so it reads on light and dark backgrounds alike;
|
||||
* grouping uses foreground-only bold, underlined role headers and reverse video
|
||||
* rather than fixed background fills or per-line prefixes, so a transcript
|
||||
* drag-select copies message text without stray glyphs.
|
||||
*
|
||||
* @param enabled - Whether ANSI is emitted at all.
|
||||
* @param scheme - Active terminal color scheme; adjusts the code role.
|
||||
* @returns The role palette for the given scheme.
|
||||
*/
|
||||
export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
|
||||
const spec = paletteSpec(scheme)
|
||||
const roles = {} as Record<string, unknown>
|
||||
for (const name of COLOR_ROLES) roles[name] = ansi(spec.colors[name], enabled)
|
||||
for (const name of ATTRIBUTE_ROLES) roles[name] = ansi(spec.attributes[name], enabled)
|
||||
return roles as unknown as Palette
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/** Official DeepSeek icon ink from the shipped 24x24 SVG. */
|
||||
const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0]
|
||||
|
||||
/**
|
||||
* Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink.
|
||||
* @param text - Static brand text or raster cells.
|
||||
* @returns text wrapped in the official truecolor foreground and a foreground reset.
|
||||
*/
|
||||
export function brandText(text: string): string {
|
||||
const [r, g, b] = DEEPSEEK_BRAND_RGB
|
||||
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const glyphs = Array.from(text)
|
||||
const last = Math.max(1, glyphs.length - 1)
|
||||
let painted = ''
|
||||
for (let index = 0; index < glyphs.length; index += 1) {
|
||||
const [r, g, b] = brandColorAt(index / last)
|
||||
painted += `\x1b[38;2;${r};${g};${b}m${glyphs[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.dim(text),
|
||||
quoteBorder: text => palette.accent(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.dim,
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample text every `/palette` row renders, long enough to judge a tone against its neighbours. */
|
||||
const PALETTE_SAMPLE = 'The quick brown fox 0123'
|
||||
|
||||
/**
|
||||
* Render every palette role as a labelled sample row, each painted by the role
|
||||
* it names, so a reader compares the actual tones their terminal produces rather
|
||||
* than reading SGR numbers. Colors print first and attributes second because the
|
||||
* two groups compose in that order; every row shows its SGR pair so a mismatch
|
||||
* between the table and the screen is visible.
|
||||
*
|
||||
* @param palette - Active role palette, used to paint each sample.
|
||||
* @param scheme - Active color scheme, reported in the heading and selecting the spec.
|
||||
* @param colorEnabled - Whether ANSI is emitted; reported so an unstyled listing is not confusing.
|
||||
* @returns The rendered rows, without a trailing blank.
|
||||
*/
|
||||
export function renderPalette(
|
||||
palette: Palette,
|
||||
scheme: TerminalColorScheme,
|
||||
colorEnabled: boolean,
|
||||
): string[] {
|
||||
const spec = paletteSpec(scheme)
|
||||
const width = Math.max(...[...COLOR_ROLES, ...ATTRIBUTE_ROLES].map(name => name.length))
|
||||
// Two rows per role: the painted sample beside its name and SGR pair, then the
|
||||
// purpose indented under it. Splitting the purpose onto its own row keeps every
|
||||
// sample on one visual line at the narrow widths a side-by-side pane gives.
|
||||
const head = (name: string, role: RoleSpec, sample: string): string => {
|
||||
const pair = role.open === '' ? 'no escape' : `ESC[${role.open}m ESC[${role.close}m`
|
||||
return ` ${sample} ${palette.dim(`${name.padEnd(width)} ${pair}`)}`
|
||||
}
|
||||
const purpose = (role: RoleSpec): string => ` ${palette.dim(` ${role.purpose}`)}`
|
||||
const rows = [
|
||||
palette.bold(palette.accent('Palette')),
|
||||
palette.dim(`${scheme} scheme · color ${colorEnabled ? 'on' : 'off'}`),
|
||||
'',
|
||||
palette.dim('Colors — exactly one per span; they never nest inside each other.'),
|
||||
]
|
||||
for (const name of COLOR_ROLES) {
|
||||
rows.push(head(name, spec.colors[name], palette[name](PALETTE_SAMPLE)), purpose(spec.colors[name]))
|
||||
}
|
||||
rows.push('', palette.dim('Attributes — compose with any color, in either order.'))
|
||||
for (const name of ATTRIBUTE_ROLES) {
|
||||
rows.push(head(name, spec.attributes[name], palette[name](PALETTE_SAMPLE)), purpose(spec.attributes[name]))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -1,833 +0,0 @@
|
||||
/**
|
||||
* 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 { diffLines as compareLines } from 'diff'
|
||||
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 { preview, renderUnknownXml } from './xml-tool-output.ts'
|
||||
import { displayInlineText, displayText } from './text.ts'
|
||||
import { gradientText, type Palette } from './theme.ts'
|
||||
import { contentText, type ParsedArguments } from './content.ts'
|
||||
import {
|
||||
formatCompletionTime,
|
||||
formatTimingTotals,
|
||||
type StepPosition,
|
||||
type StepTimingTracker,
|
||||
} 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))
|
||||
}
|
||||
|
||||
interface RenderedDiff {
|
||||
lines: string[]
|
||||
added: number
|
||||
removed: number
|
||||
approximate: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A side's content lines under the terminator rule the Web DiffBlock also
|
||||
* applies: empty text is zero lines, a trailing newline terminates the last
|
||||
* line, and an interior blank line survives.
|
||||
*/
|
||||
function diffContentLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
const body = text.endsWith('\n') ? text.slice(0, -1) : text
|
||||
return body.split('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* A file diff whose unchanged context stays neutral and does not affect exact
|
||||
* change totals. Comparisons beyond the edit-distance budget fall back to
|
||||
* whole-side rendering so a model-authored pending edit cannot stall the TUI.
|
||||
*/
|
||||
function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff {
|
||||
// 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))]
|
||||
let added = 0
|
||||
let removed = 0
|
||||
if (diff.oldText === null) {
|
||||
const newLines = diffContentLines(displayText(diff.newText))
|
||||
added = newLines.length
|
||||
for (const line of newLines) lines.push(palette.success(`+ ${line}`))
|
||||
return { lines, added, removed, approximate: false }
|
||||
}
|
||||
const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength })
|
||||
if (changes === undefined) {
|
||||
const oldLines = diffContentLines(displayText(diff.oldText))
|
||||
const newLines = diffContentLines(displayText(diff.newText))
|
||||
lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`))
|
||||
removed = oldLines.length
|
||||
added = newLines.length
|
||||
for (const line of oldLines) lines.push(palette.error(`- ${line}`))
|
||||
for (const line of newLines) lines.push(palette.success(`+ ${line}`))
|
||||
return { lines, added, removed, approximate: true }
|
||||
}
|
||||
for (const change of changes) {
|
||||
const changedLines = diffContentLines(displayText(change.value))
|
||||
if (change.added) {
|
||||
added += changedLines.length
|
||||
for (const line of changedLines) lines.push(palette.success(`+ ${line}`))
|
||||
} else if (change.removed) {
|
||||
removed += changedLines.length
|
||||
for (const line of changedLines) lines.push(palette.error(`- ${line}`))
|
||||
} else {
|
||||
for (const line of changedLines) lines.push(palette.dim(` ${line}`))
|
||||
}
|
||||
}
|
||||
return { lines, added, removed, approximate: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.dim(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. A folded continuation (a later step of a turn while tool cards
|
||||
* are hidden) drops the `Assistant` header and renders nothing when it has no
|
||||
* visible body, so tool-only steps leave no blank segment behind.
|
||||
*/
|
||||
function assistantMessageChildren(
|
||||
content: readonly ContentBlock[],
|
||||
showReasoning: boolean,
|
||||
foldedContinuation: boolean,
|
||||
palette: Palette,
|
||||
mdTheme: MarkdownTheme,
|
||||
): Component[] {
|
||||
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
const showsReasoning = reasoning !== '' && showReasoning
|
||||
if (foldedContinuation && !showsReasoning && text === '') return []
|
||||
const children: Component[] = [new Spacer(1)]
|
||||
if (!foldedContinuation) {
|
||||
children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0))
|
||||
}
|
||||
if (showsReasoning) {
|
||||
children.push(
|
||||
new Text(palette.italic(palette.dim('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }),
|
||||
)
|
||||
}
|
||||
if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }))
|
||||
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 tracker: StepTimingTracker,
|
||||
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 = this.tracker.totalsAt(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
|
||||
private foldedContinuation = false
|
||||
/**
|
||||
* 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(
|
||||
/** The step's turn/step coordinates, used to group steps into their turn. */
|
||||
readonly position: StepPosition,
|
||||
events: () => readonly SessionEvent[],
|
||||
tracker: StepTimingTracker,
|
||||
now: () => number,
|
||||
private showReasoning: boolean,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
super()
|
||||
this.timing = new StepTimingComponent(position, events, tracker, 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this step as a folded continuation of its turn: no `Assistant` header,
|
||||
* and no output at all while the step has no visible body. Used while tool
|
||||
* cards are hidden so a turn reads as one assistant message.
|
||||
* @param folded - Whether to render as a headerless continuation.
|
||||
*/
|
||||
setFoldedContinuation(folded: boolean): void {
|
||||
if (this.foldedContinuation === folded) return
|
||||
this.foldedContinuation = folded
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the step currently renders visible reasoning or text.
|
||||
* @returns `true` when a header-owning render would show a body.
|
||||
*/
|
||||
hasVisibleBody(): boolean {
|
||||
const content = this.presentedContent()
|
||||
return textBlocks(content, 'text').trim() !== ''
|
||||
|| (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '')
|
||||
}
|
||||
|
||||
/** The settled content when available, otherwise the streamed blocks in model order. */
|
||||
private presentedContent(): readonly ContentBlock[] {
|
||||
return 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 []
|
||||
})
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const children = assistantMessageChildren(
|
||||
this.presentedContent(),
|
||||
this.showReasoning,
|
||||
this.foldedContinuation,
|
||||
this.palette,
|
||||
this.mdTheme,
|
||||
)
|
||||
for (const child of children) this.addChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool card's body split at the Markdown boundary. `prelude` rows are already
|
||||
* styled and render verbatim (a terminal `$` command, its cwd, a diff's hunks);
|
||||
* `lines` is the tool's own text. A generic card renders both as one Markdown
|
||||
* document under the dim body tone.
|
||||
*/
|
||||
interface CardBody {
|
||||
readonly prelude: readonly string[]
|
||||
readonly lines: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+O card-visibility cycle: `hidden` drops tool cards from the transcript,
|
||||
* `collapsed` previews the first body lines, `expanded` shows everything.
|
||||
*/
|
||||
export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded'
|
||||
|
||||
/**
|
||||
* Transcript card with a width-keyed rendered-row cache. pi-tui re-renders
|
||||
* every component each frame and relies on per-component line caches (its own
|
||||
* `Text`/`Markdown` do this); a card that rebuilds rows inside `render(width)`
|
||||
* would re-wrap its output every frame
|
||||
* ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
|
||||
* Subclasses render through {@link renderLines} and call {@link dropLines}
|
||||
* from every state mutator; with `invalidate()` (pi-tui's tree-wide cascade)
|
||||
* also dropping, a state change always re-renders.
|
||||
*/
|
||||
abstract class CachedCardComponent implements Component {
|
||||
private cached: { width: number; lines: string[] } | undefined
|
||||
|
||||
/** Discard the cached rows so the next render recomputes them. */
|
||||
protected dropLines(): void {
|
||||
this.cached = undefined
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cached = undefined
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cached?.width !== width) this.cached = { width, lines: this.renderLines(width) }
|
||||
return this.cached.lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the card's rows for `width` without caching.
|
||||
* @param width - Render width the rows are wrapped to.
|
||||
* @returns The card's rows.
|
||||
*/
|
||||
protected abstract renderLines(width: number): string[]
|
||||
}
|
||||
|
||||
/** A tool call and its result, rendered as a collapsible status card. */
|
||||
export class ToolCardComponent extends CachedCardComponent {
|
||||
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
|
||||
private visibility: ToolCardVisibility = 'collapsed'
|
||||
private callView: ToolCallView
|
||||
private resultView: ToolResultView | undefined
|
||||
private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined
|
||||
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly parsed: ParsedArguments,
|
||||
private readonly definition: ToolDefinition | undefined,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly maxDiffEditLength: number,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
super()
|
||||
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.diffBodyCache = undefined
|
||||
this.dropLines()
|
||||
const result = event.message.content[0]
|
||||
this.result = {
|
||||
content: [...result.content],
|
||||
isError: result.isError === true,
|
||||
...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)}` }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the card's visibility state.
|
||||
* @param visibility - Hidden, collapsed preview, or full body.
|
||||
*/
|
||||
setVisibility(visibility: ToolCardVisibility): void {
|
||||
this.visibility = visibility
|
||||
this.dropLines()
|
||||
}
|
||||
|
||||
protected renderLines(width: number): string[] {
|
||||
// Hidden renders nothing — not even the leading gap — so the transcript
|
||||
// keeps only the conversation, the way Codex hides tool calls.
|
||||
if (this.visibility === 'hidden') return []
|
||||
const isError = this.result?.isError ?? false
|
||||
// A ring marker: hollow while the call is pending, filled once it settles;
|
||||
// the header color (warning/success/error) tells pending from ok from error.
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
// A generic card's own content, a read card's `content` fallback (the
|
||||
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
|
||||
// read renders exactly as before the read card existed), or a search/web
|
||||
// card's fallback to the raw result content (neither the `search` nor the
|
||||
// `web` view carries a `content` copy), all render as one dim Markdown block
|
||||
// below, so links/lists/headings keep the unified dim styling rather than
|
||||
// reading as bare text. A search card thus stays byte-identical to the
|
||||
// pre-search-card generic fallback. Terminal and diff cards own their body
|
||||
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic' || view.card === 'read'
|
||||
? view.content ?? this.result?.content
|
||||
: view.card === 'search'
|
||||
? this.result?.content
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
// handler sets both) and the pending callView is never a web card, so
|
||||
// the optional-chain undefined side is unreachable here.
|
||||
/* v8 ignore next */
|
||||
? this.result?.content
|
||||
: undefined
|
||||
const unknownXml = this.definition === undefined && markdownContent !== undefined
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(markdownContent)),
|
||||
this.maxOutputLines,
|
||||
this.visibility === 'expanded',
|
||||
displayText,
|
||||
text => this.palette.dim(text),
|
||||
text => this.palette.dim(text),
|
||||
/* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */
|
||||
count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`),
|
||||
)
|
||||
: undefined
|
||||
// A generic card renders title and result as one Markdown document, so the
|
||||
// document's own block spacing is preserved, then dims every row — the whole
|
||||
// card body reads as one dim block under the status-colored header.
|
||||
const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0
|
||||
? this.dimBody(rawBody, width)
|
||||
: [...rawBody.prelude, ...rawBody.lines])
|
||||
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
|
||||
? body
|
||||
: preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`))
|
||||
// The header is a fixed `Tool / <name>` frame in the status color (warning
|
||||
// pending / success ok / error), flat — no bold or underline, so one color
|
||||
// reads consistently across the whole row. Every tool-specific detail (a
|
||||
// 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), '')
|
||||
// The blank first row is the card's own paragraph gap (no external Spacer),
|
||||
// so the hidden state removes the gap together with the card.
|
||||
const lines: string[] = ['', statusColor(header)]
|
||||
if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width))
|
||||
return lines
|
||||
}
|
||||
|
||||
/** 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(): CardBody {
|
||||
const view = this.resultView ?? this.callView
|
||||
if (view.card === 'terminal') {
|
||||
const pending = this.terminalPending()
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The command shows as a $-line here whenever it is not the header: either a
|
||||
// description headlines the row (the command still belongs somewhere) or the row
|
||||
// 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) prelude.push(this.palette.dim(`$ ${displayInlineText(pending.title)}`))
|
||||
if (pending?.cwd) prelude.push(this.palette.dim(displayInlineText(pending.cwd)))
|
||||
if (this.resultView?.card === 'terminal') {
|
||||
if (this.resultView.output) lines.push(...this.dimOutput(this.resultView.output))
|
||||
if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
|
||||
if (this.resultView.signal !== undefined) {
|
||||
lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
|
||||
}
|
||||
} else if (this.result !== undefined) {
|
||||
lines.push(...this.dimOutput(contentText(this.result.content)))
|
||||
}
|
||||
return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) }
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
if (this.diffBodyCache?.view === view) return this.diffBodyCache.body
|
||||
// The header no longer names the file, so each diff keeps its own path
|
||||
// header. A trailing footer summarizes the exact changed rows when the
|
||||
// bounded comparison succeeds (`+A -R · N file(s)`).
|
||||
const renderedDiffs = view.diffs.map(diff =>
|
||||
renderDiff(diff, this.maxDiffEditLength, this.palette),
|
||||
)
|
||||
const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0)
|
||||
const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0)
|
||||
const approximate = renderedDiffs.some(rendered => rendered.approximate)
|
||||
const hunks = renderedDiffs.flatMap((rendered, index) => {
|
||||
return [...index > 0 ? [''] : [], ...rendered.lines]
|
||||
})
|
||||
const files = new Set(view.diffs.map(diff => diff.path)).size
|
||||
const footer = this.palette.dim(
|
||||
`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`,
|
||||
)
|
||||
// A diff's own `+`/`-` colors carry its meaning, so it renders verbatim
|
||||
// rather than under the dim result-output color.
|
||||
const body = { prelude: [...hunks, footer], lines: [] }
|
||||
this.diffBodyCache = { view, body }
|
||||
return body
|
||||
}
|
||||
// A generic or read card carries its own envelope-stripped `content`; a
|
||||
// search or web card carries no `content` copy and falls back to the raw
|
||||
// result content here. (Mirrors the `markdownContent` selection in render();
|
||||
// a read card has no dedicated TUI rendering, so its `content` takes the same
|
||||
// body path, keeping read output as it was before the read card existed, and
|
||||
// a search card stays byte-identical to the pre-search-card fallback.)
|
||||
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
// `Tool / <name>` frame (a terminal card keeps its command $-line instead).
|
||||
// Skip it when it only repeats the tool name (the fallback presenter for a
|
||||
// tool with no presentCall, or an unknown tool), which the header already shows.
|
||||
const bodyTitle = this.bodyTitle()
|
||||
if (bodyTitle !== displayText(this.name)) prelude.push(displayInlineText(bodyTitle))
|
||||
if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
|
||||
const rawInput = this.result === undefined && this.callView.card === 'generic'
|
||||
? this.callView.rawInput
|
||||
: undefined
|
||||
if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
|
||||
// Blank-line trimming spans the whole body, so the title counts as a row:
|
||||
// interior blanks (a result's own paragraph break) survive while the body's
|
||||
// leading and trailing ones are dropped.
|
||||
const total = prelude.length + lines.length
|
||||
return {
|
||||
prelude,
|
||||
lines: lines.filter((line, index) => {
|
||||
const row = prelude.length + index
|
||||
return line.length > 0 || (row > 0 && row < total - 1)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's own output text as dim rows — the card's result-output color, which
|
||||
* separates what the tool produced from the card's own framing. A blank row
|
||||
* stays the empty string so the terminal branch's blank-row filter still reads
|
||||
* it as blank instead of as an ANSI-wrapped value.
|
||||
*/
|
||||
private dimOutput(text: string): string[] {
|
||||
return displayText(text).split('\n').map(line => line === '' ? line : this.palette.dim(line))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a generic card's prelude and result as one Markdown document under the
|
||||
* dim body tone. Rendering both together preserves the document's own block
|
||||
* spacing (Markdown's blank row before a heading); dimming every row keeps the
|
||||
* card body one uniform tone, so only the status-colored header carries color.
|
||||
*/
|
||||
private dimBody(body: CardBody, width: number): string[] {
|
||||
const rows = new Markdown([...body.prelude, ...body.lines].join('\n'), 0, 0, this.mdTheme, {
|
||||
color: value => this.palette.text(value),
|
||||
}).render(width)
|
||||
// A whitespace-only row carries no output to dim; leaving it unwrapped keeps
|
||||
// Markdown's padding out of the styled ranges.
|
||||
return rows.map(row => row.trim() === '' ? row : this.palette.dim(row))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a lone reminder-frame tag on its own line, capturing the element name.
|
||||
* Producers emit the frame as whole lines (`workspace-context`, `dsh-tool-skill`),
|
||||
* so anchoring the whole line keeps a tag mentioned inside prose from matching.
|
||||
*/
|
||||
const REMINDER_FRAME_LINE = /^<(\/?)([a-zA-Z][\w:.-]*)>$/u
|
||||
|
||||
/**
|
||||
* Drop a producer's outer reminder frame, keeping the instruction body verbatim.
|
||||
* The card header already names the source, so the frame lines carry nothing.
|
||||
* Only a matched open/close pair on the first and last lines is removed, so a
|
||||
* body that merely starts with a tag-like line is left intact.
|
||||
* @param text - Complete model-facing context text.
|
||||
* @returns The body without its outer frame lines, trimmed of the blank lines they leave.
|
||||
*/
|
||||
function stripReminderFrame(text: string): string {
|
||||
// A frame needs an open line and a distinct close line, so anything shorter than
|
||||
// two lines is already frameless.
|
||||
const [first = '', ...rest] = text.split('\n')
|
||||
const last = rest.at(-1)
|
||||
if (last === undefined) return text
|
||||
const open = REMINDER_FRAME_LINE.exec(first.trim())
|
||||
const close = REMINDER_FRAME_LINE.exec(last.trim())
|
||||
if (open?.[1] !== '' || close?.[1] !== '/' || open[2] !== close[2]) return text
|
||||
return rest.slice(0, -1).join('\n').replace(/^\n+|\n+$/gu, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected context (plugin/goal source, e.g. `workspace-context`), rendered as a
|
||||
* collapsible dim card that shares the tool-card `Ctrl+O` toggle. The header is
|
||||
* `Context · <label>`; the body is the message text as dim prose, one tone with
|
||||
* the header and the fold marker, folded to `maxOutputLines`, with a surrounding
|
||||
* reminder frame stripped because the source label already names the context.
|
||||
*
|
||||
* Injected context is prose, not markup, so this card does not parse it. The
|
||||
* `<system-reminder>` frame is a prompting convention no model is trained on
|
||||
* ([envelope rationale](../../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)),
|
||||
* and instruction bodies legitimately contain a raw `&` or angle-bracket
|
||||
* placeholders (`packages/<group>/<pkg>/`, `-t <name>`) that are prose rather than
|
||||
* elements. Tree-rendering such a payload depended on whether it happened to be
|
||||
* well-formed XML, which made both the fold and the frame-line suppression
|
||||
* content-dependent.
|
||||
*/
|
||||
export class ContextCardComponent extends CachedCardComponent {
|
||||
private expanded = false
|
||||
|
||||
constructor(
|
||||
private readonly label: string,
|
||||
private readonly text: string,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly palette: Palette,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card body.
|
||||
* @param expanded - Whether the full body is shown.
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
this.dropLines()
|
||||
}
|
||||
|
||||
protected renderLines(width: number): string[] {
|
||||
const header = this.palette.dim(`Context · ${displayText(this.label)}`)
|
||||
// Emptiness is decided on the stripped text: styling a blank body would yield
|
||||
// one escape-only row, which reads as a stray blank line under the header.
|
||||
const stripped = stripReminderFrame(this.text)
|
||||
if (stripped === '') return [header]
|
||||
const body = stripped.split('\n')
|
||||
.map(line => line === '' ? line : this.palette.dim(displayText(line)))
|
||||
const visibleBody = this.expanded
|
||||
? body
|
||||
: preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`))
|
||||
return [header, ...new Text(visibleBody.join('\n'), 0, 0).render(width)]
|
||||
}
|
||||
}
|
||||
|
||||
/** 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: string[] = [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: string = todo.status === 'completed' ? this.palette.dim(content) : content
|
||||
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
|
||||
}
|
||||
return ['', ...lines]
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Conservative readable-tree rendering for model-facing text containing one XML
|
||||
* document, used by the transcript's tool cards for unknown tool results. Injected
|
||||
* context is prose and is not parsed; only {@link preview} is shared with its card.
|
||||
* @module @deepseek-ai/dsh-tui/components/xml-tool-output
|
||||
*/
|
||||
|
||||
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, body: (text: string) => string): string[] {
|
||||
return text.replace(/^\n|\n$/gu, '').split('\n')
|
||||
.map(line => line === '' ? line : `${' '.repeat(depth)}${body(line)}`)
|
||||
}
|
||||
|
||||
function treeLines(
|
||||
element: XmlElement,
|
||||
depth: number,
|
||||
label: (text: string) => string,
|
||||
body: (text: string) => string,
|
||||
): string[] {
|
||||
const indent = ' '.repeat(depth)
|
||||
const children = meaningfulChildren(element)
|
||||
if (children.length === 0) return [`${indent}${label(elementLabel(element))}`]
|
||||
if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) {
|
||||
return [`${indent}${label(`${elementLabel(element)}:`)} ${body(children[0].trim())}`]
|
||||
}
|
||||
const lines = [`${indent}${label(elementLabel(element))}`]
|
||||
for (const child of children) {
|
||||
if (typeof child === 'string') lines.push(...textBlock(child, depth + 1, body))
|
||||
else lines.push(...treeLines(child, depth + 1, label, body))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `lines` to a head/tail preview around one omitted-count marker.
|
||||
* The single fold rule for every transcript card, so a card's fold never depends
|
||||
* on how its body was rendered: tool cards share it with their tree output and
|
||||
* context cards apply it to prose rows.
|
||||
* @param lines - Fully rendered body rows.
|
||||
* @param limit - Maximum retained rows, excluding the marker.
|
||||
* @param omitted - Renders the marker for the omitted row count.
|
||||
* @returns `lines` unchanged when within `limit`, else head rows, the marker, and tail rows.
|
||||
*/
|
||||
export function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] {
|
||||
if (lines.length <= limit) return [...lines]
|
||||
const head = Math.ceil(limit / 2)
|
||||
const tail = limit - head
|
||||
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 an unknown tool result.
|
||||
* @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and
|
||||
* to the number of top-level children, so many siblings cannot grow the collapsed card without bound.
|
||||
* @param expanded - Whether to retain every rendered child line.
|
||||
* @param display - Escapes parsed text and attribute values for terminal output; character references
|
||||
* can expand to control characters that pre-parse escaping never saw.
|
||||
* @param label - Styles element names and attributes.
|
||||
* @param body - Styles the text content under those elements; the card's body tone, so tree
|
||||
* content matches the surrounding card rows instead of falling back to the default foreground.
|
||||
* @param omitted - Renders the omitted-line marker for a collapsed child or child range.
|
||||
* @returns Tree rows, or `undefined` when `source` is not one supported complete XML document.
|
||||
*/
|
||||
export function renderUnknownXml(
|
||||
source: string,
|
||||
maxChildLines: number,
|
||||
expanded: boolean,
|
||||
display: (text: string) => string,
|
||||
label: (text: string) => string,
|
||||
body: (text: string) => string,
|
||||
omitted: (count: number) => string,
|
||||
): string[] | undefined {
|
||||
const root = parseXml(source, display)
|
||||
if (root === undefined) return undefined
|
||||
const blocks = meaningfulChildren(root).map(child =>
|
||||
typeof child === 'string' ? textBlock(child, 1, body) : treeLines(child, 1, label, body))
|
||||
const rootLine = label(elementLabel(root))
|
||||
if (expanded) return [rootLine, ...blocks.flat()]
|
||||
const previewed = blocks.map(block => preview(block, maxChildLines, omitted))
|
||||
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(),
|
||||
]
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* 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 added and removed lines explored while deriving an exact line diff. */
|
||||
maxDiffEditLength?: 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
|
||||
/** Maximum concurrent cold projection reads in one resume scan. */
|
||||
resumeScanConcurrency?: 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
|
||||
/** Transcript-details selector width in terminal columns. */
|
||||
detailsDialogWidth?: 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 maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000)
|
||||
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 resumeScanConcurrencySchema = z.number().step(1).min(1).default(4)
|
||||
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 detailsDialogWidthSchema = z.number().step(1).min(20).default(72)
|
||||
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 = '${queued}'
|
||||
const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}'
|
||||
const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel'
|
||||
const TuiThemeConfigSchema: z<TuiThemeConfig> = z.object({
|
||||
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,
|
||||
maxDiffEditLength: maxDiffEditLengthSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
maxResumeOptions: maxResumeOptionsSchema,
|
||||
resumeScanConcurrency: resumeScanConcurrencySchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
detailsDialogWidth: detailsDialogWidthSchema,
|
||||
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
|
||||
/**
|
||||
* Skill name auto-invoked as this session's first user turn, exactly as if
|
||||
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent
|
||||
* leaves the first turn to the user.
|
||||
*/
|
||||
initialSkill?: string
|
||||
}
|
||||
|
||||
/** Schemastery schema for the full plugin configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
welcome: z.string(),
|
||||
sessionId: z.string().default('main'),
|
||||
initialSkill: z.string(),
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
|
||||
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
|
||||
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
|
||||
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
|
||||
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
|
||||
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
|
||||
detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth,
|
||||
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
|
||||
maxDiffEditLength: number
|
||||
maxQuestionOptions: number
|
||||
maxModelOptions: number
|
||||
maxResumeOptions: number
|
||||
resumeScanConcurrency: number
|
||||
questionDialogWidth: number
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
modelDialogMaxHeight: number
|
||||
detailsDialogWidth: 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,
|
||||
maxDiffEditLength: config?.maxDiffEditLength ?? 1000,
|
||||
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
|
||||
maxModelOptions: config?.maxModelOptions ?? 8,
|
||||
maxResumeOptions: config?.maxResumeOptions ?? 8,
|
||||
resumeScanConcurrency: config?.resumeScanConcurrency ?? 4,
|
||||
questionDialogWidth: config?.questionDialogWidth ?? 200,
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 76,
|
||||
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
|
||||
detailsDialogWidth: config?.detailsDialogWidth ?? 72,
|
||||
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',
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
/**
|
||||
* Private bridge between the public TUI extension contract and pi-tui.
|
||||
*
|
||||
* The manager serializes modal ownership, guards extension callbacks, and
|
||||
* settles every queued or active operation before terminal teardown.
|
||||
* @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 {
|
||||
Component,
|
||||
Focusable,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type {
|
||||
TuiComponent,
|
||||
TuiFocusable,
|
||||
TuiOverlayCloseReason,
|
||||
TuiOverlayHost,
|
||||
TuiOverlayOutcome,
|
||||
TuiOverlayOptions,
|
||||
TuiOverlayRequest,
|
||||
TuiOverlaySession,
|
||||
TuiOverlayState,
|
||||
TuiTheme,
|
||||
TuiViewport,
|
||||
} from './types.ts'
|
||||
|
||||
/** pi-tui operations retained by the front door instead of exposed to plugins. */
|
||||
export interface TuiOverlayDriver {
|
||||
/** Current terminal viewport. */
|
||||
viewport(): TuiViewport
|
||||
/** Current semantic theme facade. */
|
||||
theme(): TuiTheme
|
||||
/** Escape text at the terminal display boundary. */
|
||||
display(value: string): string
|
||||
/** Mount one guarded modal and return its private focus/lifecycle handle. */
|
||||
show(component: Component, options: TuiOverlayOptions | undefined, placement: TuiOverlayPlacement): TuiModalHandle
|
||||
/** Invalidate the mounted UI and request a render. */
|
||||
invalidate(): void
|
||||
/** Report a contained extension failure. */
|
||||
reportError(error: unknown): void
|
||||
}
|
||||
|
||||
type TuiOverlayPlacement = 'overlay' | 'inline'
|
||||
|
||||
interface TuiModalHandle {
|
||||
hide(): void
|
||||
}
|
||||
|
||||
interface OverlayEntry {
|
||||
readonly request: TuiOverlayRequest
|
||||
readonly controller: AbortController
|
||||
readonly signal: AbortSignal
|
||||
readonly closed: Promise<TuiOverlayOutcome>
|
||||
readonly resolveClosed: (outcome: TuiOverlayOutcome) => void
|
||||
readonly session: TuiOverlaySession
|
||||
readonly placement: TuiOverlayPlacement
|
||||
state: TuiOverlayState
|
||||
component?: GuardedOverlayComponent
|
||||
handle?: TuiModalHandle
|
||||
removeRequestAbort?: () => void
|
||||
outcome?: TuiOverlayOutcome
|
||||
failing?: boolean
|
||||
}
|
||||
|
||||
/** Turn a close reason into its immutable public outcome. */
|
||||
function outcome(reason: Exclude<TuiOverlayCloseReason, 'error'>): TuiOverlayOutcome {
|
||||
return Object.freeze({ reason })
|
||||
}
|
||||
|
||||
/** Retain only supported layout fields before a queued request returns to its caller. */
|
||||
function retainOptions(options: TuiOverlayOptions): TuiOverlayOptions {
|
||||
return Object.freeze({
|
||||
...options.width === undefined ? {} : { width: options.width },
|
||||
...options.minWidth === undefined ? {} : { minWidth: options.minWidth },
|
||||
...options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight },
|
||||
...options.anchor === undefined ? {} : { anchor: options.anchor },
|
||||
...options.margin === undefined
|
||||
? {}
|
||||
: {
|
||||
margin: typeof options.margin === 'object'
|
||||
? Object.freeze({ ...options.margin })
|
||||
: options.margin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Guard plugin component methods while preserving focus and key-release state. */
|
||||
class GuardedOverlayComponent implements Component, Focusable {
|
||||
constructor(
|
||||
private readonly component: TuiComponent & Partial<TuiFocusable>,
|
||||
private readonly fail: (error: unknown) => void,
|
||||
) {}
|
||||
|
||||
get focused(): boolean {
|
||||
try {
|
||||
return this.component.focused ?? false
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
try {
|
||||
if ('focused' in this.component) this.component.focused = value
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
get wantsKeyRelease(): boolean {
|
||||
try {
|
||||
return this.component.wantsKeyRelease ?? false
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
try {
|
||||
return this.component.render(width)
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
try {
|
||||
this.component.handleInput?.(data)
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): boolean {
|
||||
try {
|
||||
this.component.invalidate()
|
||||
return true
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** FIFO modal owner for one mounted TUI. */
|
||||
export class TuiOverlayManager {
|
||||
private readonly queue: OverlayEntry[] = []
|
||||
private active: OverlayEntry | undefined
|
||||
private accepting = true
|
||||
private disposeTask: Promise<void> | undefined
|
||||
|
||||
constructor(private readonly driver: TuiOverlayDriver) {}
|
||||
|
||||
/**
|
||||
* Whether one extension or built-in overlay currently owns terminal focus.
|
||||
* @returns `true` while an overlay is active.
|
||||
*/
|
||||
hasActiveOverlay(): boolean {
|
||||
return this.active !== undefined
|
||||
}
|
||||
|
||||
/** Reject new work while the TUI unloads dependent extension fibers. */
|
||||
beginShutdown(): void {
|
||||
this.accepting = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one modal without assigning Cordis ownership.
|
||||
* @param request - component factory, constraints, and request signal.
|
||||
* @param placement - terminal overlay for extensions, or inline for the built-in question panel.
|
||||
* @returns an internal session that can close with an ownership reason.
|
||||
*/
|
||||
open(request: TuiOverlayRequest, placement: TuiOverlayPlacement = 'overlay'): TuiOverlaySession & {
|
||||
closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
||||
} {
|
||||
if (!this.accepting) throw new Error('TUI is shutting down')
|
||||
const requestSignal = request.signal
|
||||
const retainedRequest: TuiOverlayRequest = Object.freeze({
|
||||
create: request.create,
|
||||
...request.options === undefined ? {} : { options: retainOptions(request.options) },
|
||||
...requestSignal === undefined ? {} : { signal: requestSignal },
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const signal = requestSignal === undefined
|
||||
? controller.signal
|
||||
: AbortSignal.any([requestSignal, controller.signal])
|
||||
const deferred = Promise.withResolvers<TuiOverlayOutcome>()
|
||||
const session: TuiOverlaySession & {
|
||||
closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
||||
} = {
|
||||
get state(): TuiOverlayState {
|
||||
return entry.state
|
||||
},
|
||||
closed: deferred.promise,
|
||||
close: () => this.close(entry, outcome('closed')),
|
||||
closeWith: (reason: Exclude<TuiOverlayCloseReason, 'error'>) =>
|
||||
this.close(entry, outcome(reason)),
|
||||
}
|
||||
const entry: OverlayEntry = {
|
||||
request: retainedRequest,
|
||||
controller,
|
||||
signal,
|
||||
closed: deferred.promise,
|
||||
resolveClosed: deferred.resolve,
|
||||
session,
|
||||
placement,
|
||||
state: 'queued',
|
||||
}
|
||||
if (requestSignal?.aborted === true) {
|
||||
void this.close(entry, outcome('aborted'))
|
||||
return session
|
||||
}
|
||||
if (requestSignal !== undefined) {
|
||||
const onAbort = (): void => { void this.close(entry, outcome('aborted')) }
|
||||
requestSignal.addEventListener('abort', onAbort, { once: true })
|
||||
entry.removeRequestAbort = () => { requestSignal.removeEventListener('abort', onAbort) }
|
||||
}
|
||||
this.queue.push(entry)
|
||||
this.activateNext()
|
||||
return session
|
||||
}
|
||||
|
||||
/** Stop accepting work and settle every active or queued overlay. */
|
||||
dispose(): Promise<void> {
|
||||
if (this.disposeTask !== undefined) return this.disposeTask
|
||||
this.beginShutdown()
|
||||
const entries = [
|
||||
...this.active === undefined ? [] : [this.active],
|
||||
...this.queue,
|
||||
]
|
||||
return this.disposeTask = Promise.all(
|
||||
entries.map(entry => this.close(entry, outcome('tui-disposed'))),
|
||||
).then(() => {})
|
||||
}
|
||||
|
||||
private activateNext(): void {
|
||||
if (!this.accepting || this.active !== undefined) return
|
||||
const entry = this.queue.shift()
|
||||
if (entry === undefined) return
|
||||
this.active = entry
|
||||
entry.state = 'active'
|
||||
const host = this.host(entry)
|
||||
let component: TuiComponent & Partial<TuiFocusable>
|
||||
try {
|
||||
component = entry.request.create(host)
|
||||
} catch (error) {
|
||||
this.fail(entry, error)
|
||||
return
|
||||
}
|
||||
if (this.active !== entry) return
|
||||
const guarded = new GuardedOverlayComponent(component, (error) => {
|
||||
this.fail(entry, error)
|
||||
})
|
||||
entry.component = guarded
|
||||
try {
|
||||
const handle = this.driver.show(guarded, entry.request.options, entry.placement)
|
||||
if (this.active !== entry) {
|
||||
this.hide(handle)
|
||||
return
|
||||
}
|
||||
entry.handle = handle
|
||||
this.driver.invalidate()
|
||||
} catch (error) {
|
||||
this.fail(entry, error)
|
||||
}
|
||||
}
|
||||
|
||||
private host(entry: OverlayEntry): TuiOverlayHost {
|
||||
const driver = this.driver
|
||||
return Object.freeze({
|
||||
get signal(): AbortSignal {
|
||||
return entry.signal
|
||||
},
|
||||
get viewport(): TuiViewport {
|
||||
return Object.freeze({ ...driver.viewport() })
|
||||
},
|
||||
get theme(): TuiTheme {
|
||||
return driver.theme()
|
||||
},
|
||||
display: (value: string) => this.driver.display(value),
|
||||
invalidate: () => {
|
||||
if (this.active !== entry || entry.component === undefined || entry.failing === true) return
|
||||
if (!entry.component.invalidate() || this.active !== entry) return
|
||||
try {
|
||||
this.driver.invalidate()
|
||||
} catch (error) {
|
||||
this.fail(entry, error)
|
||||
}
|
||||
},
|
||||
close: () => { void this.close(entry, outcome('closed')) },
|
||||
})
|
||||
}
|
||||
|
||||
private fail(entry: OverlayEntry, error: unknown): void {
|
||||
if (entry.state === 'closed' || entry.failing === true) return
|
||||
entry.failing = true
|
||||
this.report(error)
|
||||
queueMicrotask(() => {
|
||||
void this.close(entry, Object.freeze({ reason: 'error', error }))
|
||||
})
|
||||
}
|
||||
|
||||
private report(error: unknown): void {
|
||||
try {
|
||||
this.driver.reportError(error)
|
||||
} catch {
|
||||
// Error reporting is a containment boundary, never a second failure path.
|
||||
}
|
||||
}
|
||||
|
||||
private hide(handle: TuiModalHandle): void {
|
||||
try {
|
||||
handle.hide()
|
||||
} catch (error) {
|
||||
this.report(error)
|
||||
}
|
||||
}
|
||||
|
||||
private close(entry: OverlayEntry, result: TuiOverlayOutcome): Promise<TuiOverlayOutcome> {
|
||||
if (entry.outcome !== undefined) return entry.closed
|
||||
entry.outcome = result
|
||||
entry.state = 'closed'
|
||||
entry.removeRequestAbort?.()
|
||||
delete entry.removeRequestAbort
|
||||
if (!entry.controller.signal.aborted) entry.controller.abort(result)
|
||||
const queuedIndex = this.queue.indexOf(entry)
|
||||
if (queuedIndex >= 0) this.queue.splice(queuedIndex, 1)
|
||||
if (this.active === entry) {
|
||||
this.active = undefined
|
||||
if (entry.handle !== undefined) this.hide(entry.handle)
|
||||
delete entry.handle
|
||||
}
|
||||
delete entry.component
|
||||
entry.resolveClosed(result)
|
||||
try {
|
||||
this.driver.invalidate()
|
||||
} catch (error) {
|
||||
this.report(error)
|
||||
}
|
||||
queueMicrotask(() => { this.activateNext() })
|
||||
return entry.closed
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis service whose method effects bind to the calling plugin fiber. */
|
||||
export class TuiExtensionServiceImpl extends Service implements TuiExtensionService {
|
||||
constructor(
|
||||
ctx: Context,
|
||||
readonly agent: Agent,
|
||||
private readonly overlays: TuiOverlayManager,
|
||||
) {
|
||||
super(ctx, 'tui')
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
openOverlay(request: TuiOverlayRequest): TuiOverlaySession {
|
||||
let operation: ReturnType<TuiOverlayManager['open']> | undefined
|
||||
const disposeOwner = this.ctx.effect(
|
||||
() => () => operation?.closeWith('owner-disposed'),
|
||||
'tui.openOverlay()',
|
||||
)
|
||||
try {
|
||||
operation = this.overlays.open(request)
|
||||
} catch (error) {
|
||||
void disposeOwner()
|
||||
throw error
|
||||
}
|
||||
void operation.closed.then(() => { void disposeOwner() })
|
||||
return operation
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Public interactive-extension contract for one mounted TUI front door.
|
||||
*
|
||||
* Plugins receive terminal-specific rendering primitives without access to
|
||||
* 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/types
|
||||
*/
|
||||
|
||||
/** Terminal component shape accepted from a trusted TUI extension. */
|
||||
export interface TuiComponent {
|
||||
/**
|
||||
* Render this component for the supplied viewport width.
|
||||
* @param width - Available terminal columns.
|
||||
* @returns terminal lines owned by this component.
|
||||
*/
|
||||
render(width: number): string[]
|
||||
/**
|
||||
* Handle one terminal input sequence while this component owns focus.
|
||||
* @param data - Raw terminal input sequence.
|
||||
*/
|
||||
handleInput?(data: string): void
|
||||
/** Receive key-release events instead of having them filtered by the host. */
|
||||
wantsKeyRelease?: boolean
|
||||
/** Drop cached rendering derived from theme, size, or component state. */
|
||||
invalidate(): void
|
||||
}
|
||||
|
||||
/** Optional focus state forwarded by the host to a component. */
|
||||
export interface TuiFocusable {
|
||||
/** Whether the component currently owns terminal focus. */
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
/** Read-only semantic color roles supplied by the mounted TUI. */
|
||||
export interface TuiTheme {
|
||||
/** Render ordinary foreground text. */
|
||||
readonly text: (value: string) => string
|
||||
/** Render trusted static brand art with the host's configured brand treatment. */
|
||||
readonly brand: (value: string) => string
|
||||
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
|
||||
readonly dim: (value: string) => string
|
||||
/** Render the active accent role. */
|
||||
readonly accent: (value: string) => string
|
||||
/** Render a successful outcome. */
|
||||
readonly success: (value: string) => string
|
||||
/** Render a warning. */
|
||||
readonly warning: (value: string) => string
|
||||
/** Render an error. */
|
||||
readonly error: (value: string) => string
|
||||
/** Apply the host's bold role. */
|
||||
readonly bold: (value: string) => string
|
||||
}
|
||||
|
||||
/** Current terminal viewport exposed without the mutable Terminal object. */
|
||||
export interface TuiViewport {
|
||||
/** Terminal columns. */
|
||||
readonly columns: number
|
||||
/** Terminal rows. */
|
||||
readonly rows: number
|
||||
}
|
||||
|
||||
/** Supported overlay anchor points. */
|
||||
export type TuiOverlayAnchor =
|
||||
| 'center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'top-center'
|
||||
| 'bottom-center'
|
||||
| 'left-center'
|
||||
| 'right-center'
|
||||
|
||||
/** Terminal-edge spacing for an overlay. */
|
||||
export interface TuiOverlayMargin {
|
||||
/** Rows reserved above the overlay. */
|
||||
readonly top?: number
|
||||
/** Columns reserved to the right of the overlay. */
|
||||
readonly right?: number
|
||||
/** Rows reserved below the overlay. */
|
||||
readonly bottom?: number
|
||||
/** Columns reserved to the left of the overlay. */
|
||||
readonly left?: number
|
||||
}
|
||||
|
||||
/** Position and size constraints retained under TUI host ownership. */
|
||||
export interface TuiOverlayOptions {
|
||||
/** Width in columns or as a percentage of terminal width. */
|
||||
readonly width?: number | `${number}%`
|
||||
/** Minimum width in columns. */
|
||||
readonly minWidth?: number
|
||||
/** Maximum height in rows or as a percentage of terminal height. */
|
||||
readonly maxHeight?: number | `${number}%`
|
||||
/** Overlay anchor; defaults to the terminal center. */
|
||||
readonly anchor?: TuiOverlayAnchor
|
||||
/** Terminal-edge spacing. */
|
||||
readonly margin?: number | TuiOverlayMargin
|
||||
}
|
||||
|
||||
/** Capabilities available while an overlay component is queued or visible. */
|
||||
export interface TuiOverlayHost {
|
||||
/**
|
||||
* Aborts when the request, caller fiber, overlay session, or TUI closes.
|
||||
* Extension work started for the overlay must cooperate with this signal.
|
||||
*/
|
||||
readonly signal: AbortSignal
|
||||
/** Current viewport; a fresh immutable value is returned on every read. */
|
||||
readonly viewport: TuiViewport
|
||||
/** Semantic styles that follow terminal color-scheme changes. */
|
||||
readonly theme: TuiTheme
|
||||
/**
|
||||
* Escape control characters in untrusted display text.
|
||||
* @param value - text crossing into terminal presentation.
|
||||
* @returns a printable representation that cannot emit terminal controls.
|
||||
*/
|
||||
display(value: string): string
|
||||
/** Invalidate the component and schedule one contained terminal redraw. */
|
||||
invalidate(): void
|
||||
/** Close this overlay normally; repeated calls are no-ops. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** One effect-owned request to create an interactive overlay. */
|
||||
export interface TuiOverlayRequest {
|
||||
/**
|
||||
* Construct the component when this request reaches the front of the modal
|
||||
* queue. A throw closes the session with `reason: "error"`.
|
||||
*/
|
||||
readonly create: (host: TuiOverlayHost) => TuiComponent & Partial<TuiFocusable>
|
||||
/** Host-owned position and size constraints. */
|
||||
readonly options?: TuiOverlayOptions
|
||||
/** Optional request cancellation in addition to caller and TUI ownership. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable reason an overlay stopped being queued or visible. */
|
||||
export type TuiOverlayCloseReason =
|
||||
| 'closed'
|
||||
| 'aborted'
|
||||
| 'owner-disposed'
|
||||
| 'tui-disposed'
|
||||
| 'error'
|
||||
|
||||
/** Settled overlay outcome; component failures retain their original value. */
|
||||
export type TuiOverlayOutcome =
|
||||
| { readonly reason: Exclude<TuiOverlayCloseReason, 'error'> }
|
||||
| { readonly reason: 'error'; readonly error: unknown }
|
||||
|
||||
/** Live state of an overlay operation. */
|
||||
export type TuiOverlayState = 'queued' | 'active' | 'closed'
|
||||
|
||||
/** Handle returned to the extension that opened an overlay. */
|
||||
export interface TuiOverlaySession {
|
||||
/** Current queue/display state. */
|
||||
readonly state: TuiOverlayState
|
||||
/** Settles exactly once after the overlay leaves the queue or display. */
|
||||
readonly closed: Promise<TuiOverlayOutcome>
|
||||
/**
|
||||
* Close the overlay normally and await its settled outcome.
|
||||
* @returns the same immutable value exposed through {@link closed}.
|
||||
*/
|
||||
close(): Promise<TuiOverlayOutcome>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
|
||||
* @module @deepseek-ai/dsh-tui/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tui'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tui-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
|
||||
* boundary and replay tests cover its protocol mapping.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* 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` in
|
||||
* `cwd`. Success does not return. A host may reject before it commits
|
||||
* teardown; after commit it owns fatal reporting and process exit.
|
||||
* @param sessionId - validated persisted session selected by the user.
|
||||
* @param cwd - the selected session's own workspace, which the replacement
|
||||
* process must run in: process cwd, not the restored session header, is what
|
||||
* filesystem and shell tools resolve against. It may differ from the current
|
||||
* workspace, so a host that cannot enter it must reject before committing
|
||||
* teardown.
|
||||
*/
|
||||
handoff(sessionId: SessionId, cwd: string): 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 the session selectable but not resumable in place. */
|
||||
handoffResume?: TuiResumeHost['handoff']
|
||||
/**
|
||||
* Line the host wants printed once the terminal is released on exit, such as
|
||||
* the command that resumes this session. Absent prints nothing. The host owns
|
||||
* the wording; the TUI owns rendering and escapes terminal controls, so
|
||||
* embedded ANSI is shown literally rather than applied.
|
||||
*/
|
||||
goodbyeMessage?: string
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { gitBranch } from '../src/chat/helpers.ts'
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(() => 'main\n'),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('chat helpers', () => {
|
||||
it('scrubs ambient credentials and DSH names from the Git child', () => {
|
||||
vi.stubEnv('TUI_TEST_PASSWORD', 'ambient-password')
|
||||
vi.stubEnv('DSH_TUI_TEST_FLAG', 'ambient-harness-state')
|
||||
expect(gitBranch('/workspace')).toBe('main')
|
||||
const call = vi.mocked(execFileSync).mock.calls[0] as unknown as [
|
||||
string,
|
||||
string[],
|
||||
{ env: NodeJS.ProcessEnv },
|
||||
]
|
||||
expect(call[0]).toBe('git')
|
||||
expect(call[1]).toEqual(['branch', '--show-current'])
|
||||
expect(call[2].env).not.toHaveProperty('TUI_TEST_PASSWORD')
|
||||
expect(call[2].env).not.toHaveProperty('DSH_TUI_TEST_FLAG')
|
||||
})
|
||||
})
|
||||
@@ -1,588 +0,0 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Component,
|
||||
OverlayHandle,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type {
|
||||
TuiComponent,
|
||||
TuiOverlayHost,
|
||||
TuiOverlayOptions,
|
||||
TuiOverlaySession,
|
||||
TuiTheme,
|
||||
} from '../src/extension/types.ts'
|
||||
import {
|
||||
TuiExtensionServiceImpl,
|
||||
TuiOverlayManager,
|
||||
type TuiOverlayDriver,
|
||||
} from '../src/extension/overlay-manager.ts'
|
||||
|
||||
const theme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => `text:${value}`,
|
||||
brand: (value: string) => `brand:${value}`,
|
||||
muted: (value: string) => `muted:${value}`,
|
||||
dim: (value: string) => `dim:${value}`,
|
||||
accent: (value: string) => `accent:${value}`,
|
||||
success: (value: string) => `success:${value}`,
|
||||
warning: (value: string) => `warning:${value}`,
|
||||
error: (value: string) => `error:${value}`,
|
||||
bold: (value: string) => `bold:${value}`,
|
||||
})
|
||||
|
||||
interface ShownOverlay {
|
||||
component: Component
|
||||
options: TuiOverlayOptions | undefined
|
||||
hidden: boolean
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
interface DriverFixture {
|
||||
driver: TuiOverlayDriver
|
||||
shown: ShownOverlay[]
|
||||
errors: unknown[]
|
||||
invalidations: number
|
||||
showError?: unknown
|
||||
onShow?: (component: Component) => void
|
||||
}
|
||||
|
||||
function driverFixture(): DriverFixture {
|
||||
const fixture: DriverFixture = {
|
||||
shown: [],
|
||||
errors: [],
|
||||
invalidations: 0,
|
||||
driver: undefined as never,
|
||||
}
|
||||
fixture.driver = {
|
||||
viewport: () => ({ columns: 96, rows: 32 }),
|
||||
theme: () => theme,
|
||||
display: value => `safe:${value}`,
|
||||
show(component, options) {
|
||||
if (fixture.showError !== undefined) throw fixture.showError
|
||||
const shown: ShownOverlay = {
|
||||
component,
|
||||
options,
|
||||
hidden: false,
|
||||
focused: true,
|
||||
}
|
||||
fixture.shown.push(shown)
|
||||
const handle: OverlayHandle = {
|
||||
hide() {
|
||||
shown.hidden = true
|
||||
shown.focused = false
|
||||
},
|
||||
setHidden(hidden) {
|
||||
shown.hidden = hidden
|
||||
},
|
||||
isHidden: () => shown.hidden,
|
||||
focus() {
|
||||
shown.focused = true
|
||||
},
|
||||
unfocus() {
|
||||
shown.focused = false
|
||||
},
|
||||
isFocused: () => shown.focused,
|
||||
}
|
||||
fixture.onShow?.(component)
|
||||
return handle
|
||||
},
|
||||
invalidate() {
|
||||
fixture.invalidations += 1
|
||||
},
|
||||
reportError(error) {
|
||||
fixture.errors.push(error)
|
||||
},
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
function component(lines = ['overlay']): TuiComponent {
|
||||
return {
|
||||
render: () => lines,
|
||||
invalidate() {},
|
||||
}
|
||||
}
|
||||
|
||||
async function microtask(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('TuiOverlayManager', () => {
|
||||
it('serializes overlays, exposes the constrained host, and settles normal close once', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
let firstHost: TuiOverlayHost | undefined
|
||||
const firstComponent = {
|
||||
focused: false,
|
||||
wantsKeyRelease: true,
|
||||
inputs: [] as string[],
|
||||
invalidated: 0,
|
||||
render: (width: number) => [`first:${String(width)}`],
|
||||
handleInput(data: string) {
|
||||
this.inputs.push(data)
|
||||
},
|
||||
invalidate() {
|
||||
this.invalidated += 1
|
||||
},
|
||||
}
|
||||
const first = manager.open({
|
||||
create(host) {
|
||||
firstHost = host
|
||||
return firstComponent
|
||||
},
|
||||
options: { width: '75%', minWidth: 24, maxHeight: 20, anchor: 'center', margin: { bottom: 1 } },
|
||||
})
|
||||
const secondOptions: TuiOverlayOptions = { width: 40, margin: { bottom: 2 } }
|
||||
const second = manager.open({
|
||||
create: () => component(['second']),
|
||||
options: secondOptions,
|
||||
})
|
||||
;(secondOptions as { width: number }).width = 80
|
||||
;(secondOptions.margin as { bottom: number }).bottom = 4
|
||||
|
||||
expect(manager.hasActiveOverlay()).toBe(true)
|
||||
expect(first.state).toBe('active')
|
||||
expect(second.state).toBe('queued')
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(fixture.shown[0]?.options).toEqual({
|
||||
width: '75%',
|
||||
minWidth: 24,
|
||||
maxHeight: 20,
|
||||
anchor: 'center',
|
||||
margin: { bottom: 1 },
|
||||
})
|
||||
expect(firstHost?.viewport).toEqual({ columns: 96, rows: 32 })
|
||||
expect(Object.isFrozen(firstHost?.viewport)).toBe(true)
|
||||
expect(firstHost?.theme.accent('x')).toBe('accent:x')
|
||||
expect(firstHost?.display('\u001b')).toBe('safe:\u001b')
|
||||
firstHost?.invalidate()
|
||||
expect(firstComponent.invalidated).toBe(1)
|
||||
expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40'])
|
||||
fixture.shown[0]!.component.handleInput?.('x')
|
||||
fixture.shown[0]!.component.invalidate()
|
||||
expect(firstComponent.inputs).toEqual(['x'])
|
||||
expect(firstComponent.invalidated).toBe(2)
|
||||
expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true)
|
||||
;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true
|
||||
expect(firstComponent.focused).toBe(true)
|
||||
expect((fixture.shown[0]?.component as Component & { focused: boolean }).focused).toBe(true)
|
||||
|
||||
const firstOutcome = await first.close()
|
||||
expect(firstOutcome).toEqual({ reason: 'closed' })
|
||||
expect(await first.close()).toBe(firstOutcome)
|
||||
expect(firstHost?.signal.aborted).toBe(true)
|
||||
const beforeClosedInvalidation = fixture.invalidations
|
||||
firstHost?.invalidate()
|
||||
expect(fixture.invalidations).toBe(beforeClosedInvalidation)
|
||||
await microtask()
|
||||
|
||||
expect(first.state).toBe('closed')
|
||||
expect(second.state).toBe('active')
|
||||
expect(fixture.shown[0]?.hidden).toBe(true)
|
||||
expect(fixture.shown[1]?.options).toEqual({ width: 40, margin: { bottom: 2 } })
|
||||
expect(Object.isFrozen(fixture.shown[1]?.options)).toBe(true)
|
||||
expect(Object.isFrozen(fixture.shown[1]?.options?.margin)).toBe(true)
|
||||
expect(fixture.shown[1]?.component.wantsKeyRelease).toBe(false)
|
||||
expect((fixture.shown[1]?.component as Component & { focused: boolean }).focused).toBe(false)
|
||||
;(fixture.shown[1]?.component as Component & { focused: boolean }).focused = true
|
||||
fixture.shown[1]!.component.handleInput?.('ignored')
|
||||
await second.close()
|
||||
await microtask()
|
||||
|
||||
const numericMargin = manager.open({
|
||||
create: () => component(['numeric margin']),
|
||||
options: { margin: 1 },
|
||||
})
|
||||
expect(fixture.shown[2]?.options).toEqual({ margin: 1 })
|
||||
await numericMargin.close()
|
||||
await microtask()
|
||||
|
||||
const emptyOptions = manager.open({
|
||||
create: () => component(['empty options']),
|
||||
options: {},
|
||||
})
|
||||
expect(fixture.shown[3]?.options).toEqual({})
|
||||
await emptyOptions.close()
|
||||
await microtask()
|
||||
|
||||
expect(manager.hasActiveOverlay()).toBe(false)
|
||||
await manager.dispose()
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('removes pre-aborted, active, and queued requests without activating cancelled work', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
const pre = manager.open({
|
||||
signal: preAborted.signal,
|
||||
create: () => component(['never']),
|
||||
})
|
||||
expect(await pre.closed).toEqual({ reason: 'aborted' })
|
||||
expect(fixture.shown).toHaveLength(0)
|
||||
|
||||
const activeAbort = new AbortController()
|
||||
let activeHost: TuiOverlayHost | undefined
|
||||
const active = manager.open({
|
||||
signal: activeAbort.signal,
|
||||
create(host) {
|
||||
activeHost = host
|
||||
return component(['active'])
|
||||
},
|
||||
})
|
||||
const queuedAbort = new AbortController()
|
||||
const queued = manager.open({
|
||||
signal: queuedAbort.signal,
|
||||
create: () => component(['queued']),
|
||||
})
|
||||
queuedAbort.abort()
|
||||
expect(await queued.closed).toEqual({ reason: 'aborted' })
|
||||
expect(queued.state).toBe('closed')
|
||||
activeAbort.abort()
|
||||
expect(await active.closed).toEqual({ reason: 'aborted' })
|
||||
expect(activeHost?.signal.aborted).toBe(true)
|
||||
await microtask()
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(manager.hasActiveOverlay()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not mount entries closed or aborted during component construction', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const closed = manager.open({
|
||||
create(host) {
|
||||
host.invalidate()
|
||||
host.close()
|
||||
return component(['closed during construction'])
|
||||
},
|
||||
})
|
||||
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborted = manager.open({
|
||||
signal: controller.signal,
|
||||
create() {
|
||||
controller.abort()
|
||||
return component(['aborted during construction'])
|
||||
},
|
||||
})
|
||||
await expect(aborted.closed).resolves.toEqual({ reason: 'aborted' })
|
||||
|
||||
const after = manager.open({ create: () => component(['after construction closes']) })
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(fixture.shown[0]?.component.render(40)).toEqual(['after construction closes'])
|
||||
await after.close()
|
||||
})
|
||||
|
||||
it('hides a handle returned after reentrant closure during mounting', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
fixture.onShow = (shown) => {
|
||||
;(shown as Component & { focused: boolean }).focused = true
|
||||
}
|
||||
const closed = manager.open({
|
||||
create(host) {
|
||||
return {
|
||||
get focused(): boolean {
|
||||
return false
|
||||
},
|
||||
set focused(_value: boolean) {
|
||||
host.close()
|
||||
},
|
||||
render: () => ['closed during mount'],
|
||||
invalidate() {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
||||
expect(fixture.shown[0]?.hidden).toBe(true)
|
||||
expect(manager.hasActiveOverlay()).toBe(false)
|
||||
|
||||
delete fixture.onShow
|
||||
const after = manager.open({ create: () => component(['after mount close']) })
|
||||
expect(fixture.shown[1]?.hidden).toBe(false)
|
||||
expect(fixture.shown[1]?.component.render(40)).toEqual(['after mount close'])
|
||||
await after.close()
|
||||
})
|
||||
|
||||
it('stops admission and disposes active and queued overlays with the TUI', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const active = manager.open({ create: () => component(['active']) })
|
||||
const queued = manager.open({ create: () => component(['queued']) })
|
||||
manager.beginShutdown()
|
||||
expect(() => manager.open({ create: () => component() })).toThrow('TUI is shutting down')
|
||||
await manager.dispose()
|
||||
expect(await active.closed).toEqual({ reason: 'tui-disposed' })
|
||||
expect(await queued.closed).toEqual({ reason: 'tui-disposed' })
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(fixture.shown[0]?.hidden).toBe(true)
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('contains factory, mount, render, input, and invalidation failures', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const factoryError = new Error('factory failed')
|
||||
const factory = manager.open({
|
||||
create() {
|
||||
throw factoryError
|
||||
},
|
||||
})
|
||||
const afterFactory = manager.open({ create: () => component(['after factory']) })
|
||||
expect(await factory.closed).toEqual({ reason: 'error', error: factoryError })
|
||||
await microtask()
|
||||
expect(afterFactory.state).toBe('active')
|
||||
await afterFactory.close()
|
||||
await microtask()
|
||||
|
||||
const showError = new Error('show failed')
|
||||
fixture.showError = showError
|
||||
const show = manager.open({ create: () => component(['show']) })
|
||||
expect(await show.closed).toEqual({ reason: 'error', error: showError })
|
||||
delete fixture.showError
|
||||
await microtask()
|
||||
|
||||
const renderError = new Error('render failed')
|
||||
const rendering = manager.open({
|
||||
create: () => ({
|
||||
render() {
|
||||
throw renderError
|
||||
},
|
||||
invalidate() {
|
||||
throw new Error('must be suppressed after the first failure')
|
||||
},
|
||||
}),
|
||||
})
|
||||
const renderComponent = fixture.shown.at(-1)!.component
|
||||
expect(renderComponent.render(20)).toEqual([])
|
||||
renderComponent.invalidate()
|
||||
expect(fixture.errors.filter(error => error === renderError)).toHaveLength(1)
|
||||
expect(await rendering.closed).toEqual({ reason: 'error', error: renderError })
|
||||
await microtask()
|
||||
|
||||
const inputError = new Error('input failed')
|
||||
const input = manager.open({
|
||||
create: () => ({
|
||||
render: () => ['input'],
|
||||
handleInput() {
|
||||
throw inputError
|
||||
},
|
||||
invalidate() {},
|
||||
}),
|
||||
})
|
||||
fixture.shown.at(-1)!.component.handleInput?.('x')
|
||||
expect(await input.closed).toEqual({ reason: 'error', error: inputError })
|
||||
await microtask()
|
||||
|
||||
const invalidateError = new Error('invalidate failed')
|
||||
let invalidatingHost: TuiOverlayHost | undefined
|
||||
const invalidating = manager.open({
|
||||
create(host) {
|
||||
invalidatingHost = host
|
||||
return {
|
||||
render: () => ['invalidate'],
|
||||
invalidate() {
|
||||
throw invalidateError
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
const invalidationsBeforeFailure = fixture.invalidations
|
||||
invalidatingHost?.invalidate()
|
||||
invalidatingHost?.invalidate()
|
||||
expect(fixture.invalidations).toBe(invalidationsBeforeFailure)
|
||||
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError })
|
||||
await microtask()
|
||||
|
||||
const focusError = new Error('focus failed')
|
||||
const focus = manager.open({
|
||||
create: () => ({
|
||||
get focused(): boolean {
|
||||
throw focusError
|
||||
},
|
||||
set focused(_value: boolean) {
|
||||
throw new Error('focus assignment failed')
|
||||
},
|
||||
get wantsKeyRelease(): boolean {
|
||||
throw new Error('key-release query failed')
|
||||
},
|
||||
render: () => ['focus'],
|
||||
invalidate() {},
|
||||
}),
|
||||
})
|
||||
const guarded = fixture.shown.at(-1)!.component as Component & { focused: boolean }
|
||||
expect(guarded.focused).toBe(false)
|
||||
guarded.focused = true
|
||||
expect(guarded.wantsKeyRelease).toBe(false)
|
||||
expect(await focus.closed).toEqual({ reason: 'error', error: focusError })
|
||||
expect(fixture.errors).toEqual([
|
||||
factoryError,
|
||||
showError,
|
||||
renderError,
|
||||
inputError,
|
||||
invalidateError,
|
||||
focusError,
|
||||
])
|
||||
})
|
||||
|
||||
it('contains host redraw, overlay removal, and error-reporter failures', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
let host: TuiOverlayHost | undefined
|
||||
const invalidationError = new Error('redraw failed')
|
||||
let redrawFails = false
|
||||
fixture.driver.invalidate = () => {
|
||||
if (redrawFails) throw invalidationError
|
||||
}
|
||||
fixture.driver.reportError = () => { throw new Error('report failed') }
|
||||
const invalidating = manager.open({
|
||||
create(value) {
|
||||
host = value
|
||||
return component()
|
||||
},
|
||||
})
|
||||
redrawFails = true
|
||||
host?.invalidate()
|
||||
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidationError })
|
||||
await microtask()
|
||||
|
||||
redrawFails = false
|
||||
fixture.driver.invalidate = () => {}
|
||||
const hideError = new Error('hide failed')
|
||||
fixture.driver.show = () => ({
|
||||
hide() { throw hideError },
|
||||
setHidden() {},
|
||||
isHidden: () => false,
|
||||
focus() {},
|
||||
unfocus() {},
|
||||
isFocused: () => true,
|
||||
})
|
||||
const hiding = manager.open({
|
||||
create(value) {
|
||||
host = value
|
||||
return component()
|
||||
},
|
||||
})
|
||||
host?.close()
|
||||
expect(await hiding.closed).toEqual({ reason: 'closed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TuiExtensionService', () => {
|
||||
it('binds an open overlay to the calling plugin fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const agent = {} as Agent
|
||||
const provider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, agent, manager)
|
||||
})
|
||||
await provider
|
||||
let session: TuiOverlaySession | undefined
|
||||
let host: TuiOverlayHost | undefined
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
expect(consumerCtx.tui.agent).toBe(agent)
|
||||
session = consumerCtx.tui.openOverlay({
|
||||
create(value) {
|
||||
host = value
|
||||
return component(['plugin'])
|
||||
},
|
||||
})
|
||||
})
|
||||
await consumer
|
||||
expect(session?.state).toBe('active')
|
||||
|
||||
await consumer.dispose()
|
||||
expect(await session?.closed).toEqual({ reason: 'owner-disposed' })
|
||||
expect(host?.signal.aborted).toBe(true)
|
||||
await provider.dispose()
|
||||
await manager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('unloads and reloads dependent plugins with the mounted TUI service', async () => {
|
||||
const ctx = new Context()
|
||||
const agent = {} as Agent
|
||||
const sessions: TuiOverlaySession[] = []
|
||||
let starts = 0
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
starts += 1
|
||||
sessions.push(consumerCtx.tui.openOverlay({ create: () => component([`start:${String(starts)}`]) }))
|
||||
})
|
||||
|
||||
const firstFixture = driverFixture()
|
||||
const firstManager = new TuiOverlayManager(firstFixture.driver)
|
||||
const firstProvider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, agent, firstManager)
|
||||
})
|
||||
await firstProvider
|
||||
await consumer
|
||||
expect(starts).toBe(1)
|
||||
await firstProvider.dispose()
|
||||
expect(await sessions[0]?.closed).toEqual({ reason: 'owner-disposed' })
|
||||
|
||||
const secondFixture = driverFixture()
|
||||
const secondManager = new TuiOverlayManager(secondFixture.driver)
|
||||
const secondProvider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, agent, secondManager)
|
||||
})
|
||||
await secondProvider
|
||||
await vi.waitFor(() => { expect(starts).toBe(2) })
|
||||
await sessions[1]?.close()
|
||||
await consumer.dispose()
|
||||
await secondProvider.dispose()
|
||||
await firstManager.dispose()
|
||||
await secondManager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects new service work after terminal shutdown begins', async () => {
|
||||
const ctx = new Context()
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const provider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
|
||||
})
|
||||
await provider
|
||||
manager.beginShutdown()
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
expect(() => consumerCtx.tui.openOverlay({ create: () => component() }))
|
||||
.toThrow('TUI is shutting down')
|
||||
})
|
||||
await consumer
|
||||
await consumer.dispose()
|
||||
await provider.dispose()
|
||||
await manager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not admit an overlay when called from an unloading plugin', async () => {
|
||||
const ctx = new Context()
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const provider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
|
||||
})
|
||||
await provider
|
||||
let error: unknown
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
consumerCtx.effect(() => () => {
|
||||
try {
|
||||
consumerCtx.tui.openOverlay({ create: () => component() })
|
||||
} catch (value) {
|
||||
error = value
|
||||
}
|
||||
})
|
||||
})
|
||||
await consumer
|
||||
await consumer.dispose()
|
||||
expect(error).toMatchObject({ code: 'INACTIVE_EFFECT' })
|
||||
expect(fixture.shown).toHaveLength(0)
|
||||
await provider.dispose()
|
||||
await manager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activeAtToken,
|
||||
formatFileMention,
|
||||
WorkspaceFileSearch,
|
||||
} from '../src/chat/file-autocomplete.ts'
|
||||
|
||||
const searches: WorkspaceFileSearch[] = []
|
||||
const roots: string[] = []
|
||||
|
||||
async function workspace(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-'))
|
||||
roots.push(root)
|
||||
await mkdir(join(root, 'src'), { recursive: true })
|
||||
await mkdir(join(root, 'docs'), { recursive: true })
|
||||
await mkdir(join(root, '.hidden'), { recursive: true })
|
||||
await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true })
|
||||
await writeFile(join(root, 'README.md'), 'readme')
|
||||
await writeFile(join(root, 'src', 'tui.spec.ts'), 'test')
|
||||
await writeFile(join(root, 'src', 'terminal-view.ts'), 'view')
|
||||
await writeFile(join(root, 'docs', 'design notes.md'), 'design')
|
||||
await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden')
|
||||
await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored')
|
||||
try {
|
||||
await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts'))
|
||||
} catch {
|
||||
// Windows may deny symlink creation without Developer Mode; the product
|
||||
// still skips every non-file/non-directory Dirent on platforms that expose one.
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function search(root: string, overrides: Partial<ConstructorParameters<typeof WorkspaceFileSearch>[1]> = {}): WorkspaceFileSearch {
|
||||
const instance = new WorkspaceFileSearch(root, {
|
||||
maxResults: overrides.maxResults ?? 20,
|
||||
maxEntries: overrides.maxEntries ?? 10_000,
|
||||
excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'],
|
||||
})
|
||||
searches.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const instance of searches.splice(0)) instance.dispose()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('TUI file autocomplete grammar', () => {
|
||||
it('recognizes boundary and quoted mentions without treating emails as references', () => {
|
||||
expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false })
|
||||
expect(activeAtToken('read @"docs/design n', 20)).toEqual({
|
||||
prefix: '@"docs/design n',
|
||||
query: 'docs/design n',
|
||||
quoted: true,
|
||||
})
|
||||
expect(activeAtToken('mail a@b.test', 13)).toBeUndefined()
|
||||
expect(activeAtToken('done @src/x" next', 17)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('formats files, directories, quotes, and rejects unsafe editor values', () => {
|
||||
expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts')
|
||||
expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/')
|
||||
expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false))
|
||||
.toBe('@"docs/design notes.md"')
|
||||
expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"')
|
||||
expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined()
|
||||
expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined()
|
||||
expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceFileSearch', () => {
|
||||
it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => {
|
||||
const root = await workspace()
|
||||
const files = search(root)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(await files.list('', signal)).toEqual([
|
||||
{ path: 'docs', kind: 'directory' },
|
||||
{ path: 'src', kind: 'directory' },
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('src/', signal)).toEqual([
|
||||
{ path: 'src/terminal-view.ts', kind: 'file' },
|
||||
{ path: 'src/tui.spec.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('src/ts', signal)).toEqual([
|
||||
{ path: 'src/tui.spec.ts', kind: 'file' },
|
||||
{ path: 'src/terminal-view.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('docs/design n', signal)).toEqual([
|
||||
{ path: 'docs/design notes.md', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('node_modules/', signal)).toEqual([])
|
||||
expect(await files.list('.hidden/', signal)).toEqual([
|
||||
{ path: '.hidden/secret.txt', kind: 'file' },
|
||||
])
|
||||
const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/`
|
||||
expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([
|
||||
{ path: `${absoluteSrc}tui.spec.ts`, kind: 'file' },
|
||||
{ path: `${absoluteSrc}terminal-view.ts`, kind: 'file' },
|
||||
])
|
||||
expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([])
|
||||
expect(await files.list('../', signal)).toEqual([])
|
||||
expect(await files.list('README.md/', signal)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not traverse directory symlinks during direct completion', async () => {
|
||||
const root = await workspace()
|
||||
const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-'))
|
||||
roots.push(outside)
|
||||
await writeFile(join(outside, 'outside-secret.txt'), 'secret')
|
||||
await symlink(
|
||||
outside,
|
||||
join(root, 'escape'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
)
|
||||
const files = search(root)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(await files.list('escape/', signal)).toEqual([])
|
||||
expect(await files.list('escape/outside', signal)).toEqual([])
|
||||
})
|
||||
|
||||
it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => {
|
||||
const root = await workspace()
|
||||
await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper')
|
||||
const files = search(root, { maxResults: 2 })
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(await files.list('tspc', signal)).toEqual([
|
||||
{ path: 'src/tspc-helper.ts', kind: 'file' },
|
||||
{ path: 'src/tui.spec.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('README.md', signal)).toEqual([
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('terminal', signal)).toEqual([
|
||||
{ path: 'src/terminal-view.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('secret', signal)).toEqual([])
|
||||
expect(await files.list('.hidden', signal)).toEqual([
|
||||
{ path: '.hidden', kind: 'directory' },
|
||||
{ path: '.hidden/secret.txt', kind: 'file' },
|
||||
])
|
||||
})
|
||||
|
||||
it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => {
|
||||
const root = await workspace()
|
||||
const capped = search(root, { maxEntries: 2 })
|
||||
const signal = new AbortController().signal
|
||||
expect(await capped.list('README', signal)).toEqual([
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
|
||||
const files = search(root)
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
await writeFile(join(root, 'fresh-file.ts'), 'fresh')
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
files.invalidate()
|
||||
expect(await files.list('fresh-file', signal)).toEqual([
|
||||
{ path: 'fresh-file.ts', kind: 'file' },
|
||||
])
|
||||
files.dispose()
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('cancels individual callers, skips missing directories, and validates limits', async () => {
|
||||
const root = await workspace()
|
||||
expect(() => search(root, { maxResults: 0 })).toThrow('maxResults')
|
||||
expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries')
|
||||
expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames')
|
||||
|
||||
const files = search(root)
|
||||
expect(await files.list('missing/', new AbortController().signal)).toEqual([])
|
||||
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort(new Error('pre-aborted'))
|
||||
await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted')
|
||||
|
||||
files.invalidate()
|
||||
const running = new AbortController()
|
||||
const pending = files.list('tui', running.signal)
|
||||
running.abort(new Error('superseded'))
|
||||
await expect(pending).rejects.toThrow('superseded')
|
||||
|
||||
files.invalidate()
|
||||
const nonErrorAbort = new AbortController()
|
||||
const nonErrorPending = files.list('tui', nonErrorAbort.signal)
|
||||
nonErrorAbort.abort('cancelled')
|
||||
await expect(nonErrorPending).rejects.toThrow('file search aborted')
|
||||
})
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
import { createUserMessage, MessageId , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, {
|
||||
agentEvents,
|
||||
Inbox,
|
||||
type Agent,
|
||||
type AgentCancelCause,
|
||||
type AgentOptions,
|
||||
type AgentStatus,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LlmModelInfo,
|
||||
LlmProviderInfo,
|
||||
LlmResolvedModelInfo,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
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
|
||||
sent: ContentBlock[][]
|
||||
sentMessages: UserMessage[]
|
||||
steered: ContentBlock[][]
|
||||
steeredIds: MessageId[]
|
||||
steeredOptions: UserMessage[]
|
||||
injected: ContentBlock[][]
|
||||
injectedOptions: UserMessage[]
|
||||
cancelled: AgentCancelCause[]
|
||||
}
|
||||
|
||||
export interface TuiHarnessOptions {
|
||||
status?: AgentStatus
|
||||
config?: Config
|
||||
/** Leave the session event log empty instead of seeding one turn and step. */
|
||||
omitInitialLifecycle?: boolean
|
||||
/** Omit the harness's default `welcome`, exercising the banner sweep-reveal path. */
|
||||
omitWelcome?: boolean
|
||||
tools?: Record<string, ToolDefinition>
|
||||
configureContext?: (ctx: Context) => Promise<void>
|
||||
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
|
||||
contextTokens?: number
|
||||
now?: () => number
|
||||
catalog?: {
|
||||
providers: LlmProviderInfo[]
|
||||
models: LlmModelInfo[]
|
||||
listModels?: (provider: string) => Promise<LlmModelInfo[]>
|
||||
resolveModelInfo?: (
|
||||
provider: string,
|
||||
model: string,
|
||||
) => Promise<Pick<LlmResolvedModelInfo, 'context' | 'reasoning'>>
|
||||
}
|
||||
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
|
||||
sessionPersistence?: {
|
||||
list(): Promise<SessionHeader[]>
|
||||
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
|
||||
/** Per-session artifact location for mtime-based activity; defaults to none. */
|
||||
locate?(meta: SessionHeader): { kind: string; path: string } | undefined
|
||||
}
|
||||
handoffResume?: TuiRuntime['handoffResume']
|
||||
/** Host-supplied exit line; absent exercises the no-message path. */
|
||||
goodbyeMessage?: TuiRuntime['goodbyeMessage']
|
||||
/** Set false to exercise the optional session-query degradation path. */
|
||||
mountSessionQuery?: boolean
|
||||
}
|
||||
|
||||
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
|
||||
ctx: Context
|
||||
session: Session
|
||||
agent: FakeAgent
|
||||
terminal: TerminalType
|
||||
exit: Exit
|
||||
controller: ReturnType<typeof createTuiChat>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the production TUI around an in-memory session and controllable agent.
|
||||
* @param terminal - Terminal boundary driven by the test.
|
||||
* @param exit - Process-exit observer.
|
||||
* @param options - Initial session, agent, tool, and TUI configuration.
|
||||
* @returns The mounted TUI and every boundary the test may drive or inspect.
|
||||
*/
|
||||
export async function createTuiTestHarness<TerminalType extends Terminal, Exit extends (code: number) => void>(
|
||||
terminal: TerminalType,
|
||||
exit: Exit,
|
||||
options: TuiHarnessOptions = {},
|
||||
): Promise<TuiHarness<TerminalType, Exit>> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(TuiPromptService)
|
||||
const catalog = options.catalog ?? {
|
||||
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
|
||||
models: [
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
|
||||
],
|
||||
}
|
||||
ctx.provide('tokenMeter', {
|
||||
measure() {
|
||||
return { totalTokens: options.contextTokens ?? 0 }
|
||||
},
|
||||
} as never)
|
||||
if (options.configureContext === undefined) {
|
||||
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)
|
||||
}
|
||||
// A configureContext may mount the real LlmService; only fill the
|
||||
// advisory-catalog stub when none was provided.
|
||||
if (ctx.get('llm') === undefined) {
|
||||
ctx.provide('llm', {
|
||||
listProviders() {
|
||||
return catalog.providers.map(provider => ({ ...provider }))
|
||||
},
|
||||
listModels(provider: string) {
|
||||
return catalog.listModels?.(provider)
|
||||
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
|
||||
},
|
||||
async resolveModelInfo(provider: string, model: string) {
|
||||
const advertised = catalog.models.find(candidate =>
|
||||
candidate.provider === provider && candidate.id === model)
|
||||
const capabilities = await (catalog.resolveModelInfo?.(provider, model)
|
||||
?? Promise.resolve({
|
||||
context: { contextWindow: options.contextWindow ?? 128_000 },
|
||||
}))
|
||||
return {
|
||||
provider,
|
||||
id: model,
|
||||
name: advertised?.name ?? model,
|
||||
...advertised?.description === undefined ? {} : { description: advertised.description },
|
||||
...capabilities,
|
||||
}
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
|
||||
if (options.sessionPersistence !== undefined) {
|
||||
const persistence = options.sessionPersistence
|
||||
ctx.provide('sessionPersistence', {
|
||||
...persistence,
|
||||
locate: (meta: SessionHeader) => persistence.locate?.(meta),
|
||||
create: () => Promise.resolve(),
|
||||
append: () => Promise.resolve(),
|
||||
load: persistence.load === undefined
|
||||
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
|
||||
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
|
||||
inspect: persistence.load === undefined
|
||||
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
|
||||
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
|
||||
} as never)
|
||||
}
|
||||
if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
}
|
||||
const sessionId = SessionId('main-session')
|
||||
const session = ctx.sessions.create(
|
||||
sessionId,
|
||||
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
|
||||
)
|
||||
if (options.omitInitialLifecycle !== true) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
}
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const sentMessages: UserMessage[] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const steeredIds: MessageId[] = []
|
||||
const steeredOptions: UserMessage[] = []
|
||||
const injected: ContentBlock[][] = []
|
||||
const injectedOptions: UserMessage[] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const inbox = new Inbox(session, {
|
||||
inserted: (message) =>{ agentEvents(ctx, agent).emit('agent/inbox/inserted', { message }) },
|
||||
discarded: (message) =>{ agentEvents(ctx, agent).emit('agent/inbox/discarded', { message }) },
|
||||
})
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: options.agentOptions ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
session,
|
||||
inbox,
|
||||
status: options.status ?? 'idle',
|
||||
ctx,
|
||||
sent,
|
||||
sentMessages,
|
||||
steered,
|
||||
steeredIds,
|
||||
steeredOptions,
|
||||
injected,
|
||||
injectedOptions,
|
||||
cancelled,
|
||||
send() {},
|
||||
followup(input) {
|
||||
sent.push(input.content)
|
||||
sentMessages.push(input)
|
||||
return input.id
|
||||
},
|
||||
steer(input) {
|
||||
steered.push(input.content)
|
||||
steeredOptions.push(input)
|
||||
const id = input.id
|
||||
steeredIds.push(id)
|
||||
return { outcome: Promise.resolve({ status: 'admitted' as const, turn: 1, step: 1 }) }
|
||||
},
|
||||
inject(input) {
|
||||
injected.push(input.content)
|
||||
injectedOptions.push(input)
|
||||
return input.id
|
||||
},
|
||||
cancel(cause) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle() {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
ctx.agents.register(agent)
|
||||
const controller = createTuiChat(ctx, Object.assign({
|
||||
...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' },
|
||||
sessionId,
|
||||
theme: { color: false },
|
||||
}, options.config), {
|
||||
terminal,
|
||||
exit,
|
||||
// Default to the real clock (runtime.now falls back to Date.now) so the
|
||||
// elapsed-status suites can drive time via timers or Date.now spies; a
|
||||
// test pins the clock only by passing `now` explicitly.
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
|
||||
...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
|
||||
...(options.goodbyeMessage === undefined ? {} : { goodbyeMessage: options.goodbyeMessage }),
|
||||
gitBranch: options.gitBranch ?? (() => 'tui-staging'),
|
||||
})
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
}
|
||||
|
||||
/** Dispose the mounted TUI before its owning Cordis context. */
|
||||
export async function disposeTuiTestHarness(
|
||||
setup: Pick<TuiHarness<Terminal, (code: number) => void>, 'controller' | 'ctx'>,
|
||||
): Promise<void> {
|
||||
await setup.controller.dispose()
|
||||
await setup.ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
/** Append a production-shaped user message to the active session surface. */
|
||||
export function appendUser(session: Session, text: string): void {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the active session surface. */
|
||||
export function appendAssistant(
|
||||
session: Session,
|
||||
content: ContentBlock[],
|
||||
usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
|
||||
position: { turn: number; step: number } = { turn: 1, step: 1 },
|
||||
): void {
|
||||
session.append('assistant/message', {
|
||||
...position,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content,
|
||||
source: { kind: 'model', provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
...usage === undefined ? {} : { usage },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import { Terminal as XtermTerminal, type IBufferCell } from '@xterm/headless'
|
||||
|
||||
const FRAME_END = '\x1b[?2026l'
|
||||
const FRAME_TIMEOUT_MS = 2_000
|
||||
|
||||
const ANSI_COLORS = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'bright-black',
|
||||
'bright-red',
|
||||
'bright-green',
|
||||
'bright-yellow',
|
||||
'bright-blue',
|
||||
'bright-magenta',
|
||||
'bright-cyan',
|
||||
'bright-white',
|
||||
] as const
|
||||
|
||||
interface FrameWaiter {
|
||||
target: number
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface RowSnapshot {
|
||||
text: string
|
||||
wrapped: boolean
|
||||
styles: string[]
|
||||
}
|
||||
|
||||
export interface TerminalSnapshotOptions {
|
||||
/** Include the whole active buffer instead of only the visible viewport. */
|
||||
includeScrollback?: boolean
|
||||
}
|
||||
|
||||
function occurrenceCount(value: string, needle: string): number {
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while (true) {
|
||||
const match = value.indexOf(needle, offset)
|
||||
if (match < 0) return count
|
||||
count += 1
|
||||
offset = match + needle.length
|
||||
}
|
||||
}
|
||||
|
||||
function colorLabel(cell: IBufferCell, kind: 'fg' | 'bg'): string | undefined {
|
||||
const isDefault = kind === 'fg' ? cell.isFgDefault() : cell.isBgDefault()
|
||||
if (isDefault) return undefined
|
||||
const isRgb = kind === 'fg' ? cell.isFgRGB() : cell.isBgRGB()
|
||||
const value = kind === 'fg' ? cell.getFgColor() : cell.getBgColor()
|
||||
if (isRgb) return `${kind}=#${value.toString(16).padStart(6, '0')}`
|
||||
const name = ANSI_COLORS[value]
|
||||
return `${kind}=${name ?? `ansi-${value}`}`
|
||||
}
|
||||
|
||||
function styleLabel(cell: IBufferCell): string {
|
||||
const labels = [
|
||||
colorLabel(cell, 'fg'),
|
||||
colorLabel(cell, 'bg'),
|
||||
cell.isBold() !== 0 ? 'bold' : undefined,
|
||||
cell.isDim() !== 0 ? 'dim' : undefined,
|
||||
cell.isItalic() !== 0 ? 'italic' : undefined,
|
||||
cell.isUnderline() !== 0 ? 'underline' : undefined,
|
||||
cell.isBlink() !== 0 ? 'blink' : undefined,
|
||||
cell.isInverse() !== 0 ? 'inverse' : undefined,
|
||||
cell.isInvisible() !== 0 ? 'invisible' : undefined,
|
||||
cell.isStrikethrough() !== 0 ? 'strike' : undefined,
|
||||
cell.isOverline() !== 0 ? 'overline' : undefined,
|
||||
].filter((label): label is string => label !== undefined)
|
||||
return labels.join(' ')
|
||||
}
|
||||
|
||||
function snapshotRow(terminal: XtermTerminal, row: number): RowSnapshot {
|
||||
const line = terminal.buffer.active.getLine(row)
|
||||
if (line === undefined) return { text: '', wrapped: false, styles: [] }
|
||||
const styles: string[] = []
|
||||
let activeStyle = ''
|
||||
let activeStart = 0
|
||||
for (let column = 0; column <= terminal.cols; column++) {
|
||||
const cell = column < terminal.cols ? line.getCell(column) : undefined
|
||||
const style = cell === undefined ? '' : styleLabel(cell)
|
||||
if (style === activeStyle) continue
|
||||
if (activeStyle !== '') styles.push(`${activeStart}-${column - 1} ${activeStyle}`)
|
||||
activeStyle = style
|
||||
activeStart = column
|
||||
}
|
||||
return {
|
||||
text: line.translateToString(true),
|
||||
wrapped: line.isWrapped,
|
||||
styles,
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(rows: readonly RowSnapshot[], firstRow: number): string[] {
|
||||
const rendered: string[] = []
|
||||
let blankStart: number | undefined
|
||||
const flushBlanks = (end: number): void => {
|
||||
if (blankStart === undefined) return
|
||||
rendered.push(blankStart === end ? `${blankStart}| <blank>` : `${blankStart}-${end}| <blank>`)
|
||||
blankStart = undefined
|
||||
}
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const absoluteRow = firstRow + index
|
||||
const row = rows[index] as RowSnapshot
|
||||
if (row.text === '' && row.styles.length === 0 && !row.wrapped) {
|
||||
blankStart ??= absoluteRow
|
||||
continue
|
||||
}
|
||||
flushBlanks(absoluteRow - 1)
|
||||
rendered.push(`${absoluteRow}${row.wrapped ? '~' : ''}| ${JSON.stringify(row.text)}`)
|
||||
for (const style of row.styles) rendered.push(` style ${style}`)
|
||||
}
|
||||
flushBlanks(firstRow + rows.length - 1)
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal emulator used by TUI snapshots. It consumes the same ANSI stream as
|
||||
* a real terminal and exposes completed synchronized frames as an awaitable boundary.
|
||||
*/
|
||||
export class HeadlessTerminal implements Terminal {
|
||||
readonly kittyProtocolActive = false
|
||||
readonly drainInput = (): Promise<void> => Promise.resolve()
|
||||
started = 0
|
||||
stopped = 0
|
||||
title = ''
|
||||
progress = false
|
||||
cursorVisible = true
|
||||
frames = 0
|
||||
private readonly emulator: XtermTerminal
|
||||
private onInput: (data: string) => void = () => {}
|
||||
private onResize: () => void = () => {}
|
||||
private pendingWrite: Promise<void> = Promise.resolve()
|
||||
private readonly frameWaiters = new Set<FrameWaiter>()
|
||||
|
||||
constructor(columns = 80, rows = 24) {
|
||||
this.emulator = new XtermTerminal({
|
||||
cols: columns,
|
||||
rows,
|
||||
scrollback: 1_000,
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors: false,
|
||||
logLevel: 'off',
|
||||
})
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this.emulator.cols
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this.emulator.rows
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.started += 1
|
||||
this.onInput = onInput
|
||||
this.onResize = onResize
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped += 1
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
const completedFrames = occurrenceCount(data, FRAME_END)
|
||||
this.pendingWrite = new Promise((resolve) => {
|
||||
this.emulator.write(data, () => {
|
||||
this.frames += completedFrames
|
||||
for (const waiter of this.frameWaiters) {
|
||||
if (this.frames < waiter.target) continue
|
||||
clearTimeout(waiter.timer)
|
||||
this.frameWaiters.delete(waiter)
|
||||
waiter.resolve()
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
if (lines > 0) this.write(`\x1b[${lines}B`)
|
||||
if (lines < 0) this.write(`\x1b[${-lines}A`)
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
this.cursorVisible = false
|
||||
this.write('\x1b[?25l')
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
this.cursorVisible = true
|
||||
this.write('\x1b[?25h')
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
this.write('\x1b[K')
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
this.write('\x1b[J')
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
this.write('\x1b[2J\x1b[H')
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this.title = title
|
||||
this.write(`\x1b]0;${title}\x07`)
|
||||
}
|
||||
|
||||
setProgress(active: boolean): void {
|
||||
this.progress = active
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.onInput(data)
|
||||
}
|
||||
|
||||
resize(columns: number, rows = this.rows): void {
|
||||
this.emulator.resize(columns, rows)
|
||||
this.onResize()
|
||||
}
|
||||
|
||||
/** Wait until pi-tui completes a synchronized frame newer than `after`. */
|
||||
async waitForFrame(after = this.frames): Promise<void> {
|
||||
if (this.frames <= after) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: FrameWaiter = {
|
||||
target: after + 1,
|
||||
resolve,
|
||||
reject,
|
||||
timer: setTimeout(() => {
|
||||
this.frameWaiters.delete(waiter)
|
||||
reject(new Error(`TUI did not complete frame ${after + 1} within ${FRAME_TIMEOUT_MS}ms`))
|
||||
}, FRAME_TIMEOUT_MS),
|
||||
}
|
||||
this.frameWaiters.add(waiter)
|
||||
})
|
||||
}
|
||||
await this.flush()
|
||||
}
|
||||
|
||||
/** Await every terminal write queued through the current task. */
|
||||
async flush(): Promise<void> {
|
||||
let pending: Promise<void>
|
||||
do {
|
||||
pending = this.pendingWrite
|
||||
await pending
|
||||
} while (pending !== this.pendingWrite)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject palette output that would become theme-specific in a user's terminal.
|
||||
* @returns One location per RGB, extended-palette, or explicit-background cell.
|
||||
*/
|
||||
themeViolations(): string[] {
|
||||
const violations: string[] = []
|
||||
const buffer = this.emulator.buffer.active
|
||||
for (let row = 0; row < buffer.length; row++) {
|
||||
const line = buffer.getLine(row)
|
||||
if (line === undefined) continue
|
||||
for (let column = 0; column < this.columns; column++) {
|
||||
const cell = line.getCell(column)
|
||||
if (cell === undefined) continue
|
||||
const reasons = [
|
||||
cell.isFgRGB() ? 'rgb-fg' : undefined,
|
||||
cell.isBgRGB() ? 'rgb-bg' : undefined,
|
||||
cell.isFgPalette() && cell.getFgColor() > 15 ? `extended-fg-${cell.getFgColor()}` : undefined,
|
||||
cell.isBgPalette() && cell.getBgColor() > 15 ? `extended-bg-${cell.getBgColor()}` : undefined,
|
||||
!cell.isBgDefault() ? 'explicit-bg' : undefined,
|
||||
].filter((reason): reason is string => reason !== undefined)
|
||||
if (reasons.length > 0) violations.push(`${row}:${column} ${reasons.join(',')}`)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/** Serialize terminal cells and metadata into a stable, reviewable expected output. */
|
||||
async snapshot(options: TerminalSnapshotOptions = {}): Promise<string> {
|
||||
await this.flush()
|
||||
const buffer = this.emulator.buffer.active
|
||||
const firstRow = options.includeScrollback === true ? 0 : buffer.viewportY
|
||||
const rowCount = options.includeScrollback === true ? buffer.length : this.rows
|
||||
const rows = Array.from({ length: rowCount }, (_, index) => snapshotRow(this.emulator, firstRow + index))
|
||||
const cursorBufferRow = buffer.baseY + buffer.cursorY
|
||||
const cursorViewportRow = cursorBufferRow - buffer.viewportY
|
||||
return [
|
||||
`terminal ${this.columns}x${this.rows} buffer=${buffer.type} length=${buffer.length} base=${buffer.baseY} viewport=${buffer.viewportY}`,
|
||||
`lifecycle started=${this.started} stopped=${this.stopped} progress=${this.progress ? 'active' : 'inactive'}`,
|
||||
`title ${JSON.stringify(this.title)}`,
|
||||
`cursor ${this.cursorVisible ? 'visible' : 'hidden'} column=${buffer.cursorX} viewportRow=${cursorViewportRow} bufferRow=${cursorBufferRow}`,
|
||||
options.includeScrollback === true ? 'buffer' : 'viewport',
|
||||
...renderRows(rows, firstRow),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.flush()
|
||||
for (const waiter of this.frameWaiters) {
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.reject(new Error('terminal disposed before the requested frame completed'))
|
||||
}
|
||||
this.frameWaiters.clear()
|
||||
this.emulator.dispose()
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as tui from '../src/index.ts'
|
||||
|
||||
/** Real Loader export-path guard for the namespace TUI plugin. */
|
||||
describe('dsh-tui plugin export shape', () => {
|
||||
it('preserves name, inject, Config, and apply through Loader unwrapping', () => {
|
||||
expect('default' in tui).toBe(false)
|
||||
expect(typeof tui.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tui)
|
||||
expect(unwrapped.name).toBe('ui-tui')
|
||||
expect(unwrapped.inject).toEqual([
|
||||
'agents',
|
||||
'sessions',
|
||||
'commands',
|
||||
'userInteraction',
|
||||
'tools',
|
||||
'llm',
|
||||
'systemPrompt',
|
||||
'tokenMeter',
|
||||
'tuiPrompt',
|
||||
])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,169 +0,0 @@
|
||||
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-official']])
|
||||
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek-official')
|
||||
})
|
||||
|
||||
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,19 +0,0 @@
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
/** Test-only backend-independent query service. */
|
||||
export class TestSessionQueryService extends SessionQueryService {
|
||||
override searchSessions(
|
||||
..._args: Parameters<SessionQueryService['searchSessions']>
|
||||
): ReturnType<SessionQueryService['searchSessions']> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
...args: Parameters<SessionQueryService['searchEvents']>
|
||||
): ReturnType<SessionQueryService['searchEvents']> {
|
||||
return this.readSurface(args[0].sessionId).then(surface => ({
|
||||
session: surface.session,
|
||||
items: [],
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
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, TuiPromptService } from '../src/index.ts'
|
||||
import { HeadlessTerminal } from './headless-terminal.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
|
||||
const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt')
|
||||
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
class SnapshotAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
// The snapshot rides the prompt's pre-step result: the loop appends the
|
||||
// prompt first, then its additional contexts (the branch-wide ordering
|
||||
// for plugin-sourced context).
|
||||
const [prompt, context] = options.messages.slice(-2)
|
||||
if (context?.role !== 'user' || prompt?.role !== 'user'
|
||||
|| prompt.content[0]?.type !== 'text' || prompt.content[0].text !== 'Use @Source session') {
|
||||
throw new Error('session reference context did not follow the direct user message')
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Combined reference request accepted.' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
function nextIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle') return
|
||||
dispose()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
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)
|
||||
|
||||
const adapter = new SnapshotAdapter()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: '/workspace/project', createdAt: 1 } })
|
||||
const oldUser = source.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const oldAssistant = source.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
source.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<compacted-summary>Retained checkpoint.</compacted-summary>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq },
|
||||
sourceEventSeqs: [oldUser.seq, oldAssistant.seq],
|
||||
})
|
||||
source.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Recent retained question.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
const target = ctx.agentLoop.create(
|
||||
SessionId('target-session'),
|
||||
{ provider: 'mock', model: 'mock' },
|
||||
{ cwd: '/workspace/project' },
|
||||
)
|
||||
const terminal = new HeadlessTerminal(96, 24)
|
||||
const controller = createTuiChat(ctx, {
|
||||
sessionId: target.id,
|
||||
welcome: 'Session reference snapshot.',
|
||||
theme: { color: true },
|
||||
title: 'DSH session reference',
|
||||
}, { terminal, exit: () => {} })
|
||||
await terminal.waitForFrame(0)
|
||||
|
||||
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'Source session' })
|
||||
const idle = nextIdle(ctx, target)
|
||||
const frame = terminal.frames
|
||||
terminal.send(`Use ${mention}`)
|
||||
terminal.send('\r')
|
||||
await idle
|
||||
await terminal.waitForFrame(frame)
|
||||
|
||||
const request = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(request).toContain('untrusted, read-only snapshot')
|
||||
expect(request).toContain('Retained checkpoint.')
|
||||
expect(request).toContain('Recent retained question.')
|
||||
expect(request).not.toContain('SHADOWED OLD USER')
|
||||
expect(request).not.toContain('SHADOWED OLD ASSISTANT')
|
||||
const context = target.session.events.find(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'session-reference')
|
||||
expect(context?.type === 'user/message' && context.data.source).toMatchObject({
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source-session', compacted: true }],
|
||||
})
|
||||
const user = target.session.events.find(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'user')
|
||||
expect(user?.type === 'user/message' && user.data.content).toEqual([
|
||||
{ type: 'text', text: 'Use @Source session' },
|
||||
])
|
||||
|
||||
const snapshot = await terminal.snapshot({ includeScrollback: true })
|
||||
if (REFRESHING) {
|
||||
await mkdir(dirname(EXPECTED), { recursive: true })
|
||||
await writeFile(EXPECTED, snapshot)
|
||||
}
|
||||
await expect(snapshot).toMatchFileSnapshot(EXPECTED)
|
||||
|
||||
await controller.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
await terminal.dispose()
|
||||
clock.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
terminal 100x40 buffer=normal length=41 base=1 viewport=1
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=39 bufferRow=40
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
7| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
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| "● Tool / edit"
|
||||
style 0-12 fg=green
|
||||
13| "src/view.ts "
|
||||
style 0-10 bold
|
||||
14| "- old line "
|
||||
style 0-9 fg=red
|
||||
15| "… +2 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
16| "└ +1 -1 · 1 file "
|
||||
style 0-15 dim
|
||||
17| <blank>
|
||||
18| "● Tool / subagent"
|
||||
style 0-16 fg=green
|
||||
19| "Delegate renderer audit "
|
||||
style 0-99 dim
|
||||
20| "The renderer has explicit lifecycle ownership. "
|
||||
style 0-99 dim
|
||||
21| <blank>
|
||||
22| "● Tool / task_output"
|
||||
style 0-19 fg=green
|
||||
23| "Read output from background task subagent-7 "
|
||||
style 0-99 dim
|
||||
24| " "
|
||||
25| "… +2 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
26| " "
|
||||
27| <blank>
|
||||
28| "● Tool / skill"
|
||||
style 0-13 fg=green
|
||||
29| "Load skill dsh-code-review "
|
||||
style 0-99 dim
|
||||
30| "Loaded review instructions. "
|
||||
style 0-99 dim
|
||||
31| <blank>
|
||||
32| "● Tool / large_edit"
|
||||
style 0-18 fg=green
|
||||
33| "src/large.ts "
|
||||
style 0-11 bold
|
||||
34| "[exact line diff omitted: >2 changed lines] "
|
||||
style 0-42 dim
|
||||
35| "… +6 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
36| "└ +3 -3 · 1 file · approximate "
|
||||
style 0-29 dim
|
||||
37| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
38| <blank>
|
||||
39| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
40| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -1,109 +0,0 @@
|
||||
terminal 100x40 buffer=normal length=53 base=13 viewport=13
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=39 bufferRow=52
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
7| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
8| "/workspace/project "
|
||||
style 0-17 dim
|
||||
9| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
10| "4016 tests passed "
|
||||
style 0-16 dim
|
||||
11| "1 test skipped "
|
||||
style 0-13 dim
|
||||
12| "coverage complete "
|
||||
style 0-16 dim
|
||||
13| "[exit 0] "
|
||||
style 0-7 dim
|
||||
14| <blank>
|
||||
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| "+ new line "
|
||||
style 0-9 fg=green
|
||||
19| " keep "
|
||||
style 0-5 dim
|
||||
20| "└ +1 -1 · 1 file "
|
||||
style 0-15 dim
|
||||
21| <blank>
|
||||
22| "● Tool / subagent"
|
||||
style 0-16 fg=green
|
||||
23| "Delegate renderer audit "
|
||||
style 0-99 dim
|
||||
24| "The renderer has explicit lifecycle ownership. "
|
||||
style 0-99 dim
|
||||
25| <blank>
|
||||
26| "● Tool / task_output"
|
||||
style 0-19 fg=green
|
||||
27| "Read output from background task subagent-7 "
|
||||
style 0-99 dim
|
||||
28| " "
|
||||
29| "console "
|
||||
style 0-6 dim
|
||||
30| " started background task bash-5 "
|
||||
style 0-1 dim
|
||||
style 2-31 fg=cyan dim
|
||||
style 32-99 dim
|
||||
31| " "
|
||||
32| <blank>
|
||||
33| "● Tool / skill"
|
||||
style 0-13 fg=green
|
||||
34| "Load skill dsh-code-review "
|
||||
style 0-99 dim
|
||||
35| "Loaded review instructions. "
|
||||
style 0-99 dim
|
||||
36| <blank>
|
||||
37| "● Tool / large_edit"
|
||||
style 0-18 fg=green
|
||||
38| "src/large.ts "
|
||||
style 0-11 bold
|
||||
39| "[exact line diff omitted: >2 changed lines] "
|
||||
style 0-42 dim
|
||||
40| "- old one "
|
||||
style 0-8 fg=red
|
||||
41| "- old two "
|
||||
style 0-8 fg=red
|
||||
42| "- old three "
|
||||
style 0-10 fg=red
|
||||
43| "+ new one "
|
||||
style 0-8 fg=green
|
||||
44| "+ new two "
|
||||
style 0-8 fg=green
|
||||
45| "+ new three "
|
||||
style 0-10 fg=green
|
||||
46| "└ +3 -3 · 1 file · approximate "
|
||||
style 0-29 dim
|
||||
47| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
48| <blank>
|
||||
49| "Tool and context cards expanded. "
|
||||
style 0-31 dim
|
||||
50| <blank>
|
||||
51| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
52| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -1,36 +0,0 @@
|
||||
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=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-1 fg=#4d6bfe bold
|
||||
style 2-2 fg=#4772fe bold
|
||||
style 3-3 fg=#4278ff bold
|
||||
style 4-4 fg=#3c7fff bold
|
||||
style 5-5 fg=#3685ff bold
|
||||
style 6-6 fg=#308bff bold
|
||||
style 7-7 fg=#2a92ff bold
|
||||
style 8-8 fg=#2498ff bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
9-35| <blank>
|
||||
@@ -1,37 +0,0 @@
|
||||
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=15 bufferRow=15
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <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-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
15| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
16-35| <blank>
|
||||
@@ -1,44 +0,0 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=active
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=18 bufferRow=18
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Show the live update. "
|
||||
6| <blank>
|
||||
7| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
8| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
9| "Inspecting width and styles. "
|
||||
style 0-27 dim italic
|
||||
10| "Streaming visible state… "
|
||||
style 10-22 bold
|
||||
11| " "
|
||||
12| "ts "
|
||||
style 0-1 dim
|
||||
13| " const visible = true "
|
||||
style 2-21 fg=cyan
|
||||
14| " "
|
||||
15| "Model wait 1.0s · Thinking 2.0s "
|
||||
style 0-30 dim
|
||||
16| <blank>
|
||||
17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
18| " dsh ● press enter to steer and esc to cancel "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-44 dim
|
||||
19-35| <blank>
|
||||
@@ -1,45 +0,0 @@
|
||||
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=21 bufferRow=21
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <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-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
21| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
22-35| <blank>
|
||||
@@ -1,42 +0,0 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=17 bufferRow=17
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Inspect the renderer. "
|
||||
6| <blank>
|
||||
7| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
8| "Running the check now. "
|
||||
9| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 "
|
||||
style 0-46 dim
|
||||
11| <blank>
|
||||
12| "Reasoning blocks hidden. "
|
||||
style 0-23 dim
|
||||
13| <blank>
|
||||
14| "Tool cards hidden. "
|
||||
style 0-17 dim
|
||||
15| <blank>
|
||||
16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
17| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
18-39| <blank>
|
||||
@@ -1,72 +0,0 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=0 viewportRow=39 bufferRow=39
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "Inspect the renderer. "
|
||||
6| <blank>
|
||||
7| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
8| "Running the check now. "
|
||||
9| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
10| <blank>
|
||||
11| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
12| <blank>
|
||||
13| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
14| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
15| "/workspace/project "
|
||||
style 0-17 dim
|
||||
16| "… +4 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
17| "[exit 0] ╭ Transcript details ──────────────────────────────────────────────────╮ "
|
||||
style 0-7 dim
|
||||
style 14-85 fg=bright-magenta
|
||||
18| "Model wait 0.0│ → Tool cards collapsed │ "
|
||||
style 0-13 dim
|
||||
style 14-14 fg=bright-magenta
|
||||
style 16-58 fg=bright-magenta inverse
|
||||
style 85-85 fg=bright-magenta
|
||||
19| " │ Reasoning hidden │ "
|
||||
style 14-14 fg=bright-magenta
|
||||
style 27-55 dim
|
||||
style 85-85 fg=bright-magenta
|
||||
20| "Reasoning bloc│ │ "
|
||||
style 0-13 dim
|
||||
style 14-14 fg=bright-magenta
|
||||
style 85-85 fg=bright-magenta
|
||||
21| " │ ↑/↓ move • Tab toggle • Enter/Esc close │ "
|
||||
style 14-14 fg=bright-magenta
|
||||
style 16-54 dim
|
||||
style 85-85 fg=bright-magenta
|
||||
22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯ "
|
||||
style 0-13 dim
|
||||
style 14-85 fg=bright-magenta
|
||||
23| <blank>
|
||||
24| "Tool and context cards collapsed. "
|
||||
style 0-32 dim
|
||||
25| <blank>
|
||||
26| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
27| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
28-39| <blank>
|
||||
@@ -1,80 +0,0 @@
|
||||
terminal 92x32 buffer=normal length=39 base=7 viewport=7
|
||||
lifecycle started=1 stopped=1 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor visible column=0 viewportRow=31 bufferRow=38
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
|
||||
style 0-46 dim
|
||||
6| <blank>
|
||||
7| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-magenta bold
|
||||
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 dim
|
||||
9| "Esc cancel active work • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning"
|
||||
style 0-91 dim
|
||||
10| "• Ctrl+L redraw "
|
||||
style 0-14 dim
|
||||
11| "Ctrl+C cancel active work; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-70 dim
|
||||
12| " "
|
||||
13| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and "
|
||||
style 0-91 dim
|
||||
15| "reasoning display "
|
||||
style 0-16 dim
|
||||
16| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
17| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
18| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
19| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
20| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 dim
|
||||
22| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 dim
|
||||
23| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 dim
|
||||
24| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
25| <blank>
|
||||
26| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
27| <blank>
|
||||
28| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
29| <blank>
|
||||
30| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
31| <blank>
|
||||
32| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
33| <blank>
|
||||
34| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
35| <blank>
|
||||
36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
37| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
38| <blank>
|
||||
@@ -1,40 +0,0 @@
|
||||
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=17 bufferRow=17
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <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-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
17| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
18-35| <blank>
|
||||
@@ -1,79 +0,0 @@
|
||||
terminal 92x32 buffer=normal length=38 base=6 viewport=6
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=31 bufferRow=37
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
|
||||
style 0-46 dim
|
||||
6| <blank>
|
||||
7| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-magenta bold
|
||||
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 dim
|
||||
9| "Esc cancel active work • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning"
|
||||
style 0-91 dim
|
||||
10| "• Ctrl+L redraw "
|
||||
style 0-14 dim
|
||||
11| "Ctrl+C cancel active work; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-70 dim
|
||||
12| " "
|
||||
13| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and "
|
||||
style 0-91 dim
|
||||
15| "reasoning display "
|
||||
style 0-16 dim
|
||||
16| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
17| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
18| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
19| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
20| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 dim
|
||||
22| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 dim
|
||||
23| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 dim
|
||||
24| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
25| <blank>
|
||||
26| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
27| <blank>
|
||||
28| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
29| <blank>
|
||||
30| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
31| <blank>
|
||||
32| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
33| <blank>
|
||||
34| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
35| <blank>
|
||||
36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
37| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -1,31 +0,0 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=11 viewportRow=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > @tsc "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 11-11 inverse
|
||||
9| " → File · terminal-special-case.t src/terminal-special-case.ts "
|
||||
style 7-38 fg=bright-magenta
|
||||
10-35| <blank>
|
||||
@@ -1,52 +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=15 viewportRow=13 bufferRow=13
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
9-11| <blank>
|
||||
12| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
|
||||
style 8-83 fg=bright-magenta
|
||||
13| " │ > pro │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 15-15 inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
14| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
15| " │ → deepseek-official/deepseek-v4- DeepSeek V4 Pro │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-41 fg=bright-magenta inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
16| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
17| " │ type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-77 dim
|
||||
style 83-83 fg=bright-magenta
|
||||
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 8-83 fg=bright-magenta
|
||||
19-31| <blank>
|
||||
@@ -1,56 +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=12 viewportRow=13 bufferRow=13
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
9-11| <blank>
|
||||
12| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
|
||||
style 8-83 fg=bright-magenta
|
||||
13| " │ > │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 12-12 inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
14| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
15| " │ → deepseek-official/deepseek-v4- DeepSeek V4 Flash — current │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-41 fg=bright-magenta inverse
|
||||
style 83-83 fg=bright-magenta
|
||||
16| " │ deepseek-official/deepseek-v4- DeepSeek V4 Pro │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 42-58 dim
|
||||
style 83-83 fg=bright-magenta
|
||||
17| " │ │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 83-83 fg=bright-magenta
|
||||
18| " │ type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc │ "
|
||||
style 8-8 fg=bright-magenta
|
||||
style 10-77 dim
|
||||
style 83-83 fg=bright-magenta
|
||||
19| " ╰──────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 8-83 fg=bright-magenta
|
||||
20-31| <blank>
|
||||
@@ -1,32 +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=7 viewportRow=10 bufferRow=10
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Model selected: deepseek-official/deepseek-v4-pro. New steps will use it. "
|
||||
style 0-72 dim
|
||||
8| <blank>
|
||||
9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-48 dim
|
||||
style 51-55 dim
|
||||
style 58-67 dim
|
||||
10| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
11-31| <blank>
|
||||
@@ -1,39 +0,0 @@
|
||||
terminal 56x20 buffer=normal length=25 base=5 viewport=5
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=13 bufferRow=18
|
||||
viewport
|
||||
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-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-55 dim
|
||||
8| " Review the complete plan including every required "
|
||||
9| " checkpoint including every required checkpoint "
|
||||
10| " including every required checkpoint including every "
|
||||
11| " required checkpoint including every required "
|
||||
12| " checkpoint including every required checkpoint "
|
||||
13| " including every required checkpoint including every "
|
||||
14| " required checkpoint including every required "
|
||||
15| " checkpoint including every required checkpoint "
|
||||
16| " including every required checkpoint including every "
|
||||
17| " required checkpoint visible plan tail "
|
||||
18| " … lines 4-13/13 • PgUp/PgDn "
|
||||
style 2-28 dim
|
||||
19| " › 1. [ ] Code Mode "
|
||||
style 2-20 fg=bright-magenta bold
|
||||
20| " run_code programs and captured output with "
|
||||
style 12-53 dim
|
||||
21| " … lines 1-2/12 • PgUp/PgDn "
|
||||
style 2-27 dim
|
||||
22| " ↓ 3 more "
|
||||
style 2-9 dim
|
||||
23| " ↑↓ Tab Sp ↵Esc "
|
||||
style 2-15 dim
|
||||
24| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -1,39 +0,0 @@
|
||||
terminal 56x20 buffer=normal length=25 base=5 viewport=5
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=16 bufferRow=21
|
||||
viewport
|
||||
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-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-55 dim
|
||||
8| " Review the complete plan including every required "
|
||||
9| " checkpoint including every required checkpoint "
|
||||
10| " including every required checkpoint including every "
|
||||
11| " required checkpoint including every required "
|
||||
12| " checkpoint including every required checkpoint "
|
||||
13| " including every required checkpoint including every "
|
||||
14| " required checkpoint including every required "
|
||||
15| " checkpoint including every required checkpoint "
|
||||
16| " including every required checkpoint including every "
|
||||
17| " required checkpoint visible plan tail "
|
||||
18| " … lines 4-13/13 • PgUp/PgDn "
|
||||
style 2-28 dim
|
||||
19| " detail with complete wrapped detail "
|
||||
style 12-46 dim
|
||||
20| " visible tail "
|
||||
style 12-23 dim
|
||||
21| " … lines 11-12/12 • PgUp/PgDn "
|
||||
style 2-29 dim
|
||||
22| " ↓ 3 more "
|
||||
style 2-9 dim
|
||||
23| " ↑↓ Tab Sp ↵Esc "
|
||||
style 2-15 dim
|
||||
24| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -1,40 +0,0 @@
|
||||
terminal 56x20 buffer=normal length=20 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=16 bufferRow=16
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 "
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-55 dim
|
||||
8| " "
|
||||
9| " Question 1/1 (1 unanswered) · Confirm "
|
||||
style 2-38 dim
|
||||
10| " Continue with this change? "
|
||||
11| " "
|
||||
12| " › 1. Proceed "
|
||||
style 2-14 fg=bright-magenta bold
|
||||
13| " Apply the proposed change "
|
||||
style 8-32 dim
|
||||
14| " Tab custom answer • Enter submit • Esc interrupt "
|
||||
style 2-49 dim
|
||||
15| " "
|
||||
16| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
17-19| <blank>
|
||||
@@ -1,40 +0,0 @@
|
||||
terminal 56x20 buffer=normal length=25 base=5 viewport=5
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=17 bufferRow=22
|
||||
viewport
|
||||
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-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-55 dim
|
||||
8| " Review the complete plan including every required "
|
||||
9| " checkpoint including every required checkpoint "
|
||||
10| " including every required checkpoint including every "
|
||||
11| " required checkpoint including every required "
|
||||
12| " checkpoint including every required checkpoint "
|
||||
13| " including every required checkpoint including every "
|
||||
14| " required checkpoint including every required "
|
||||
15| " checkpoint including every required checkpoint "
|
||||
16| " including every required checkpoint including every "
|
||||
17| " … lines 4-12/13 • PgUp/PgDn "
|
||||
style 2-28 dim
|
||||
18| " detail with complete wrapped detail "
|
||||
style 12-46 dim
|
||||
19| " visible tail "
|
||||
style 12-23 dim
|
||||
20| " … lines 11-12/12 • PgUp/PgDn "
|
||||
style 2-29 dim
|
||||
21| " ↓ 3 more "
|
||||
style 2-9 dim
|
||||
22| " Error: Select at least one option, or press Tab for… "
|
||||
style 2-52 fg=red
|
||||
23| " ↑↓ Tab Sp ↵Esc "
|
||||
style 2-15 dim
|
||||
24| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -1,39 +0,0 @@
|
||||
terminal 56x20 buffer=normal length=25 base=5 viewport=5
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=19 bufferRow=24
|
||||
viewport
|
||||
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-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-55 dim
|
||||
8| " Which advanced TUI states belong in the required "
|
||||
9| " matrix? "
|
||||
10| " "
|
||||
11| " Review the complete plan including every required "
|
||||
12| " checkpoint including every required checkpoint "
|
||||
13| " including every required checkpoint including every "
|
||||
14| " required checkpoint including every required "
|
||||
15| " checkpoint including every required checkpoint "
|
||||
16| " including every required checkpoint including every "
|
||||
17| " required checkpoint including every required "
|
||||
18| " … lines 1-10/13 • PgUp/PgDn "
|
||||
style 2-28 dim
|
||||
19| " › 1. [ ] Code Mode "
|
||||
style 2-20 fg=bright-magenta bold
|
||||
20| " run_code programs and captured output with "
|
||||
style 12-53 dim
|
||||
21| " … lines 1-2/12 • PgUp/PgDn "
|
||||
style 2-27 dim
|
||||
22| " ↓ 3 more "
|
||||
style 2-9 dim
|
||||
23| " ↑↓ Tab Sp ↵Esc "
|
||||
style 2-15 dim
|
||||
24| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -1,54 +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=6 viewportRow=4 bufferRow=4
|
||||
buffer
|
||||
0| " "
|
||||
1| " Resume session (1 of 3) "
|
||||
style 2-24 fg=bright-magenta bold
|
||||
2| " "
|
||||
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 2-89 dim
|
||||
4| " │ ⌕ │ "
|
||||
style 2-2 dim
|
||||
style 6-6 inverse
|
||||
style 89-89 dim
|
||||
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 2-89 dim
|
||||
6| " "
|
||||
7| " all workspaces (3) ⇥ this workspace (2) "
|
||||
style 2-19 fg=bright-magenta
|
||||
style 20-41 dim
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-magenta bold
|
||||
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
|
||||
style 2-59 dim
|
||||
11| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
12| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
13| " Other workspace work "
|
||||
14| " 2024-02-02T00:00:00.000Z · persisted · elsewhere-session "
|
||||
style 2-59 dim
|
||||
15| " workspace /workspace/other "
|
||||
style 2-29 dim
|
||||
16| " Resume selector design "
|
||||
17| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
|
||||
style 2-57 dim
|
||||
18| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
19| " "
|
||||
20| " "
|
||||
21| " "
|
||||
22| " "
|
||||
23| " "
|
||||
24| " "
|
||||
25| " "
|
||||
26| " "
|
||||
27| " "
|
||||
28| " "
|
||||
29| " "
|
||||
30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel "
|
||||
style 2-84 dim
|
||||
31| " "
|
||||
@@ -1,47 +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=6 viewportRow=4 bufferRow=4
|
||||
buffer
|
||||
0| " "
|
||||
1| " Resume session "
|
||||
style 2-15 fg=bright-magenta bold
|
||||
2| " "
|
||||
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 2-89 dim
|
||||
4| " │ ⌕ │ "
|
||||
style 2-2 dim
|
||||
style 6-6 inverse
|
||||
style 89-89 dim
|
||||
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 2-89 dim
|
||||
6| " "
|
||||
7| " this workspace /workspace/project ⇥ all workspaces (0) "
|
||||
style 2-34 fg=bright-magenta
|
||||
style 35-56 dim
|
||||
8| " "
|
||||
9| " Loading sessions… "
|
||||
style 2-18 dim
|
||||
10| " "
|
||||
11| " "
|
||||
12| " "
|
||||
13| " "
|
||||
14| " "
|
||||
15| " "
|
||||
16| " "
|
||||
17| " "
|
||||
18| " "
|
||||
19| " "
|
||||
20| " "
|
||||
21| " "
|
||||
22| " "
|
||||
23| " "
|
||||
24| " "
|
||||
25| " "
|
||||
26| " "
|
||||
27| " "
|
||||
28| " "
|
||||
29| " "
|
||||
30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel "
|
||||
style 2-84 dim
|
||||
31| " "
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user