diff --git a/.agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml b/.agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml new file mode 100644 index 0000000000..f64a48f00f --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.md +2026-07-22-tui-interactive-extension-service.md: e523361ceb920d2848369d7d3cf1093765d296d4 +2026-07-22-tui-interactive-extension-service.zh.md: 23c46f6bc18d576912aaa648b26877873ea60eda diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md b/.agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md rename to .agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.md index 86cb397483..e523361ceb 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md +++ b/.agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.md @@ -1,6 +1,7 @@ # Agent Note: Effect-owned TUI interactive extensions Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-22-tui-interactive-extension-service.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md b/.agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md rename to .agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.zh.md index d53f526a07..23c46f6bc1 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md +++ b/.agents/notes/archived/architecture/2026-07-22-tui-interactive-extension-service.zh.md @@ -1,6 +1,7 @@ # Agent Note: 由 effect 持有的 TUI 交互扩展 Status: implemented +Archived: 2026-08-04 [English](2026-07-22-tui-interactive-extension-service.md) | 中文 diff --git a/.agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml b/.agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml new file mode 100644 index 0000000000..0d20a831f8 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.md +2026-07-27-tui-chat-channel-module-split.md: 3cf9548d4d9d1b845bee5f7137d79a490827b082 +2026-07-27-tui-chat-channel-module-split.zh.md: d5de02bd0f7090c24ccfc5dabbbadff9ff42174e diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md b/.agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md rename to .agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.md index 56b345b670..3cf9548d4d 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md +++ b/.agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.md @@ -1,6 +1,7 @@ # Agent Note: dsh-tui chat channel module split Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-27-tui-chat-channel-module-split.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md b/.agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md rename to .agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.zh.md index d74844a762..d5de02bd0f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md +++ b/.agents/notes/archived/architecture/2026-07-27-tui-chat-channel-module-split.zh.md @@ -1,6 +1,7 @@ # Agent Note: dsh-tui 聊天通道模块拆分 Status: implemented +Archived: 2026-08-04 [English](2026-07-27-tui-chat-channel-module-split.md) | 中文 diff --git a/.agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml new file mode 100644 index 0000000000..135a8442d2 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.md +2026-07-28-consolidated-tui-presentation.md: c772985a11bc61c2bfcbb4422cae219966246163 +2026-07-28-consolidated-tui-presentation.zh.md: c96fd105bc4ff6632017b16a8b7a0e8f448449bd diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md rename to .agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.md index 8200c8e96c..c772985a11 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md +++ b/.agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.md @@ -1,6 +1,7 @@ # Agent Note: Consolidated TUI presentation and navigation Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-28-consolidated-tui-presentation.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md rename to .agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.zh.md index 852bd413b2..c96fd105bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md +++ b/.agents/notes/archived/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -1,6 +1,7 @@ # Agent Note: 统一的 TUI 呈现与导航 Status: implemented +Archived: 2026-08-04 [English](2026-07-28-consolidated-tui-presentation.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml new file mode 100644 index 0000000000..05d62eea95 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.md +2026-07-23-tui-generic-card-markdown.md: 5c092c4d5ffbc566074f9b280d3fbc207d645ec7 +2026-07-23-tui-generic-card-markdown.zh.md: 93e6388c64e69d9028822ce65f9eda368ed26e7d diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md b/.agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md rename to .agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.md index 494ba58048..5c092c4d5f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md +++ b/.agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.md @@ -1,6 +1,7 @@ # Agent Note: TUI generic-card Markdown rendering Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-23-tui-generic-card-markdown.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md b/.agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md rename to .agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md index 214edd00f5..93e6388c64 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 通用卡片的 Markdown 渲染 Status: implemented +Archived: 2026-08-04 [English](2026-07-23-tui-generic-card-markdown.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml new file mode 100644 index 0000000000..2ac19448b5 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md +2026-07-24-tui-turn-end-stop-reason-notices.md: 62784ed7cd085ada49b2e0e3d9d809c33a2af917 +2026-07-24-tui-turn-end-stop-reason-notices.zh.md: 162ba4ed44c98bfe00153b7acf69424784f92f5e diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md b/.agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md rename to .agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md index 7c783ce5a3..62784ed7cd 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md +++ b/.agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md @@ -1,6 +1,7 @@ # Agent Note: TUI presents a reason for every turn-end kind Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-24-tui-turn-end-stop-reason-notices.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md b/.agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md rename to .agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md index 4a98352577..162ba4ed44 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 为每种轮次结束 kind 呈现原因 Status: implemented +Archived: 2026-08-04 [English](2026-07-24-tui-turn-end-stop-reason-notices.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml new file mode 100644 index 0000000000..c7d248fb65 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md +2026-07-27-tool-card-single-row-fields-inline.md: 23989e08ac7e32097212bf951461ca1d4213f729 +2026-07-27-tool-card-single-row-fields-inline.zh.md: 8358f7b8dff67c0d3f459d08bf37e3a47f276107 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md b/.agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md rename to .agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md index e04110ed74..23989e08ac 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md @@ -1,6 +1,7 @@ # Agent Note: Tool-card single-row fields render inline Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-27-tool-card-single-row-fields-inline.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md index ac532ec6eb..8358f7b8df 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md @@ -1,6 +1,7 @@ # Agent Note: 工具卡片的单行字段以内联方式渲染 Status: implemented +Archived: 2026-08-04 [English](2026-07-27-tool-card-single-row-fields-inline.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml new file mode 100644 index 0000000000..e230947bf4 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md +2026-07-27-tui-step-timing-trails-tool-cards.md: 00256fc0f1c0e9469d0dd4e8567fdbc4a48dcaff +2026-07-27-tui-step-timing-trails-tool-cards.zh.md: a566b521923fecb47c721617ac1b4a5fffce7a0f diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md index 82f46b44d3..00256fc0f1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md @@ -1,6 +1,7 @@ # Agent Note: TUI step timing trails the step's last message Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-27-tui-step-timing-trails-tool-cards.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md index 885b232973..a566b52192 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 步骤计时跟在该步骤最后一条消息之后 Status: implemented +Archived: 2026-08-04 [English](2026-07-27-tui-step-timing-trails-tool-cards.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml new file mode 100644 index 0000000000..025b6610a9 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.md +2026-07-30-tui-adapter-registration-race.md: 051f5e6af5d7fd6c0096c84af064d065dd6f8983 +2026-07-30-tui-adapter-registration-race.zh.md: 1706acb1d23bab987333a7ac0b5e2695226c1af5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md b/.agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md rename to .agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.md index fd08e7b613..051f5e6af5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md +++ b/.agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.md @@ -1,6 +1,7 @@ # Agent Note: TUI model-context resolution defers on the adapter-registration race Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-30-tui-adapter-registration-race.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md b/.agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md rename to .agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md index 0c6bba4bbc..1706acb1d2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 模型上下文解析在适配器注册竞争时延后重试 Status: implemented +Archived: 2026-08-04 [English](2026-07-30-tui-adapter-registration-race.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml new file mode 100644 index 0000000000..b3414c0f98 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.md +2026-07-31-tui-diff-context-line-accounting.md: 5172069c7def39808b018030b630ea4313bd7038 +2026-07-31-tui-diff-context-line-accounting.zh.md: b9a9863f05d1c5b829facdc850eccc7a321a76ab diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md rename to .agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.md index d465568d5f..5172069c7d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff context lines stay neutral Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md rename to .agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md index dd1a3eb1ac..b9a9863f05 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff 上下文行保持中性 Status: implemented +Archived: 2026-08-04 [English](2026-07-31-tui-diff-context-line-accounting.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml new file mode 100644 index 0000000000..f5b795742e --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.md +2026-08-03-tui-long-session-render-costs.md: 2f6e909aa280cf72cd15a635a64c80aee6cd0cf1 +2026-08-03-tui-long-session-render-costs.zh.md: eeff7bc33df1f6e69d749c590953a42675a4d688 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md b/.agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md rename to .agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.md index c5b03960b6..2f6e909aa2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md +++ b/.agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.md @@ -1,6 +1,7 @@ # Agent Note: TUI long-session render costs — shared step-timing scan and card line caches Status: implemented +Archived: 2026-08-04 English | [中文](2026-08-03-tui-long-session-render-costs.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md b/.agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md rename to .agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md index b41c5a8c54..eeff7bc33d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 长会话渲染开销:共享步骤耗时扫描与卡片行缓存 Status: implemented +Archived: 2026-08-04 [English](2026-08-03-tui-long-session-render-costs.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml new file mode 100644 index 0000000000..c44bf6e177 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +2026-07-17-dedicated-full-screen-tui-front-door.md: 1a04e738a2810abdb4bac81aebe9b23db404879e +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 75fc3d47aa258fa6db767b1049194663148d1afa diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md similarity index 79% rename from .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md rename to .agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index c011a0284e..1a04e738a2 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -1,11 +1,14 @@ # Agent Note: Dedicated full-screen TUI front door Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-17-dedicated-full-screen-tui-front-door.zh.md) ## Problem +The reusable TUI package remains implemented, but [`dsh` no longer ships it as an application entrypoint](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md). This note continues to own the package boundary and terminal behavior; the later note owns product composition. + At the time this front door was introduced, the line-oriented agent handled pipes and ordinary terminals, but a full-screen coding interface had to own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin would have coupled a stream-oriented path to a TTY-only lifecycle. The later [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) removes that line agent; this Note continues to own the TUI design. The interactive channel must remain a Cordis plugin over the same agent, session, tool, and user-interaction services as every other front door. It needs to resume durable history, follow compaction replacements, display tool-owned presentation, and restore the terminal on startup failure and disposal. A standalone chat application or a second agent composition would duplicate behavior outside the plugin graph. @@ -14,9 +17,9 @@ The interactive channel must remain a Cordis plugin over the same agent, session DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. -There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/config/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition. Non-interactive tasks use the official headless surface; ACP remains a separate automation protocol and owns the supported Code Mode demo. +The package is a terminal front door, not a complete application. A host mounts `@deepseek-ai/dsh-tui` before its configured agent and composes the backends, tools, and policies around it. The product CLI currently ships no terminal composition; non-interactive tasks use headless mode, Web owns the installed human surface, and ACP remains a separate automation protocol. -The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. +The host supplies the exact generated or resumed `SessionId` used by its pre-created agent. The TUI waits for the matching root agent and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover. ### Session projection and interaction @@ -34,18 +37,18 @@ The built-in palette uses standard 16-color ANSI foregrounds and SGR attributes, ## Verification -The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-tui-terminal-state-snapshots.md) owns the four-layer verification contract: direct behavior tests, transient semantic terminal snapshots, recorded JSONL journeys through production tools, and Loader/PTY smoke tests. The package README owns configuration, commands, model-visible effects, and current limitations. +The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-tui-terminal-state-snapshots.md) owns the package verification contract: direct behavior tests and semantic terminal snapshots. A deployment shipping this front door owns its assembled transcript and process/PTY acceptance. The package README owns configuration, commands, model-visible effects, and current limitations. ## Alternatives considered - **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit. -- **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud. -- **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the repl-agent backend composition. +- **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. A host may select a different front door; an explicitly mounted TUI fails loud. +- **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected at the time because one leaf would represent two distinct front doors. The later product-entrypoint removal deleted that application wiring while retaining the package boundary. - **Mutate `agent.options` when `/model` runs** — rejected because creation options do not provide an atomic boundary between asynchronous prompt assembly and request routing. Agent-scoped waterfalls preserve immutable creation input and snapshot the selected pair for each step. ## Consequences -- Interactive terminal work has a stateful Markdown, card, plan, and question interface with no second terminal protocol to keep aligned. +- Deployments that mount the TUI gain a stateful Markdown, card, plan, and question interface with no second terminal protocol to keep aligned. - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md similarity index 81% rename from .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md rename to .agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 5aea4ac0c5..75fc3d47aa 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -1,11 +1,14 @@ # Agent Note: 独立的全屏 TUI 入口 Status: implemented +Archived: 2026-08-04 [English](2026-07-17-dedicated-full-screen-tui-front-door.md) | 中文 ## 问题 +可复用的 TUI 包(package)仍然保留实现,但 [`dsh` 不再将其作为应用入口交付](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)。本记录继续负责包边界和终端行为;后续记录负责产品组合。 + 在本入口引入时,面向行的 agent 负责 pipe 与普通终端,但全屏 coding 界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使面向 stream 的路径依赖仅适用于 TTY 的生命周期。后续的[移除重复 agent 决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)移除了这个面向行 agent;本 Note 继续负责 TUI 设计。 交互通道必须继续作为 Cordis 插件,使用与其他入口相同的 agent(智能体)、会话、工具和用户交互服务。它需要恢复持久历史、跟随压缩替换、显示工具自有的呈现内容,并在启动失败和资源释放时恢复终端。独立聊天应用或第二套 agent 组合会在插件图之外重复实现这些行为。 @@ -14,9 +17,9 @@ Status: implemented DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 -只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/config/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装。非交互任务使用官方 headless 界面;ACP 仍是独立的自动化协议,并拥有受支持的 Code Mode demo。 +该包是终端入口,而不是完整应用。宿主在已配置 agent 之前挂载 `@deepseek-ai/dsh-tui`,并围绕它组合后端、工具和策略。产品 CLI 目前不交付终端组合;非交互任务使用 headless 模式,Web 是已安装产品中面向人的界面,而 ACP 仍是独立的自动化协议。 -所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 +宿主提供其预创建 agent 使用的同一个新建或恢复 `SessionId`。TUI 等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告。 ### 会话投影与交互 @@ -34,18 +37,18 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 ## 验证 -已实现的 [TUI 终端状态快照 Agent Note](../testing/2026-07-18-tui-terminal-state-snapshots.md) 规定四层验证契约:直接行为测试、瞬态语义终端快照、通过生产工具执行的已录制 JSONL 流程,以及 Loader/PTY 冒烟测试。包(package)README 负责记录配置、命令、模型可见效果和当前限制。 +已实现的 [TUI 终端状态快照 Agent Note](../testing/2026-07-18-tui-terminal-state-snapshots.md) 规定包验证契约:直接行为测试和语义终端快照。交付该入口的部署负责其组装后 transcript 和进程/PTY 验收。包 README 负责记录配置、命令、模型可见效果和当前限制。 ## 曾考虑的替代方案 - **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。 -- **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。 -- **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 repl-agent 的后端组合。 +- **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。宿主可以选择其他入口;明确挂载的 TUI 会快速失败。 +- **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:当时不予采纳,因为一个叶节点会代表两个不同入口。后续移除产品入口时删除了该应用接线,但保留了包边界。 - **在 `/model` 运行时修改 `agent.options`**:不予采纳,因为创建选项无法在异步 prompt 组装与请求路由之间提供原子边界。agent 作用域内的 waterfall 会在保持创建输入不可变的同时,为每个 step 快照一次选中的字段组合。 ## 后果 -- 交互式终端拥有带状态的 Markdown、卡片、计划和提问界面,无需再对齐第二套终端协议。 +- 挂载 TUI 的部署会获得带状态的 Markdown、卡片、计划和提问界面,无需再对齐第二套终端协议。 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 diff --git a/.agents/notes/archived/feature/2026-07-20-windows-tui-support.i18n.yaml b/.agents/notes/archived/feature/2026-07-20-windows-tui-support.i18n.yaml new file mode 100644 index 0000000000..52f5e24a1b --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-20-windows-tui-support.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-20-windows-tui-support.md +2026-07-20-windows-tui-support.md: 9308df7fb43378610cb2d480324c55bff07ea108 +2026-07-20-windows-tui-support.zh.md: 8b4a1eb4cef3056b47b911a07ed62202b52d1f73 diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md b/.agents/notes/archived/feature/2026-07-20-windows-tui-support.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-20-windows-tui-support.md rename to .agents/notes/archived/feature/2026-07-20-windows-tui-support.md index 6b728486dd..9308df7fb4 100644 --- a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md +++ b/.agents/notes/archived/feature/2026-07-20-windows-tui-support.md @@ -1,6 +1,7 @@ # Agent Note: Support the TUI on Windows Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-20-windows-tui-support.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md b/.agents/notes/archived/feature/2026-07-20-windows-tui-support.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md rename to .agents/notes/archived/feature/2026-07-20-windows-tui-support.zh.md index 2b53b05ff6..8b4a1eb4ce 100644 --- a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md +++ b/.agents/notes/archived/feature/2026-07-20-windows-tui-support.zh.md @@ -1,6 +1,7 @@ # Agent Note: 在 Windows 上支持 TUI Status: implemented +Archived: 2026-08-04 [English](2026-07-20-windows-tui-support.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/archived/feature/2026-07-21-tui-resume-command.i18n.yaml new file mode 100644 index 0000000000..e6628da8c3 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-21-tui-resume-command.md +2026-07-21-tui-resume-command.md: de6c488b51b300705b33db13b8959b8114fdd025 +2026-07-21-tui-resume-command.zh.md: d7639c7a51fc5c35f10c514356ceb79d9c1ca76a diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/archived/feature/2026-07-21-tui-resume-command.md similarity index 87% rename from .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md rename to .agents/notes/archived/feature/2026-07-21-tui-resume-command.md index c8cb855378..de6c488b51 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/archived/feature/2026-07-21-tui-resume-command.md @@ -1,6 +1,7 @@ # Agent Note: Product-level TUI session resume Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-21-tui-resume-command.zh.md) @@ -36,4 +37,4 @@ The exit line is a launcher-owned context slot rather than a config template, an ## Testing -TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, the no-host warning, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. +TUI package tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, the no-host warning, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The package semantic snapshot owns the full-viewport selector and its IME cursor anchor; a deployment shipping the TUI owns its process and PTY handoff acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/archived/feature/2026-07-21-tui-resume-command.zh.md similarity index 86% rename from .agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md rename to .agents/notes/archived/feature/2026-07-21-tui-resume-command.zh.md index 2a7e74d110..d7639c7a51 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/archived/feature/2026-07-21-tui-resume-command.zh.md @@ -1,6 +1,7 @@ # Agent Note: 产品级 TUI 会话恢复 Status: implemented +Archived: 2026-08-04 [English](2026-07-21-tui-resume-command.md) | 中文 @@ -36,4 +37,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、无宿主时的告警,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 +TUI 包(package)测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、无宿主时的告警,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。包级语义快照固定全屏选择页和输入法光标锚点;交付 TUI 的部署负责其进程与 PTY 交接验收。 diff --git a/.agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.i18n.yaml new file mode 100644 index 0000000000..a8a6577e01 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.md +2026-07-21-tui-skill-slash-command.md: e151f92d0d9eb1486bcf21498fb73ba156edd861 +2026-07-21-tui-skill-slash-command.zh.md: ac1c4da948dcbd6b01bdb6e62d66d5f704c0b132 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.md similarity index 82% rename from .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md rename to .agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.md index 872e1f1097..e151f92d0d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.md @@ -1,6 +1,7 @@ # Agent Note: TUI skill slash command Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-21-tui-skill-slash-command.zh.md) @@ -30,4 +31,4 @@ Autocomplete filters the invocation-neutral `list()` result with `isUserInvocabl ## Consequences -Manual invocation always reloads the full skill body: the TUI does not detect a skill already present in the conversation, so a repeated `/skill:` appends its instructions again — acceptable because re-injection is sometimes the intent, and documented under the package README's Known Limitations. The two-renderer duplication is a standing maintenance cost accepted above. The `` wrapper is stable model-visible text and is pinned verbatim in unit tests against a real `SkillService`; the help-panel line is pinned by the `errors-and-help` terminal snapshot. Autocomplete population and the disposed-lookup and failed-lookup branches are covered by unit tests that mount the real registry or a controllable service. End-to-end delivery is proven by a dedicated real-composition test: the `examples/tui-agent` keyless PTY smoke (`tui-keyless-smoke.e2e.ts`) boots the production TUI/agent/skill stack through the Loader under a genuine pseudo-terminal with only the model scripted, drops a fixture skill under the agents-home `skills/` root, types `/skill:` as live keystrokes, and asserts the scripted adapter echoes the fixture's body marker only when the rendered `` block arrives — exercising `ctx.get('skills')` resolution in the shipped tree, the client-side parse, the local provider load, and the user turn reaching the model together. That fixture's frontmatter description avoids a `: ` colon-space so its YAML stays a plain scalar; an invalid-frontmatter skill is silently dropped during discovery. +Manual invocation always reloads the full skill body: the TUI does not detect a skill already present in the conversation, so a repeated `/skill:` appends its instructions again — acceptable because re-injection is sometimes the intent, and documented under the package README's Known Limitations. The two-renderer duplication is a standing maintenance cost accepted above. The `` wrapper is stable model-visible text and is pinned verbatim in package tests against a real `SkillService`; the package semantic matrix pins the help-panel line. Autocomplete population, user-only discovery, delivery to idle and running agents, and the disposed-lookup and failed-lookup branches are covered by package tests that mount the real registry or a controllable service. The removed product TUI's keyless PTY smoke formerly covered the assembled Loader path; a future terminal deployment owns that application-level scenario. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.zh.md similarity index 80% rename from .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md rename to .agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.zh.md index 772e25745e..ac1c4da948 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/archived/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI skill slash command Status: implemented +Archived: 2026-08-04 [English](2026-07-21-tui-skill-slash-command.md) | 中文 @@ -30,4 +31,4 @@ TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因 ## Consequences -手动调用总是重新加载完整的 skill 正文:TUI 不会检测某个 skill 是否已在对话中出现,因此重复的 `/skill:` 会再次追加其指令——这可以接受,因为重新注入有时正是意图所在,且已在本包 README 的已知限制中说明。上文接受的双渲染器重复是一项长期维护成本。`` 包裹是稳定的、模型可见的文本,并在单元测试中针对一个真实的 `SkillService` 逐字固定;帮助面板那一行由 `errors-and-help` 终端快照固定。自动补全的填充、dispose 后查找分支、以及查找失败分支,都由挂载真实注册表或可控服务的单元测试覆盖。端到端的投递由一项专门的真实组合测试证明:`examples/tui-agent` 的无密钥 PTY 冒烟测试(`tui-keyless-smoke.e2e.ts`)在真实伪终端下经由 loader 引导生产环境的 TUI/agent/skill 栈,仅对模型进行脚本化,把一个夹具 skill 放入 agents home 的 `skills/` 根下,以真实按键输入 `/skill:`,并断言:只有当渲染出的 `` 文本块抵达时,脚本化适配器才会回显该夹具的正文标记——从而一并演练了 `ctx.get('skills')` 在发布树中的解析、客户端解析、本地 provider 的加载,以及用户回合抵达模型。该夹具的 frontmatter 描述避免出现 `: ` 冒号加空格,使其 YAML 保持为纯标量;frontmatter 无效的 skill 会在发现阶段被静默丢弃。 +手动调用总是重新加载完整的 skill 正文:TUI 不会检测某个 skill 是否已在对话中出现,因此重复的 `/skill:` 会再次追加其指令——这可以接受,因为重新注入有时正是意图所在,且已在本包 README 的已知限制中说明。上文接受的双渲染器重复是一项长期维护成本。`` 包装层是稳定的、模型可见的文本,并在包测试中针对一个真实的 `SkillService` 逐字固定;包语义矩阵固定帮助面板中的这一行。自动补全填充、仅限用户的发现、向空闲及运行中 agent 投递,以及 dispose 后查找和查找失败分支,都由挂载真实注册表或可控服务的包测试覆盖。已移除的产品 TUI 的无密钥 PTY 冒烟测试过去覆盖组装后的 Loader 路径;未来的终端部署负责该应用级场景。 diff --git a/.agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml b/.agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml new file mode 100644 index 0000000000..5e79c40332 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.md +2026-07-23-tui-file-reference-autocomplete.md: 3ceda81858bc3364a2c4ac5b91d599b23479412a +2026-07-23-tui-file-reference-autocomplete.zh.md: c0b947edf6df42d27f5fee40d95eb073029e32b1 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md b/.agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.md similarity index 96% rename from .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md rename to .agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.md index 1a13600921..3ceda81858 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md +++ b/.agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.md @@ -1,6 +1,7 @@ # Agent Note: TUI file-reference autocomplete Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-23-tui-file-reference-autocomplete.zh.md) @@ -30,4 +31,4 @@ Structured session mentions keep their existing snapshot preparation. Unlike fil Users can discover and insert paths without making selection itself expensive or model-visible beyond the path. The model preserves agency over whether to inspect a file, and any inspection remains reconstructable through the logged tool transcript. The fixed instruction slightly enlarges TUI system prompts when `read` is present, and content-requiring requests take an additional tool round trip. -Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots and the real Loader PTY smoke pin the visible menu and keyboard completion. +Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, path-only submission, the visible menu, and keyboard completion; a deployment shipping the TUI owns its Loader and PTY acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md b/.agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.zh.md similarity index 95% rename from .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md rename to .agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.zh.md index 410f0d49db..c0b947edf6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md +++ b/.agents/notes/archived/feature/2026-07-23-tui-file-reference-autocomplete.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 文件引用自动补全 Status: implemented +Archived: 2026-08-04 [English](2026-07-23-tui-file-reference-autocomplete.md) | 中文 @@ -30,4 +31,4 @@ TUI 维护一个有容量上限且可取消的主机工作区路径索引,以 用户可以发现并插入路径,而选择操作本身不会带来高开销,对模型可见的内容也仅限路径。模型仍可自行决定是否检查文件,任何检查都能通过已记录的工具 transcript 重建。存在 `read` 时,固定指令会略微增大 TUI 系统提示词;需要文件内容的请求还会增加一次工具往返。 -补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照与真实 Loader PTY 冒烟测试固定可见菜单和键盘补全。 +补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效、仅提交路径的行为、可见菜单和键盘补全;交付 TUI 的部署负责其 Loader 与 PTY 验收。 diff --git a/.agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml b/.agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml new file mode 100644 index 0000000000..0322b85af5 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.md +2026-07-23-tui-status-prompt-tools.md: b8e5e9fc4fd8f9d6dafa689512ef991ee1013b39 +2026-07-23-tui-status-prompt-tools.zh.md: 3d10073b4332609371fdd1ebed7486970b439251 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md b/.agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.md similarity index 87% rename from .agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md rename to .agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.md index 42524d021d..b8e5e9fc4f 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md +++ b/.agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.md @@ -1,6 +1,7 @@ # Agent Note: TUI status inspects model request inputs Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-23-tui-status-prompt-tools.zh.md) @@ -26,4 +27,4 @@ The command can run prompt providers and assembly listeners, just like request p ## Testing -Unit coverage pins scoped assembly output, ordered tool names, empty labels, and terminal-control escaping. The keyless TUI smoke and terminal snapshot exercise `/status` through the assembled application. +Package behavior tests pin scoped assembly output, ordered tool names, empty labels, and terminal-control escaping. The package semantic snapshots exercise `/status` at normal and narrow widths; a deployment shipping the TUI owns its assembled process acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md b/.agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.zh.md similarity index 87% rename from .agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md rename to .agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.zh.md index 5a33e19e97..3d10073b43 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md +++ b/.agents/notes/archived/feature/2026-07-23-tui-status-prompt-tools.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 状态检查模型请求输入 Status: implemented +Archived: 2026-08-04 [English](2026-07-23-tui-status-prompt-tools.md) | 中文 @@ -26,4 +27,4 @@ Status: implemented ## 测试 -单元测试固定按作用域组装的输出、工具名称顺序、空值标签和终端控制字符转义。无密钥 TUI 冒烟测试与终端快照通过完整组装的应用执行 `/status`。 +包(package)行为测试固定按作用域组装的输出、工具名称顺序、空值标签和终端控制字符转义。包级语义快照在正常宽度和窄宽度下执行 `/status`;交付 TUI 的部署负责其组装后的进程验收。 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml b/.agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml rename to .agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml index 338f90664d..29edf8a739 100644 --- a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md -2026-07-29-tui-hidden-mode-assistant-fold.md: e2bae1d4669fd0a4c8f9278c58f641704b425109 -2026-07-29-tui-hidden-mode-assistant-fold.zh.md: 583d6099f9cd5edfc592a9d510d260d7a4172013 +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.md +2026-07-24-configurable-tui-prompt-theme.md: 918eaa617811049a5caa4b9f43a8cc12854471c3 +2026-07-24-configurable-tui-prompt-theme.zh.md: eccfed00c3fe866291d2f18bd7cecf117296bd58 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.md similarity index 93% rename from .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md rename to .agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.md index f8815c6c19..918eaa6178 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +++ b/.agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -1,6 +1,7 @@ # Agent Note: TUI prompt themes compose mutable plugin values Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-24-configurable-tui-prompt-theme.zh.md) @@ -36,4 +37,4 @@ Changing `inputPrompt` through a registered value preserves editor text, cursor, ## Testing -Registry tests pin validation, duplicate rejection, updates, unavailable values, coalesced-notification containment, unsubscribe, disposal, interpolation, trailing-literal retention, whitespace cleanup, and ANSI preservation. TUI tests pin nested theme defaults, custom templates, out-of-band value redraw, mutable redraw, Powerline-capable fragments, dynamic input-prefix width, and the static running placeholder. The assembled TUI demo test pins service load order and config forwarding. +Registry tests pin validation, duplicate rejection, updates, unavailable values, coalesced-notification containment, unsubscribe, disposal, interpolation, trailing-literal retention, whitespace cleanup, and ANSI preservation. TUI package tests pin service availability, nested theme defaults, config forwarding, custom templates, out-of-band value redraw, mutable redraw, Powerline-capable fragments, dynamic input-prefix width, and the static running placeholder. A deployment shipping the TUI owns assembled load-order acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.zh.md similarity index 92% rename from .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md rename to .agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.zh.md index 831471860a..eccfed00c3 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md +++ b/.agents/notes/archived/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 提示符主题组合可变的插件值 Status: implemented +Archived: 2026-08-04 [English](2026-07-24-configurable-tui-prompt-theme.md) | 中文 @@ -36,4 +37,4 @@ TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPromp ## 测试 -注册表测试固定校验、重名拒绝、更新、不可用值、合并通知的容错、取消订阅、dispose、插值、尾随字面保留、空白清理与 ANSI 保留等行为。TUI 测试固定嵌套主题默认值、自定义模板、带外值重绘、可变重绘、支持 Powerline 的片段、动态输入前缀宽度以及运行状态下的静态占位文本。组装后的 TUI 演示测试固定服务加载顺序与配置转发。 +注册表测试固定校验、重名拒绝、更新、不可用值、合并通知的容错、取消订阅、dispose、插值、尾随字面保留、空白清理与 ANSI 保留等行为。TUI 包(package)测试固定服务可用性、嵌套主题默认值、配置转发、自定义模板、带外值重绘、可变重绘、支持 Powerline 的片段、动态输入前缀宽度以及运行状态下的静态占位文本。交付 TUI 的部署负责组装后的加载顺序验收。 diff --git a/.agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml new file mode 100644 index 0000000000..90558a3d22 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.md +2026-07-24-tui-question-dialog-multiline.md: 9f1e75325942f28d69d9cadadf938c0f0b5e3ae5 +2026-07-24-tui-question-dialog-multiline.zh.md: bd112898367e7a7dd940f2b5fa76e531029ab803 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md b/.agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md rename to .agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.md index fc6e9bceee..9f1e753259 100644 --- a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md +++ b/.agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.md @@ -1,6 +1,7 @@ # Agent Note: TUI QuestionDialog renders options across multiple lines Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md b/.agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md rename to .agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.zh.md index a56821921b..bd11289836 100644 --- a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md +++ b/.agents/notes/archived/feature/2026-07-24-tui-question-dialog-multiline.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI QuestionDialog 以多行方式渲染选项 Status: implemented +Archived: 2026-08-04 [English](2026-07-24-tui-question-dialog-multiline.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml b/.agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml new file mode 100644 index 0000000000..a78bd1a593 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.md +2026-07-24-tui-shell-prompt-editor.md: a3707d3fcd0ae30517083ed388eb38cb3b92cad7 +2026-07-24-tui-shell-prompt-editor.zh.md: ca050e2000ff772389086b5023935c816ec1154e diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md b/.agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md rename to .agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.md index bba03e788b..a3707d3fcd 100644 --- a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md +++ b/.agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.md @@ -1,6 +1,7 @@ # Agent Note: TUI shell-prompt editor Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-24-tui-shell-prompt-editor.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md b/.agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md rename to .agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.zh.md index 1897d11292..ca050e2000 100644 --- a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md +++ b/.agents/notes/archived/feature/2026-07-24-tui-shell-prompt-editor.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI shell 提示符编辑器 Status: implemented +Archived: 2026-08-04 [English](2026-07-24-tui-shell-prompt-editor.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml b/.agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml new file mode 100644 index 0000000000..2d8d84405a --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.md +2026-07-27-assistant-timing-header-trailing.md: 2ef926cff989499aa05ebfa781eea10399c69d18 +2026-07-27-assistant-timing-header-trailing.zh.md: 282ac1e65b3c4e185839d3c6b70a085f685e8013 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md b/.agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.md similarity index 98% rename from .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md rename to .agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.md index a315a0620c..2ef926cff9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md +++ b/.agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.md @@ -1,6 +1,7 @@ # Agent Note: Assistant timing line renders after the message body Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-27-assistant-timing-header-trailing.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md b/.agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.zh.md similarity index 98% rename from .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md rename to .agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.zh.md index 84b8612337..282ac1e65b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md +++ b/.agents/notes/archived/feature/2026-07-27-assistant-timing-header-trailing.zh.md @@ -1,6 +1,7 @@ # Agent Note: Assistant timing line renders after the message body Status: implemented +Archived: 2026-08-04 [English](2026-07-27-assistant-timing-header-trailing.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml b/.agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml new file mode 100644 index 0000000000..0ceac7c49a --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.md +2026-07-27-tui-running-glyph-smooth-fade.md: 09a944a5e39d407713da5665a3dd32a171a04bc1 +2026-07-27-tui-running-glyph-smooth-fade.zh.md: 5fc6a7e490670ce02d011651741a952cfb97dcce diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md b/.agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md rename to .agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.md index e4c8fee399..09a944a5e3 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md +++ b/.agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.md @@ -1,6 +1,7 @@ # Agent Note: Dim-gray pulse for the running prompt glyph Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-27-tui-running-glyph-smooth-fade.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md b/.agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md rename to .agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md index 25bda3d549..5fc6a7e490 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md +++ b/.agents/notes/archived/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md @@ -1,6 +1,7 @@ # Agent Note: Dim-gray pulse for the running prompt glyph Status: implemented +Archived: 2026-08-04 [English](2026-07-27-tui-running-glyph-smooth-fade.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/archived/feature/2026-07-27-tui-tool-card-header.i18n.yaml new file mode 100644 index 0000000000..8653f43d71 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-27-tui-tool-card-header.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-27-tui-tool-card-header.md +2026-07-27-tui-tool-card-header.md: f054ca378481be477ac2d261423c3e6af2035b2a +2026-07-27-tui-tool-card-header.zh.md: bf3bf39662f8cc04a28d8f5ea411077bb190c59a diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/archived/feature/2026-07-27-tui-tool-card-header.md similarity index 93% rename from .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md rename to .agents/notes/archived/feature/2026-07-27-tui-tool-card-header.md index 13f5e7fec1..f054ca3784 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md +++ b/.agents/notes/archived/feature/2026-07-27-tui-tool-card-header.md @@ -1,6 +1,7 @@ # Agent Note: Fixed `Tool / ` header for tool-call cards Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-27-tui-tool-card-header.zh.md) @@ -32,4 +33,4 @@ A tool call now shows its identity in one stable place, and status reads as one ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins the new header (`Tool / `), the dropped diff title, the relocated generic title, and the `· N file(s)` footer. The keyless terminal snapshots under `packages/ui/tui/tests/snapshots/` and `examples/tui-agent/tests/snapshots/` — rendered through the real assembled TUI and a pseudo-terminal — were re-recorded and show the new cards for read, bash (described and undescribed), edit, and the other tools. +`packages/ui/tui/tests/tui.spec.ts` pins the new header (`Tool / `), the dropped diff title, the relocated generic title, and the `· N file(s)` footer. Package semantic snapshots cover the card families in a headless terminal. The deleted application journeys formerly supplied assembled tool executions; a future terminal deployment owns equivalent transcript coverage. diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/archived/feature/2026-07-27-tui-tool-card-header.zh.md similarity index 93% rename from .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md rename to .agents/notes/archived/feature/2026-07-27-tui-tool-card-header.zh.md index 85f9f1244a..bf3bf39662 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md +++ b/.agents/notes/archived/feature/2026-07-27-tui-tool-card-header.zh.md @@ -1,6 +1,7 @@ # Agent Note: Fixed `Tool / ` header for tool-call cards Status: implemented +Archived: 2026-08-04 [English](2026-07-27-tui-tool-card-header.md) | 中文 @@ -32,4 +33,4 @@ TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 pre ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定了新表头(`Tool / `)、弃用的 diff 标题、迁移后的 generic 标题以及 `· N file(s)` 页脚。`packages/ui/tui/tests/snapshots/` 与 `examples/tui-agent/tests/snapshots/` 下的无密钥终端快照——经由真实组装的 TUI 与伪终端渲染——已重新录制,展示了 read、bash(有描述与无描述)、edit 及其他工具的新卡片。 +`packages/ui/tui/tests/tui.spec.ts` 固定了新表头(`Tool / `)、弃用的 diff 标题、迁移后的 generic 标题以及 `· N file(s)` 页脚。包级语义快照在无界面终端中覆盖各类卡片。已删除的应用流程此前提供组装后的工具执行;未来的终端部署负责提供等价的 transcript 覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml similarity index 67% rename from .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml rename to .agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml index cd0da77981..8feded0ad4 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md -2026-07-28-dsh-guided-skill-session-commands.md: 8a091f7a03c85b0723d96b4fc546875a4e6c0f95 -2026-07-28-dsh-guided-skill-session-commands.zh.md: 861e6cf6cf07c41fde4f78b0833e3f8eb9508767 +2026-07-28-dsh-guided-skill-session-commands.md: 454d090a55db8987f8e4987aba67deff1b21b1a0 +2026-07-28-dsh-guided-skill-session-commands.zh.md: 64dad4e39c6fd3e3341313c3f8dd2b04947695cf diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md rename to .agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.md index 8a091f7a03..454d090a55 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +++ b/.agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -1,6 +1,7 @@ # Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill Status: implemented +Archived: 2026-08-03 English | [中文](2026-07-28-dsh-guided-skill-session-commands.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md similarity index 98% rename from .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md rename to .agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md index 861e6cf6cf..64dad4e39c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md +++ b/.agents/notes/archived/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -1,6 +1,7 @@ -# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮 +# Agent Note: `dsh migrate`/`dsh upgrade` 以 skill 播种首轮 Status: implemented +Archived: 2026-08-03 [English](2026-07-28-dsh-guided-skill-session-commands.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml similarity index 68% rename from .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml rename to .agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml index 72c62d1e60..1df1788849 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md -2026-07-28-dsh-meta-source-workspace.md: 95270a276cd5df03ffd2dfb419a33289d7b5b901 -2026-07-28-dsh-meta-source-workspace.zh.md: 645b20705386a3501d026fb58ca224c49cc69a17 +2026-07-28-dsh-meta-source-workspace.md: 1e433bb50ae5cd5cc893e867d0f4b6edce2bf168 +2026-07-28-dsh-meta-source-workspace.zh.md: 95b1edabbbac2e78d09097db614ee4e5d1b33bf7 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md rename to .agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.md index 95270a276c..1e433bb50a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +++ b/.agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.md @@ -1,6 +1,7 @@ # Agent Note: `dsh meta` boots the TUI over the harness checkout Status: implemented +Archived: 2026-08-03 English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.zh.md similarity index 98% rename from .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md rename to .agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.zh.md index 645b207053..95b1edabbb 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md +++ b/.agents/notes/archived/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -1,6 +1,7 @@ -# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI +# Agent Note: `dsh meta` 以 harness 检出为 workspace 启动 TUI Status: implemented +Archived: 2026-08-03 [English](2026-07-28-dsh-meta-source-workspace.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml b/.agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml new file mode 100644 index 0000000000..4c92c6521f --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.md +2026-07-29-tui-hidden-mode-assistant-fold.md: 53cd3d812212f8a72c56165c37b806cae58f37f6 +2026-07-29-tui-hidden-mode-assistant-fold.zh.md: cf3644478588bbba955fb6539dd99744ef6eb84c diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md b/.agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md rename to .agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.md index e2bae1d466..53cd3d8122 100644 --- a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md +++ b/.agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.md @@ -1,6 +1,7 @@ # Agent Note: TUI hidden mode folds a turn's assistant steps into one message Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-29-tui-hidden-mode-assistant-fold.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md b/.agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md rename to .agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md index 583d6099f9..cf36444785 100644 --- a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md +++ b/.agents/notes/archived/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 隐藏模式把一个轮次的 assistant 步骤折叠为一条消息 Status: implemented +Archived: 2026-08-04 [English](2026-07-29-tui-hidden-mode-assistant-fold.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.i18n.yaml similarity index 67% rename from .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml rename to .agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.i18n.yaml index c922eb5a91..aad3f3c42f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md -2026-07-30-compaction-progress-visibility.md: b4d95d4bc645924b96eab6a36ee7b8f36b76a2c6 -2026-07-30-compaction-progress-visibility.zh.md: e444fbdfbf1c865a5bb3b6675a5ff318c5cef9e7 +2026-07-30-compaction-progress-visibility.md: e0d44c8616661161f6a99b54e3fe75a75dd5b9ad +2026-07-30-compaction-progress-visibility.zh.md: 5b181da16280393db9da5bc00127e71de1d9341a diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md b/.agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.md similarity index 95% rename from .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md rename to .agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.md index b4d95d4bc6..e0d44c8616 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md +++ b/.agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.md @@ -1,6 +1,7 @@ # Agent Note: Live standalone compaction progress in the terminal Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-30-compaction-progress-visibility.zh.md) @@ -46,4 +47,4 @@ Manual compaction has a named elapsed-time display above the prompt while the ag The live cell and timer are additional process-local state, cleared on both bracket close and TUI teardown. This is intentionally not reconstructible presentation state: durable history supplies the successful marker and timing facts, while current-process observation alone supplies liveness. -The package-level TUI tests pin standalone start, elapsed-time refresh, single-indicator presentation, numbered-start exclusion, fade-out, failure warning, idle-status preservation, running-turn precedence, orphaned resume, and timer disposal. The assembled `queued-manual-compact` terminal scenario observes `Context being compacted 1.0s` and `dsh ⊙` while the real summary boundary is held. +The package-level TUI tests pin standalone start, elapsed-time refresh, single-indicator presentation, numbered-start exclusion, fade-out, failure warning, idle-status preservation, running-turn precedence, orphaned resume, and timer disposal. The removed product TUI scenario formerly observed `Context being compacted 1.0s` and `dsh ⊙` across a held real summary boundary; a future terminal deployment owns that assembled journey. diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md b/.agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.zh.md similarity index 96% rename from .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md rename to .agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.zh.md index e444fbdfbf..5b181da162 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-compaction-progress-visibility.zh.md @@ -1,6 +1,7 @@ # Agent Note: 终端中的实时独立压缩进度 Status: implemented +Archived: 2026-08-04 [English](2026-07-30-compaction-progress-visibility.md) | 中文 @@ -46,4 +47,4 @@ TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end` 实时状态及其定时器是额外的进程局部状态,在标记对闭合和 TUI 清理这两种情况下都会清除。按设计,这种显示状态不可重建:持久历史提供成功标记与计时事实,只有当前进程的观察才能提供运行中状态。 -包(package)级 TUI 测试固定了以下行为:独立开始事件、已用时间刷新、单指示器呈现、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。组装后的 `queued-manual-compact` 终端场景会在真实摘要边界保持开放期间观察到 `Context being compacted 1.0s` 和 `dsh ⊙`。 +包(package)级 TUI 测试固定了以下行为:独立开始事件、已用时间刷新、单指示器呈现、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。已移除的产品 TUI 场景此前会在真实摘要边界保持开放期间观察到 `Context being compacted 1.0s` 和 `dsh ⊙`;未来的终端部署负责该组装流程。 diff --git a/.agents/notes/archived/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-tui-details-command.i18n.yaml new file mode 100644 index 0000000000..f3b185bbde --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-tui-details-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-30-tui-details-command.md +2026-07-30-tui-details-command.md: b17cd493fc0c47d7484714f72f418266cd006353 +2026-07-30-tui-details-command.zh.md: ef2a682db1248dfc97fd6ccaa4260b4b9d1a0861 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/archived/feature/2026-07-30-tui-details-command.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-tui-details-command.md rename to .agents/notes/archived/feature/2026-07-30-tui-details-command.md index fb7c4dfaed..b17cd493fc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/archived/feature/2026-07-30-tui-details-command.md @@ -1,6 +1,7 @@ # Agent Note: /details command for transcript detail state Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-30-tui-details-command.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/archived/feature/2026-07-30-tui-details-command.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md rename to .agents/notes/archived/feature/2026-07-30-tui-details-command.zh.md index 5f9e033311..ef2a682db1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-tui-details-command.zh.md @@ -1,6 +1,7 @@ # Agent Note: 用于 transcript 细节状态的 /details 命令 Status: implemented +Archived: 2026-08-04 [English](2026-07-30-tui-details-command.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml similarity index 67% rename from .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml rename to .agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index 5a414af614..d73372fda8 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md -2026-07-30-versioned-tui-first-run-welcome.md: 5270c239f0bccbf58e68364e195ff2175355a816 -2026-07-30-versioned-tui-first-run-welcome.zh.md: cd132fde5ff0601cef6cd3cd433fdd15dc05f7f0 +2026-07-30-versioned-tui-first-run-welcome.md: c032785a34455e868c2643bc58aafcb0e4916592 +2026-07-30-versioned-tui-first-run-welcome.zh.md: 0f3e684baeaed178ce3d7590590d0086f345e461 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md rename to .agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.md index 5270c239f0..c032785a34 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -1,6 +1,7 @@ # Agent Note: Versioned TUI first-run welcome Status: implemented +Archived: 2026-08-03 English | [中文](2026-07-30-versioned-tui-first-run-welcome.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md rename to .agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index cd132fde5f..0f3e684bae 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -1,6 +1,7 @@ # Agent Note: 版本化 TUI 首次运行欢迎页 Status: implemented +Archived: 2026-08-03 [English](2026-07-30-versioned-tui-first-run-welcome.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml b/.agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml similarity index 68% rename from .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml rename to .agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml index 8d925d4eee..5db76248b3 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md -2026-07-31-experimental-subcommand-gate.md: 41447c2d23bc71964f990298de3c48b2fe7ef309 -2026-07-31-experimental-subcommand-gate.zh.md: 35598a61d98ff7490b34d175fc915043f5b5236a +2026-07-31-experimental-subcommand-gate.md: a12c93935805a9ab19d4a15905f90783851eabba +2026-07-31-experimental-subcommand-gate.zh.md: ee05ec2c1c488e9d815bc150135202963f030ae7 diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md b/.agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md rename to .agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.md index 41447c2d23..a12c939358 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md +++ b/.agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.md @@ -1,6 +1,7 @@ # Agent Note: experimental subcommands gate behind `--experimental` or `DSH_EXPERIMENTAL=1` Status: implemented +Archived: 2026-08-03 English | [中文](2026-07-31-experimental-subcommand-gate.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md b/.agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.zh.md similarity index 97% rename from .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md rename to .agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.zh.md index 35598a61d9..ee05ec2c1c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md +++ b/.agents/notes/archived/feature/2026-07-31-experimental-subcommand-gate.zh.md @@ -1,6 +1,7 @@ -# Agent Note:实验性子命令由 `--experimental` 或 `DSH_EXPERIMENTAL=1` 把守 +# Agent Note: 实验性子命令由 `--experimental` 或 `DSH_EXPERIMENTAL=1` 把守 Status: implemented +Archived: 2026-08-03 [English](2026-07-31-experimental-subcommand-gate.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 1ed77225ae..58928312af 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -25,12 +25,21 @@ "architecture/2026-07-05-windows-fs-permissions.i18n.yaml": "sha256:7e61ee9bbd9de4bf3285a6f250d9625bd062e5fb90279dbffd64c820f1f7fe6b", "architecture/2026-07-05-windows-fs-permissions.md": "sha256:03734da511eae3b0736f7cad73d9da76ae2f69f9d5ed09089b0121ccb135a861", "architecture/2026-07-05-windows-fs-permissions.zh.md": "sha256:454848057ea905fe76c88d17264e71e71fb685f08f82088de6976878372865c3", + "architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml": "sha256:1b4822af5c8d642b73e3a0b04fb0a1dea9f50d0147046fbef53f5e49c030fb91", + "architecture/2026-07-22-tui-interactive-extension-service.md": "sha256:ca6b2774f4821e66f7c8397f20fcd34926728ded853fa48cbe451db7a8d2f883", + "architecture/2026-07-22-tui-interactive-extension-service.zh.md": "sha256:5b060c7626ee796c27108be7467a5e4be0677d7525d383336e7ec31ddce5c303", "architecture/2026-07-23-unified-session-query-service.i18n.yaml": "sha256:e8733b6543d9602ec206a087d9e89815f041f60fb57e93bee80e1309b9f03067", "architecture/2026-07-23-unified-session-query-service.md": "sha256:28d003686f29ec5e072e51e73da353575bcdcba5af20fefdfad88340e1ddd32c", "architecture/2026-07-23-unified-session-query-service.zh.md": "sha256:cfbe6525bc3b072fbc6db6bdca7a4d8cb4fc5507b1655bebc6af0589ed29ed31", "architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml": "sha256:cf99eda0e58b49630d5f95792459d7095666fafbef61f614165d5cdd031b7118", "architecture/2026-07-24-dsh-commander-argument-adapter.md": "sha256:705654c8a43bcd199f72c21a77d24ca8bfa02447aff1c7f3e4e820be61dcd562", "architecture/2026-07-24-dsh-commander-argument-adapter.zh.md": "sha256:3844f02d7659d18caf5d39e1131ed775c789cbf92dc44b4a446c7d6468aa5d00", + "architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml": "sha256:7b9dbe8b4a340640610abe7e54fb29492d77a187c176996a53d0e1fc7c8e1945", + "architecture/2026-07-27-tui-chat-channel-module-split.md": "sha256:3e2cd43f306a18b3eaf9bac23e6bdc3a5dbdc7388b7c399ce71e4f71b8f71d2a", + "architecture/2026-07-27-tui-chat-channel-module-split.zh.md": "sha256:d6b84fdcd91a2693b72cf6884b3a0c39e56e571b2b694f630805d894a6ba292f", + "architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml": "sha256:1eb43c420a21b7a3adf0aa5274d9aa597187630a29d7e535c5e266f82e803665", + "architecture/2026-07-28-consolidated-tui-presentation.md": "sha256:e6fa4ea0c9d1d94942ab98de47c554f4e8aa3b639a1cce52113107b1dbb0f4b0", + "architecture/2026-07-28-consolidated-tui-presentation.zh.md": "sha256:01814434482a84ebd7f672eb5c26fc468b568e773452563bf39ba52ad25d054a", "bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml": "sha256:1035dae11d049d32ab09fd7d4f950eceae44bf46ba498b3cfaf3c75102b9fb64", "bug-fix/2026-07-20-code-mode-result-card-completeness.md": "sha256:6ca2c9d4df98be18813ef38b7462db880900b5bcd6944fbcd1b8f2258006b93e", "bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md": "sha256:ed85fa7f935e5f525d566bc37a92014614983e649c75de9a9f244939097a7991", @@ -43,12 +52,33 @@ "bug-fix/2026-07-23-thinking-row-disclosure-target.i18n.yaml": "sha256:fd926967311f30ea4a222e88b845f95d74af75d1e94b24ebef59186593b9ca78", "bug-fix/2026-07-23-thinking-row-disclosure-target.md": "sha256:92815c170972b1b91c3d75dd0c846c070805ec1e99ce368b6aae37b048e19869", "bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md": "sha256:0e09f5f5e14d74214e5157ceb5859c866bab6de701c47e2ce5c450866d75aecf", + "bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml": "sha256:51fec8e6d7a998fd6189e70d32a6be293500abc8435b23257449e6f6d15a0053", + "bug-fix/2026-07-23-tui-generic-card-markdown.md": "sha256:7dda1114a392737465837e014059c1049da37330e3069b74f6e2ea34eccbce6e", + "bug-fix/2026-07-23-tui-generic-card-markdown.zh.md": "sha256:5040a1aaa8ff9dddeeb572a628410a79d870a90f84be00bf421e4b63e1974a4b", + "bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml": "sha256:8ac9e40a4a7ac1f526b5a23656579414322bd947d991cfc02c0be0e1aacf2d68", + "bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md": "sha256:5a1f6ba4baf25ad412eda3601ee074aeb607388da0375979797797a81eeba9da", + "bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md": "sha256:a7bbc530b9fc5e31ceb0f72d87edc720a0bd04ce7613b2ca9f9704cd5e7f682e", "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml": "sha256:4b94aded16c60628d22414dce524e8a98a8af4fff298805ee7efc63cae02c90d", + "bug-fix/2026-07-27-tool-card-single-row-fields-inline.md": "sha256:40adcd522a9a2eeacc6f2b0196d1f24888a4f57830b7490a3be3d78c86c4e968", + "bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md": "sha256:a79d56c9b781442ee596b47707d1a8c80abcd6466094b01802189c8e55f16da7", "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55", "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402", "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384", + "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml": "sha256:280b93ece72662501f65edd58a00cdafb5b5941e4ef1314d7198fab18950cb03", + "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md": "sha256:112bdbde16b6023eeb5b8a79cd2a711385e7198d51bbfc0520e9612acaa95c8a", + "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md": "sha256:fc4e7f778ea63c4583cf81132c264cf6c4b9cc3e1818778061b0497ff16b8ef6", + "bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml": "sha256:50b7a32e11591719c249258ecc2ec0f45e58f1a04050d2e53f6e2650f58ba137", + "bug-fix/2026-07-30-tui-adapter-registration-race.md": "sha256:7e17eb1dd8f92e1efb7a18477df277b13580840b473ffe8a5309fc70ec3cfa3e", + "bug-fix/2026-07-30-tui-adapter-registration-race.zh.md": "sha256:efcbd3d82af6a58677efe1a0580edd715945b6a93418fde47badac9c01a29866", + "bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml": "sha256:cd39ae2646fdc6827bf29a63953b5463faa37d5b404ae8cc3c0913c47bc92d0c", + "bug-fix/2026-07-31-tui-diff-context-line-accounting.md": "sha256:57066bccd22c2dc2c3546b363de73d13b55ff8683ee12b17a81ed2bcf536645b", + "bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md": "sha256:a658d886c5eb203f5f30a6fac70ad18e4a24cf756746254723d8f1d144653c04", + "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", + "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", + "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", @@ -79,9 +109,15 @@ "feature/2026-07-14-time-context-plugin.i18n.yaml": "sha256:670c093817c77e093562e02f43984d42ed44ebcced7c91d09366839e412d05e1", "feature/2026-07-14-time-context-plugin.md": "sha256:618b121da38a8b610bcadaecf121ca823b2c8c13598c012b350c214b82fd238f", "feature/2026-07-14-time-context-plugin.zh.md": "sha256:1e9eee8ba427a6f2ee08c79e2fcb33c0948e67a80758fdf9f8c9f7dff9aea361", + "feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml": "sha256:c04ec462f846887f349ffeb48aef8febf50f06903e7177ffd7679a694f6c1a57", + "feature/2026-07-17-dedicated-full-screen-tui-front-door.md": "sha256:feb70faaad016965e8cc1bd7339947e48937fb3e8ea1425dada581073396f50c", + "feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md": "sha256:71e4a4abf01e5fc46f7da60e0a66bca659c1ee3c4ea2c4bc20587844f33920fe", "feature/2026-07-20-tui-startup-slogans.i18n.yaml": "sha256:265d1fd79dae6c785201c81ffe2de3baa9fe9e3b6c0f84aac79c90f4040ced15", "feature/2026-07-20-tui-startup-slogans.md": "sha256:aaaab4b419d35ce24317b7730f15af0029878bf3d17c6f184b05138c2cd44930", "feature/2026-07-20-tui-startup-slogans.zh.md": "sha256:01fba568cd92e9c54857f6dba1a3a5a6a4d0e906f36915d7e64682e67d456708", + "feature/2026-07-20-windows-tui-support.i18n.yaml": "sha256:fa7253a8e20308b216c21d201720a4b7d6b74ccc9a650c0d30081c9e5b6bf04c", + "feature/2026-07-20-windows-tui-support.md": "sha256:abf80ade38ec4d9e558c0fa9b0ecc8877acc18442f4ed945ebc2f4b153d85087", + "feature/2026-07-20-windows-tui-support.zh.md": "sha256:6690a7693b8a4c8ce495c13ae84d9dc81f41c691d217db3152ea0d926dffa0f8", "feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml": "sha256:22efaf3237425fecbac1b40a444454e0fc244a3c85c2f6a14535de22ea777719", "feature/2026-07-21-dsh-system-prompt-source-path.md": "sha256:5fa554932c62a8bbd5a619581710d7f8b6b65d79ec1e340129cda96d279c5ae3", "feature/2026-07-21-dsh-system-prompt-source-path.zh.md": "sha256:995cd593074881c72510a6af3ba80108bbf986d49508cce9f698c2fcb493fd23", @@ -109,6 +145,12 @@ "feature/2026-07-21-tui-reload-command.i18n.yaml": "sha256:9be416ccd681aed0781fdfd2c44c4821c1e45f2a0deccb1f2b47d46163bde488", "feature/2026-07-21-tui-reload-command.md": "sha256:b8616457822ae87c90062308bc8c0d2badd5f368092ec65847d0d9520b1ac372", "feature/2026-07-21-tui-reload-command.zh.md": "sha256:c24bfcb0df13977a9c11c4d0fe433169e535b5f764995b668430dbb14a8e6b33", + "feature/2026-07-21-tui-resume-command.i18n.yaml": "sha256:50526c2ec1bf5fe4f2f912e70fc8145f6978907f80f9d716ca3bcb17cd028933", + "feature/2026-07-21-tui-resume-command.md": "sha256:821cfee22ff6ed491807ecca492538e2dd18ce4b00e7c09b6f4e8604cb02b97b", + "feature/2026-07-21-tui-resume-command.zh.md": "sha256:7fe8e638df0f1977bb93a9efd973d4f69a19ad48e43dae6b1bc8eb70758a561b", + "feature/2026-07-21-tui-skill-slash-command.i18n.yaml": "sha256:c711fd237649c81705a6bc5b20d7b4585ced3166acbc8cc3c2877d97bb386edf", + "feature/2026-07-21-tui-skill-slash-command.md": "sha256:b2904829540a1801852e072937dc259830e93f0c719590da0a189879aca1d7a6", + "feature/2026-07-21-tui-skill-slash-command.zh.md": "sha256:350ed930218b5c4dfb721ebaceac419f4cb2c73f047c004747ba01535a77cab5", "feature/2026-07-21-tui-steering-queue-badge.i18n.yaml": "sha256:a029da558a6e14e1f13269960b98273ca9af0141579967acfbc19b656775f4a5", "feature/2026-07-21-tui-steering-queue-badge.md": "sha256:9aabd68c8910fdc7e7b05674492ddb8dc9285dd691fe554adcb84026fb846cc8", "feature/2026-07-21-tui-steering-queue-badge.zh.md": "sha256:919fd737866c3700f945751628071dab89eabdbf8f809deab93b9e6fbe2c8c59", @@ -118,15 +160,60 @@ "feature/2026-07-23-trajectory-step-cell.i18n.yaml": "sha256:fe2e935a0affdef877902a40d9861ef5f55b30f40650469f6a52a4d45a92793f", "feature/2026-07-23-trajectory-step-cell.md": "sha256:185e3b87174cb6d2f2d2271fd2a74b1517d03e8570be602570d027bf6002d106", "feature/2026-07-23-trajectory-step-cell.zh.md": "sha256:51f46be43d2f5c4f78a05ed9aeec92d1f33ac988f45cf24d35528e9c43828ef3", + "feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml": "sha256:f95d9b37369ca8a0eecb07784759e94b5e0e85b7565c7256008c2f9db0d0c157", + "feature/2026-07-23-tui-file-reference-autocomplete.md": "sha256:5a6fd182d9080d757b4ad565e98233b0f128589cbfa6d741dc76a7cc0f41b5c6", + "feature/2026-07-23-tui-file-reference-autocomplete.zh.md": "sha256:8676fd1f57fd705c25f8c37fbfd91126a85387d5b6261d22470b035723a6ad73", + "feature/2026-07-23-tui-status-prompt-tools.i18n.yaml": "sha256:e8072d6661c91b43d13ef88b69fc49b9a312a7b5cb0b6f0171c0fdafc36b0435", + "feature/2026-07-23-tui-status-prompt-tools.md": "sha256:0dd669c70ac34024b389d76c81dcf4f1d5b747f56b7d6d82587c8c15a421c0a2", + "feature/2026-07-23-tui-status-prompt-tools.zh.md": "sha256:6a488b18e1516887d6073375651fba59c3a266b888f9ca2d4804f6681838059b", + "feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml": "sha256:694de3851ca1e3de76c4f4fb7f3581ad0df568a99046c905f56d24a54407a5ba", + "feature/2026-07-24-configurable-tui-prompt-theme.md": "sha256:694136b6d9bf7de25c240c373cb268cf798f8617c206d53c1d0ef7443e75adeb", + "feature/2026-07-24-configurable-tui-prompt-theme.zh.md": "sha256:ee083f864bf6915a887b7883d14a6662ff6ecb4e702f4ac0f583d0c8f823bf50", "feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml": "sha256:978638cbf18bc6dce9fea0817654f41cc307f99004a637b85a63ae2208fe9095", "feature/2026-07-24-new-session-clears-to-empty-state.md": "sha256:b6b71d3883a167056070713e3dffb5046de953bdd218074d17c88e7690e03d83", "feature/2026-07-24-new-session-clears-to-empty-state.zh.md": "sha256:82a80b48337487029acd05a0137d268f0850f46801fa44a0e62733cacd00d5e9", + "feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml": "sha256:b8a90f13f579c17476e94740079ac2e058f2a97ec4d723fe2841398426129858", + "feature/2026-07-24-tui-question-dialog-multiline.md": "sha256:0bf07eb8731cf6928db275f160ba36b07ce93d53873eec2fb6dcaabaa0c57f76", + "feature/2026-07-24-tui-question-dialog-multiline.zh.md": "sha256:85f5dedc794014152792557158b826650b147fe6d439e71ea23c115d2c2c84de", + "feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml": "sha256:f40b5ba2d22e3a52fea2d3d0f3f481ef710764c3ec4379e7c859f52c61434b1b", + "feature/2026-07-24-tui-shell-prompt-editor.md": "sha256:7bbb99108a7dfe219c031f77cafa5bbcfacd1dd05080822fe11e0ca58da4b3f5", + "feature/2026-07-24-tui-shell-prompt-editor.zh.md": "sha256:6e20225724941290bbdc9af86d4f91e1beb93bde5c2d67144f0a32c4abb30e7b", "feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml": "sha256:916525fdc3a12061928380fd8f7b81cd9763a7873663aad564843fccab0ccedc", "feature/2026-07-26-code-mode-trajectory-waterfall-spans.md": "sha256:a822963e4c34c9737681d6d70d8167731d4350e66ef7f356684ad096c04fa7ab", "feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md": "sha256:ef01163adf1245f75cc8291db70d389314dee43b6bff958a9da49b097531218d", + "feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml": "sha256:e5a8aa9e5194dae6f4369299a64acae2c751d7f85e457e8dec1c14e1dbcee3e9", + "feature/2026-07-27-assistant-timing-header-trailing.md": "sha256:85fac4a712ac5b4d61c224eddede08edb27db2e3718321c094a55034c75ce984", + "feature/2026-07-27-assistant-timing-header-trailing.zh.md": "sha256:260d115d2da429eeac73b7256944e60cb03a93714f5d13b5e219dff316f5a958", + "feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml": "sha256:e440b7f4800f722f1a0f9d089081ac19a7fbf809cb75136566ae363b4aa0aab9", + "feature/2026-07-27-tui-running-glyph-smooth-fade.md": "sha256:cc58dce4788c8478c97ce589e4519cfb1b6a6cc3ec46d263f3038823231e5ae5", + "feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md": "sha256:c1735d5bd4b5e75989a9aead8af29136e7fadc01e79fb8c8cc68601a31fbf94a", + "feature/2026-07-27-tui-tool-card-header.i18n.yaml": "sha256:1ed2c377f6d2c589195a00e84f92e6511b9a0793a2684a75ec3a0ca1f2b98670", + "feature/2026-07-27-tui-tool-card-header.md": "sha256:ebb3d913960cdb0949a753c5811bd7d68a96f64f3b0549cd2ab7e4997b6b2d28", + "feature/2026-07-27-tui-tool-card-header.zh.md": "sha256:402a619dce15534fbf88a59361b693eeb2121584fbb2e7af4e93eeaccaf2d0ea", "feature/2026-07-27-user-message-icon-actions.i18n.yaml": "sha256:b33e480f19ec58c8c60417a6c03999953d463ca54606a5ac80ec528edf57c49b", "feature/2026-07-27-user-message-icon-actions.md": "sha256:b6332e67c6dad0a3fcdb597cec9e4dc32b44ad33665f39c1a50501cf38d3f5ad", "feature/2026-07-27-user-message-icon-actions.zh.md": "sha256:0fc824eac66a18063f7098e1c395c09b580d5a30b96f2b856608c084212c2ac2", + "feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml": "sha256:cf56e0ff7f2af7a5b50b27818fddb66a0c5300de98fc5733673ce094cd14bb22", + "feature/2026-07-28-dsh-guided-skill-session-commands.md": "sha256:9234465259f89fbedd5dfc07f26acbc9e48e16ce829cc6ada672c4d597b0b255", + "feature/2026-07-28-dsh-guided-skill-session-commands.zh.md": "sha256:57ee966476f755c1a6832d35d5f045f9d9e63a97a824e8a3723ba914502ade7f", + "feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml": "sha256:f7c6b5db53c32c7475f4f6bb3ae189ed8d67f2163196ab315d6f81fde3aa37d9", + "feature/2026-07-28-dsh-meta-source-workspace.md": "sha256:ee8b2f6055b27957fa27258f26a07183b7102933af68c64d0df418e32d3d8754", + "feature/2026-07-28-dsh-meta-source-workspace.zh.md": "sha256:0b10db368e04c24be03569a56ec4b69fed66b70c39a3c4f168b5f1c912efcd96", + "feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml": "sha256:0865835802348b730542adbe6b7db613750f3786993c6a14dbb2f47686c13c70", + "feature/2026-07-29-tui-hidden-mode-assistant-fold.md": "sha256:a5fefebd802e2d9c3c79c7852c1c34c7bbef3f2ac2150d224608b9ec44e966ad", + "feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md": "sha256:21bccd1e07ec8dc73b618f428461848bb90b6235afe0b842afb0afab2d5cc575", + "feature/2026-07-30-compaction-progress-visibility.i18n.yaml": "sha256:4c2267054ad5d73aecc8d39d138a0cb532981b33175252e67252d07c3314b0f5", + "feature/2026-07-30-compaction-progress-visibility.md": "sha256:2dfe07244cd784f27a9e5850801d40e96eae21a20ba10aaf56f9a793cdf5b505", + "feature/2026-07-30-compaction-progress-visibility.zh.md": "sha256:6180b8aff0536147ab6ed6a78ecdbe1448fd12d89407746ecb1c7c05c73d4d60", + "feature/2026-07-30-tui-details-command.i18n.yaml": "sha256:033cea6df0a16fc68cbdb435babdc6e75c1199a8e70e1a71d87c800c40f5a044", + "feature/2026-07-30-tui-details-command.md": "sha256:a13478d4e55ec6d358209b51b541413ec75d0e20dfc22196ace28020f03f0c2d", + "feature/2026-07-30-tui-details-command.zh.md": "sha256:de9c449b98468cef34ce4f9a9d2a854a5d8905eecd61f80e27a9a0e4495e9901", + "feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml": "sha256:4c3fc380b0512ad7c00baacd0ac610e1a78ae45374311d9bd43bab6b5e29e630", + "feature/2026-07-30-versioned-tui-first-run-welcome.md": "sha256:296f153e6c839f3743078e4f5aab3b2befc211c934835238668c57bdeae52231", + "feature/2026-07-30-versioned-tui-first-run-welcome.zh.md": "sha256:82871a9cca1fec46bb08a5b39daad28a44bb2419dea367b4ae41af3cf07bfa65", + "feature/2026-07-31-experimental-subcommand-gate.i18n.yaml": "sha256:d223669bebbf6ea65b4ec636e8e7ed618eff389117335946be713897151c6968", + "feature/2026-07-31-experimental-subcommand-gate.md": "sha256:8fdee37340f7e72397cf2f440a2ca70639a987d07f0c2102e02e79c0fec4bfeb", + "feature/2026-07-31-experimental-subcommand-gate.zh.md": "sha256:bcdec0f82319670a1d1de54a27b103b5e2d86306b884a5f9f415b89cd5a373f4", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", @@ -232,6 +319,9 @@ "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml": "sha256:7acf002ea8c1533f052c7bfc0c4e3da013ecf43c5872866a3ee4a8c2691c5e33", "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md": "sha256:f18a913096b7defd2192c4bac888a33f68075c3662703a0e28a6146897d17777", "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md": "sha256:ff48a37673c97059536fe5b61aff746133eac682145550badb049eb5c83b097c", + "simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml": "sha256:821f96f3e203e03b80553c07b10a511926bb5014be95c7df6bffb30c8e226d31", + "simplification/2026-07-27-copyable-transcript-no-gutter-bar.md": "sha256:4b6aa150bbc8a4da0acac4d20f5fb8c2b77fef7e9c4c4dba8fd8e84dec36d619", + "simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md": "sha256:5225e627ff301be171434a5b9f18905fe1578f50eb2d4bf9998e126aba6cc3e3", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml": "sha256:4177012c0821a8c22499852ecdf096af56d7263cb91c5d9d1bcd552cc26a3e00", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md": "sha256:45234e7cc04b6010c6141f8d5924c04547300098f96262d423c50108e7c7011a", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md": "sha256:15e5a4ad3dee0bb711480cabe45cd97ec37bbdba19c2c2b47d1e9c203b07a48b", @@ -249,6 +339,9 @@ "testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md": "sha256:cac75d4475666239bbe0030b90c0fa7cc66024af5b9f8ef217e53018be64890e", "testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml": "sha256:fc37fcdfe8744f8c8f39eda3f494eed25ee8c4d5322d1f3f671eabd2c5d7026e", "testing/2026-07-08-shared-acp-snapshot-package.md": "sha256:285b4a3c0b1ef7a837e6713cf0192ddc8a26101f6737fa3923682a9e91350c50", - "testing/2026-07-08-shared-acp-snapshot-package.zh.md": "sha256:02da3f910c2060f70038a0d86a7ddae4a8890905600440e1373412f54fbdcea8" + "testing/2026-07-08-shared-acp-snapshot-package.zh.md": "sha256:02da3f910c2060f70038a0d86a7ddae4a8890905600440e1373412f54fbdcea8", + "testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml": "sha256:c1a22174274b9f34ef4368039b3547f221507b040bd51b87af73a2722ee6b4d2", + "testing/2026-07-18-tui-terminal-state-snapshots.md": "sha256:9a7fdcbeafc34376cb049b9668e0f4e9e541f523116fb11c3af9d35c2963e908", + "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa" } } diff --git a/.agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml b/.agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml new file mode 100644 index 0000000000..c7ce910e5f --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md +2026-07-27-copyable-transcript-no-gutter-bar.md: a4eca191d28a834b05a6839997aab602852912c0 +2026-07-27-copyable-transcript-no-gutter-bar.zh.md: b1f661cb76bb879e414a0dcfd08fc9349ae070c7 diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md b/.agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md rename to .agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md index 659b7d2f2f..a4eca191d2 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md +++ b/.agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md @@ -1,6 +1,7 @@ # Agent Note: Copyable TUI transcript without gutter bars Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-27-copyable-transcript-no-gutter-bar.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md b/.agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md rename to .agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md index 5c43169ba3..b1f661cb76 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md +++ b/.agents/notes/archived/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md @@ -1,6 +1,7 @@ # Agent Note: 无 gutter bar 的可复制 TUI transcript Status: implemented +Archived: 2026-08-04 [English](2026-07-27-copyable-transcript-no-gutter-bar.md) | 中文 diff --git a/.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml new file mode 100644 index 0000000000..4d5f9581c3 --- /dev/null +++ b/.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md +2026-07-18-tui-terminal-state-snapshots.md: eef9777c1f3d19c03985c0d105223960dd7d4878 +2026-07-18-tui-terminal-state-snapshots.zh.md: 50d2e1b81762538a08abda5608a7d26f2dd0b841 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md similarity index 55% rename from .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md rename to .agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md index b8e6f77d96..eef9777c1f 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -1,6 +1,7 @@ # Agent Note: Snapshot semantic terminal state for the TUI Status: implemented +Archived: 2026-08-04 English | [中文](2026-07-18-tui-terminal-state-snapshots.zh.md) @@ -10,26 +11,24 @@ The TUI is a stateful renderer. Its user-visible result depends on ANSI parsing, Component-line snapshots stop before ANSI reaches a terminal and miss cursor movement, clearing, styling, overlay composition, and reflow. Raster screenshots include font and platform rendering noise that is unrelated to the TUI contract. A completed flow built by directly appending plausible session events has another blind spot: it proves the renderer accepts those shapes, not that the production agent loop and tool implementations produce them. -The TUI therefore needs a deterministic, reviewable representation of terminal state, recorded model journeys that execute the real downstream stack, and a smaller test at the real process and PTY boundary. +The reusable TUI therefore needs a deterministic, reviewable representation of terminal state. A product deployment that ships it additionally needs recorded model journeys through the assembled stack and a smaller test at the real process and PTY boundary. ## Decision -TUI coverage has four complementary layers: +Reusable TUI coverage has two complementary package layers: 1. `packages/ui/tui/tests/tui.spec.ts` tests event mapping, input routing, disposal, and error behavior directly. 2. `packages/ui/tui/tests/tui.snapshot.ts` mounts the production TUI against a headless terminal emulator for transient states that a completed session log cannot retain: in-flight streaming, pending tool calls, overlays, expansion, compaction reflow, errors, and shutdown. -3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. -4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI is the shipped `apps/cli` composition: the shared `base.cordis.yml` plus the `tui.cordis.yml` overlay, which owns the interactive coding backends, tools, and front door. TUI snapshots and PTY tests live in `apps/cli/tests/`. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. +The [explicit-config entrypoint decision](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md) removed the product TUI composition, recorded application journeys, and PTY suite. A deployment shipping a terminal front door owns those assembled-application layers; package tests do not claim that product coverage. -### Recorded-session replay +### Removed application replay -Each example-level scenario directory owns `session.jsonl`, optional child logs `session..jsonl`, and `terminal.expected.txt`. The primary log supplies user-authored `user/message` prompts and the recorded `assistant/chunk` sequence. `dsh-llm-replay` derives one model-call script per session, binds child logs to fresh child sessions, and is the only mocked boundary. The agent loop, bash and filesystem implementations, Code Mode worker, subagent provider, workflow worker, Cordis tools, presenters, and TUI are production implementations. +The deleted application suite gave each scenario `session.jsonl`, optional child logs `session..jsonl`, and `terminal.expected.txt`. The primary log supplied user-authored `user/message` prompts and the recorded `assistant/chunk` sequence. `dsh-llm-replay` derived one model-call script per session and was the only mocked boundary; the agent loop, tools, workers, presenters, and TUI were production implementations. -The suite rejects a journey when its tool-call sequence differs, an expected event count is missing, a tool result is an error, a turn ends in error, a workflow lifecycle is incomplete, or the live child-session count differs from the fixture set. These assertions prevent an attractive terminal expected output from hiding a failed or bypassed production path. +That suite rejected a journey when its tool-call sequence differed, an expected event count was missing, a tool result was an error, a turn ended in error, a workflow lifecycle was incomplete, or the live child-session count differed from the fixture set. These checks remain the acceptance pattern for any future terminal deployment; they are no longer shipped fixtures. -The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their primary and child JSONL logs and terminal expected outputs. The deterministic Cordis toolchain keeps an authored complete JSONL script because reliably coercing a live model through five exact tool boundaries and two children is not a stable recording contract. `DSH_SNAPSHOT=refresh` replays every committed script keylessly and rewrites only derived terminal expected outputs. Plain replay compares without writing, and unknown mode values fail loud. +The removed recording workflow used `DSH_SNAPSHOT=record` for model journeys and `DSH_SNAPSHOT=refresh` for derived terminal output. Removing the product entrypoint also removed those modes from the repository snapshot lane; reusable TUI snapshots are authored directly from package scenarios. ### Semantic terminal projection @@ -43,13 +42,6 @@ Every checkpoint enforces theme independence across the complete terminal state: | Layer | Scenario | Contract pinned | |---|---|---| -| Recorded journey | Multi-turn conversation | Recorded reasoning/text chunks, two input turns, retained history, token totals, and idle editor state | -| Recorded journey | Todo plan | Real `todo_write` execution, result card, and persistent plan rendering | -| Recorded journey | Bash terminal card | Real local executor output, description, exit status, and completed terminal card | -| Recorded journey | Parallel filesystem reads | Two calls from one assistant message, real file contents, ordering, and separate completed cards | -| Recorded journey | Code Mode | Real `run_code` worker execution, two `tool/code-dispatch` events, captured program output, and completed card | -| Recorded journey | Dynamic workflow | Real workflow worker, phase lifecycle, replayed child session, structured return value, and completed card | -| Recorded journey | Cordis dynamic toolchain | Real mount, Code Mode inspect, direct subagent, workflow child, unmount, and all production presenters | | Transient state | Streaming and pending advanced calls | In-flight reasoning/text plus pending Code Mode, workflow, and Cordis cards that disappear from completed logs | | Transient state | Cards, interaction, layout, failure, and shutdown | Collapsed/expanded card families, question validation, compaction replacement, resize reflow, help/errors, cursor restoration, and terminal stop | @@ -58,13 +50,13 @@ Every checkpoint enforces theme independence across the complete terminal state: - **Snapshot raw terminal writes** — rejected because differential rendering may change write boundaries without changing the screen, while cursor and clear sequences are unreadable in review. - **Snapshot component render lines before terminal output** — rejected because it does not test ANSI parsing, cursor movement, overlays, viewport behavior, or independent components in one frame. - **Build every completed flow by appending session events** — rejected because a hand-authored event sequence can drift from the agent loop, tool execution, child-session binding, or worker behavior while its presentation test stays green. Direct event construction remains limited to transient renderer states. -- **Reuse ACP stdout expected outputs as the TUI oracle** — rejected because a recorded model journey is transport-neutral but its presentation is not. TUI scenarios own terminal expected outputs while using the same JSONL replay vocabulary. +- **Reuse ACP stdout expected outputs as the TUI oracle** — rejected because a recorded model journey is transport-neutral but its presentation is not. A terminal deployment owns its expected output while it may reuse the same JSONL replay vocabulary. - **Commit raster screenshots** — rejected because fonts, glyph metrics, antialiasing, and host terminal themes make them platform-sensitive and make semantic style changes difficult to review. - **Use only PTY end-to-end tests** — rejected because raw PTY output is a stream of historical drawing operations, not queryable final state. PTY tests retain the real Loader/input/teardown boundary, while the emulator owns broad state coverage. ## Consequences -- Completed advanced snapshots now fail when the real Code Mode, workflow, subagent, filesystem, bash, or Cordis path breaks, rather than accepting a fabricated result event. +- Package snapshots fail when TUI event mapping or presentation breaks; they do not substitute for an assembled application's tool-path transcript. - TUI visual regressions produce readable cell-and-style diffs, while JSONL fixtures retain the exact model chunks that made the production path execute. -- The emulator uses xterm's proposed buffer API. An xterm upgrade requires rerunning and reviewing the semantic projection; terminal-specific behavior still needs the PTY smoke. -- Expected outputs deliberately encode wrapping and viewport behavior at fixed sizes. Intentional layout changes use keyless refresh, while model-journey changes use record mode and review both JSONL and terminal diffs. +- The emulator uses xterm's proposed buffer API. An xterm upgrade requires rerunning and reviewing the semantic projection; terminal-specific behavior still needs a PTY smoke owned by the deployment that ships it. +- Expected outputs deliberately encode wrapping and viewport behavior at fixed sizes. Intentional layout changes update and review the package semantic snapshots. diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.zh.md similarity index 56% rename from .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md rename to .agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index 1b2f6b58e5..50d2e1b817 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 语义终端状态快照 Status: implemented +Archived: 2026-08-04 [English](2026-07-18-tui-terminal-state-snapshots.md) | 中文 @@ -10,26 +11,24 @@ TUI 是有状态的渲染器。用户最终看到的结果取决于 ANSI 解析 组件行快照止于 ANSI 进入终端之前,无法覆盖光标移动、清屏、样式、浮层组合和重排。栅格截图会带入与 TUI 契约无关的字体和平台渲染噪声。直接追加看似合理的会话事件来构造完整流程还存在另一处盲区:这种测试只能证明渲染器接受这些数据形态,无法证明生产环境的 agent loop(智能体循环)和工具实现会生成这些事件。 -因此,TUI 既需要确定、便于评审的终端状态表示,也需要通过已录制模型流程执行真实下游组件,并保留一项范围更小、覆盖真实进程与 PTY 边界的测试。 +因此,可复用 TUI 需要确定、便于评审的终端状态表示。交付它的产品部署还需要通过组装后的技术栈运行已录制模型流程,并保留一项范围更小、覆盖真实进程与 PTY 边界的测试。 ## 决策 -TUI 覆盖分为四个互补层次: +可复用 TUI 的覆盖分为两个互补的包级层次: 1. `packages/ui/tui/tests/tui.spec.ts` 直接测试事件映射、输入路由、资源释放和错误行为。 2. `packages/ui/tui/tests/tui.snapshot.ts` 将生产 TUI 挂载到无界面终端模拟器,覆盖完整会话日志无法保留的瞬态:进行中的流式输出、待完成工具调用、浮层、展开状态、压缩重排、错误和关闭过程。 -3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 -4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 就是交付的 `apps/cli` 组合:共享的 `base.cordis.yml` 加 `tui.cordis.yml` overlay,后者拥有交互式 coding 后端、工具与前端入口。TUI 快照和 PTY 测试位于 `apps/cli/tests/`。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 +[显式配置入口决策](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)移除了产品 TUI 组合、已录制应用流程和 PTY 测试套件。交付终端入口的部署负责这些组装应用层;包测试不声称提供产品覆盖。 -### 已录制会话回放 +### 已移除的应用回放 -每个示例级场景目录都包含 `session.jsonl`、可选的子会话日志 `session..jsonl`,以及 `terminal.expected.txt`。主日志提供用户来源的 `user/message` 提示词和已录制的 `assistant/chunk` 序列。`dsh-llm-replay` 为每个会话派生一份模型调用脚本,并将子日志绑定到新建的子会话;这是测试中唯一的 mock 边界。agent loop、bash 与文件系统实现、Code Mode worker、subagent 提供方、工作流 worker、Cordis 工具、呈现器和 TUI 都使用生产实现。 +已删除的应用测试套件为每个场景提供 `session.jsonl`、可选的子会话日志 `session..jsonl`,以及 `terminal.expected.txt`。主日志提供用户来源的 `user/message` 提示词和已录制的 `assistant/chunk` 序列。`dsh-llm-replay` 为每个会话派生一份模型调用脚本,并且是测试中唯一的 mock 边界;agent loop、工具、worker、呈现器和 TUI 都使用生产实现。 -如果工具调用顺序不符、预期事件数量不足、工具结果报错、轮次以错误结束、工作流生命周期不完整,或者实时子会话数量与 fixture(测试前置数据)集合不一致,测试都会失败。即使终端预期输出表面正确,这些断言也能阻止失败或被绕过的生产路径混入结果。 +如果工具调用顺序不符、预期事件数量不足、工具结果报错、轮次以错误结束、工作流生命周期不完整,或者实时子会话数量与 fixture(测试前置数据)集合不一致,该测试套件都会拒绝流程。这些检查仍是未来任何终端部署的验收模式;它们已不再作为 fixture 交付。 -真实模型 fixture 通过 `DSH_SNAPSHOT=record` 更新;录制模式会重写其主会话与子会话 JSONL 日志以及终端预期输出。确定性的 Cordis 工具链保留一份人工编写的完整 JSONL 脚本,因为要求真实模型稳定经过五个指定工具边界和两个子会话并不是可靠的录制契约。`DSH_SNAPSHOT=refresh` 会无密钥回放所有已提交脚本,并且只重写派生的终端预期输出。普通回放只比较而不写入,未知模式值会快速失败。 +已移除的录制工作流使用 `DSH_SNAPSHOT=record` 录制模型流程,使用 `DSH_SNAPSHOT=refresh` 更新派生的终端输出。移除产品入口时也从仓库快照通道中移除了这些模式;可复用 TUI 快照直接由包级场景编写。 ### 语义终端投影 @@ -43,13 +42,6 @@ TUI 覆盖分为四个互补层次: | 层次 | 场景 | 固定的契约 | |---|---|---| -| 已录制流程 | 多轮会话 | 已录制的推理与文本分片、两轮输入、保留历史、token 总量和空闲编辑器状态 | -| 已录制流程 | Todo 计划 | 真实 `todo_write` 执行、结果卡片和持久计划渲染 | -| 已录制流程 | Bash 终端卡片 | 真实本地执行器输出、说明、退出状态和已完成终端卡片 | -| 已录制流程 | 并行文件读取 | 同一条 assistant 消息中的两次调用、真实文件内容、顺序和两个独立完成卡片 | -| 已录制流程 | Code Mode | 真实 `run_code` worker 执行、两条 `tool/code-dispatch` 事件、捕获的程序输出和已完成卡片 | -| 已录制流程 | 动态工作流 | 真实工作流 worker、阶段生命周期、回放的子会话、结构化返回值和已完成卡片 | -| 已录制流程 | Cordis 动态工具链 | 真实挂载、Code Mode 检查、直接 subagent、工作流子会话、卸载和全部生产呈现器 | | 瞬态 | 流式输出与待完成高级调用 | 进行中的推理和文本,以及完整日志中不会保留的待完成 Code Mode、工作流和 Cordis 卡片 | | 瞬态 | 卡片、交互、布局、失败和关闭 | 折叠与展开的卡片族、问题校验、压缩替换、尺寸重排、帮助与错误、光标恢复和终端停止 | @@ -58,13 +50,13 @@ TUI 覆盖分为四个互补层次: - **快照原始终端写入**:不予采纳,因为差分渲染可能在画面不变时改变写入边界,而且光标与清屏序列难以评审。 - **快照进入终端输出之前的组件渲染行**:不予采纳,因为它无法测试 ANSI 解析、光标移动、浮层、视口行为,也无法测试独立组件在同一帧中的相互作用。 - **通过追加会话事件构造所有完整流程**:不予采纳,因为人工编写的事件序列可能与 agent loop、工具执行、子会话绑定或 worker 行为发生偏差,但呈现测试仍然保持绿色。直接构造事件只用于渲染器瞬态。 -- **复用 ACP stdout 预期输出作为 TUI 判定依据**:不予采纳,因为已录制模型流程与传输方式无关,其呈现方式却并非如此。TUI 场景使用同一套 JSONL 回放词汇,但拥有独立的终端预期输出。 +- **复用 ACP stdout 预期输出作为 TUI 判定依据**:不予采纳,因为已录制模型流程与传输方式无关,其呈现方式却并非如此。终端部署拥有自己的预期输出,同时可以复用同一套 JSONL 回放词汇。 - **提交栅格截图**:不予采纳,因为字体、字形度量、抗锯齿和宿主终端主题会使结果依赖平台,也会增加语义样式变更的评审难度。 - **只使用 PTY 端到端测试**:不予采纳,因为原始 PTY 输出是一系列历史绘制操作,而不是可查询的最终状态。PTY 测试保留真实 Loader、输入与清理边界,模拟器负责广泛的状态覆盖。 ## 后果 -- 当真实 Code Mode、工作流、subagent、文件系统、bash 或 Cordis 路径损坏时,已完成高级快照会失败,不会继续接受伪造的结果事件。 +- 当 TUI 事件映射或呈现损坏时,包快照会失败;它们不能代替组装应用的工具路径 transcript。 - TUI 视觉回归会产生便于阅读的单元格和样式 diff,而 JSONL fixture 会保留触发生产路径的确切模型分片。 -- 模拟器使用 xterm 的拟议缓冲区 API。升级 xterm 时必须重新运行并评审语义投影;终端特有行为仍需由 PTY 冒烟测试覆盖。 -- 预期输出有意固定指定尺寸下的换行与视口行为。预期布局变更使用无密钥刷新;模型流程变更使用录制模式,并同时评审 JSONL 与终端 diff。 +- 模拟器使用 xterm 的拟议缓冲区 API。升级 xterm 时必须重新运行并评审语义投影;终端特有行为仍需由交付该终端的部署所拥有的 PTY 冒烟测试覆盖。 +- 预期输出有意固定指定尺寸下的换行与视口行为。预期布局变更会更新并评审包级语义快照。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml deleted file mode 100644 index 0b599bc639..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml +++ /dev/null @@ -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 -2026-07-22-tui-interactive-extension-service.md: 86cb39748358882d26766467d08f4f43510c1cc2 -2026-07-22-tui-interactive-extension-service.zh.md: d53f526a07b20fcff7086a1f501558d23e7eea8a diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml deleted file mode 100644 index a22f2b68fa..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md -2026-07-27-tui-chat-channel-module-split.md: 56b345b670cd8426780bfdd8c2b2f5719461554c -2026-07-27-tui-chat-channel-module-split.zh.md: d74844a762bc519d0f499696fe343567eebfe920 diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml deleted file mode 100644 index 43675cbd56..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md -2026-07-28-consolidated-tui-presentation.md: 8200c8e96cdea7cf5ae54c1328623bb03f841d00 -2026-07-28-consolidated-tui-presentation.zh.md: 852bd413b25f93f3b9096b78d69b21ab420df37b diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml deleted file mode 100644 index c4612a4b7d..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md -2026-07-28-launcher-owned-resume-identity.md: da9b4571d154137d34ef3690e7b4aa9bc7bb9082 -2026-07-28-launcher-owned-resume-identity.zh.md: 51ccffd7bb9c8eeda03afe2528d3cb4250e2d906 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md deleted file mode 100644 index da9b4571d1..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +++ /dev/null @@ -1,64 +0,0 @@ -# Agent Note: Launcher-owned session identity and exit line - -Status: implemented - -English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md) - -## Problem - -Two facts a launcher owns were shipped as deployment config keys on the TUI app bundle: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. - -Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". - -Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. - -A config key cannot express these facts safely, because the deployment is not the authority on them. - -## Decision - -Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` nor in any plugin's `Config`. - -Both sit beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config. Each slot is declared by the package that consumes it: - -- `CONFIGURED_AGENT_IDENTITIES_KEY` (`dsh-agent-loop`) carries launcher identities keyed by configured-agent `id`, each a `LauncherAgentIdentity` (`{ id: SessionId, resume: boolean }`). `agent-loop` applies the matching identity over its configured agent, replacing both identity keys, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot leaves the configured identity untouched. The `tui` row resolves the same id through its own `sessionId` key, so the front door renders exactly the agent that was bound. -- `TUI_GOODBYE_MESSAGE_KEY` (`dsh-tui`) carries the complete line printed once the terminal is released on exit. Absent prints nothing. - -Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md). - -`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line names `--config` when one was passed. Resume always re-enters the default surface through `dsh --resume `; `dsh meta` accepts no default-surface options and always starts fresh. - -**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly. - -The message is a plain string, not a callback. That forces the launcher to know the id before boot, which is why minting moved out of the app bundle — and it keeps exit free of awaited work after the terminal is released. - -The TUI owns rendering, not wording: it applies `displayText` before its own `palette.muted`, so a hostile `--config` path cannot inject terminal escapes into the exit line. Sanitizing means the launcher cannot embed its own ANSI. - -## Alternatives considered - -**Keep the keys and add built-in defaults in the app bundle.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. - -**Merge the app bundle into `apps/cli` and delete the slot entirely.** Rejected here, then [adopted later](../simplification/2026-07-29-shared-base-config-overlays.md) in a form this note did not consider: the composition moved into flat config files (`apps/cli/config/base.cordis.yml` plus a per-surface overlay) rather than into CLI code, so it never entered the `v8 ignore` process-wiring block, and the overlay extension points survive as ordinary row patches. The slot itself was not deleted — it moved to `dsh-agent-loop`, because a launcher fact still cannot travel through a replaceable config key. - -**Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots. - -**Have the host supply only the command text and let the TUI keep the `To resume this session:` prefix.** Rejected: the TUI would retain resume vocabulary for a string it no longer understands, and meta mode proves the launcher is the only component that knows what the command should say. - -**Let the TUI keep suppressing the line until the session is durably persisted.** Rejected: that check is why the exit path queried persistence and swallowed listing failures. A plain string cannot consult persistence, and misuse now fails loud through `agent-loop/config-start-failed` rather than silently resuming nothing. - -**A callback (`goodbyeMessage(agent)`) so the host could decide at exit time.** Rejected: it restores async work after `ui.stop()`, reintroducing a hang risk during teardown for a string that is already knowable at boot. - -## Consequences - -- Removing two published `Config` keys is a breaking config change: a stale config naming either now fails schema validation at boot instead of degrading silently. Intended, and acceptable pre-release. -- `TuiResumeHost` is unchanged, but `TuiRuntime` gains `goodbyeMessage`; `apps/cli` is the only provider. -- The exit line prints even for a session with no log (launch, quit immediately). Using it then fails loud rather than starting a surprise session. This is the deliberate cost of dropping the persistence check. -- `dsh-tui` no longer reads `sessionPersistence` at all: `currentResumeCommand`, `listWorkspaceSessions`, and its swallowed-error path are deleted, and the `/resume` selector's `sessionQuery` reads are now the only session discovery in the TUI. -- The launcher mints session ids for its own app, so a non-CLI host that provides no slot keeps the bundle's own minting. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/core/agent-loop/tests/` drives the identity slot for the resume, launcher-minted, and no-slot cases. - -The load-bearing coverage is `apps/cli/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `agent-loop` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. - -Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md deleted file mode 100644 index 51ccffd7bb..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md +++ /dev/null @@ -1,64 +0,0 @@ -# Agent Note:由启动器持有的会话身份与退出行 - -Status: implemented - -[English](2026-07-28-launcher-owned-resume-identity.md) | 中文 - -## Problem - -有两项本应由启动器持有的事实,却被作为 TUI 应用组合包上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 - -把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 - -两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 - -一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。 - -## Decision - -会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在任何插件的 `Config` 中。 - -这两个槽位与既有的 `tuiResumeHost` 宿主能力并列,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置。每个槽位都由消费它的包声明: - -- `CONFIGURED_AGENT_IDENTITIES_KEY`(`dsh-agent-loop`)按所配置 agent 的 `id` 承载启动器身份,每项为一个 `LauncherAgentIdentity`(`{ id: SessionId, resume: boolean }`)。`agent-loop` 将匹配的身份覆盖到其所配置的 agent 上,替换两个身份键;并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失则保留配置中的身份不变。`tui` 配置项通过自身的 `sessionId` 键解析同一个 id,因此前端入口渲染的正是被绑定的那个 agent。 -- `TUI_GOODBYE_MESSAGE_KEY`(`dsh-tui`)承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 - -身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。 - -`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行会在传入了 `--config` 时将其写入命令。恢复始终通过 `dsh --resume ` 重新进入默认界面;`dsh meta` 不接受任何默认界面选项,并且总是启动新会话。 - -**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。 - -该消息是一个纯字符串,而非回调。这迫使启动器在启动前就知道 id,也正是铸造从应用 bundle 中移出的原因——并且它让退出在终端释放之后免于任何被 await 的工作。 - -TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应用 `displayText`,因此一个恶意的 `--config` 路径无法把终端转义序列注入退出行。做净化意味着启动器无法嵌入自己的 ANSI。 - -## Alternatives considered - -**保留这些键,并在应用组合包中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 - -**把应用组合包合并进 `apps/cli` 并彻底删除该槽位。** 此处拒绝,但后来以本 note 未曾设想的形式[被采纳](../simplification/2026-07-29-shared-base-config-overlays.md):组合被搬进平铺的配置文件(`apps/cli/config/base.cordis.yml` 加各 surface 一份 overlay),而非搬进 CLI 代码,因此从未进入 `v8 ignore` 进程接线块,overlay 的扩展点也作为普通配置项 patch 保留了下来。槽位本身并未被删除——它迁移到了 `dsh-agent-loop`,因为启动器的事实依然不能经由一个可被整体替换的配置键传递。 - -**把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。 - -**让宿主只提供命令文本,而由 TUI 保留 `To resume this session:` 前缀。** 拒绝:TUI 将为一个它已不再理解的字符串保留 resume 词汇,而 meta 模式证明启动器才是唯一知道该命令应当说什么的组件。 - -**让 TUI 继续在会话被持久化之前抑制该行。** 拒绝:这项检查正是退出路径要查询持久化并吞掉列举失败的原因。一个纯字符串无法查询持久化,而误用现在会经由 `agent-loop/config-start-failed` 明确报错,而不是静默地恢复了个空。 - -**用一个回调(`goodbyeMessage(agent)`)让宿主能在退出时决定。** 拒绝:它会在 `ui.stop()` 之后恢复异步工作,为一个在启动时就已可知的字符串,重新引入拆解期间的挂起风险。 - -## Consequences - -- 移除两个已发布的 `Config` 键是一次破坏性配置变更:一份命名了任一键的陈旧配置,现在会在启动时的 schema 校验中明确报错,而不再静默降级。这是有意为之,且在预发布阶段可以接受。 -- `TuiResumeHost` 保持不变,但 `TuiRuntime` 新增 `goodbyeMessage`;`apps/cli` 是唯一的提供方。 -- 即便某会话没有日志(启动后立即退出),退出行也会打印。此时使用它会明确报错,而不是开启一个意外的会话。这是丢弃持久化检查的有意代价。 -- `dsh-tui` 完全不再读取 `sessionPersistence`:`currentResumeCommand`、`listWorkspaceSessions` 及其吞错路径都被删除,`/resume` 选择器的 `sessionQuery` 读取如今是 TUI 中唯一的会话发现途径。 -- 启动器为其自身的应用铸造会话 id,因此一个不提供任何槽位的非 CLI 宿主,仍保留 bundle 自带的铸造逻辑。 - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/core/agent-loop/tests/` 为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 - -承重的覆盖是 `apps/cli/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `agent-loop` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 - -在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml deleted file mode 100644 index 98dfa90c4d..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml +++ /dev/null @@ -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 -2026-07-23-tui-generic-card-markdown.md: 494ba580480fa99de54e84025a65bf4589d410f2 -2026-07-23-tui-generic-card-markdown.zh.md: 214edd00f50b88a4e8901b19dcdafc7382399831 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml deleted file mode 100644 index 6d83cc0cf3..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml +++ /dev/null @@ -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 -2026-07-24-tui-turn-end-stop-reason-notices.md: 7c783ce5a347b15d682dbeca03ad5355aca7950b -2026-07-24-tui-turn-end-stop-reason-notices.zh.md: 4a983525779b96c296ac2d621ca5928e7bed61c9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml deleted file mode 100644 index dfde0f9ad4..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md -2026-07-27-tool-card-single-row-fields-inline.md: e04110ed74b68afffc6ea45fc2cb52c4063268e7 -2026-07-27-tool-card-single-row-fields-inline.zh.md: ac532ec6eb51d42a529e0816d1204cb2729a074a diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml deleted file mode 100644 index c246c6fb0d..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml +++ /dev/null @@ -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 -2026-07-27-tui-step-timing-trails-tool-cards.md: 82f46b44d3b939ca89c4508eb948ed584082d9fd -2026-07-27-tui-step-timing-trails-tool-cards.zh.md: 885b232973d20013782dee7ec1e846e7a012b01e diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 8926d51744..1349e9fd47 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: a47dd49dd831cdd32d520137417bf47d2c056a09 -2026-07-29-human-transcript-append-origin.zh.md: 31639bd9aac5d6dace80392004f37c747bff2c36 +2026-07-29-human-transcript-append-origin.md: 4d8d66ea625d02f098da906ae58af1a2e5e41819 +2026-07-29-human-transcript-append-origin.zh.md: d4c1c4a83d868af5e27d7bda2a22827cf9d6c5b1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index a47dd49dd8..4d8d66ea62 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -26,7 +26,7 @@ No persisted event, RPC envelope, compaction transaction, or model-visible surfa The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint's provenance group, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page. -The terminal's [live compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction. +The terminal's [archived live compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 31639bd9aa..d4c1c4a83d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -26,7 +26,7 @@ Status: implemented 浏览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中单独修复:它按日志顺序投影同一份 append 来源记录并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会按检查点的溯源分组切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。 -终端的[实时压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件。 +终端的[已归档实时压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 5d12a08e36..26108d6850 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 4bc629bfa619a2858ec0335a6136d3a5a24b025a -2026-07-30-web-transcript-log-ordered-projection.zh.md: d011ce9453bc1dbb9fdb62372aa1126c95ffb143 +2026-07-30-web-transcript-log-ordered-projection.md: 558d60cf6f6638c3e776396dd754d31b95819b28 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 2eb216fd1970700fb54a2aa17ec5ce515869e5b0 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 4bc629bfa6..558d60cf6f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -69,4 +69,4 @@ The web e2e scenario now seeds a real compaction transaction over its recorded t ## Deferred -The terminal's [compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress. +The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index d011ce9453..2eb216fd19 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -69,4 +69,4 @@ Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事 ## Deferred -终端的[压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。 +终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml deleted file mode 100644 index b2bfc991c9..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md -2026-07-31-tui-diff-context-line-accounting.md: d465568d5f6cad15ef4647be7ef936c2f5824bba -2026-07-31-tui-diff-context-line-accounting.zh.md: dd1a3eb1acf03477059d48461219700b224b2f96 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml deleted file mode 100644 index 8beb847e60..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md -2026-08-03-tui-long-session-render-costs.md: c5b03960b6951cb2de2b847f03ec8eb2b92cc55c -2026-08-03-tui-long-session-render-costs.zh.md: b41c5a8c546e296525645d82808117673fdeec6d diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index ca5cc25241..70dd43d0f6 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: 242650ec8ba64fd0801a958711d5790fae07b259 -2026-07-05-skill-system.zh.md: da5f4af4b2f8bc0144be0a7ed608de7edd9a9947 +2026-07-05-skill-system.md: 36a2ae220db24c3183253c18957769ab5d5c068c +2026-07-05-skill-system.zh.md: 3a673d44d74ab38732dd5f5bda4d607b174db251 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index 242650ec8b..36a2ae220d 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -52,4 +52,4 @@ The catalog is deterministic for a fixed root set and runtime registration revis ## Deferred -Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields. Direct user invocation ships as a TUI affordance over the shared invocation policy and trusted `get()` primitive; see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). +Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields. Direct user invocation shipped as a TUI affordance over the shared invocation policy and trusted `get()` primitive; see [the archived TUI skill slash command](../../archived/feature/2026-07-21-tui-skill-slash-command.md). diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index da5f4af4b2..3a673d44d7 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -52,4 +52,4 @@ agent-core 主干包含一个目录贡献者、一个本地提供方和一个面 ## 延后 -Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。直接用户调用作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。 +Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。直接用户调用曾作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见[已归档的 TUI skill 斜杠命令](../../archived/feature/2026-07-21-tui-skill-slash-command.md)。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml deleted file mode 100644 index 688fe6864a..0000000000 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: c011a0284ea0efe59693785c038f814a866068ac -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5aea4ac0c5a3b29c098c297e514fab49caf643ff diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml deleted file mode 100644 index 4edd7b7223..0000000000 --- a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml +++ /dev/null @@ -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 -2026-07-20-windows-tui-support.md: 6b728486dd50faac067933ce06f883447aae821f -2026-07-20-windows-tui-support.zh.md: 2b53b05ff6231361d79b4304181dc0e6d8e24e68 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml deleted file mode 100644 index ea5ef87849..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md -2026-07-21-tui-resume-command.md: c8cb855378d793a168e1f87d6b41f9a48db7dc14 -2026-07-21-tui-resume-command.zh.md: 2a7e74d1106499cb7d7232dc13954ae126246550 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml deleted file mode 100644 index 4574ce7c75..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md -2026-07-21-tui-skill-slash-command.md: 872e1f109728731e0d55e81a538c81e794724856 -2026-07-21-tui-skill-slash-command.zh.md: 772e25745ea7ab25f715208a9c6b1d10cf0c6e65 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml deleted file mode 100644 index 05b15028e6..0000000000 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml +++ /dev/null @@ -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 -2026-07-23-tui-file-reference-autocomplete.md: 1a136009213c845af28f4ac47a8b31d426ac8cf5 -2026-07-23-tui-file-reference-autocomplete.zh.md: 410f0d49dbd20a2dcf704892a192406020aaa86e diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml deleted file mode 100644 index 0573cca056..0000000000 --- a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml +++ /dev/null @@ -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 -2026-07-23-tui-footer-session-identity.md: aa17ead4194c52464de0caad86d8611eae94786c -2026-07-23-tui-footer-session-identity.zh.md: 686c11294ffd02304dc87cd790252a347fe35011 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md deleted file mode 100644 index aa17ead419..0000000000 --- a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: Keep the TUI session identity visible - -Status: implemented - -English | [中文](2026-07-23-tui-footer-session-identity.zh.md) - -## Problem - -The startup banner identifies the active session, but it scrolls out of view during a conversation. Operators working with several resumable sessions then lack a persistent way to confirm which session receives their input. - -## Decision - -The TUI footer begins with the active session id, before the model, working directory, token counts, cache rate, and context use. It shows tool-card state only while cards are expanded; the default collapsed state adds no label. The session id uses the same control-character escaping as other terminal labels and participates in the footer's existing left-to-right clipping behavior. - -The footer reads the id from the mounted agent's session, so fresh and resumed sessions use the same authoritative identity without separate UI state. - -## Alternatives considered - -- **Keep the identity only in the startup banner** — rejected because the banner leaves the viewport in longer conversations. -- **Show the session id only in `/status`** — rejected because an on-demand diagnostic does not let an operator confirm identity before sending input. -- **Put the session id in the right footer segment** — rejected because narrow terminals clip that segment first; session identity is more important than context and expanded tool-card state. - -## Consequences - -The current session remains identifiable while the editor is active. On narrow terminals, the longer left segment leaves less room for context and the expanded tool-card label, while the existing clipping policy preserves session identity, model, and as much operational context as fits. - -Package coverage pins the footer ordering and escaping path, and the runnable TUI terminal snapshots pin the assembled layout. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md deleted file mode 100644 index 686c11294f..0000000000 --- a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: 保持 TUI 会话标识可见 - -[English](2026-07-23-tui-footer-session-identity.md) | 中文 - -Status: implemented - -## Problem - -启动横幅会标识当前会话,但在对话过程中会滚出视野。操作多个可恢复会话时,用户因而无法持续确认输入将发送到哪个会话。 - -## Decision - -TUI 页脚以当前会话 id 开头,之后依次显示模型、工作目录、token 用量、缓存命中率和上下文用量。工具卡片状态仅在卡片展开时显示;默认的折叠状态不添加任何标签。会话 id 与其他终端标签采用相同的控制字符转义,并遵循页脚现有的从左到右裁剪行为。 - -页脚从已挂载 agent 的会话读取 id,因此新建和恢复的会话都使用同一权威标识,无需单独维护 UI 状态。 - -## Alternatives considered - -- **仅在启动横幅中保留标识** — 未采用,因为对话较长时横幅会离开视野。 -- **仅在 `/status` 中显示会话 id** — 未采用,因为按需诊断无法让用户在发送输入前确认会话标识。 -- **将会话 id 放入页脚右侧区域** — 未采用,因为窄终端会优先裁剪该区域;会话标识比上下文和展开的工具卡片状态更重要。 - -## Consequences - -编辑器处于活动状态时,当前会话始终可识别。在窄终端中,更长的左侧区域会减少上下文和展开的工具卡片标签的显示空间;现有裁剪策略会保留会话标识、模型,以及空间允许的其他运行信息。 - -包级覆盖固定页脚顺序和转义路径,可运行 TUI 的终端快照固定组装后的布局。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml deleted file mode 100644 index 177ddc27e6..0000000000 --- a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml +++ /dev/null @@ -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 -2026-07-23-tui-status-prompt-tools.md: 42524d021d0f2786371762b447ad5d195dc828bd -2026-07-23-tui-status-prompt-tools.zh.md: 5a33e19e9780749a721395a0b07f43790103013c diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml deleted file mode 100644 index d8ac4d0dca..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md -2026-07-24-configurable-tui-prompt-theme.md: f8815c6c1904c47ebb899c3da7ac62a50f7f88f0 -2026-07-24-configurable-tui-prompt-theme.zh.md: 831471860a9dcd8bb10408c492e1fc6299af6262 diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml deleted file mode 100644 index 88fbc26ceb..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml +++ /dev/null @@ -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 -2026-07-24-readable-xml-tool-output.md: 4f7327a7c6f5e2f04e36576da0fb739c34955e8a -2026-07-24-readable-xml-tool-output.zh.md: 3c56d256b489863210b44449111f03a5752a889a diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md deleted file mode 100644 index 4f7327a7c6..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: Readable XML tool output - -Status: implemented - -English | [中文](2026-07-24-readable-xml-tool-output.zh.md) - -## Problem - -Model-facing context and tool result text can expose transport-oriented XML wrappers instead of the information people need. Context producers do not declare presentation intent, and replayed tool calls whose definition is unavailable still need a conservative fallback that does not reinterpret ordinary prose or partial markup. - -## Decision - -The read tool declares a generic completed-result presentation that removes its ``, ``, and `` wrapper while preserving the numbered content and footer. This tool-owned projection applies consistently to every UI that consumes tool presentation intent. - -The TUI parses a context message or unavailable-tool result as XML only when the complete text is one supported XML document. It renders element names and attributes as an indented tree, preserves the context source label, applies the collapsed line budget independently to each tool result's top-level child lines and child count, and keeps raw text for malformed XML, mixed text, declarations, processing instructions, doctypes, and comments. A known tool's raw XML remains literal unless that tool declares its own result presenter. This XML fallback is TUI-only. - -## Alternatives considered - -**Strip XML-like tags with regular expressions.** Rejected because nested elements, attributes, entities, and malformed input require a real parser; partial conversion would make ambiguous output harder to inspect. - -**Parse every generic result.** Rejected because known tools own their presentation contract, and silently reinterpreting their literal XML would override that decision. - -**Show only raw XML.** Rejected because wrappers optimized for model consumption add terminal noise, particularly for filesystem reads and deeply nested structured results. - -## Consequences - -Filesystem reads are shorter in TUI cards without changing canonical model-facing content. Complete XML context messages, including workspace instruction reminders, become readable trees; unknown complete XML results become navigable trees and retain per-child context when collapsed. The TUI adds a strict SAX parser dependency and deliberately declines XML features (undefined entities, DOCTYPE, comments, processing instructions) that could hide or transform input beyond the conservative tree view. Predefined entities and character references do expand, so parsed text and attribute values are re-escaped for terminal output after parsing: a character reference can produce a control character that escaping the raw source never saw. Other UIs show raw generic content. diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md deleted file mode 100644 index 3c56d256b4..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: 可读的 XML 工具输出 - -Status: implemented - -[English](2026-07-24-readable-xml-tool-output.md) | 中文 - -## 问题 - -面向模型的上下文和工具结果文本可能呈现面向传输的 XML 包装,而不是人们真正需要的信息。上下文生产方不声明呈现意图,而对于回放时拿不到工具定义的调用,仍需要一个保守的回退方案,并且该方案不得重新解释普通文字或不完整的标记。 - -## 决策 - -read 工具声明一个通用的完成结果呈现:去除自身的 ``、`` 和 `` 包装,同时保留带行号的内容和尾部信息。这一由工具自身持有的投影一致地作用于所有消费工具呈现意图的 UI。 - -只有当完整文本恰为一个受支持的 XML 文档时,TUI 才把上下文消息或工具定义不可用的工具结果按 XML 解析。TUI 将元素名和属性渲染为缩进树,保留上下文的来源标签;对于每个工具结果,分别按折叠行数预算限制各顶层子元素的行数和顶层子元素数量;对于格式错误的 XML、混合文本、XML 声明、处理指令、doctype 和注释,则保留原始文本。除非已知工具声明了自己的结果呈现器,否则其原始 XML 仍按字面显示。这一 XML 回退机制仅限 TUI。 - -## 曾考虑的替代方案 - -**用正则表达式剥除类 XML 标签。** 已否决:嵌套元素、属性、实体和格式错误的输入都需要真正的解析器;部分转换会让本就有歧义的输出更难检查。 - -**解析所有通用结果。** 已否决:已知工具拥有自己的呈现契约,静默重新解释它们的字面 XML 会推翻这一决定。 - -**只显示原始 XML。** 已否决:为模型消费而优化的包装会给终端增加噪音,对文件系统读取和嵌套很深的结构化结果尤其如此。 - -## 后果 - -文件系统读取在 TUI 卡片中变得更短,而规范的面向模型内容保持不变。完整的 XML 上下文消息(包括工作区指令提醒)变成可读的树;未知的完整 XML 结果变成可导航的树,折叠时也保留每个子元素的上下文。TUI 新增一个严格 SAX 解析器依赖,并有意不支持那些可能在保守树视图之外隐藏或变换输入的 XML 特性(未定义实体、DOCTYPE、注释、处理指令)。预定义实体和字符引用会被展开,因此解析出的文本和属性值在解析后会为终端输出重新转义:字符引用可能产生对原始源文本转义时从未见过的控制字符。其他 UI 展示原始的通用内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml deleted file mode 100644 index 8cb52df982..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md -2026-07-24-tui-banner-model-deduplication.md: afd370a8762d8e6c17c61d50c95d68998a063df5 -2026-07-24-tui-banner-model-deduplication.zh.md: 86a3cdb0e714642253162f1fe062e19bdc40bbe4 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md deleted file mode 100644 index afd370a876..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: The startup banner omits the model - -Status: implemented - -English | [中文](2026-07-24-tui-banner-model-deduplication.zh.md) - -## Problem - -The startup banner repeated the selected model directly above the prompt context, which already keeps the model visible while the TUI is idle. The duplicate added no information and made the banner detail line harder to scan. - -## Decision - -- The borderless startup banner shows the product title, optional `welcome` or session-title subtitle, and session id. -- The banner omits the model name. The prompt context remains the persistent model display and updates after `/model` selection. -- The sweep animation and configured-welcome behavior are unchanged. - -This supersedes only the model-in-banner portion of the [borderless banner decision](../../archived/feature/2026-07-21-tui-borderless-banner.md). - -## Alternatives considered - -**Remove the entire detail line.** Rejected: the session id remains useful for identifying and resuming the active session, and it is not duplicated in the prompt context. - -**Remove the model from the prompt context instead.** Rejected: the prompt context stays visible after the startup banner scrolls away and reflects later model selections. - -## Consequences - -- Startup uses the banner detail row only for the session id. -- The model appears once in the initial idle view, in the prompt context. -- Banner snapshots and runnable TUI replay snapshots contain a shorter detail row. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` asserts that completed banners retain the session id without the former `` text. Package-local and runnable-example TUI snapshots pin the resulting rows. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md deleted file mode 100644 index 86a3cdb0e7..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note:启动横幅不再显示模型 - -Status: implemented - -[English](2026-07-24-tui-banner-model-deduplication.md) | 中文 - -## 问题 - -启动横幅在提示区上下文(prompt context)的正上方重复显示所选模型,而提示区上下文本身已在 TUI 空闲时持续展示模型。这一重复不提供任何信息,还让横幅详情行更难扫读。 - -## 决策 - -- 无边框启动横幅显示产品标题、可选的 `welcome` 或会话标题副标题,以及会话 id。 -- 横幅不再显示模型名。提示区上下文仍是常驻的模型展示位,并在 `/model` 选择后随之更新。 -- 扫入动画和配置了欢迎语时的行为保持不变。 - -本 note 仅取代[无边框横幅决策](../../archived/feature/2026-07-21-tui-borderless-banner.md)中模型进横幅的那部分。 - -## 考虑过的替代方案 - -**移除整条详情行。** 否决:会话 id 对识别和恢复当前会话仍然有用,而且它在提示区上下文中没有重复。 - -**改为把模型从提示区上下文移除。** 否决:提示区上下文在启动横幅滚出视野后仍保持可见,并会反映之后的模型选择。 - -## 后果 - -- 启动时横幅详情行只承载会话 id。 -- 在初始空闲视图中模型只出现一次,位于提示区上下文。 -- 横幅快照和可运行的 TUI 回放快照包含更短的详情行。 - -## 测试 - -`packages/ui/tui/tests/tui.spec.ts` 断言完成后的横幅保留会话 id,且不含先前的 `` 文本。包内快照与可运行示例的 TUI 快照固定了最终的各行内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml deleted file mode 100644 index 481dc5cad7..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml +++ /dev/null @@ -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 -2026-07-24-tui-message-header-timing.md: 94a4d04c75f9b0ad76e2460738a07ba82ac3bb9f -2026-07-24-tui-message-header-timing.zh.md: 4713555290bbc47bb3af56cd3b4d0c493c81e6f1 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md deleted file mode 100644 index 94a4d04c75..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: TUI message header timing - -Status: implemented - -English | [中文](2026-07-24-tui-message-header-timing.zh.md) - -## Problem - -Turn timing beside the editor disappears from the transcript when the user scrolls and cannot appear until the editor status renders. A whole-turn aggregate also obscures the latency of later model requests after tool calls. - -## Decision - -Every model step creates an assistant header at `step/start`, before the first streamed chunk. The header displays `Model wait` immediately and refreshes at 100 ms resolution, then adds exclusive `Thinking`, `Response`, and `Tools` buckets as session events move the step between phases. - -`step/end` freezes the header and adds the local completion timestamp. Transcript replay derives the same timing from durable event timestamps. Empty and tool-only steps retain a header, while failed live output and its header retract together when retry handling rebuilds the active session surface. - -The prompt context retains only queued-steering state. Timing belongs to the model step that produced it rather than to the editor or the whole turn. - -## Alternatives considered - -Keeping timing beside the editor preserves a stable layout but hides per-step latency in scrollback and resume. Adding a second status line duplicates the same metric in two places. Labeling the first bucket `TTFT` is compact but requires protocol terminology; `Model wait` states the user-visible meaning without claiming that the first chunk is always text. - -## Consequences - -Users receive visible feedback before model output and can compare each request after tools or retries. Updating at 100 ms resolution causes more terminal renders while a model step is active. Internal timing state keeps the established `ttft` name because it identifies the measured bucket precisely; only rendered text uses `Model wait`. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md deleted file mode 100644 index 4713555290..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note:TUI 消息头部计时 - -Status: implemented - -[English](2026-07-24-tui-message-header-timing.md) | 中文 - -## 问题 - -编辑器旁的轮次计时会在用户滚动时从 transcript(文本记录)中消失,且要等到编辑器状态渲染后才能出现。整轮聚合值还会掩盖工具调用之后各后续模型请求的延迟。 - -## 决策 - -每个模型步骤都在 `step/start` 时(即第一个流式分片到达之前)创建一个 assistant 头部。头部立即显示 `Model wait` 并以 100 ms 分辨率刷新;随着会话事件使该步骤在不同阶段之间切换,头部再加入互斥的 `Thinking`、`Response` 和 `Tools` 时间桶。 - -`step/end` 冻结头部并附上本地完成时间戳。transcript 回放从持久事件时间戳派生出相同的计时。空步骤和纯工具步骤同样保留头部;当重试处理重建活跃会话表层时,失败的实时输出与其头部一并撤除。 - -提示区上下文(prompt context)只保留排队中的 steering(中途引导)状态。计时归属于产生它的模型步骤,而不是编辑器或整个轮次。 - -## 考虑过的替代方案 - -把计时留在编辑器旁能保持布局稳定,但在 scrollback 和会话恢复中看不到各步骤的延迟。增加第二条状态行会让同一指标出现在两处。把第一个时间桶标为 `TTFT` 更紧凑,但依赖协议术语;`Model wait` 直接陈述用户可见的含义,而不宣称第一个分片总是文本。 - -## 后果 - -用户在模型输出之前就能得到可见反馈,并能比较工具或重试之后的每次请求。以 100 ms 分辨率刷新会在模型步骤活跃期间带来更多终端渲染。内部计时状态沿用既有的 `ttft` 名称,因为它精确标识所计量的时间桶;只有渲染文本使用 `Model wait`。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml deleted file mode 100644 index dff4800cf7..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml +++ /dev/null @@ -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 -2026-07-24-tui-prompt-status-indicator.md: 8d469c3b0627325f373ca8f8d4d23d67bb09e342 -2026-07-24-tui-prompt-status-indicator.zh.md: 0dee8d1e4e7ce5a6f0299f9b7cd1595f63bd6e08 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md deleted file mode 100644 index 8d469c3b06..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: TUI prompt status indicator - -Status: implemented - -English | [中文](2026-07-24-tui-prompt-status-indicator.zh.md) - -## Problem - -While a turn runs, the input prompt shows only its static `dsh>` prefix. The assistant header carries the elapsed timing, but the editor row — where the user's attention rests — gives no live signal of what the agent is doing right now: waiting for the first token, thinking, responding, or running tools. - -## Decision - -While the agent is running, a phase-specific glyph replaces the `>` caret of the built-in `${indicator}` prompt value. The `inputPrompt` theme template defaults to `${symbol} ${indicator}`, where the built-in `${symbol}` value holds the `dsh` label and `${indicator}` holds the caret slot with its trailing gap before the cursor; the template literal space separates them, rendering `dsh ` in every state. The phase is the open step's active timing bucket, derived from the same session events and rules that drive the [message header timing](2026-07-24-tui-message-header-timing.md) — no new phase model. One glyph per bucket: `◍` model wait (pre-first-token), `✻` thinking, `●` responding, `⚙` tools. A running turn with no open step falls back to the model-wait glyph; an idle agent restores the plain `>`. - -The glyph occupies the caret's exact column with the same display width every frame, so the cursor never shifts as the phase changes or the glyph animates. Activity is conveyed by a brightness pulse, not by appearing and disappearing: a four-frame triangle wave (dim → normal → bold → normal) wraps the accent-colored glyph in the true SGR intensity codes (2 and 1) — never the palette's semantic `dim` role, which on a light scheme is a color the glyph's own accent would override — so the pulse survives every terminal scheme. The render-clock cadence is 250 ms per frame, a fixed presentation rhythm alongside the sibling 100 ms status refresh, not a deployment choice. The running-status timer refreshes every 100 ms tick unconditionally rather than only when a streaming component exists, so the pulse animates even during the pre-first-token wait. - -The caret and its animation are their own `${indicator}` value, separate from the `${symbol}` label, so the `inputPrompt` template composes the two: `${symbol} ${indicator}` reads as `dsh `. Configurability lives at that template — a deployment reorders or drops either value, and omitting `${indicator}` opts out of the running indicator. The glyph set, the pulse, and the `dsh` label are fixed in code — not per-deployment fields — matching the fixed timing-bucket labels they mirror. - -The built-in `${symbol}`/`${indicator}` updates ride the renders the TUI already drives on every state change that can move a value (`agent/status`, session events, the 100 ms running-status timer, async model-context resolution). A prompt value that changes on its own schedule — a plugin-owned `${custom}` fragment — instead redraws through the registry's coalesced change notification, which the renderer subscribes to directly rather than through a Cordis event ([registry](2026-07-24-configurable-tui-prompt-theme.md)). - -## Alternatives considered - -**Prepend the glyph before `dsh>` as its own `${status}` token.** Rejected: a leading token shifts the whole prompt — and the cursor — right by two columns whenever it appears, and collapses back when it clears. Replacing the caret keeps the cursor column fixed. - -**A blinking glyph that appears and disappears.** Rejected: on/off blanking still moves nothing horizontally once the glyph owns the caret column, but the empty frames read as flicker. A brightness pulse animates continuously while the character stays put. - -**A per-phase spinner animation** (rotating frames). Rejected: the four phases are already distinguished by their glyph shapes; swapping the character per frame would conflate "which phase" with "still working". The pulse animates intensity while the shape stays a stable phase signal, reusing the existing 100 ms status timer. - -**A new phase state machine in the TUI.** Rejected: the header-timing machinery already replays the open step's active bucket from session events. Deriving the glyph from that bucket keeps one source of truth for "what phase is this step in". - -## Consequences - -The user gets a live, glanceable phase signal in the caret they are already watching, with no horizontal movement of the cursor or the prompt. The pulse costs terminal renders on every 100 ms tick for the whole running turn, not only while a streaming component is mounted. The glyph mapping and the pulse are fixed in code, not configurable, matching the fixed timing-bucket labels they mirror. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md deleted file mode 100644 index 0dee8d1e4e..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note:TUI 提示区状态指示器 - -Status: implemented - -[English](2026-07-24-tui-prompt-status-indicator.md) | 中文 - -## 问题 - -轮次运行期间,输入提示区只显示其静态的 `dsh>` 前缀。assistant 头部承载已用计时,但编辑器所在的这一行——也就是用户注意力所在之处——对 agent 此刻正在做什么没有任何实时信号:是在等待第一个 token、思考、响应,还是在运行工具。 - -## 决策 - -agent 运行期间,一个按阶段区分的字形会替换内置 `${indicator}` 提示区值中的 `>` 光标符。`inputPrompt` 主题模板默认为 `${symbol} ${indicator}`,其中内置 `${symbol}` 值承载 `dsh` 标签,`${indicator}` 承载光标符槽位及其在光标前的尾随间隙;模板中的字面空格将两者隔开,在每种状态下渲染为 `dsh <字形> `。阶段取自当前打开步骤的活跃计时桶,其派生所依据的会话事件与规则和[消息头部计时](2026-07-24-tui-message-header-timing.md)相同——没有引入新的阶段模型。每个桶对应一个字形:`◍` 等待模型(第一个 token 之前)、`✻` 思考、`●` 响应、`⚙` 工具。运行中但没有打开步骤的轮次回退到等待模型的字形;agent 空闲时则恢复为纯 `>`。 - -字形占据光标符所在的同一列,且每一帧的显示宽度都相同,因此无论阶段切换还是字形动画,光标都不会移动。活动状态由亮度脉动传达,而不是靠出现和消失:一个四帧三角波(暗 → 正常 → 亮 → 正常)用真正的 SGR 强度码(2 与 1)包裹带 accent 色的字形——绝不使用调色板语义上的 `dim` 角色,因为在浅色 scheme 下它是一种颜色,会被字形自身的 accent 色覆盖——因此脉动在任何终端 scheme 下都能保留。渲染时钟节拍为每帧 250 ms,是与配套的 100 ms 状态刷新并列的固定呈现节奏,而非部署选项。运行状态计时器每 100 ms 无条件刷新一次,而不再只在存在流式组件时刷新,因此即使在第一个 token 之前的等待期间,脉动也能持续。 - -光标符及其动画自成一个 `${indicator}` 值,与 `${symbol}` 标签分离,因此 `inputPrompt` 模板将二者组合:`${symbol} ${indicator}` 读作 `dsh <光标符>`。可配置性位于该模板——部署可重排或丢弃任一值,省略 `${indicator}` 即退出运行指示器。字形集、脉动以及 `dsh` 标签都固定在代码中——不是逐部署字段——与它们映射的固定计时桶标签一致。 - -内置 `${symbol}`/`${indicator}` 的更新搭乘 TUI 本就在每次可能改变某个值的状态变化(`agent/status`、会话事件、100 ms 运行状态计时器、异步模型上下文解析)时驱动的渲染。而一个自行变化的值——插件拥有的 `${custom}` 片段——则通过注册表的合并变更通知重绘,而渲染器直接订阅它,而非通过 Cordis 事件(参见[注册表](2026-07-24-configurable-tui-prompt-theme.md))。 - -## 考虑过的替代方案 - -**把字形作为自己的 `${status}` token 前置在 `dsh>` 之前。** 已否决:前置 token 每次出现都会把整个提示区——连同光标——向右移动两列,清除时又缩回。在尾随的 `${indicator}` 槽位替换光标符能让光标列保持固定。 - -**出现又消失的闪烁字形。** 已否决:一旦字形占据光标符所在列,开/关式的空白帧在水平方向上不再移动任何东西,但空帧读起来像闪烁。亮度脉动让字符保持不动的同时持续做动画。 - -**按阶段的 spinner 动画**(旋转帧)。已否决:四个阶段已经通过各自的字形形状区分;逐帧切换字符会把「哪个阶段」与「仍在工作」混为一谈。脉动只改变强度做动画,而形状始终是稳定的阶段信号,且复用了既有的 100 ms 状态计时器。 - -**在 TUI 中新建阶段状态机。** 已否决:头部计时机制已从会话事件回放出当前打开步骤的活跃桶。从该桶派生字形,能让「这个步骤处于哪个阶段」保持单一事实来源。 - -## 后果 - -用户在自己本就注视的光标符处获得可一眼掌握的实时阶段信号,且光标与提示区都没有水平移动。脉动的代价是整个运行轮次内每 100 ms 一次的终端渲染,而不再只在流式组件挂载期间。字形映射与脉动都固定在代码中、不可配置,与其所对应的固定计时桶标签一致。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml deleted file mode 100644 index dd09857974..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml +++ /dev/null @@ -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 -2026-07-24-tui-prompt-workspace-label.md: c45c60c6554766cca01076b229956a7bc7d98d48 -2026-07-24-tui-prompt-workspace-label.zh.md: 170dba83becf4b529679e7db9c7c84a6de7dec13 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md deleted file mode 100644 index c45c60c655..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: The prompt context combines directory and branch - -Status: implemented - -English | [中文](2026-07-24-tui-prompt-workspace-label.zh.md) - -## Problem - -The idle prompt context rendered the working directory and `git:` as separate segments. In task worktrees, the directory can already identify the checkout, while the prefixed branch segment consumed additional horizontal space and was discarded independently on narrower terminals. - -## Decision - -- The prompt context renders the working directory and available Git branch as one workspace label: ` ()`. -- The directory remains bold and accented; the parenthesized branch remains muted. -- The combined workspace label has the highest retention priority and is clipped as one segment when it exceeds the terminal width. -- Outside a Git worktree or on detached HEAD, the label remains the directory alone. - -## Alternatives considered - -**Keep `git:` as a separate segment.** Rejected: the prefix and separator use more columns without adding meaning in this context. - -**Show only the branch.** Rejected: the session working directory determines where tools operate and remains the primary prompt context. - -**Derive a special worktree root label.** Rejected: the existing formatted directory and Git branch already provide the two relevant facts without adding repository-layout assumptions. - -## Consequences - -- A typical checkout renders as `~/git/tui-staging (tui-staging)`. -- Narrow terminals retain or clip directory and branch together instead of dropping the branch independently. -- Embedding-provided `TuiRuntime.formatCwd` labels compose with the branch in the same form. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins home, absolute, formatted, and narrow workspace labels. Package-local and runnable-example TUI snapshots verify the assembled prompt context. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md deleted file mode 100644 index 170dba83be..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note:提示区上下文合并显示目录与分支 - -Status: implemented - -[English](2026-07-24-tui-prompt-workspace-label.md) | 中文 - -## 问题 - -空闲提示区上下文(prompt context)把工作目录和 `git:` 作为两个独立片段渲染。在任务 worktree 中,目录本身往往已能标识当前检出,而带前缀的分支片段额外占用横向空间,且在较窄的终端上会被单独丢弃。 - -## 决策 - -- 提示区上下文把工作目录和可用的 Git 分支渲染为一个工作区标签(workspace label):` ()`。 -- 目录仍为加粗强调色;括号内的分支仍为弱化色。 -- 合并后的工作区标签具有最高保留优先级,超出终端宽度时作为一个整体片段裁剪。 -- 不在 Git worktree 中或处于 detached HEAD 时,标签仍只显示目录。 - -## 考虑过的替代方案 - -**保留 `git:` 作为独立片段。** 否决:前缀和分隔符占用更多列宽,在此上下文中却不增加信息。 - -**只显示分支。** 否决:会话工作目录决定工具在哪里运行,仍是提示区上下文的首要信息。 - -**派生一个特殊的 worktree 根目录标签。** 否决:现有的格式化目录和 Git 分支已经提供了这两项相关信息,无需引入对仓库布局的假设。 - -## 后果 - -- 典型的检出渲染为 `~/git/tui-staging (tui-staging)`。 -- 窄终端把目录和分支作为整体保留或裁剪,而不是单独丢弃分支。 -- 嵌入方通过 `TuiRuntime.formatCwd` 提供的标签以同样的形式与分支组合。 - -## 测试 - -`packages/ui/tui/tests/tui.spec.ts` 固定了主目录、绝对路径、格式化及窄终端下的工作区标签。包内快照与可运行示例的 TUI 快照验证了组装后的提示区上下文。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml deleted file mode 100644 index 8cc4d64238..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md -2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224 -2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml deleted file mode 100644 index bfb40b9cc8..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml +++ /dev/null @@ -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 -2026-07-24-tui-shell-prompt-editor.md: bba03e788b92692f534fd97e66035757e9c74356 -2026-07-24-tui-shell-prompt-editor.zh.md: 1897d11292ec3b189245169956ac327f0b81b0e2 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml deleted file mode 100644 index dab40896a5..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md -2026-07-27-assistant-timing-header-trailing.md: a315a0620c63f25660220e55cf5187d7117c14d1 -2026-07-27-assistant-timing-header-trailing.zh.md: 84b8612337a345912371e37952195e4602f7ca25 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml deleted file mode 100644 index 2012fedaaa..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md -2026-07-27-tui-running-glyph-smooth-fade.md: e4c8fee399c2269bfe53976d3358bc643b2daf6a -2026-07-27-tui-running-glyph-smooth-fade.zh.md: 25bda3d549b1a7548e997f8801858d1efa32e3eb diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml index f2b2b5b22e..ea1b12edeb 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md -2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140 -2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917 +2026-07-28-skill-invocation-policy.md: 9d34bcb77ab7933ef9bf4e9b5227dade39903667 +2026-07-28-skill-invocation-policy.zh.md: 4b190dbd095306d41837bd9734049f5e3d85da72 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md index f74b0bcfdd..9d34bcb77a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -29,7 +29,7 @@ These rules permit all four combinations: | `{ modelInvocable: false, userInvocable: true }` | excluded | included | | `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | -This decision extends the [skill system](2026-07-05-skill-system.md) and supersedes the invocation-policy limitation recorded by the [TUI skill slash command](2026-07-21-tui-skill-slash-command.md). +This decision extends the [skill system](2026-07-05-skill-system.md) and supersedes the invocation-policy limitation recorded by the [archived TUI skill slash command](../../archived/feature/2026-07-21-tui-skill-slash-command.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md index 1a7117a382..4b190dbd09 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -29,7 +29,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会 | `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | | `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | -该决策扩展了 [skill 系统](2026-07-05-skill-system.md),并取代 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)中记录的调用策略限制。 +该决策扩展了 [skill 系统](2026-07-05-skill-system.md),并取代[已归档的 TUI skill 斜杠命令](../../archived/feature/2026-07-21-tui-skill-slash-command.md)中记录的调用策略限制。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml deleted file mode 100644 index 5a1b64f6b3..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c -2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 4502aa230f..d910be95cf 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: e325f4614f8d7305ce2c6199a25afd56b51fad61 -2026-07-31-even-out-shipped-tool-rosters.zh.md: a9b49c454d78387583aa7dd9e25f5d5c850a15ae +2026-07-31-even-out-shipped-tool-rosters.md: 312e61d017abad1a6ac57f2ba491a715f8fd92d0 +2026-07-31-even-out-shipped-tool-rosters.zh.md: c77370c312004052bc4f8ee9545ed287a6d92554 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index e325f4614f..312e61d017 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -38,9 +38,9 @@ The layer that would make MCP a default is the one this repository does not have ## Testing -[`apps/cli/tests/shipped-composition.e2e.ts`](../../../../apps/cli/tests/shipped-composition.e2e.ts) boots the shipped tree through the real Loader in a pseudo-terminal and reads the tool names out of the `request/header` the session log persisted, so the assertion is the catalog the model was actually sent. Its `--config` overlay, [`composition-keyless-tail.cordis.yml`](../../../../apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml), is test isolation only: a network-free adapter and workspace-local session artifacts. +`apps/cli/tests/shipped-composition.e2e.ts` booted the shipped tree through the real Loader in a pseudo-terminal and read the tool names out of the `request/header` the session log persisted, so the assertion was the catalog the model was actually sent. Its `--config` overlay, `composition-keyless-tail.cordis.yml`, provided test isolation only: a network-free adapter and workspace-local session artifacts. -That tail also inserts [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts), which announces settled Loader activation on the terminal stream. The TUI renders as soon as its own fiber starts, so a prompt typed at the banner can reach the loop while tool rows and persistence are still activating and assemble a partial catalog; gating the smoke's first prompt on that marker is what makes the assertion deterministic. +That tail also inserted `composition-settled.ts`, which announced settled Loader activation on the terminal stream. The TUI rendered as soon as its own fiber started, so a prompt typed at the banner could reach the loop while tool rows and persistence were still activating and assemble a partial catalog; gating the smoke's first prompt on that marker made the assertion deterministic. The same smoke also pins the TUI execution posture from the same artifact. Those sandbox-schema and initial-permission assertions belong to the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md), independently of this roster. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index a9b49c454d..c77370c312 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -38,9 +38,9 @@ Status: implemented ## 测试 -[`apps/cli/tests/shipped-composition.e2e.ts`](../../../../apps/cli/tests/shipped-composition.e2e.ts) 在伪终端中通过真实 Loader 启动交付树,并从会话日志持久化的 `request/header` 中读出工具名,因此断言的正是模型实际收到的目录。它传入的 `--config` overlay [`composition-keyless-tail.cordis.yml`](../../../../apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml) 只做测试隔离:一个无网络适配器,以及落在工作区内的会话产物。 +`apps/cli/tests/shipped-composition.e2e.ts` 曾在伪终端中通过真实 Loader 启动交付树,并从会话日志持久化的 `request/header` 中读出工具名,因此断言的是模型实际收到的目录。它传入的 `--config` overlay `composition-keyless-tail.cordis.yml` 只用于测试隔离:一个无网络适配器,以及落在工作区内的会话产物。 -该尾部还插入了 [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts),它在终端字节流上宣告 Loader 激活已 settle。TUI 在自己的 fiber 一启动就渲染,因此在 banner 处敲下的提示词可能在工具行与持久化仍在激活时就抵达循环,从而组装出不完整的目录;把冒烟的首个提示词 gate 在该标记上,正是断言得以确定的原因。 +该尾部还曾插入 `composition-settled.ts`,用于在终端字节流上宣告 Loader 激活已 settle。TUI 在自己的 fiber 一启动就渲染,因此在 banner 处敲下的提示词可能在工具行与持久化仍在激活时就抵达循环,从而组装出不完整的目录;把冒烟的首个提示词 gate 在该标记上,正是断言得以确定的原因。 同一份冒烟还根据同一份产物固定 TUI 的执行姿态。那些沙箱 schema 与初始权限断言归[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)所有,独立于本工具清单决策。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index c059ba010c..463285cfb1 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -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 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: b578e49f004cabf0e37385ea06a769ced5388217 -2026-07-20-remove-stdio-and-echo-agents.zh.md: 3d8eecf73f17f3b62dc4b35b0f3c1af03f6f3a86 +2026-07-20-remove-stdio-and-echo-agents.md: 1c7ff4b5337341ea7678c1455dd7bbd3365df6ba +2026-07-20-remove-stdio-and-echo-agents.zh.md: 67445d25f6a0534c92c50568da898a5bffc73a52 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index b578e49f00..1c7ff4b533 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -18,7 +18,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: -- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. +- `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 3d8eecf73f..67445d25f6 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -18,7 +18,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: -- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 +- `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml deleted file mode 100644 index 6575dbed79..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md -2026-07-27-copyable-transcript-no-gutter-bar.md: 659b7d2f2f85bf7efe6b1006a2c61a14f3044560 -2026-07-27-copyable-transcript-no-gutter-bar.zh.md: 5c43169ba352ef1fef73ecf2188a32304aba9c26 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index b100535da6..ecfb0ca0e8 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -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 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: ee642cbc786bef708791fb58e655c5a3f0e9c4e7 -2026-07-29-shared-base-config-overlays.zh.md: b7fc9c6b121b8d0eb94d734af6bda6df45e25b1d +2026-07-29-shared-base-config-overlays.md: 9fae9775e0b37acc99ce2eb5fc630e6f4f52bf1d +2026-07-29-shared-base-config-overlays.zh.md: 20cc3e522c369315a1fafc84778fced0260581d0 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index ee642cbc78..9fae9775e0 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -22,7 +22,7 @@ Precedence is list order, last write winning per row: base, then the surface ove `--config ` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. -A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records. +A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. `examples/tui-agent`, `examples/cordis-agent`, `examples/code-mode`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and the supported Code Mode demo remains the ACP overlay at `examples/acp-agent/code-mode.cordis.yml`. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index b7fc9c6b12..20cc3e522c 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -22,7 +22,7 @@ Status: implemented `--config ` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 -patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。 +patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 `examples/tui-agent`、`examples/cordis-agent`、`examples/code-mode` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/`,受支持的 Code Mode demo 则保留为 `examples/acp-agent/code-mode.cordis.yml` 中的 ACP overlay。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index b3d987b985..c0a15302a9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md -2026-07-30-tui-adapter-registration-race.md: fd08e7b6130bc8f7e3cd5287a9970f5fb47244a8 -2026-07-30-tui-adapter-registration-race.zh.md: 0c6bba4bbc8c3303d9c471c3164faa816438b333 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md +2026-08-03-explicit-config-dsh-entrypoint.md: bbb40babf8abca726126678f4bccb40a63160568 +2026-08-03-explicit-config-dsh-entrypoint.zh.md: a97221a73bab0b181562ddfb7ddab1211f87f168 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md new file mode 100644 index 0000000000..bbb40babf8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md @@ -0,0 +1,45 @@ +# Agent Note: Explicit-config dsh entrypoint + +Status: implemented + +English | [中文](2026-08-03-explicit-config-dsh-entrypoint.zh.md) + +## Problem + +Bare `dsh` selected a product TUI implicitly. That made one command own terminal lifecycle, session identity and resume handoff, onboarding, source-workspace shortcuts, guided upgrade sessions, personal config watching, and a large app-level PTY and transcript snapshot suite. The default also hid the actual composition boundary: `--config` was an optional third layer over a TUI overlay rather than the deployment definition a raw launcher needs. + +The shared base is intentionally neutral: it provides capabilities but creates no startup agent or interaction front door. A neutral base paired with an implicit application made raw config composition less explicit and kept product policy in the CLI rather than in the caller-selected overlay. + +## Decision + +Raw executable use is `dsh --config `. The named file must be an Include patch list and is applied directly over `apps/cli/config/base.cordis.yml` at the same include level. It is required for boot, is not a complete replacement tree, and does not inherit `apps/cli/config/web.cordis.yml` or `$DSH_HOME/config.yaml`. Relative paths resolve from the invoking directory. Boot errors fail loud; SIGINT and SIGTERM dispose the root before exit. + +The raw diagnostic forms remain boot-free: `dsh --dump-default-config` prints the base, while `dsh --config --dump-config` prints base plus the required overlay. The dump uses the Include implementation's patch algorithm and YAML dialect. + +The CLI no longer ships a TUI application. Its TUI overlay, launcher, first-run onboarding assets, app-level TUI fixtures, PTY harness, terminal journeys, and snapshots are deleted. The `meta` and `upgrade` subcommands, their experimental gate, default-surface resume, and full-tree `--config-replace` path are deleted with that application. The installer builds and launches Web without an interface selector. + +`dsh web` retains the shared base plus Web overlay and personal-or-explicit user layer. `dsh -p` retains the one-shot Web/headless composition. The reusable TUI package initially remained after this entrypoint change, then [the package-wide removal decision](2026-08-04-remove-tui-package.md) deleted it and its SDK interface. + +This decision supersedes the `dsh`-specific parts of the [dedicated TUI front door](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md), [personal config](../feature/2026-07-20-dsh-cli-personal-config.md), [guided skill commands](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md), [meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md), [shared config overlays](2026-07-29-shared-base-config-overlays.md), [config dump](../feature/2026-07-30-dsh-dump-config.md), [first-run welcome](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md), and [experimental subcommand gate](../../archived/feature/2026-07-31-experimental-subcommand-gate.md) notes. The later [package-wide removal decision](2026-08-04-remove-tui-package.md) supersedes their reusable-package decisions and consolidates the deleted launcher-identity record. + +## Verification + +Parser tests require `--config` for raw boot and reject the removed command names and incompatible option combinations. Built-bin acceptance runs the published JavaScript entry without tsx, checks base-only and base-plus-overlay dumps, and drives an invalid raw provider overlay to prove a boot failure settles and exits rather than hanging. Source-launch compatibility checks the same required-config diagnostic through `bin/dsh`. No `apps/cli` TUI demo or test remains. + +## Alternatives considered + +**Keep bare `dsh` as a TUI and add an explicit config subcommand.** Rejected because the CLI would still own two unrelated application policies and retain the TUI-only launcher, onboarding, and test infrastructure. + +**Allow bare `dsh` to boot the neutral base.** Rejected because the base creates no agent or interaction front door. A process that settles successfully but has no usable entry point hides a missing deployment decision. + +**Keep `--config-replace` for complete trees.** Rejected because raw execution now has one composition contract: a required overlay over the product base. Complete-tree deployments can use the generic Cordis loader or a dedicated application bin without adding a second meaning to `dsh --config`. + +**Delete the TUI package with the product entrypoint.** Initially rejected because removing one shipped application did not by itself require removing a reusable UI implementation. Once no shipped composition or independent consumer remained, [the package-wide removal decision](2026-08-04-remove-tui-package.md) accepted this alternative. + +## Consequences + +Invoking `dsh` without a mode or raw config is a usage error. Existing TUI startup, `meta`, `upgrade`, resume, and full-tree replacement invocations stop working without compatibility aliases. This is acceptable under the pre-release compatibility stance and keeps the supported grammar small. + +Raw deployments must state their agent and front-door rows in an overlay, which makes the application boundary reviewable and keeps base updates available underneath. They do not receive personal config implicitly; deployments that want that policy must compose it themselves. Web remains the installed interactive product surface, while headless and automation entries remain separate. + +Reintroducing a shipped terminal application requires a concrete product need, a named entry mode rather than an implicit raw default, and its own current snapshot and lifecycle acceptance surface. diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md new file mode 100644 index 0000000000..a97221a73b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -0,0 +1,45 @@ +# Agent Note:显式配置的 dsh 入口 + +Status: implemented + +[English](2026-08-03-explicit-config-dsh-entrypoint.md) | 中文 + +## 问题 + +裸 `dsh` 会隐式选择产品 TUI。这使一条命令负责终端生命周期、会话身份与恢复移交、onboarding、源码 workspace 快捷入口、引导式升级会话、个人配置监听,以及一整套规模庞大的应用级 PTY 和 transcript(文本记录)快照测试。该默认行为还隐藏了真实的组合边界:`--config` 是 TUI overlay 之上的可选第三层,而不是 raw 启动器所需的部署定义。 + +共享 base 有意保持中性:它提供各项能力,但不会创建启动 agent(智能体)或交互入口。将中性 base 与隐式应用配对,使 raw 配置组合缺乏明确边界,也使产品政策留在 CLI(命令行界面)中,而不是由调用方选定的 overlay 持有。 + +## 决策 + +raw 执行方式为 `dsh --config `。指定文件必须是一份 Include 补丁列表,并在同一 include 层级直接应用到 `apps/cli/config/base.cordis.yml` 之上。启动时必须提供该文件;它不是完整的替换配置树,也不会继承 `apps/cli/config/web.cordis.yml` 或 `$DSH_HOME/config.yaml`。相对路径从调用目录解析。启动错误会明确报错;SIGINT 和 SIGTERM 会先对根上下文执行 dispose(资源释放),再退出。 + +raw 诊断形式仍然无需启动:`dsh --dump-default-config` 打印 base,`dsh --config --dump-config` 则打印 base 与必需 overlay 的合成结果。转储过程使用 Include 实现的补丁算法和 YAML 方言。 + +CLI 不再交付 TUI 应用。TUI overlay、启动器、首次运行 onboarding 资产、应用级 TUI fixture(测试前置数据)、PTY harness、终端流程和快照均已删除。`meta` 与 `upgrade` 子命令、对应的实验功能门禁、默认 surface 恢复入口,以及整棵配置树的 `--config-replace` 路径也随该应用一并删除。安装器不再提供界面选择器,只构建并启动 Web。 + +`dsh web` 保留共享 base、Web overlay 与个人或显式用户层。`dsh -p` 保留一次性 Web/headless 组合。可复用 TUI 包(package)在本入口变更后起初保留,随后[全包移除决策](2026-08-04-remove-tui-package.md)将其及 SDK 接口删除。 + +本决策取代以下记录中专用于 `dsh` 的部分:[独立 TUI 入口](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)、[个人配置](../feature/2026-07-20-dsh-cli-personal-config.md)、[引导式 skill 命令](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md)、[meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md)、[共享配置 overlay](2026-07-29-shared-base-config-overlays.md)、[配置转储](../feature/2026-07-30-dsh-dump-config.md)、[首次运行欢迎页](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md)和[实验性子命令门禁](../../archived/feature/2026-07-31-experimental-subcommand-gate.md)。后续的[全包移除决策](2026-08-04-remove-tui-package.md)取代了其中关于可复用包的决策,并整合了已删除的启动器身份记录。 + +## 验证 + +解析器测试要求 raw 启动提供 `--config`,并拒绝已删除的命令名和不兼容的选项组合。构建后二进制验收测试在不使用 tsx 的情况下运行已发布的 JavaScript 入口,检查仅含 base 及 base 加 overlay 的转储,并传入无效的 raw 提供方 overlay,以证明启动失败能够结束并退出,而不会挂起。源码启动兼容性测试通过 `bin/dsh` 检查同一条缺少必需配置的诊断。`apps/cli` 不再包含任何 TUI demo 或测试。 + +## 曾考虑的替代方案 + +**保留裸 `dsh` 作为 TUI,并新增显式配置子命令。** 不予采纳,因为 CLI 仍需持有两套互不相关的应用政策,并保留仅供 TUI 使用的启动器、onboarding 和测试基础设施。 + +**允许裸 `dsh` 启动中性 base。** 不予采纳,因为 base 不会创建 agent 或交互入口。进程成功结束启动但没有可用入口,会掩盖缺失的部署决策。 + +**保留 `--config-replace` 以支持完整配置树。** 不予采纳,因为 raw 执行现在只有一份组合契约:在产品 base 之上施加一份必需的 overlay。完整配置树部署可以使用通用 Cordis loader 或专用应用二进制文件,无需为 `dsh --config` 增加第二种含义。 + +**随产品入口一并删除 TUI 包。** 最初不予采纳,因为仅移除一项已交付应用本身并不要求移除可复用 UI 实现。当已交付组合与独立消费方均不复存在后,[全包移除决策](2026-08-04-remove-tui-package.md)采纳了这一方案。 + +## 后果 + +调用 `dsh` 时如果既不指定模式,也不提供 raw 配置,将产生用法错误。既有的 TUI 启动、`meta`、`upgrade`、恢复和整棵配置树替换调用会停止工作,且不提供兼容别名。根据发布前兼容性立场,这是可以接受的,并能使支持的命令语法保持精简。 + +raw 部署必须在 overlay 中声明 agent 和入口配置项,使应用边界可供评审,并能继续吸收底层 base 的更新。它们不会隐式接收个人配置;需要该政策的部署必须自行组合。Web 仍是安装后提供的交互式产品 surface,headless 与自动化入口仍保持独立。 + +重新引入已交付的终端应用,需要有具体产品需求,采用具名入口模式而非隐式 raw 默认值,并建立自身当前有效的快照与生命周期验收面。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml similarity index 59% rename from .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index ed6406245e..cd1133483c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md -2026-07-27-tui-tool-card-header.md: 13f5e7fec1a82d02d5bf8cae4784505c02ab6f38 -2026-07-27-tui-tool-card-header.zh.md: 85f9f1244a7da568f5dcc892729021ac0fa270c9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +2026-08-04-remove-tui-package.md: 7f7a0dd86ddd36e940ed8b7d6154185d9740341c +2026-08-04-remove-tui-package.zh.md: 36cb4b6a5e4eddd152eccee92c913bcca5b7fbae diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md new file mode 100644 index 0000000000..7f7a0dd86d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -0,0 +1,39 @@ +# Agent Note: Remove the TUI package + +Status: implemented + +English | [中文](2026-08-04-remove-tui-package.zh.md) + +## Problem + +Removing the implicit `dsh` terminal application left `@deepseek-ai/dsh-tui` without a shipped composition. The package still carried a terminal renderer, interactive command and question adapters, extension overlays, snapshot fixtures, a patched `pi-tui` dependency, and SDK scaffolding that advertised TUI as a supported application interface. Keeping that surface required maintaining a product-sized frontend whose only remaining consumer was the project generator itself. + +The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI front doors, while the SDK continued to offer a terminal choice that no example or product command exercised. + +## Decision + +The `packages/ui/tui` package is deleted without a compatibility package or alias. Its source, package tests, terminal snapshots, dependency declarations, patched `pi-tui` artifact, workspace references, generated service catalog entry, and documentation are removed together. Generic host and agent-loop capabilities remain unchanged. + +The SDK run-interface union now contains only `acp` and `embed`. `create-sdk` defaults to ACP, generated templates contain no terminal startup, resume, session-environment, or model-argument branch, and the builtin `ask-user` feature is removed because neither remaining generated interface supplies a `UserInteractionProvider`. Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation seams directly. + +This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. + +This note consolidates the deleted package-only records that could not remain current after removal. The terminal UI had kept session identity visible during long conversations, removed duplicate model labels, attached elapsed timing and phase status to messages, showed workspace and branch context beside the prompt, and conservatively parsed complete XML wrappers for human-readable fallback output. Those choices improved one terminal frontend but do not justify retaining it without a deployment. A future XML fallback must still use a real parser rather than regular expressions. + +## Verification + +Repository searches and generated catalogs contain no TUI package, dependency patch, SDK interface option, service key, or package link. Focused SDK tests cover ACP and embedded creation, configuration, templates, and snapshots. The ordinary source build, typecheck, lint, hygiene, documentation gates, and remaining assembled snapshot suites run without the deleted workspace. + +## Alternatives considered + +**Keep the package unshipped.** Rejected because it preserves the maintenance cost and continues to present an unsupported terminal frontend as reusable product surface without a real composition proving its lifecycle. + +**Keep the SDK option for external consumers.** Rejected because the generator would be the package's only in-repository consumer and would scaffold an application the repository no longer accepts end to end. The pre-release compatibility stance does not require preserving that option. + +**Move the package to an examples or experimental group.** Rejected because moving code does not provide a current product need, a maintained deployment, or assembled acceptance. A future terminal frontend should start from its actual host and interaction requirements rather than inherit this implementation by default. + +## Consequences + +DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web front doors. + +The provider-neutral command, user-interaction, approval, tool-presentation, PTY, and session-projection capabilities remain available to other hosts. Reintroducing a terminal frontend requires a named product or deployment, an explicit package boundary, a concrete interaction provider, and assembled lifecycle and transcript acceptance for that frontend. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md new file mode 100644 index 0000000000..36cb4b6a5e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 移除 TUI 包(package) + +Status: implemented + +[English](2026-08-04-remove-tui-package.md) | 中文 + +## 问题 + +移除隐式的 `dsh` 终端应用后,`@deepseek-ai/dsh-tui` 不再拥有任何已交付的组合。该包仍包含终端渲染器、交互式命令与问答适配器、扩展浮层、快照 fixture(测试前置数据)、已打补丁的 `pi-tui` 依赖,以及仍将 TUI 宣称为受支持应用接口的 SDK 脚手架。保留这一表面意味着继续维护一个产品规模的前端,而其唯一剩余消费方就是项目生成器本身。 + +该包还会使仓库所支持的应用清单产生误导。当前可运行产品使用 Web、ACP(Agent Client Protocol)、JSON-RPC 或一次性 CLI(命令行界面)入口,SDK 却仍提供终端选项,而没有任何示例或产品命令会实际运行它。 + +## 决策 + +删除 `packages/ui/tui` 包,不提供兼容包或别名。其源码、包测试、终端快照、依赖声明、已打补丁的 `pi-tui` 产物、workspace 引用、生成的服务目录条目和文档会一并移除。通用宿主能力与 agent-loop 能力保持不变。 + +SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk` 默认使用 ACP,生成的模板不再包含终端启动、恢复、会话环境或模型参数分支;内置的 `ask-user` 功能也被移除,因为剩余两个生成接口都不提供 `UserInteractionProvider`。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction`、`dsh-commands` 和呈现 seam。 + +本决策取代[显式配置 `dsh` 入口决策](2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 + +本记录汇总了删除后无法继续保持当前状态的仅限包记录。终端 UI 曾在长对话期间保持会话身份可见、移除重复模型标签、为消息附加耗时与阶段状态、在提示词旁显示 workspace 与分支上下文,并保守地解析完整 XML 包装层,以生成人类可读的回退输出。这些选择改善了一个终端前端,但没有部署时不足以证明应保留它。未来的 XML 回退仍必须使用真实解析器而非正则表达式。 + +## 验证 + +仓库搜索结果与生成目录中不再包含 TUI 包、依赖补丁、SDK 接口选项、服务键或包链接。聚焦的 SDK 测试覆盖 ACP 与嵌入式创建、配置、模板和快照。常规源码构建、类型检查、lint、hygiene、文档门禁以及其余组装快照测试套件均可在没有已删除 workspace 的情况下运行。 + +## 考虑过的替代方案 + +**保留未交付的包。** 不予采纳,因为这会保留维护成本,并继续将一个没有真实组合证明其生命周期的、不受支持的终端前端呈现为可复用产品表面。 + +**为外部消费方保留 SDK 选项。** 不予采纳,因为生成器会成为该包在仓库内唯一的消费方,并会搭建一个仓库不再进行端到端验收的应用。预发布兼容性立场不要求保留该选项。 + +**将包移入 examples 或 experimental 组。** 不予采纳,因为移动代码无法提供当前产品需求、受维护的部署或组装验收。未来的终端前端应以其实际宿主和交互需求为起点,而不是默认继承此实现。 + +## 后果 + +DeepSeek Harness 不再提供终端 UI 包或生成的 TUI 应用。现有 import、`cordis.yml` 条目、SDK `--interface=tui` 请求以及依赖该包的项目会直接失败,不会得到兼容转换。Web 仍是已交付的交互表面;ACP、JSON-RPC 与一次性 CLI 仍是 Web 之外的入口。 + +提供方无关的命令、用户交互、审批、工具呈现、PTY 与会话投影能力仍可供其他宿主使用。重新引入终端前端时,必须为其提供具名产品或部署、显式包边界、具体交互提供方,以及组装后的生命周期与 transcript(文本记录)验收。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml deleted file mode 100644 index 9345449920..0000000000 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md -2026-07-18-tui-terminal-state-snapshots.md: b8e6f77d96fbd4d077d55fefa3652813c737a37b -2026-07-18-tui-terminal-state-snapshots.zh.md: 1b2f6b58e53d9ccd5f0935dab9e9812d2bfe3eb4 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 77d5ca3489..7f8463064f 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -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 .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 5ef6629500171d3de2add070b0797d2c2d9b119a -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: a11c593cdb056477689ad6e3e86d9d5c32a206a4 +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 421ce93a20c567cac4d6a96f806949348dff3e6b +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 8ae9eb83269a910160aef3a563dce6d83dbc7ad8 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index 5ef6629500..421ce93a20 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -48,7 +48,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does. - **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill. -- **node-pty everywhere for the TUI test driver**: [Windows-TUI note](../../implemented/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it is already the Windows leg. +- **node-pty everywhere for the TUI test driver**: the archived [Windows-TUI note](../../archived/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it was already the Windows leg. **Servers and HTTP:** diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index a11c593cdb..8ae9eb8326 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -48,7 +48,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。 - **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。 -- **在 TUI 测试驱动器上到处使用 node-pty**:[Windows TUI 决策](../../implemented/feature/2026-07-20-windows-tui-support.md)已明确否决在每个宿主上都用 node-pty;它已经是 Windows 那一条腿。 +- **在 TUI 测试驱动器上到处使用 node-pty**:已归档的 [Windows TUI 决策](../../archived/feature/2026-07-20-windows-tui-support.md)明确否决了在每个宿主上都使用 node-pty;它当时已经是 Windows 那一条腿。 **服务器与 HTTP:** diff --git a/AGENTS.md b/AGENTS.md index b7128ff89d..e03b128876 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,8 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// settings/ user-settings seam + file-backed provider credentials/ credential-reference seam + env-over-.env provider acp/ automation-only Agent Client Protocol server - ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins - examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load + ui/ JSON-RPC bridge; boot, approval, interaction plugins + examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) @@ -57,7 +57,7 @@ pnpm run clean # remove build outputs and safe residue from deleted pa pnpm run test # vitest unit tests pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY -pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t +pnpm run test:snapshot # keyless ACP/headless replay vs expected outputs; filter: -t pnpm run test:snapshot:record # re-record expected outputs (needs key) pnpm run typecheck pnpm run lint @@ -68,7 +68,6 @@ pnpm run hygiene # knip + publint + workspace constraints + NodeNext cons pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) -pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY) ``` @@ -92,7 +91,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. diff --git a/README.i18n.yaml b/README.i18n.yaml index ce57fc83eb..f5eb3be571 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -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 README.md -README.md: 8ecd0928ee630eeca1cb8ce8b9c59d19f6984969 -README.zh.md: 9ffb3b3086415550df4a0f776c7b91c94122dd97 +README.md: 7777c35a2785856e553cb963920288ff8021dc27 +README.zh.md: 85ee977063e4d822d71e1e780ab4a461260aaf53 diff --git a/README.md b/README.md index 8ecd0928ee..7777c35a27 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ cd deepseek-harness scripts/install.sh ``` -The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, then lets you launch the Web UI or TUI. Choosing Web UI builds the required repository artifacts first. +The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI. The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. @@ -41,14 +41,16 @@ dsh web The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. -### TUI +### Configured runtime -Start the full-screen terminal interface: +Raw `dsh` requires a patch-list configuration applied over the shipped base: ```sh -dsh +dsh --config ./app.cordis.yml ``` +The [CLI contract](apps/cli/README.md#raw-config) describes the base, overlay semantics, and config dump commands. + ### Headless Run one task, print the final answer, and exit: @@ -69,7 +71,7 @@ The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examp ## Why DeepSeek Harness -Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode. +Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode. - **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design. - **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log). diff --git a/README.zh.md b/README.zh.md index 9ffb3b3086..85ee977063 100644 --- a/README.zh.md +++ b/README.zh.md @@ -24,7 +24,7 @@ cd deepseek-harness scripts/install.sh ``` -安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,随后让你选择启动 Web UI 或 TUI。选择 Web UI 时,安装器会先构建所需的仓库产物。 +安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。 安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 @@ -41,14 +41,16 @@ dsh web 上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 -### TUI +### 自定义运行时 -启动全屏终端界面: +原始 `dsh` 要求传入一份 patch 列表配置,并将其叠加在随附 base 之上: ```sh -dsh +dsh --config ./app.cordis.yml ``` +base、overlay 语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#raw-config)。 + ### Headless 运行一项任务,打印最终答案后退出: @@ -69,7 +71,7 @@ pnpm run demo:acp ## 为什么选择 DeepSeek Harness -内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。 +内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。 - **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。 - **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 03fd24e5b7..8cd2964da6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,7 +36,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT | | [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | -| [`@earendil-works/pi-tui`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | @@ -74,7 +73,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`rehype-katex`](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) | MIT | | [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT | | [`remark-math`](https://github.com/remarkjs/remark-math/tree/main/packages/remark-math) | MIT | -| [`saxes`](https://github.com/lddubeau/saxes) | ISC | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | | [`tsx`](https://github.com/privatenumber/tsx) | MIT | @@ -87,7 +85,6 @@ External packages that a workspace package resolves at runtime. `scripts/install pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: -- `@earendil-works/pi-tui@0.80.7` — [`patches/@earendil-works__pi-tui@0.80.7.patch`](patches/@earendil-works__pi-tui@0.80.7.patch) - `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch) ## Development-only npm dependencies @@ -115,7 +112,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | -| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 96a4588f2c..3614a1aa8a 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 apps/cli/README.md -README.md: 76d9ed65398322cb9244a31661ee59b60c23f793 -README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b +README.md: 360ab26ec3dfecf2841a012fda8947d6a84fdfec +README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4 diff --git a/apps/cli/README.md b/apps/cli/README.md index 76d9ed6539..360ab26ec3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,82 +2,73 @@ English | [中文](README.zh.md) +The `dsh` command has three entry modes: a required raw config overlay, a one-shot headless prompt, and the Web UI. [`src/args.ts`](src/args.ts) owns the Commander grammar, and [`src/bin.ts`](src/bin.ts) dynamically imports only the selected runner. Unknown commands and leaked options fail with a nonzero exit code. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is a guided-session entry, and whose `web` subcommand is the browser UI. `meta` and `upgrade` are experimental: each runs only with its `--experimental` flag or with `DSH_EXPERIMENTAL=1` in the environment, and fails loud (stderr, exit 1) otherwise. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +## Raw config -The TUI surface: - -- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; -- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); -- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; -- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only. -- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. -- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. - -`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. - -`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options beyond the experimental gate — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. - -`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. - -The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). - -The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one. - -All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command: - -```yaml -- id: repository-plugins - name: '@deepseek-ai/dsh-repository-plugin' - config: - repositories: - - 'github:PolyArch/humanize#' -``` - -The repository must contain a prepared `.dsh-plugin` package; the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration) documents authoring, nested Plugin paths, the immutable cache, trust boundary, and failure semantics. A failed live edit keeps the last good tree and emits Cordis's `hmr/config-update-failed` event. - -The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. - -Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). - -`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it and pins `native`. - -[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry. - -From a source checkout, start this minimal Web profile with: +Raw `dsh` requires an explicit patch-list config: ```sh -pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +dsh --config ./app.cordis.yml ``` -Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). +The named file is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout: +A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve: ```yaml -- insert: - - id: mcp-github - name: '@deepseek-ai/dsh-mcp-client' - config: - serverName: github - transport: stdio - command: npx - args: ['-y', '@modelcontextprotocol/server-github'] - env: - GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN +- id: agent-loop + config: + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash ``` -The model then sees `mcp__github__*`. See the [mcp-client README](../../packages/mcp/mcp-client/README.md) for the Streamable HTTP transport and the full field table. +Inspect the effective tree without booting it: -## Install (developer machine) +```sh +dsh --dump-default-config +dsh --config ./app.cordis.yml --dump-config +``` -Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step: +`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. + +## Web and headless + +`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config ` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. + +```sh +dsh web +dsh web --config ./web-profile.cordis.yml +dsh web --dump-default-config +dsh web --dump-config +``` + +The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. + +`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags. + +Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution. + +New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. + +`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. + +## Shared deployment behavior + +The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it. + +Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. + +The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. + +## Source launcher + +Link the source-running launcher onto PATH: ```sh ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` -Source launches run `apps/cli/src/bin.ts` through tsx's ESM-only hook (`node --import tsx/esm`), which transforms TypeScript and projects the root tsconfig `paths` map into module resolution. Node's native TypeScript modes are not used: Node 26 removed `--experimental-transform-types`, and strip-only mode rejects syntax the source graph relies on (vendored parameter properties, decorators, runtime enums/namespaces). The CJS hook stays off because the source graph is ESM-only and the CJS resolver adds ~0.4s of startup. `bin/dsh` pins `TSX_TSCONFIG_PATH` to the checkout's root tsconfig so resolution is cwd-independent, and the `dsh-source-launch-smoke` node-compat gate runs this exact launch vector on every supported Node line. tsx applies the `paths` map without checking dependency declarations, so declaration completeness rests on the static gates: the TUI configs resolve bare plugins through `examples/package.json`, the Web/headless `cordis.yml` through this package's `dependencies`, and `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies. - -`pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node. +It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 16a7a4ec52..3ffa5d7726 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,82 +2,73 @@ [English](README.md) | 中文 +`dsh` 命令有三种入口模式:必需的原始配置 overlay、一次性 headless 提示词,以及 Web UI。[`src/args.ts`](src/args.ts) 拥有 Commander 命令行语法,[`src/bin.ts`](src/bin.ts) 只会动态导入选中模式的运行器。未知命令和误传入其他模式的选项都会以非零代码退出。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是引导会话入口,`web` 子命令则是浏览器 UI。`meta` 与 `upgrade` 是实验性命令:只有带上各自的 `--experimental` 标志或在环境中设置 `DSH_EXPERIMENTAL=1` 才会运行,否则明确报错(stderr,退出码 1)。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +## 原始配置 -TUI 界面: - -- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; -- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; -- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); -- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; -- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。 -- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 -- 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 - -`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 - -`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令除实验性门槛外不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 - -`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 - -Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 - -共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset(`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器;TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。 - -三个界面都会使用 `$DSH_HOME/config.yaml`;TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件: - -```yaml -- id: repository-plugins - name: '@deepseek-ai/dsh-repository-plugin' - config: - repositories: - - 'github:PolyArch/humanize#' -``` - -仓库必须包含已准备的 `.dsh-plugin` 包;[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)说明创作方式、嵌套插件路径、不可变缓存、信任边界和失败语义。实时编辑失败时,最后一个可用树保持运行,并发出 Cordis 的 HMR(热模块替换)事件 `hmr/config-update-failed`。 - -已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 - -每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 - -`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:`native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 线路)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam——Loader 组合是静态的,因此该设置作用于整个进程——待 Web UI 负责逐会话工具模式选择后便会移除;TUI 界面会忽略该变量并固定为 `native`。 - -[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个可选启用的 `dsh web --config` 覆盖层:它保留已交付的 Web 宿主、浏览器、Workspace、持久化与权限组合,同时将默认的原生模型界面精简为以所有者为作用域的持久 `bash` 以及 `str_replace_editor`。PTY 后端和编辑器分别消费现有的 Web 沙箱与文件系统提供方。持久 shell 处于打开状态时,会阻止所属会话更改权限模式;因此,在较宽权限下创建的 shell 无法在降权后继续存活。`DSH_TOOLS_MODE` 仍控制由此得到的双工具注册表采用原生/Code Mode 呈现。 - -在源码 checkout 中,用以下命令启动这个精简 Web profile: +原始 `dsh` 要求显式传入一份 patch 列表配置: ```sh -pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +dsh --config ./app.cordis.yml ``` -每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 +指定文件会通过 Include 插件的 patch 算法,直接应用在 [`config/base.cordis.yml`](config/base.cordis.yml) 之上。它不是完整替换树,系统也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。base 有意不包含启动 agent(智能体)或交互入口;必需的 overlay 负责选择这些部署细节。相对配置路径以调用目录为基准解析。配置解析、schema 校验、模块解析或插件启动失败都会被报告,并以非零代码退出。SIGINT 和 SIGTERM 会在退出前 dispose(资源释放)已挂载的根上下文。 -MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml` 或 `--config` 覆盖层挂载你自己的服务器: +patch 通过 `id` 定位 base 配置项,并替换该配置项的完整 `config` 值,而不是深度合并各个键。它也可以插入新配置项: ```yaml -- insert: - - id: mcp-github - name: '@deepseek-ai/dsh-mcp-client' - config: - serverName: github - transport: stdio - command: npx - args: ['-y', '@modelcontextprotocol/server-github'] - env: - GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN +- id: agent-loop + config: + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash ``` -模型随后会看到 `mcp__github__*`。Streamable HTTP 传输与完整字段表见 [mcp-client README](../../packages/mcp/mcp-client/README.md)。 +可以在不启动应用的情况下检查有效配置树: -## 安装(开发机) +```sh +dsh --dump-default-config +dsh --config ./app.cordis.yml --dump-config +``` -将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建: +`--dump-default-config` 只打印随附 base。`--dump-config` 要求提供 `--config`,并打印带来源注释的 base 与 overlay。组合过程使用 `@cordisjs/plugin-include` 的 `applyEntryPatches` 和 `entryListSchema`;`!!js` 表达式保持未求值状态,未匹配的 patch 目标会报告到 stderr。 + +## Web 与 headless + +`dsh web` 会启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续应用该文件。`dsh web --config ` 会以显式 patch 列表替换个人层。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会转为 Web 宿主 patch;各自所属插件的 schema 会在启动时校验它们。`--dev` 会挂载客户端插件 HMR(热模块替换)接收器,要实现无需刷新的客户端 bundle 更新,还需单独运行 `pnpm run dev:web` watcher。 + +```sh +dsh web +dsh web --config ./web-profile.cordis.yml +dsh web --dump-default-config +dsh web --dump-config +``` + +生产 Web 运行器需要已构建的包(package)与前端产物(`pnpm run build`)。它默认通过 `http://127.0.0.1:3080` 提供服务。绑定所有网络接口时,系统也会信任本机探测到的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任边界所接受的具名权威。 + +`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。 + +两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑;headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。 + +新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。 + +`DSH_TOOLS_MODE` 为 Web/headless 进程选择 `native`、`code` 或 `both`;其他值会在启动时失败。[`config/core-web.cordis.yml`](config/core-web.cordis.yml) 是可选的 Web overlay,它在保留随附宿主、浏览器、workspace、持久化与权限组合的同时,将面向原生模型的工具缩减为持久 `bash` 和 `str_replace_editor`。 + +## 共享部署行为 + +base 会挂载原生 DeepSeek 适配器、设置与凭据提供方、稳定的 `web_search`、仓库插件支持与会话遥测。提供方凭据位于 `$DSH_HOME/.env` 或环境中,且仍可轮换,因为启动器绝不会把凭据文件提升进 `process.env`。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;除非 overlay 插入提供方并启用 `web_fetch`,否则后者处于禁用状态。 + +会话事件默认以 OTLP/HTTP 日志的形式流式发送。`DSH_TELEMETRY_OTLP_URL` 用于选择其他 collector。`DSH_TELEMETRY_DISABLED` 的任何非空值都会在启动前禁用遥测配置项。随附 base 没有遥测脱敏规则,因此导出记录可能包含消息文本、工具参数与结果,以及 workspace 路径;该部署决策由[遥测 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) 统一定义。 + +空的 `repository-plugins` 配置项允许 Web/headless 个人配置与原始 overlay 挂载已准备的不可变仓库插件 generation。详见[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI(命令行界面)还将 `@deepseek-ai/dsh-mcp-client` 作为 overlay 依赖发布,但默认不启用任何 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。 + +## 源码启动器 + +将以源码运行的启动器链接到 PATH: ```sh ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` -源码启动会通过 tsx 的 ESM-only hook(`node --import tsx/esm`)运行 `apps/cli/src/bin.ts`,由它转换 TypeScript 并将根 tsconfig 的 `paths` 映射投射到模块解析中。不使用 Node 原生 TypeScript 模式:Node 26 移除了 `--experimental-transform-types`,而 strip-only 模式无法接受源码图依赖的语法(vendor 中的参数属性、装饰器、运行时 enum/namespace)。CJS hook 保持关闭,因为源码图是纯 ESM,而 CJS 解析器会增加约 0.4s 启动耗时。`bin/dsh` 将 `TSX_TSCONFIG_PATH` 固定到 checkout 的根 tsconfig,使解析与 cwd 无关;node-compat 门禁 `dsh-source-launch-smoke` 会在每条受支持的 Node 版本线上运行这一精确启动向量。tsx 应用 `paths` 映射时不检查依赖声明,声明完整性由静态门禁保障:TUI 配置通过 `examples/package.json` 解析裸插件,Web/无头 `cordis.yml` 通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。 - -`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。 +它会通过自身实际路径解析该检出,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`。`TSX_TSCONFIG_PATH` 固定指向检出根目录,因此 workspace 包解析不受调用目录影响。`pnpm run dsh` 使用同一入口并转发参数。构建后的形式是执行 `pnpm run build` 后的 `apps/cli/lib/bin.js`。 diff --git a/apps/cli/assets/deepseek-color.svg b/apps/cli/assets/deepseek-color.svg deleted file mode 100644 index 52eec25cd3..0000000000 --- a/apps/cli/assets/deepseek-color.svg +++ /dev/null @@ -1 +0,0 @@ -DeepSeek diff --git a/apps/cli/composition.md b/apps/cli/composition.md index e71b5e3914..0bede25716 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -1,149 +1,149 @@ -# TUI Agent App Composition +# DSH Base Composition -The TUI surface combines the shared CLI base with its surface overlay and full-screen terminal package. +The raw CLI applies one required caller-selected patch list over this shared base; Web and headless apply their own shipped overlays. ```mermaid flowchart LR - cfg["apps/cli/config
cordis.yml"] - plugin_tui_timer["timer
@cordisjs/plugin-timer"] - cfg --> plugin_tui_timer - plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_tui_hmr - plugin_tui_repository_plugins["repository-plugins
@deepseek-ai/dsh-repository-plugin"] - cfg --> plugin_tui_repository_plugins - plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] - cfg --> plugin_tui_llm - plugin_tui_session["session
@deepseek-ai/dsh-session"] - cfg --> plugin_tui_session - plugin_tui_session_title["session-title
@deepseek-ai/dsh-session-title"] - cfg --> plugin_tui_session_title - plugin_tui_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] - cfg --> plugin_tui_session_title_llm - plugin_tui_user_interaction["user-interaction
@deepseek-ai/dsh-user-interaction"] - cfg --> plugin_tui_user_interaction - plugin_tui_agent["agent
@deepseek-ai/dsh-agent"] - cfg --> plugin_tui_agent - plugin_tui_tasks["tasks
@deepseek-ai/dsh-tasks-local"] - cfg --> plugin_tui_tasks - plugin_tui_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] - cfg --> plugin_tui_llm_retry - plugin_tui_settings["settings
@deepseek-ai/dsh-settings-local"] - cfg --> plugin_tui_settings - plugin_tui_credentials["credentials
@deepseek-ai/dsh-credentials-local"] - cfg --> plugin_tui_credentials - plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] - cfg --> plugin_tui_llm_pi_ai - plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] - cfg --> plugin_tui_session_persistence_jsonl - plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] - cfg --> plugin_tui_session_query_sqlite - plugin_tui_telemetry_otel["telemetry-otel
@deepseek-ai/dsh-session-telemetry-otel"] - cfg --> plugin_tui_telemetry_otel - plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] - cfg --> plugin_tui_subprocess - plugin_tui_sandbox["sandbox
@deepseek-ai/dsh-sandbox-local"] - cfg --> plugin_tui_sandbox - plugin_tui_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] - cfg --> plugin_tui_sandbox_policy - plugin_tui_bash_sandbox["bash-sandbox
@deepseek-ai/dsh-bash-sandbox"] - cfg --> plugin_tui_bash_sandbox - plugin_tui_approval["approval
@deepseek-ai/dsh-user-approval"] - cfg --> plugin_tui_approval - plugin_tui_permission["permission
@deepseek-ai/dsh-permission"] - cfg --> plugin_tui_permission - plugin_tui_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] - cfg --> plugin_tui_tool_bash - plugin_tui_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] - cfg --> plugin_tui_tool_tasks - plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_tui_fs_policy - plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_tui_tool_fs - plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_tui_tool_fs_search - plugin_tui_workspace_context["workspace-context
@deepseek-ai/dsh-workspace-context"] - cfg --> plugin_tui_workspace_context - plugin_tui_skill["skill
@deepseek-ai/dsh-skill"] - cfg --> plugin_tui_skill - plugin_tui_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] - cfg --> plugin_tui_skill_local - plugin_tui_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] - cfg --> plugin_tui_tool_skill - plugin_tui_commands["commands
@deepseek-ai/dsh-commands"] - cfg --> plugin_tui_commands - plugin_tui_goal["goal
@deepseek-ai/dsh-goal"] - cfg --> plugin_tui_goal - plugin_tui_goal_session["goal-session
@deepseek-ai/dsh-goal-session"] - cfg --> plugin_tui_goal_session - plugin_tui_command_goal["command-goal
@deepseek-ai/dsh-command-goal"] - cfg --> plugin_tui_command_goal - plugin_tui_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] - cfg --> plugin_tui_plan_mode - plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_tui_token_meter - plugin_tui_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] - cfg --> plugin_tui_compact_basic - plugin_tui_command_compact["command-compact
@deepseek-ai/dsh-command-compact"] - cfg --> plugin_tui_command_compact - plugin_tui_subagent["subagent
@deepseek-ai/dsh-subagent"] - cfg --> plugin_tui_subagent - plugin_tui_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] - cfg --> plugin_tui_subagent_spawn - plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] - cfg --> plugin_tui_subagent_fork - plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] - cfg --> plugin_tui_tool_subagent_control - plugin_tui_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] - cfg --> plugin_tui_tool_subagent_list_agents - plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_tui_tool_subagent - plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_tui_tool_subagent_fork - plugin_tui_tool_subagent_report["tool-subagent-report
@deepseek-ai/dsh-tool-subagent-report"] - cfg --> plugin_tui_tool_subagent_report - plugin_tui_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] - cfg --> plugin_tui_workflow_workerthread - plugin_tui_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] - cfg --> plugin_tui_tool_workflow - plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] - cfg --> plugin_tui_timeout_policy - plugin_tui_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] - cfg --> plugin_tui_spill_local - plugin_tui_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] - cfg --> plugin_tui_spill_policy - plugin_tui_session_checkpoint_policy["session-checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] - cfg --> plugin_tui_session_checkpoint_policy - plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] - cfg --> plugin_tui_tool_result_prune - plugin_tui_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] - cfg --> plugin_tui_tool_todo - plugin_tui_tool_goal["tool-goal
@deepseek-ai/dsh-tool-goal"] - cfg --> plugin_tui_tool_goal - plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] - cfg --> plugin_tui_tool_ralph - plugin_tui_tool_str_replace_editor["tool-str-replace-editor
@deepseek-ai/dsh-tool-str-replace-editor"] - cfg --> plugin_tui_tool_str_replace_editor - plugin_tui_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] - cfg --> plugin_tui_repeat_tool_guard - plugin_tui_web["web
@deepseek-ai/dsh-web"] - cfg --> plugin_tui_web - plugin_tui_web_search_deepseek["web-search-deepseek
@deepseek-ai/dsh-web-search-deepseek"] - cfg --> plugin_tui_web_search_deepseek - plugin_tui_tool_web["tool-web
@deepseek-ai/dsh-tool-web"] - cfg --> plugin_tui_tool_web - plugin_tui_tools["tools
@deepseek-ai/dsh-tools"] - cfg --> plugin_tui_tools - plugin_tui_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] - cfg --> plugin_tui_system_prompt - plugin_tui_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] - cfg --> plugin_tui_agent_loop - plugin_tui_fs_sandbox["fs-sandbox
@deepseek-ai/dsh-fs-sandbox"] - cfg --> plugin_tui_fs_sandbox - plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_tui_llm_deepseek + cfg["apps/cli/config/base.cordis.yml
cordis.yml"] + plugin_dsh_base_timer["timer
@cordisjs/plugin-timer"] + cfg --> plugin_dsh_base_timer + plugin_dsh_base_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_dsh_base_hmr + plugin_dsh_base_repository_plugins["repository-plugins
@deepseek-ai/dsh-repository-plugin"] + cfg --> plugin_dsh_base_repository_plugins + plugin_dsh_base_llm["llm
@deepseek-ai/dsh-llm"] + cfg --> plugin_dsh_base_llm + plugin_dsh_base_session["session
@deepseek-ai/dsh-session"] + cfg --> plugin_dsh_base_session + plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"] + cfg --> plugin_dsh_base_session_title + plugin_dsh_base_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] + cfg --> plugin_dsh_base_session_title_llm + plugin_dsh_base_user_interaction["user-interaction
@deepseek-ai/dsh-user-interaction"] + cfg --> plugin_dsh_base_user_interaction + plugin_dsh_base_agent["agent
@deepseek-ai/dsh-agent"] + cfg --> plugin_dsh_base_agent + plugin_dsh_base_tasks["tasks
@deepseek-ai/dsh-tasks-local"] + cfg --> plugin_dsh_base_tasks + plugin_dsh_base_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] + cfg --> plugin_dsh_base_llm_retry + plugin_dsh_base_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_dsh_base_settings + plugin_dsh_base_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_dsh_base_credentials + plugin_dsh_base_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_dsh_base_llm_pi_ai + plugin_dsh_base_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] + cfg --> plugin_dsh_base_session_persistence_jsonl + plugin_dsh_base_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] + cfg --> plugin_dsh_base_session_query_sqlite + plugin_dsh_base_telemetry_otel["telemetry-otel
@deepseek-ai/dsh-session-telemetry-otel"] + cfg --> plugin_dsh_base_telemetry_otel + plugin_dsh_base_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_dsh_base_subprocess + plugin_dsh_base_sandbox["sandbox
@deepseek-ai/dsh-sandbox-local"] + cfg --> plugin_dsh_base_sandbox + plugin_dsh_base_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] + cfg --> plugin_dsh_base_sandbox_policy + plugin_dsh_base_bash_sandbox["bash-sandbox
@deepseek-ai/dsh-bash-sandbox"] + cfg --> plugin_dsh_base_bash_sandbox + plugin_dsh_base_approval["approval
@deepseek-ai/dsh-user-approval"] + cfg --> plugin_dsh_base_approval + plugin_dsh_base_permission["permission
@deepseek-ai/dsh-permission"] + cfg --> plugin_dsh_base_permission + plugin_dsh_base_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] + cfg --> plugin_dsh_base_tool_bash + plugin_dsh_base_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] + cfg --> plugin_dsh_base_tool_tasks + plugin_dsh_base_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_dsh_base_fs_policy + plugin_dsh_base_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_dsh_base_tool_fs + plugin_dsh_base_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_dsh_base_tool_fs_search + plugin_dsh_base_workspace_context["workspace-context
@deepseek-ai/dsh-workspace-context"] + cfg --> plugin_dsh_base_workspace_context + plugin_dsh_base_skill["skill
@deepseek-ai/dsh-skill"] + cfg --> plugin_dsh_base_skill + plugin_dsh_base_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] + cfg --> plugin_dsh_base_skill_local + plugin_dsh_base_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] + cfg --> plugin_dsh_base_tool_skill + plugin_dsh_base_commands["commands
@deepseek-ai/dsh-commands"] + cfg --> plugin_dsh_base_commands + plugin_dsh_base_goal["goal
@deepseek-ai/dsh-goal"] + cfg --> plugin_dsh_base_goal + plugin_dsh_base_goal_session["goal-session
@deepseek-ai/dsh-goal-session"] + cfg --> plugin_dsh_base_goal_session + plugin_dsh_base_command_goal["command-goal
@deepseek-ai/dsh-command-goal"] + cfg --> plugin_dsh_base_command_goal + plugin_dsh_base_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] + cfg --> plugin_dsh_base_plan_mode + plugin_dsh_base_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_dsh_base_token_meter + plugin_dsh_base_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_dsh_base_compact_basic + plugin_dsh_base_command_compact["command-compact
@deepseek-ai/dsh-command-compact"] + cfg --> plugin_dsh_base_command_compact + plugin_dsh_base_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_dsh_base_subagent + plugin_dsh_base_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_dsh_base_subagent_spawn + plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_dsh_base_subagent_fork + plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_dsh_base_tool_subagent_control + plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] + cfg --> plugin_dsh_base_tool_subagent_list_agents + plugin_dsh_base_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_dsh_base_tool_subagent + plugin_dsh_base_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_dsh_base_tool_subagent_fork + plugin_dsh_base_tool_subagent_report["tool-subagent-report
@deepseek-ai/dsh-tool-subagent-report"] + cfg --> plugin_dsh_base_tool_subagent_report + plugin_dsh_base_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_dsh_base_workflow_workerthread + plugin_dsh_base_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] + cfg --> plugin_dsh_base_tool_workflow + plugin_dsh_base_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_dsh_base_timeout_policy + plugin_dsh_base_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_dsh_base_spill_local + plugin_dsh_base_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_dsh_base_spill_policy + plugin_dsh_base_session_checkpoint_policy["session-checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] + cfg --> plugin_dsh_base_session_checkpoint_policy + plugin_dsh_base_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] + cfg --> plugin_dsh_base_tool_result_prune + plugin_dsh_base_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_dsh_base_tool_todo + plugin_dsh_base_tool_goal["tool-goal
@deepseek-ai/dsh-tool-goal"] + cfg --> plugin_dsh_base_tool_goal + plugin_dsh_base_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_dsh_base_tool_ralph + plugin_dsh_base_tool_str_replace_editor["tool-str-replace-editor
@deepseek-ai/dsh-tool-str-replace-editor"] + cfg --> plugin_dsh_base_tool_str_replace_editor + plugin_dsh_base_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] + cfg --> plugin_dsh_base_repeat_tool_guard + plugin_dsh_base_web["web
@deepseek-ai/dsh-web"] + cfg --> plugin_dsh_base_web + plugin_dsh_base_web_search_deepseek["web-search-deepseek
@deepseek-ai/dsh-web-search-deepseek"] + cfg --> plugin_dsh_base_web_search_deepseek + plugin_dsh_base_tool_web["tool-web
@deepseek-ai/dsh-tool-web"] + cfg --> plugin_dsh_base_tool_web + plugin_dsh_base_tools["tools
@deepseek-ai/dsh-tools"] + cfg --> plugin_dsh_base_tools + plugin_dsh_base_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] + cfg --> plugin_dsh_base_system_prompt + plugin_dsh_base_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] + cfg --> plugin_dsh_base_agent_loop + plugin_dsh_base_fs_sandbox["fs-sandbox
@deepseek-ai/dsh-fs-sandbox"] + cfg --> plugin_dsh_base_fs_sandbox + plugin_dsh_base_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_dsh_base_llm_deepseek ``` | Plugin id | Package / module | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index b7860e2eaa..623b4d1153 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -1,15 +1,13 @@ -# The shared `dsh` core: every row both the TUI (`tui.cordis.yml`) and the web -# surface (`web.cordis.yml`) mount identically. Neither surface includes the -# other — each is a patch list applied over THIS file at one include level, so a -# surface overlay, a `--config` overlay, and the personal `~/.dsh/config.yaml` -# all address these rows by id. Patch lists stack in that order, last write -# winning per row. +# The shared `dsh` core. Raw `dsh --config ` applies its required patch +# list directly over this file. Web and headless apply their shipped overlay, +# followed by an explicit or personal user layer. Every layer addresses these +# rows by id at one include level, with the last write winning per row. # # A patch replaces the targeted row's whole `config` rather than merging into -# it, so a row whose value differs per surface does NOT live here: it belongs to +# it, so a row whose value differs by mode does NOT live here: it belongs to # each overlay, keeping any single row down to one overlay layer plus the user's. -# Rows with surface-specific values appear below only with shared plugin identity -# and neutral defaults; each overlay restates the complete surface configuration. +# Mode-specific rows appear below only with shared plugin identity and neutral +# defaults; each overlay restates its complete configuration. # # Row order carries no load semantics (activation is service-availability # driven); the grouping is for readers. @@ -93,15 +91,15 @@ config: root: !!js dshHomePath('sessions') -# TUI consumes this shared session capability. Its launcher supplies a unique -# process-local path; other surfaces repoint or disable the row in their -# overlay (web patches it to an ephemeral in-memory index). +# Raw configs can supply a process-local path or disable this shared session +# capability. The neutral default is process-local and opens only when used. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: - path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db' + path: ':memory:' + openAt: first-search -# Session telemetry, on for every dsh surface: mirrors every session-log +# Session telemetry, on for every dsh mode: mirrors every session-log # event (assistant/chunk projected to first-of-step) plus ops markers onto # OTLP/HTTP log records, streaming on the batch processor's cadence # (10s/batch here) — not at exit; a crash loses at most the last unexported @@ -119,9 +117,8 @@ # disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize # (both explicit) makes the drain a single batch, and exportTimeoutMillis # is the processor's own cap on that one export cycle — the second bound -# when the exporter's clock alone does not fire. Every surface's exit path -# drains it: web/headless dispose on SIGINT/SIGTERM, and the TUI's normal -# exit and /resume handoff both dispose the root. +# when the exporter's clock alone does not fire. Every CLI exit path drains it +# by disposing the root on SIGINT/SIGTERM. - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: @@ -138,7 +135,7 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -# Every shipped product surface starts with the same file-effect boundary. +# Every shipped CLI mode starts with the same file-effect boundary. # The environment remains an explicit deployment override; otherwise fresh # sessions pin workspace-write + ask through the permission service below. - id: sandbox @@ -342,7 +339,7 @@ thresholds: [3, 5, 8] argumentsPreviewChars: 500 -# Every surface enables the stable web_search model surface. DeepSeek search +# Every mode enables the stable web_search model surface. DeepSeek search # resolves the same DEEPSEEK_API_KEY credential the Models page manages for # chat, at each search; its Messages endpoint is separate from the # chat-completions endpoint, so it takes its own base-URL override. Fetch stays @@ -367,35 +364,35 @@ fetch: false searchTimeoutMs: 60000 -# ── rows every surface mounts, whose values each overlay states ────────────── +# ── rows every mode mounts, whose values each overlay may state ────────────── -# The tool registry. Presentation mode is a surface choice, so each overlay -# states it; omitting it here keeps the schema default (native). +# The tool registry. Presentation mode is a deployment choice; omitting it here +# keeps the schema default (native). - id: tools name: '@deepseek-ai/dsh-tools' -# The deployment persona is a surface choice; plan-mode and tool plugins own +# The deployment persona is a deployment choice; plan-mode and tool plugins own # their own prompt sections. - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: persona: '' -# Agents created at startup. The TUI pre-creates `main`; the web surface creates -# sessions on client request, so its overlay keeps this empty. +# Agents created at startup. The base stays empty; raw overlays may create +# agents, while Web creates sessions on client request. - id: agent-loop name: '@deepseek-ai/dsh-agent-loop' config: agents: [] -# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; the TUI -# states it explicitly because that value is also the session workspace. +# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; an +# overlay can pin another workspace. - id: fs-sandbox name: '@deepseek-ai/dsh-fs-sandbox' # The native DeepSeek adapter. No key or endpoint is inlined: both resolve per # request from the `llm-deepseek:` settings section over this entry, with the -# key coming from the credential store below. Thinking defaults are a surface +# key coming from the credential store below. Thinking defaults are a deployment # choice. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml deleted file mode 100644 index ba1ae7f697..0000000000 --- a/apps/cli/config/tui.cordis.yml +++ /dev/null @@ -1,110 +0,0 @@ -# `dsh` (the default surface) — the full-screen TUI, as a patch list over -# `base.cordis.yml`. The launcher includes the base and applies this file, then -# any `--config` overlay, then the personal `~/.dsh/config.yaml`, as sibling -# patch lists at ONE include level: patches never cross an include boundary, so -# stacking overlays as nested includes would silently stop reaching base rows. -# -# A patch replaces the targeted row's whole `config`, so each row below restates -# every key it owns. A patch whose `id` matches no row is skipped with a Loader -# warning, which is deliberate: one personal overlay is shared across surfaces, -# so a row that exists only under `web` must not fail the TUI's boot. -# -# The launcher owns session identity and the exit line, and provides both on the -# boot context rather than through config, so no key here — and no overlay -# replacing one — can drop `--resume`. - -# ── surface-specific values the base deliberately omits ───────────────────── - -# `main` is the agent the TUI drives. `provider`/`model` are the route `dsh -# login` rewrites and a personal overlay repoints; `cwd` anchors the session to -# the invoking directory, which is also what scopes `/resume` to this workspace. -- id: agent-loop - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-pro - cwd: !!js process.cwd() - -# Keep the persona to identity and behavior; tool plugins own tool guidance. -# The loop resolves {{model}} from this agent's configuration. -- id: system-prompt - config: - persona: | - You are a coding agent powered by the {{model}} model. - - Verify your work by running the code or tests. Keep answers brief and - factual. - -# Shipped default: full thinking at max effort on every request. Exact-model -# resolution materializes request defaults before the request header is logged. -- id: llm-deepseek - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - thinking: enabled - reasoningEffort: max - -# This single-session app resolves relative paths from the process cwd. -- id: fs-sandbox - config: - cwd: !!js process.cwd() - -# The shipped TUI presents the native tool registry. -- id: tools - config: - mode: native - -# ── TUI-only rows ─────────────────────────────────────────────────────────── - -- insert: - # The derived query index behind `/resume`. The launcher provides a unique - # process-local path because this SQLite backend has one writer owner; the - # project-local fallback applies when no launcher sets the typed slot. - - id: session-reference - name: '@deepseek-ai/dsh-session-reference' - - # The projection registry plus its durable checkpoint cache (over the same - # storage root the web surface uses): `/resume` reads titles from the - # zero-I/O checkpoint row or a tail-only cold read instead of scanning - # whole logs, and checkpoints written by either surface serve both. - - id: session-projection - name: '@deepseek-ai/dsh-session-projection' - - id: storage - name: '@deepseek-ai/dsh-storage' - - id: storage-json - name: '@deepseek-ai/dsh-storage-json' - config: - root: !!js dshHomePath('storages') - - id: storage-domain - name: '@deepseek-ai/dsh-storage-domain' - config: - backend: json - - id: session-projection-cache - name: '@deepseek-ai/dsh-session-projection-cache' - config: - writeEveryEvents: 200 - writeIntervalMs: 5000 - - # Terminal-multiplexer context, mounted only where a terminal exists. - - id: tmux-context - name: '@deepseek-ai/dsh-tmux-context' - config: - refreshIntervalMs: 900000 - - # The keyboard-backed provider behind ask_user_question and the plan-mode - # review, and the front door it renders inside. - - id: tui-prompt - name: '@deepseek-ai/dsh-tui/prompt' - - # The TUI renders exactly the agent the agent-loop row bound, so it reads the - # same launcher-owned identity rather than restating one. - - id: tui - name: '@deepseek-ai/dsh-tui' - config: - sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' - showReasoning: true - maxToolOutputLines: 6 - - - id: tool-ask-user - name: '@deepseek-ai/dsh-tool-ask-user' diff --git a/apps/cli/package.json b/apps/cli/package.json index 9bba751b27..0ccf4b2197 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh", - "description": "dsh CLI: interactive TUI, headless task, and browser UI surfaces", + "description": "dsh CLI: explicit config overlays, headless tasks, and the browser UI", "version": "0.0.1", "private": true, "type": "module", @@ -9,7 +9,6 @@ }, "files": [ "lib/bin.js", - "assets", "config", "src" ], @@ -87,7 +86,6 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", @@ -106,9 +104,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", - "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", @@ -126,7 +122,6 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", @@ -134,15 +129,12 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "@earendil-works/pi-tui": "0.80.7", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@types/js-yaml": "^4.0.9", - "execa": "^10.0.0", - "node-pty": "1.1.0" + "execa": "^10.0.0" } } diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index eaa1902eff..651dbca3f7 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,8 +1,8 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share - * (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly). + * (`dsh web` and `dsh -p`). * Everything here is what must exist before the Loader runs: the patch - * composition over the shipped base and surface overlay (profile json + CLI + * composition over the shipped base and Web overlay (profile json + CLI * flags + the resolved frontend dist), and the fail-loud activation audit after the tree * settles. The environment is what the bin already loaded (ambient plus the * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential @@ -89,7 +89,7 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b /** * Whether a config file carries the telemetry row, parsed under the same * `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers - * that compose their patch lists outside {@link AppCLIEntry} (the TUI). + * that compose their patch lists outside {@link AppCLIEntry} (raw `dsh`). * @param file - absolute path of the config or overlay file. * @returns true when a top-level (or inserted) row has the telemetry id. */ diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 19bc58ccd4..18b31fc3a9 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,44 +1,26 @@ /** - * Commander adapter for the `dsh` command-line entry: the one place argv is - * parsed and routed to a mode. `bin.ts` switches on the returned discriminant - * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; - * `meta`, `upgrade`, and `web` are real subcommands; the experimental ones - * (`meta`, `upgrade`) run only under the `--experimental` flag or - * `DSH_EXPERIMENTAL=1`. Commander owns - * `--help`/`--version` and parse - * errors — it prints and exits at the point of failure (a domain failure routes through - * `command.error`), so this returns only a resolved mode. + * Commander adapter for the `dsh` command-line entry. The default command + * boots one required `--config` overlay over the shipped base; `-p` selects + * the one-shot headless path and `web` selects the browser application. + * Commander owns help, version, and parse errors. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** - * Interactive TUI: the default mode. `--config` applies an overlay over the - * shipped composition in place of the personal one, `--config-replace` boots a - * file as the whole tree instead, and `--resume ` rehydrates a session. - */ -interface TuiInvocation { - mode: 'tui' - config?: string - configReplace?: string - resume?: string +/** Boot a caller-selected overlay over the shipped base config. */ +interface ConfigInvocation { + mode: 'config' + config: string } -/** - * Print the composed config tree and exit, without booting: `--dump-config` - * composes the shipped base, the surface overlay, and the `--config` or - * personal overlay — exactly the layers that surface would boot; - * `--dump-default-config` stops at the surface overlay (the shipped tree, no - * user layer). - */ +/** Print a composed config tree and exit without booting. */ interface DumpConfigInvocation { mode: 'dump-config' - surface: 'tui' | 'web' - /** Omit the `--config`/personal layer and print only the shipped composition. */ + surface: 'config' | 'web' + /** Omit every caller or personal layer and print the shipped tree. */ defaultOnly: boolean - /** The `--config` overlay to compose instead of the personal one. */ + /** Explicit overlay to compose over the base or Web surface. */ config?: string } @@ -48,52 +30,24 @@ interface HeadlessInvocation { prompt: string } -/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */ -interface MetaInvocation { - mode: 'meta' -} - /** - * Guided fresh-session entry: `dsh upgrade` seeds the first turn - * with the `dsh-upgrade` skill. It always mints a - * fresh session in the invoking directory and takes no options beyond the - * experimental gate — `--resume`, `--config`, and `-p` are rejected as - * mistyped, so there is nothing to carry. - */ -interface SkillSessionInvocation { - mode: 'upgrade' -} - -/** - * Browser UI: `dsh web`. `host`/`port` are present only when the flag was - * passed — pass-through overrides with no CLI default and no CLI validation: - * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal, - * `port` a natural ≤ 65535) is the single source of both the default (the - * shipped Web overlay value stands when a flag is absent) and validity (a bad - * value fails loud at boot). `port` is `Number`-coerced only because the schema - * wants a number, not a string. `dev` mounts the client HMR driver; - * `workspaceRoot` is the parent directory for name-created workspaces. + * Browser UI: `dsh web`. Host and port remain unvalidated pass-throughs to + * the webserver schema; absent values leave the shipped Web overlay intact. */ interface WebInvocation { mode: 'web' - /** Overlay of loader patches applied over the shipped web composition. */ + /** Overlay applied over the shipped Web composition instead of the personal one. */ config?: string host?: string port?: number dev: boolean workspaceRoot?: string - /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */ + /** Extra authorities for the /api browser-trust fence. */ trustedHosts?: string[] } -/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = - | TuiInvocation - | DumpConfigInvocation - | HeadlessInvocation - | MetaInvocation - | SkillSessionInvocation - | WebInvocation +/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ +export type DshInvocation = ConfigInvocation | DumpConfigInvocation | HeadlessInvocation | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -107,13 +61,9 @@ interface WebOptions { dumpDefaultConfig?: boolean } -/** - * Resolve the two dump flags for one surface, or return `undefined` when - * neither was passed. Both flags together are contradictory (one includes the - * user layer, the other excludes it) and fail loud through `error`. - */ +/** Resolve config-dump flags for one command shape. */ function resolveDump( - surface: 'tui' | 'web', + surface: 'config' | 'web', options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean }, error: (message: string) => never, ): DumpConfigInvocation | undefined { @@ -125,6 +75,9 @@ function resolveDump( if (defaultOnly && options.config !== undefined) { error('error: --dump-default-config prints the shipped tree and takes no --config') } + if (surface === 'config' && !defaultOnly && options.config === undefined) { + error('error: --dump-config requires --config ') + } return { mode: 'dump-config', surface, @@ -133,12 +86,7 @@ function resolveDump( } } -/** - * Narrow the raw `web` options into a {@link WebInvocation}. No host/port - * validation: both flow to the webserver schema, which is the sole gate. `port` - * is coerced to a number (the schema rejects a string) but not range-checked - * here — `NaN`/out-of-range fail loud at the schema on boot. - */ +/** Narrow raw `web` options into a {@link WebInvocation}. */ function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', @@ -152,137 +100,72 @@ function resolveWeb(options: WebOptions): WebInvocation { } /** - * Resolve the raw argv into a {@link DshInvocation}, or print and exit for - * `--help`/`--version`/a parse error. The default (no subcommand) is the - * TUI/headless surface; `web` is a subcommand. - * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). - * @param version - the version string `--version` prints; read from this app's package.json. - * @param experimentalEnv - whether the environment opts into experimental - * subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary. - * @returns the resolved invocation (only reached on a valid, non-help invocation). + * Resolve argv into one invocation, or print and exit for help, version, or an + * error. + * @param argv - arguments after the Node binary and script. + * @param version - version string printed by `--version`. + * @returns the resolved invocation. */ -export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation { +export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { let resolved: DshInvocation | undefined const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') - // The default surface takes no positional task, so `dsh "task"` fails - // commander's arity check with no hint; these examples are where a first - // reader learns the entry points and that a one-shot task rides `-p`. + .description('dsh: boot a DeepSeek Harness config overlay over the shipped base configuration.') .addHelpText('after', ` Examples: - dsh start an interactive session in this directory - dsh -p "run the tests" answer one task, print the result, and exit - dsh --resume continue a past session + dsh --config ./app.cordis.yml boot an overlay over the shipped base + dsh -p "run the tests" answer one task, print the result, and exit + dsh web serve the browser UI `) .exitOverride() - // Stop parent options at a subcommand boundary so `web --config` belongs to - // Web while `--config ... web` remains a leaked default-surface option. .enablePositionalOptions() - // Default surface: option-only (no positional), so `web` can be a real - // subcommand without a positional collision. - .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') - .option('--resume ', 'continue a past session by id') - .option('--config ', 'apply this overlay of loader patches instead of the personal one') - .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') - .option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit') - .option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit') + .option('-p, --prompt ', 'answer this task without an interactive UI, then exit') + .option('--config ', 'overlay of loader patches to apply over the shipped base') + .option('--dump-config', 'print the base plus --config overlay and exit') + .option('--dump-default-config', 'print the shipped base config and exit') .action((options: { config?: string - configReplace?: string prompt?: string - resume?: string dumpConfig?: boolean dumpDefaultConfig?: boolean }) => { - const dump = resolveDump('tui', options, message => program.error(message)) + if (options.config === '') program.error('error: --config needs a path') + const dump = resolveDump('config', options, message => program.error(message)) if (dump !== undefined) { - // The dump prints composition; a boot-only flag alongside it would be - // silently ignored, so reject the mix loud. - if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) { - program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace') + if (options.prompt !== undefined) { + program.error('error: --dump-config/--dump-default-config take no -p/--prompt') } resolved = dump return } if (options.prompt !== undefined) { - // A headless prompt owns the invocation; an empty task has nothing to - // run, and --config/--resume are TUI inputs that must not silently - // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config, --config-replace, or --resume') - } + if (options.config !== undefined) program.error('error: --prompt takes no --config') resolved = { mode: 'headless', prompt: options.prompt } return } - // An empty --resume= id would silently start a fresh session downstream - // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. - if (options.resume === '') program.error('error: --resume needs a session id') - // The two config flags are mutually exclusive: one layers over the shipped - // tree, the other discards it, so accepting both would silently drop one. - if (options.config !== undefined && options.configReplace !== undefined) { - program.error('error: --config and --config-replace are mutually exclusive') - } - resolved = { - mode: 'tui', - ...options.config !== undefined && { config: options.config }, - ...options.configReplace !== undefined && { configReplace: options.configReplace }, - ...options.resume !== undefined && { resume: options.resume }, - } + const config = options.config ?? program.error('error: --config is required') + resolved = { mode: 'config', config } }) - // Commander parses the parent (default-surface) options on either side of a - // subcommand into `program.opts()`. For a subcommand that shares none of them, - // a leaked config/prompt/resume option is a mistyped invocation that must fail - // loud rather than silently run and drop the input. + /** Reject parent options that crossed a subcommand boundary. */ const rejectParentOptions = (command: string): void => { const parent = program.opts<{ config?: string - configReplace?: string prompt?: string - resume?: string dumpConfig?: boolean dumpDefaultConfig?: boolean }>() - if (parent.config !== undefined || parent.configReplace !== undefined - || parent.prompt !== undefined || parent.resume !== undefined + if (parent.config !== undefined || parent.prompt !== undefined || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { - program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`) + program.error(`error: ${command} takes none of parent --config, -p/--prompt, --dump-config, or --dump-default-config`) } } - // `meta` and `upgrade` are experimental: each runs only under its own - // `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in, - // and fails loud otherwise so the gate is never silently skipped. - const requireExperimental = (command: string, flag: boolean | undefined): void => { - if (flag !== true && !experimentalEnv) { - program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`) - } - } - - // Registration order is the rendered help order, so daily use comes first - // and the harness-development surfaces (`web --dev`, `meta`) - // come last. `upgrade` is a guided fresh-session entry: beyond the - // experimental gate it takes no options and always mints a fresh session, - // so nothing is left to carry. - program - .command('upgrade') - .description('update this dsh installation to the latest version (experimental)') - .option('--experimental', 'acknowledge this subcommand is experimental') - .action((options: { experimental?: boolean }) => { - rejectParentOptions('upgrade') - requireExperimental('upgrade', options.experimental) - resolved = { mode: 'upgrade' } - }) - - // Host and port name no default: the CLI passes neither through when the flag - // is absent, so the shipped Web overlay value stands and restating it here - // would duplicate a fact this file does not own. const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--config ', 'apply this overlay of loader patches over the shipped Web configuration') .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') @@ -292,6 +175,7 @@ Examples: .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit') .action((options: WebOptions) => { rejectParentOptions('web') + if (options.config === '') program.error('error: --config needs a path') const dump = resolveDump('web', options, message => program.error(message)) if (dump !== undefined) { resolved = dump @@ -300,25 +184,12 @@ Examples: resolved = resolveWeb(options) }) - program - .command('meta') - .description('work on the dsh source that runs this command, from any directory (experimental)') - .option('--experimental', 'acknowledge this subcommand is experimental') - .action((options: { experimental?: boolean }) => { - rejectParentOptions('meta') - requireExperimental('meta', options.experimental) - resolved = { mode: 'meta' } - }) - try { program.parse(argv, { from: 'user' }) } catch (error) { - // Commander printed help/version/the error under `exitOverride`; exit with - // the code it chose (0 for help/version, 1 for a parse or domain error). - /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */ return process.exit(error instanceof CommanderError ? error.exitCode : 1) } - /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */ + /* v8 ignore next -- an action resolves or Commander throws */ if (resolved === undefined) throw new Error('dsh: no invocation resolved') return resolved } diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 3886438bed..fbdda23f2d 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh/bin */ -/* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */ +/* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -25,10 +25,14 @@ function readVersion(): string { } loadEnv('dsh') -// The env opt-in is read at the process boundary; `1` is the documented value. -const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1') +const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { + case 'config': { + const { runConfig } = await import('./config.ts') + await runConfig(invocation.config) + break + } case 'web': { const { runWeb } = await import('./web.ts') await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) @@ -39,26 +43,11 @@ switch (invocation.mode) { await runHeadless(invocation.prompt) break } - case 'tui': { - const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) - break - } case 'dump-config': { const { runDumpConfig } = await import('./dump-config.ts') runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config) break } - case 'meta': { - const { runTui, SOURCE_ROOT } = await import('./tui.ts') - await runTui(undefined, undefined, SOURCE_ROOT) - break - } - case 'upgrade': { - const { runTui } = await import('./tui.ts') - await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`) - break - } default: invocation satisfies never throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts new file mode 100644 index 0000000000..f704a35bf3 --- /dev/null +++ b/apps/cli/src/config.ts @@ -0,0 +1,54 @@ +/** + * Raw `dsh --config ` boot: apply one required patch-list overlay over + * the shipped base config, then leave process lifetime to the mounted plugins. + * @module @deepseek-ai/dsh/config + */ + +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import { + boot, + installFailLoud, + loadOverlayPatches, + resolveConfigPath, +} from '@deepseek-ai/dsh-app-boot' +import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' + +const NAME = 'dsh' +const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) + +/* v8 ignore start -- the source-launch and built-bin acceptance paths own executable dispatch */ +/** + * Boot the shipped base with one explicit overlay. + * @param config - required patch-list path parsed from `--config`. + */ +export async function runConfig(config: string): Promise { + const app: { current?: Context } = {} + let exiting = false + const shutdown = (code: number): void => { + if (exiting) return + exiting = true + void Promise.resolve(app.current?.fiber.dispose()).finally(() => { process.exit(code) }) + } + // An inserted front door can publish readiness before sibling rows finish + // mounting. Signals must own teardown throughout that startup window, not + // only after boot() settles. + process.on('SIGTERM', () => { shutdown(0) }) + process.on('SIGINT', () => { shutdown(130) }) + installFailLoud(NAME, process, async () => { + await app.current?.fiber.dispose() + }) + const overlay = resolveConfigPath(config, undefined) + const telemetryPatch = resolveTelemetryPatch( + process.env.DSH_TELEMETRY_DISABLED, + configHasTelemetryRow(BASE_CONFIG), + ) + const ctx = await boot(NAME, BASE_CONFIG, [ + ...loadOverlayPatches(NAME, overlay), + ...telemetryPatch === undefined ? [] : [telemetryPatch], + ], (hostCtx) => { + app.current = hostCtx + }) + app.current = ctx +} +/* v8 ignore stop */ diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 39a87c2dc8..cb88e8d655 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -1,12 +1,6 @@ /** - * `dsh --dump-config` / `dsh web --dump-config` — print the composed config - * tree without booting: the shipped base, the surface overlay, and (unless - * `--dump-default-config`) the `--config` or personal overlay, composed - * through the include's own patch algorithm so the printed tree is exactly - * what that surface would mount. `!!js` expressions print verbatim, - * unevaluated — the dump shows composition, not one process's environment. - * Launcher-provided boot-context values (session identity, CLI-flag patches) - * are per-invocation facts outside the config tree and do not appear. + * Config-dump entry for raw `dsh --config` and `dsh web`: compose through the + * include plugin's patch algorithm without booting or evaluating `!!js`. * @module @deepseek-ai/dsh/dump-config */ @@ -22,39 +16,36 @@ import { import { resolveDshHome } from '@deepseek-ai/dsh-paths' const NAME = 'dsh' - const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) -const SURFACE_OVERLAYS = { - tui: fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)), - web: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), -} as const +const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)) -/* v8 ignore start -- composition over the unit-tested renderConfigDump; the - built-bin e2e drives this path end to end */ +/* v8 ignore start -- built-bin acceptance drives this boot-free dispatch */ /** - * Print one surface's composed config tree to stdout, with a comment - * separator naming the file each section of rows comes from (and the layers - * that patched it). - * @param surface - which surface overlay to compose over the shared base. - * @param defaultOnly - stop at the surface overlay (no `--config`/personal layer). - * @param config - the `--config` overlay path composed instead of the personal - * one, or `undefined` to use `$DSH_HOME/config.yaml`. + * Print a raw or Web composition with provenance comments. + * @param surface - raw base-plus-config composition, or the Web composition. + * @param defaultOnly - omit the explicit or personal user layer. + * @param config - explicit overlay path; required for a non-default raw dump. */ -export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void { - const overlay = SURFACE_OVERLAYS[surface] - const layers: ConfigDumpLayer[] = [ - { label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) }, - ] - if (!defaultOnly) { - if (config === undefined) { - const personal = loadPersonalPatches(NAME) - // The personal file may be absent; the shipped layers still print. - if (personal !== undefined) { - layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal }) - } - } else { +export function runDumpConfig(surface: 'config' | 'web', defaultOnly: boolean, config?: string): void { + const layers: ConfigDumpLayer[] = [] + if (surface === 'config') { + if (!defaultOnly) { + /* v8 ignore next -- parseDshArgs requires this combination */ + if (config === undefined) throw new Error('dsh: raw config dump requires an overlay') layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) } + } else { + layers.push({ label: basename(WEB_OVERLAY), patches: loadOverlayPatches(NAME, WEB_OVERLAY) }) + if (!defaultOnly) { + if (config === undefined) { + const personal = loadPersonalPatches(NAME) + if (personal !== undefined) { + layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal }) + } + } else { + layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) + } + } } process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers)) } diff --git a/apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts deleted file mode 100644 index afaf56db3d..0000000000 --- a/apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Static terminal rasters derived from the official 24x24 DeepSeek icon. - * - * Source: `../../assets/deepseek-color.svg`, whose path data is copied exactly - * from the supplied official icon (viewBox `0 0 24 24`, fill `#4D6BFE`). Each - * tier rasterizes that path into a square binary - * mask without redrawing its contour. The Unicode form packs two source rows - * into `▀`/`▄`/`█`; the ASCII fallback packs the same two bits into - * `'`/`_`/`#`. Assets contain no ANSI and are never generated at runtime. - * @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-art - */ - -/** Responsive official-icon raster tier. */ -export type TuiFirstRunWelcomeArtTier = 'full' | 'compact' | 'minimal' - -/** One raster with a block-cell primary and bit-equivalent ASCII fallback. */ -export interface TuiFirstRunWelcomeArt { - /** Two vertical source pixels per terminal cell. */ - readonly unicode: readonly string[] - /** Same two-bit cells encoded as top `'`, bottom `_`, and both `#`. */ - readonly ascii: readonly string[] -} - -const fullUnicode = Object.freeze([ - ' ▄', - ' ▄▄▄▄▄▄▄▄▄▄███▀ ██▄', - ' ▄███████████████▄ ████▄ ▄▄▄▄██', - ' ▄███████████████████▄ ████████████▀', - ' ▄██████████████████████▄ ▀█████████▀', - '▄███▀█████████████████████▄ ████▀▀', - '███ ▀▀█████████▀▀▀█████████▀', - '███ ▀███████▀█ ▀███████', - '███▄ ▀███████▄ ▀█████▀', - '▀███ ▀██████████████', - ' ▀███▄ ▀███████████▀', - ' ▀███▄ ▄▄▄ ▀████████▀', - ' █████▄ ███▄▄ ▀█████▄▄', - ' ▀█████████████▄▄▄▄█▀█████▀', - ' ▀▀███████████▀▀', -]) - -const fullAscii = Object.freeze([ - ' _', - " __________###' ##_", - ' _###############_ ####_ ____##', - " _###################_ ############'", - " _######################_ '#########'", - "_###'#####################_ ####''", - "### ''#########'''#########'", - "### '#######'# '#######", - "###_ '#######_ '#####'", - "'### '##############", - " '###_ '###########'", - " '###_ ___ '########'", - " #####_ ###__ '#####__", - " '#############____#'#####'", - " ''###########''", -]) - -const compactUnicode = Object.freeze([ - ' ▄▄▄▄▄▄▄██▀ █▄ ▄', - ' ▄███████████▄▄ ███▄▄████', - ' ████████████████▄ ▀██████▀', - '██▀▀▀▀▀████████████▄▄██▀', - '██ ▀█████▄ ▀█████', - '██▄ ▀████▄ ▄████', - ' ██▄ ████████▀', - ' ██▄ ▄▄ ▀█████▀', - ' ▀███▄▄▄███▄ ████▄▄', - ' ▀▀▀███████▀▀', -]) - -const compactAscii = Object.freeze([ - " _______##' #_ _", - ' _###########__ ###__####', - " ################_ '######'", - "##'''''############__##'", - "## '#####_ '#####", - "##_ '####_ _####", - " ##_ ########'", - " ##_ __ '#####'", - " '###___###_ ####__", - " '''#######''", -]) - -const minimalUnicode = Object.freeze([ - ' ▄▄▄▄▄▄ ▄▄', - ' ▄████████▄ ▀████▀', - '█▀▀▀▀███████▄██▀', - '█▄ ▀███ ▀███', - '▀█▄ ▀█████', - ' ▀█▄▄ █▄▄▀███▄', - ' ▀▀▀▀▀▀', -]) - -const minimalAscii = Object.freeze([ - ' ______ __', - " _########_ '####'", - "#''''#######_##'", - "#_ '### '###", - "'#_ '#####", - " '#__ #__'###_", - " ''''''", -]) - -/** Exact-path terminal rasters by responsive tier. */ -export const TUI_FIRST_RUN_WELCOME_WHALE = Object.freeze({ - full: Object.freeze({ unicode: fullUnicode, ascii: fullAscii }), - compact: Object.freeze({ unicode: compactUnicode, ascii: compactAscii }), - minimal: Object.freeze({ unicode: minimalUnicode, ascii: minimalAscii }), -}) satisfies Readonly> diff --git a/apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts deleted file mode 100644 index 60cec25c52..0000000000 --- a/apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Centrally owned version and all-locale Chinese copy for the shipped TUI first-run notice. - * - * A material wording change increments {@link TUI_FIRST_RUN_WELCOME_NOTICE_VERSION} - * so every Harness home presents the revised notice once. - * @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-copy - */ - -/** Copy version persisted after the user explicitly continues. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 4 - -/** Locale-shaped text rendered by the first-run welcome overlay. */ -export interface TuiFirstRunWelcomeNoticeCopy { - /** Overlay heading. */ - readonly title: string - /** Ordered prose paragraphs. */ - readonly paragraphs: readonly string[] - /** Enter action label. */ - readonly continueLabel: string - /** Hint shown when the prose is scrollable. */ - readonly scrollHint: string - /** Status shown while the acknowledgement reaches disk. */ - readonly saving: string - /** Retry message shown when the acknowledgement cannot be persisted. */ - readonly saveError: string -} - -/** Complete Chinese notice used for every locale. */ -const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze({ - title: 'DeepSeek Harness', - paragraphs: Object.freeze([ - '感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', - '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', - ]), - continueLabel: '继续', - scrollHint: '↑/↓ 滚动', - saving: '正在保存确认…', - saveError: '无法保存确认,请按 Enter 重试。', -}) - -/** Locale map whose entries deliberately share the single Chinese owner copy. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ - 'zh-CN': TUI_FIRST_RUN_WELCOME_CHINESE_COPY, - en: TUI_FIRST_RUN_WELCOME_CHINESE_COPY, -}) - -/** Locale presented by the shipped first-run notice. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE = 'zh-CN' as const diff --git a/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts deleted file mode 100644 index 41b4c5aca2..0000000000 --- a/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * Effect-owned first-run overlay for the shipped `dsh` TUI. - * - * The launcher owns the per-DSH_HOME acknowledgement boundary; the component - * reaches the terminal only through the mounted `ctx.tui` overlay service and - * never touches the session or model context. - * @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome - */ - -import { randomUUID } from 'node:crypto' -import { lstat, mkdir, open, rename, rm } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' -import type { Context } from 'cordis' -import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, -} from '@earendil-works/pi-tui' -import { - disposeRootAndExit, - type TuiComponent, - type TuiFocusable, - type TuiOverlayHost, -} from '@deepseek-ai/dsh-tui' -import { - TUI_FIRST_RUN_WELCOME_NOTICE_COPY, - TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, - TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, - type TuiFirstRunWelcomeNoticeCopy, -} from './tui-first-run-welcome-copy.ts' -import { - TUI_FIRST_RUN_WELCOME_WHALE, - type TuiFirstRunWelcomeArtTier, -} from './tui-first-run-welcome-art.ts' - -// TODO: Move acknowledgement persistence behind @deepseek-ai/dsh-storage once -// its backend contract supports concurrent host processes. This same-value -// marker must not inherit JSON lost updates or SQLite busy failures. -const ACKNOWLEDGEMENT_DIRECTORY = 'notices' -const ACKNOWLEDGEMENT_BASENAME = 'tui-first-run-welcome' - -/** Cordis plugin name. */ -export const name = 'tui-first-run-welcome' -/** The notice can open only after the terminal-local overlay service mounts. */ -export const inject = ['tui'] - -/** Launcher-resolved configuration for the terminal-local notice. */ -interface Config { - /** Absolute DeepSeek Harness home owning this acknowledgement. */ - readonly dshHome: string - /** Render the bit-equivalent printable ASCII icon fallback. */ - readonly asciiArt?: boolean -} - -/** - * Detect an explicitly non-Unicode terminal locale for the static ASCII art fallback. - * @param env - Process environment carrying locale and terminal declarations. - * @returns `true` only when the environment explicitly declares an ASCII-only locale or dumb terminal. - */ -export function needsTuiFirstRunWelcomeAsciiArt( - env: Readonly> = process.env, -): boolean { - const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG - return env.TERM === 'dumb' || locale === 'C' || locale === 'POSIX' -} - -/** - * Resolve the immutable marker for one notice version. - * @param dshHome - Resolved Harness home. - * @param version - Copy version whose acknowledgement is queried. - * @returns Absolute marker path beneath the Harness home. - */ -export function tuiFirstRunWelcomeAcknowledgementPath(dshHome: string, version: number): string { - return join( - dshHome, - ACKNOWLEDGEMENT_DIRECTORY, - `${ACKNOWLEDGEMENT_BASENAME}-v${String(version)}.ack`, - ) -} - -/** - * Test whether one notice version has been acknowledged. - * @param dshHome - Resolved Harness home. - * @param version - Copy version to inspect. - * @returns `true` only for a regular marker file; a malformed marker fails loud. - */ -export async function hasTuiFirstRunWelcomeAcknowledgement( - dshHome: string, - version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, -): Promise { - const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version) - try { - const info = await lstat(path) - if (!info.isFile()) throw new Error(`TUI welcome acknowledgement is not a file: ${path}`) - return true - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return false - throw error - } -} - -/** - * Persist one version acknowledgement by syncing a random same-directory file - * before atomically replacing the immutable marker. Concurrent launches publish - * the same fact, so same-value last-writer-wins replacement loses no state. - * @param dshHome - Resolved Harness home. - * @param version - Copy version being acknowledged. - */ -export async function acknowledgeTuiFirstRunWelcome( - dshHome: string, - version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, -): Promise { - const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version) - const directory = dirname(path) - const temp = join(directory, `.${basename(path)}.${randomUUID()}.tmp`) - await mkdir(directory, { recursive: true, mode: 0o700 }) - await syncDirectory(dirname(directory)) - let handle: Awaited> | undefined - try { - handle = await open(temp, 'wx', 0o600) - await handle.sync() - const created = handle - handle = undefined - await created.close() - await rename(temp, path) - } catch (error) { - /* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */ - try { - await handle?.close() - } finally { - await rm(temp, { force: true }) - } - throw error - /* v8 ignore stop */ - } - try { - await syncDirectory(directory) - /* v8 ignore next -- rename is the commit point; directory-fsync fault injection is platform-specific */ - } catch { - // Swallow post-rename directory fsync failure: the marker is already committed, - // and crash loss can only make the notice reappear on the safe side. - } -} - -/** Sync one POSIX directory after publishing a child entry. */ -/* v8 ignore start -- Windows rejects directory opens; POSIX unit coverage owns this path. */ -async function syncDirectory(path: string): Promise { - if (process.platform === 'win32') return - const handle = await open(path, 'r') - try { - await handle.sync() - } finally { - await handle.close() - } -} -/* v8 ignore stop */ - -/** Render one visible-width-padded line inside the notice frame. */ -function framed(content: string, innerWidth: number, host: TuiOverlayHost): string { - const clipped = truncateToWidth(content, innerWidth, '') - return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}` -} - -/** Center one line by terminal column width. */ -function centered(content: string, width: number): string { - const clipped = truncateToWidth(content, width, '') - const remaining = Math.max(0, width - visibleWidth(clipped)) - return `${' '.repeat(Math.floor(remaining / 2))}${clipped}` -} - -/** - * Select the art tier for the actual overlay width and viewport height. - * @param innerWidth - Columns inside the frame. - * @param viewportRows - Current terminal rows. - * @returns full, compact, minimal, or no art when prose must take priority. - */ -export function tuiFirstRunWelcomeArtTier( - innerWidth: number, - viewportRows: number, -): TuiFirstRunWelcomeArtTier | undefined { - const compositionCapacity = Math.max(1, Math.max(7, Math.floor(viewportRows * 0.9)) - 5) - if (innerWidth >= 96 && TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length <= compositionCapacity) return 'full' - if (innerWidth >= 80 && TUI_FIRST_RUN_WELCOME_WHALE.compact.unicode.length + 4 <= compositionCapacity) return 'compact' - if (innerWidth >= 64 && TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode.length + 4 <= compositionCapacity) return 'minimal' - return undefined -} - -/** Wrap the centrally owned prose while promoting its opening quotation. */ -function proseLines( - copy: TuiFirstRunWelcomeNoticeCopy, - width: number, - host: TuiOverlayHost, -): string[] { - const lines: string[] = [] - for (const [index, paragraph] of copy.paragraphs.entries()) { - if (index > 0) lines.push('') - const quoteEnd = paragraph.startsWith('“') ? paragraph.indexOf('”') : -1 - if (quoteEnd > 0) { - const quote = paragraph.slice(0, quoteEnd + 1) - const remainder = paragraph.slice(quoteEnd + 1).trimStart() - lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width)) - lines.push('') - if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width)) - } else { - lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width)) - } - } - return lines -} - -/** Render centered static brand art without putting ANSI into its owner file. */ -function artLines( - tier: TuiFirstRunWelcomeArtTier, - width: number, - host: TuiOverlayHost, - asciiArt: boolean, -): string[] { - const art = TUI_FIRST_RUN_WELCOME_WHALE[tier][asciiArt ? 'ascii' : 'unicode'] - return art.map(line => centered(host.theme.brand(line), width)) -} - -/** Responsive, scrollable notice whose only completion input is Enter. */ -export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { - focused = false - private scrollOffset = 0 - private bodyCapacity = 1 - private maxScrollOffset = 0 - private saving = false - private saveFailed = false - - constructor( - private readonly host: TuiOverlayHost, - private readonly copy: TuiFirstRunWelcomeNoticeCopy, - private readonly acknowledge: () => Promise, - private readonly exit: () => void, - private readonly asciiArt = false, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const frameWidth = Math.max(6, width) - const innerWidth = Math.max(1, frameWidth - 4) - const viewportRows = this.host.viewport.rows - const tier = tuiFirstRunWelcomeArtTier(innerWidth, viewportRows) - const availableRows = Math.max(7, Math.floor(viewportRows * 0.9)) - const title = this.host.theme.bold(this.host.theme.brand(this.copy.title)) - let fixedHeader: string[] = [] - let fullContentHeader: string[] = [] - let body: string[] - let fullArt: string[] | undefined - const fullArtWidth = 44 - - if (tier === 'full') { - fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt) - const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3) - fullContentHeader = [centered(title, contentWidth), ''] - body = proseLines(this.copy, contentWidth, this.host) - } else { - const art = tier === undefined ? [] : artLines(tier, innerWidth, this.host, this.asciiArt) - fixedHeader = [...art, ...art.length === 0 ? [] : [''], centered(title, innerWidth), ''] - body = proseLines(this.copy, innerWidth, this.host) - } - - const compositionCapacity = Math.max(1, availableRows - 5) - const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length - fullContentHeader.length) - this.bodyCapacity = Math.min(body.length, bodyLimit) - const maxOffset = Math.max(0, body.length - this.bodyCapacity) - this.maxScrollOffset = maxOffset - this.scrollOffset = Math.min(this.scrollOffset, maxOffset) - const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyCapacity) - - const top = this.host.theme.dim(`╭${'─'.repeat(Math.max(0, frameWidth - 2))}╮`) - const separator = this.host.theme.dim(`├${'─'.repeat(Math.max(0, frameWidth - 2))}┤`) - const bottom = this.host.theme.dim(`╰${'─'.repeat(Math.max(0, frameWidth - 2))}╯`) - const action = this.host.theme.bold(this.host.theme.accent(`Enter ${this.copy.continueLabel}`)) - const hasAbove = this.scrollOffset > 0 - const hasBelow = this.scrollOffset < maxOffset - const scroll = hasAbove || hasBelow - ? `${hasAbove ? '↑' : ' '} ${this.copy.scrollHint} ${hasBelow ? '↓' : ' '}` - : '' - const status = this.saveFailed - ? this.host.theme.error(this.copy.saveError) - : this.saving - ? this.host.theme.dim(this.copy.saving) - : this.host.theme.dim(scroll) - - const fullContent = [...fullContentHeader, ...visibleBody] - const composition = fullArt === undefined - ? [...fixedHeader, ...visibleBody] - : Array.from({ length: Math.max(fullArt.length, fullContent.length) }, (_, index) => { - const art = fullArt[index] ?? '' - const line = fullContent[index] ?? '' - const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` - return `${left} ${line}` - }) - - return [ - top, - ...composition.map(line => framed(line, innerWidth, this.host)), - separator, - framed(centered(action, innerWidth), innerWidth, this.host), - framed(centered(status, innerWidth), innerWidth, this.host), - bottom, - ] - } - - handleInput(data: string): void { - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.exit() - return - } - if (matchesKey(data, Key.enter)) { - if (!this.saving) void this.commit() - return - } - if (this.saving || matchesKey(data, Key.escape)) return - if (matchesKey(data, Key.up)) this.scrollBy(-1) - else if (matchesKey(data, Key.down)) this.scrollBy(1) - else if (matchesKey(data, Key.pageUp)) this.scrollBy(-this.bodyCapacity) - else if (matchesKey(data, Key.pageDown)) this.scrollBy(this.bodyCapacity) - else if (matchesKey(data, Key.home)) this.scrollTo(0) - else if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollOffset) - } - - private scrollBy(delta: number): void { - this.scrollTo(this.scrollOffset + delta) - } - - private scrollTo(offset: number): void { - this.scrollOffset = Math.min(this.maxScrollOffset, Math.max(0, offset)) - this.host.invalidate() - } - - private async commit(): Promise { - this.saving = true - this.saveFailed = false - this.host.invalidate() - try { - await this.acknowledge() - this.host.close() - } catch { - this.saving = false - this.saveFailed = true - this.host.invalidate() - } - } -} - -/** - * Open the first-run notice through the mounted TUI's FIFO overlay owner. - * @param ctx - Plugin context carrying the terminal-local TUI service. - * @param config - Launcher-resolved Harness home. - */ -export function apply(ctx: Context, config: Config): void { - const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] - const pending = new Set>() - const acknowledge = (): Promise => { - const task = acknowledgeTuiFirstRunWelcome(config.dshHome) - pending.add(task) - const settled = (): void => { pending.delete(task) } - void task.then(settled, settled) - return task - } - ctx.effect(() => async () => { - await Promise.allSettled(pending) - }, 'tui first-run welcome acknowledgement') - ctx.tui.openOverlay({ - create: host => new TuiFirstRunWelcomeComponent( - host, - copy, - acknowledge, - () => { disposeRootAndExit(ctx, 0) }, - config.asciiArt ?? false, - ), - options: { - width: '100%', - maxHeight: '90%', - anchor: 'center', - margin: 0, - }, - }) -} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts deleted file mode 100644 index 5af1a32cbb..0000000000 --- a/apps/cli/src/tui.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * shared base and TUI overlay, followed by either `--config` or the personal overlay - * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: - * ambient environment, then the invoking directory's `.env`, then the personal one) - * and its `config.yaml` patches the booted tree. The workspace is the invoking - * directory: the session cwd, relative paths, and workspace instructions resolve - * from it, so `dsh` acts on whatever project it is launched in. Session storage - * is the exception — it lives under the Harness home so `/resume` reaches every - * workspace, and an in-place resume enters the selected session's own directory. - * `dsh meta` is the one exception — it makes this harness - * checkout the workspace. `dsh upgrade` is a fresh session whose - * first turn auto-invokes a bundled skill. After boot, the agent's system - * prompt is told the path to this harness checkout so it can find its own - * source. - * @module @deepseek-ai/dsh/tui - */ - -import { randomUUID } from 'node:crypto' -import { rm } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import { tmpdir } from 'node:os' -import { fileURLToPath } from 'node:url' -import { - addHarnessSourceSection, - boot, - installFailLoud, - loadOverlayPatches, - loadPersonalPatches, - resolveConfigPath, - watchPersonalPatches, -} from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import type { PatchOptions } from '@cordisjs/plugin-include' -import { SessionId } from '@deepseek-ai/dsh-session' -import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' -import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' -import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' -import type { Context } from 'cordis' -import { - INITIAL_SKILL_KEY, - MAIN_SESSION_ID_KEY, - TUI_GOODBYE_MESSAGE_KEY, - type MainSessionIdentity, - type TuiResumeHost, -} from '@deepseek-ai/dsh-tui' -import { - apply as applyTuiFirstRunWelcome, - hasTuiFirstRunWelcomeAcknowledgement, - inject as tuiFirstRunWelcomeInject, - name as tuiFirstRunWelcomeName, - needsTuiFirstRunWelcomeAsciiArt, -} from './tui-onboarding/tui-first-run-welcome.ts' -import { - TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, -} from './tui-onboarding/tui-first-run-welcome-copy.ts' - -const NAME = 'dsh' - -// The shared core every `dsh` surface mounts, and the TUI's own overlay over -// it. Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) -// sit one directory under apps/cli, so each resolves with the same hop. -const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) -const TUI_OVERLAY = fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)) - -// The `agents` entry in tui.cordis.yml the TUI drives; the launcher binds its -// session identity by this config id. -const MAIN_AGENT_ID = 'main' - -/** Per-process filename of the disposable `/resume` index. */ -const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.db` - -// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved -// from this bin's location so it holds however `dsh` is launched (a PATH -// symlink, an arbitrary cwd). The agent is told where its own source lives. -/** The harness checkout used as the `dsh meta` workspace and source prompt path. */ -export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) - -/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; - the CLI PTY smoke drives this path end to end, personal overlay included */ -/** - * Run the interactive TUI from the invoking directory. - * @param config - an overlay patch list applied over the shared base and the - * TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never - * inherits the user's route, or `undefined` to use the personal overlay; - * already parsed from `--config`. - * @param resumeSessionId - a persisted session id to resume, or `undefined` to - * mint a fresh one; already parsed and non-empty-validated from `--resume`. - * Either way the resulting identity reaches the booted app through - * {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session - * and an overlay replacing the agent row cannot drop it. - * @param workspace - a directory to make the workspace instead of the invoking - * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. - * @param initialSkill - a bundled skill to auto-invoke as a fresh session's - * first turn, or `undefined`. Set only by `dsh upgrade` and - * ignored on a resume, so it never re-fires; reaches the app through - * {@link INITIAL_SKILL_KEY}. - * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the - * shared base, the TUI overlay, and the personal overlay alike, or `undefined` - * to compose them; already parsed from `--config-replace`. - */ -export async function runTui( - config: string | undefined, - resumeSessionId: string | undefined, - workspace?: string, - initialSkill?: string, - configReplace?: string, -): Promise { - // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree - // is logged per-entry rather than rethrown, so a piped launch would - // otherwise settle into an idle UI-less process instead of exiting nonzero. - if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.stderr.write( - `${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`, - ) - process.exit(1) - } - // The bin already loaded the invoking directory's .env, and that is the - // whole environment: $DSH_HOME/.env is credentials-local's writable store, - // and hoisting it would make every stored key read as a read-only ambient - // override on the next run — unrotatable from the TUI or the web page. - // The environment is settled, so switching the workspace here cannot alter - // its precedence. The cwd IS the workspace seam: the shipped config - // resolves the session cwd and the HMR watch root from it, so one chdir moves - // both together. Sessions themselves live under the Harness home so `/resume` - // spans every workspace, and are unaffected by this chdir. - if (workspace !== undefined) process.chdir(workspace) - const dshHome = resolveDshHome() - const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement( - dshHome, - TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, - ) - process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') - // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` - // flag, so the resumed process rehydrates through this same intake. The - // selected session may belong to another workspace, so the handoff also enters - // that directory. The host is offered only when Node exposes `process.execve` - // and knows its own entry. - const resolvedConfig = config === undefined ? undefined : resolve(config) - const resolvedConfigReplace = configReplace === undefined ? undefined : resolve(configReplace) - const entry = process.argv[1] - const execve = process.execve?.bind(process) - const app: { current?: Context } = {} - // The Loader mounts entries concurrently, so `ui-tui` can already hold the - // terminal (raw mode, bracketed paste, keyboard protocol) when something - // else fails. A config-tree failure settles through `boot`, which disposes - // the tree itself; this release covers the rejections `boot` cannot see — a - // plugin's detached async work rejecting while mounting is still in flight - // or after the tree settled. Disposing the tree runs the TUI's own shutdown, - // which stops the terminal and hands the shell back; without it such a - // failure returns to a corrupted prompt. `app.current` is captured from - // boot's `prepare` hook, so it holds the root context for the whole mounting - // window rather than only after boot resolves. - installFailLoud(NAME, process, async () => { - await app.current?.fiber.dispose() - }) - // Resume always enters the default surface because meta rejects - // parent options, including `--resume`. The resumed session already persists - // its cwd. - const resumeArgs = (sessionId: string): string[] => [ - `--resume=${sessionId}`, - // Both config flags must survive the handoff: resuming into a different - // tree than the session was created in would silently change the agent. - ...resolvedConfig !== undefined ? ['--config', resolvedConfig] : [], - ...resolvedConfigReplace !== undefined ? ['--config-replace', resolvedConfigReplace] : [], - ] - // Mint the fresh id here rather than in the app bundle: the exit line names - // the session to resume, so the launcher must know it before the tree boots. - const identity: MainSessionIdentity = resumeSessionId === undefined - ? { id: SessionId(`main-session-${randomUUID()}`), resume: false } - : { id: SessionId(resumeSessionId), resume: true } - const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}` - const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { - async handoff(sessionId, cwd): Promise { - const current = app.current - if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) - const nextArgv = [ - process.execPath, - ...process.execArgv, - entry, - ...resumeArgs(sessionId), - ] - // `execve` inherits the cwd, and the target session may belong to another - // workspace. Enter it BEFORE teardown commits: an unreachable directory - // (deleted, unreadable) must reject while the caller can still restore the - // terminal, and a chdir after disposal would have no owner to report to. - try { - process.chdir(cwd) - } catch (error) { - throw new Error(`${NAME}: cannot resume in "${cwd}": ${String(error)}`) - } - try { - await current.fiber.dispose() - execve(process.execPath, nextArgv, process.env) - throw new Error('process replacement returned unexpectedly') - } catch (error) { - process.stderr.write(`${NAME}: resume handoff failed after terminal release: ${String(error)}\n`) - process.exit(1) - } - }, - } - // One include of the shared base, with every overlay applied as a sibling - // patch list: patches never cross an include boundary, so stacking these as - // nested includes would silently stop reaching base rows. Later lists win. - // - // `--config` REPLACES the personal overlay rather than layering under it: an - // explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a - // demo or test config would silently run on the user's provider and model. - // `--config-replace` additionally discards the base and the surface overlay. - const replaceTree = configReplace !== undefined - const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined) - // Same opt-out semantics as the web surface (resolveTelemetryPatch: any - // non-empty value disables; setting the switch against a tree without the - // row fails loud rather than silently no-opping a privacy switch). The row - // presence is checked against the tree actually booting, so a - // --config-replace tree is judged on its own rows, not the shipped base's. - const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig)) - const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [ - ...replaceTree ? [] : [ - ...loadOverlayPatches(NAME, TUI_OVERLAY), - ...resolvedConfig === undefined - ? personalPatches - : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), - ], - ...telemetryPatch === undefined ? [] : [telemetryPatch], - ] - const patches = composePatches(loadPersonalPatches(NAME) ?? []) - const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) - const ctx = await boot( - NAME, - bootConfig, - patches, - (hostCtx) => { - // Runs after the Loader installs and before any config-tree entry mounts, - // so the fail-loud release hook can reach the tree for the whole window in - // which an entry may reject. - app.current = hostCtx - // The launcher owns session identity and the exit line: a config-mounted - // app bundle reads both from these slots, so no cordis.yml key can drop - // resume. - hostCtx.provide(MAIN_SESSION_ID_KEY, identity) - hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye) - // Shared-store policy is the launcher's: sessions live in one root under - // the Harness home across every cwd, so /resume sees every workspace. - // The bundle treats the slot as opaque. - // The agent-loop row reads this to bind `main`, and the tui row reads the - // same id, so a personal overlay repointing the model route cannot drop - // the session identity or desynchronise the two. - hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) - // The query database is a disposable derived index with single-process - // ownership. Keep it process-local while it indexes the shared logs. - hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, queryIndexPath) - hostCtx.effect(() => async () => { - await Promise.all([ - rm(queryIndexPath, { force: true }), - rm(`${queryIndexPath}-wal`, { force: true }), - rm(`${queryIndexPath}-shm`, { force: true }), - ]) - }, `${SESSION_QUERY_SQLITE_PATH_KEY}.cleanup`) - if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) - // Seed the first turn only for a fresh session, so resuming never - // re-invokes the skill. - if (initialSkill !== undefined && resumeSessionId === undefined) { - hostCtx.provide(INITIAL_SKILL_KEY, initialSkill) - } - }, - ) - // The shipped tree includes HMR and keeps personal config live. An explicit - // --config tree replaces the personal overlay (so there is nothing to keep - // live), and a --config-replace or HMR-less tree remains a valid composition - // that still receives the startup overlay but deliberately has no hidden - // watcher. - if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) { - await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches }) - } - app.current = ctx - addHarnessSourceSection(ctx, SOURCE_ROOT) - if (showFirstRunWelcome) { - await ctx.plugin({ - name: tuiFirstRunWelcomeName, - inject: tuiFirstRunWelcomeInject, - apply: applyTuiFirstRunWelcome, - }, { - dshHome, - asciiArt: needsTuiFirstRunWelcomeAsciiArt(), - }) - } -} -/* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 4fbfba4d8d..feeaab9d4c 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -14,7 +14,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tool-bash' import { AppCLIEntry } from './app-cli-entry.ts' -// The shared core every `dsh` surface mounts, plus this surface's overlay over it. +// The shipped base plus the Web application's overlay. const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 5b0e76323d..38eed06eb4 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,18 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { parseDshArgs } from '../src/args.ts' -const parse = (argv: string[], experimentalEnv = false) => parseDshArgs(argv, '1.2.3', experimentalEnv) +const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') -/** - * `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets - * Commander print to the real streams; capture the exit code and mute output. - */ -function exitCode(argv: string[], experimentalEnv = false): number { +/** Capture the process exit code while muting Commander's output. */ +function exitCode(argv: string[]): number { const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) vi.spyOn(process.stdout, 'write').mockReturnValue(true) vi.spyOn(process.stderr, 'write').mockReturnValue(true) try { - parse(argv, experimentalEnv) + parse(argv) throw new Error(`expected ${JSON.stringify(argv)} to exit`) } catch { return exit.mock.calls.at(-1)?.[0] as number @@ -24,102 +21,52 @@ function exitCode(argv: string[], experimentalEnv = false): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, experimental and web subcommands', () => { - expect(parse([])).toEqual({ mode: 'tui' }) - expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) - expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) - expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) + it('routes the required raw config, one-shot prompt, and Web command', () => { + expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'config', config: 'custom.yml' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - // Experimental subcommands run under the per-invocation flag or the env opt-in. - expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' }) - expect(parse(['meta'], true)).toEqual({ mode: 'meta' }) - // Bare `web` carries no host/port: the shipped Web overlay owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' }) - // Host/port are unvalidated pass-throughs (the webserver schema gates them - // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) - // Guided fresh-session entries carry nothing: bare mode discriminant only. - expect(parse(['upgrade', '--experimental'])).toEqual({ mode: 'upgrade' }) - expect(parse(['upgrade'], true)).toEqual({ mode: 'upgrade' }) - // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) - it('routes the dump flags per surface: composed with the user layer, or shipped only', () => { - expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false }) - expect(parse(['--dump-config', '--config', 'c.yml'])) - .toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' }) - expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true }) - expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false }) + it('routes raw and Web config dumps', () => { + expect(parse(['--config', 'c.yml', '--dump-config'])) + .toEqual({ mode: 'dump-config', surface: 'config', defaultOnly: false, config: 'c.yml' }) + expect(parse(['--dump-default-config'])) + .toEqual({ mode: 'dump-config', surface: 'config', defaultOnly: true }) + expect(parse(['web', '--dump-config'])) + .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false }) expect(parse(['web', '--dump-config', '--config', 'w.yml'])) .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' }) - expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true }) - // The two dump flags contradict each other; boot-only flags alongside a - // dump would be silently ignored; the shipped tree takes no user overlay. - expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1) - expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1) - expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1) - expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) - expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) - // A leaked dump flag on a subcommand that has none is a mistyped invocation. - expect(exitCode(['meta', '--experimental', '--dump-config'])).toBe(1) - expect(exitCode(['upgrade', '--experimental', '--dump-config'])).toBe(1) + expect(parse(['web', '--dump-default-config'])) + .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true }) }) - it('exits nonzero instead of silently starting fresh or dropping inputs', () => { - // Empty resume/prompt would be swallowed downstream; --prompt mixed with - // TUI inputs must not lose them. (Bad host/port are gated by the webserver - // schema at boot, not here.) - expect(exitCode(['--resume='])).toBe(1) - expect(exitCode(['-p', ''])).toBe(1) - expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) - expect(exitCode(['--bogus'])).toBe(1) - expect(exitCode(['bogus-positional'])).toBe(1) - // A default-surface flag on either side of `web` leaks into program.opts() - // but the web subcommand shares none of them: reject rather than serve. - expect(exitCode(['web', '-p', 'task'])).toBe(1) - expect(exitCode(['web', '--resume', 's'])).toBe(1) - expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) - expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) - // Same rule for each subcommand that shares no option with the default - // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and always starts fresh, - // so every default-surface option is rejected. - expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1) - expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1) - // `upgrade` takes no options beyond the gate: any leaked default-surface - // flag is a mistyped invocation, not a silently-dropped input. - expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1) - expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1) - // The pre-release command names have no compatibility aliases. - expect(exitCode(['experimental-meta'])).toBe(1) - expect(exitCode(['experimental-upgrade'])).toBe(1) - }) - - it('gates experimental subcommands behind --experimental or the env opt-in', () => { - // Bare `meta`/`upgrade` without either opt-in must fail loud, not run. + it('rejects missing config, removed commands, and contradictory inputs', () => { + expect(exitCode([])).toBe(1) + expect(exitCode(['tui'])).toBe(1) expect(exitCode(['meta'])).toBe(1) expect(exitCode(['upgrade'])).toBe(1) - // A leaked default-surface flag stays a typo even when the gate is passed - // by the environment alone. - expect(exitCode(['meta', '--resume', 's'], true)).toBe(1) - // The flag and the env opt-in may coexist. - expect(parse(['meta', '--experimental'], true)).toEqual({ mode: 'meta' }) - expect(parse(['upgrade', '--experimental'], true)).toEqual({ mode: 'upgrade' }) + expect(exitCode(['--dump-config'])).toBe(1) + expect(exitCode(['--dump-config', '--dump-default-config', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['--dump-config', '--config', 'c.yml', '-p', 'task'])).toBe(1) + expect(exitCode(['-p', ''])).toBe(1) + expect(exitCode(['--config='])).toBe(1) + expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['--bogus'])).toBe(1) + expect(exitCode(['bogus-positional'])).toBe(1) + expect(exitCode(['web', '-p', 'task'])).toBe(1) + expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) + expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) + expect(exitCode(['web', '--config='])).toBe(1) }) - it('exits 0 for --help (disclosing web) and --version', () => { + it('exits 0 for help and version', () => { expect(exitCode(['--help'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 88f8178fba..fcfe8b3829 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,32 +1,16 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -/** - * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under - * plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot. - * `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a - * piped launch must exit nonzero with a stderr pointer at the one-shot `-p` - * mode. The guard fires inside `runTui` BEFORE the Loader resolves the config - * tree — a compose-time throw inside the tree is logged per-entry, not - * rethrown, so without this guard a piped launch would settle into an idle - * UI-less process. The bin resolves its workspace deps through the repo's - * node_modules, so no external consumer is assembled; missing-config fail-loud - * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's - * built-bin suite, and interactive TTY behavior is PTY-covered by - * apps/cli/tests. Skips before the bin is built. - */ - +/** Published-entry acceptance for raw argument errors and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') +const rawOverlay = fileURLToPath(new URL('./fixtures/raw-overlay.cordis.yml', import.meta.url)) +const rawInvalidProvider = fileURLToPath(new URL('./fixtures/raw-invalid-provider.cordis.yml', import.meta.url)) -/** - * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output - * + exit code. `env` isolates the Harness home for surfaces that read it. - */ async function runBuiltBin( args: readonly string[] = [], env: Record = {}, @@ -44,106 +28,178 @@ async function runBuiltBin( return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } +async function waitForFile(file: string): Promise { + const deadline = Date.now() + 20_000 + while (!existsSync(file)) { + if (Date.now() >= deadline) throw new Error(`dsh raw lifecycle marker did not appear: ${file}`) + await new Promise(resolve => setTimeout(resolve, 20)) + } +} + +interface RawLifecycleFixture { + home: string + ready: string + settled: string + disposed: string + overlay: string +} + +function createRawLifecycleFixture(): RawLifecycleFixture { + const home = mkdtempSync(join(tmpdir(), 'dsh-raw-lifecycle-')) + const ready = join(home, 'ready') + const settled = join(home, 'settled') + const disposed = join(home, 'disposed') + const plugin = join(home, 'lifecycle.mjs') + const overlay = join(home, 'overlay.cordis.yml') + writeFileSync(plugin, [ + "import { writeFileSync } from 'node:fs'", + "export const name = 'raw-lifecycle-fixture'", + "export const inject = ['sessionQuery']", + 'export function apply(ctx) {', + ' let active = true', + " writeFileSync(process.env.RAW_READY_FILE, 'ready')", + ' void ctx.loader.await().then(() => {', + " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')", + ' })', + ' ctx.effect(() => () => {', + ' active = false', + " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')", + ' })', + '}', + '', + ].join('\n')) + writeFileSync(overlay, [ + '- insert:', + ' - id: raw-lifecycle-fixture', + ` name: ${pathToFileURL(plugin).href}`, + '', + ].join('\n')) + return { home, ready, settled, disposed, overlay } +} + +function startRawLifecycle(fixture: RawLifecycleFixture) { + return execa(process.execPath, [dshBin, '--config', fixture.overlay], { + cwd: fixture.home, + input: '', + reject: false, + env: { + DSH_HOME: fixture.home, + DSH_TELEMETRY_DISABLED: '1', + RAW_READY_FILE: fixture.ready, + RAW_SETTLED_FILE: fixture.settled, + RAW_DISPOSED_FILE: fixture.disposed, + }, + }) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { - it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => { - const { stdout, code, stderr } = await runBuiltBin() - expect(code).not.toBe(0) - expect(stderr).toContain('requires stdin and stdout to be interactive TTYs') - expect(stderr).toContain('dsh -p') - // The refusal happens before any plugin mounts: stdout stays silent. - expect(stdout).toBe('') + it('requires --config for the raw command and rejects removed commands', async () => { + const bare = await runBuiltBin() + expect(bare.code).toBe(1) + expect(bare.stdout).toBe('') + expect(bare.stderr).toContain('--config is required') + const help = await runBuiltBin(['--help']) + expect(help.code).toBe(0) + expect(help.stdout).toContain('dsh --config ./app.cordis.yml') + expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) + for (const command of ['tui', 'meta', 'upgrade']) { + const removed = await runBuiltBin([command]) + expect(removed.code).toBe(1) + expect(removed.stderr).not.toContain('experimental') + } }, 30_000) - describe('experimental subcommand gate', () => { - // The gate has two halves: a per-invocation --experimental flag parsed by - // Commander and an env opt-in read by bin.ts as exactly '1'. Passing the - // gate is proven by reaching the NEXT failure — the TUI's piped-stdio - // refusal — instead of the gate diagnostic. - it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => { - for (const command of ['meta', 'upgrade']) { - const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' }) - expect(code).toBe(1) - expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`) - } - }, 30_000) + it('reports a raw overlay boot failure without hanging', async () => { + const result = await runBuiltBin(['--config', rawInvalidProvider], { + DEEPSEEK_API_KEY: 'keyless-invalid-config', + DSH_TELEMETRY_DISABLED: '1', + }) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('llm-pi-ai') + }, 30_000) - it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => { - const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' }) - expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs') - const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' }) - expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs') - // The env opt-in is exact: '0' (or any other value) does not enable. - const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' }) - expect(zero.code).toBe(1) - expect(zero.stderr).toContain('meta is experimental') - }, 30_000) - }) + it('applies an inserted raw plugin and disposes it on a startup-time signal', async () => { + const fixture = createRawLifecycleFixture() + const child = startRawLifecycle(fixture) + try { + await waitForFile(fixture.ready) + child.kill('SIGTERM') + const result = await child + expect(result.exitCode).toBe(0) + expect(result.signal).toBeUndefined() + expect(existsSync(fixture.disposed)).toBe(true) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) - describe('dsh --dump-config', () => { + it('fully settles a valid raw overlay and disposes it on a signal', async () => { + const fixture = createRawLifecycleFixture() + const child = startRawLifecycle(fixture) + try { + await waitForFile(fixture.settled) + child.kill('SIGTERM') + const result = await child + expect(result.exitCode).toBe(0) + expect(result.signal).toBeUndefined() + expect(existsSync(fixture.disposed)).toBe(true) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + describe('config dump', () => { let home: string beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) }) afterEach(() => { rmSync(home, { recursive: true, force: true }) }) - it('prints the shipped TUI composition without booting or needing a TTY', async () => { + it('prints the shipped base without a user layer', async () => { const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) expect(code).toBe(0) expect(stderr).toBe('') - // Base rows composed with the TUI overlay's surface values, `!!js` - // expressions verbatim (unevaluated), and TUI-only inserted rows present. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") - expect(stdout).toContain('model: deepseek-v4-pro') - expect(stdout).toContain('cwd: !!js process.cwd()') - expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'") - expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-invariants['"]/) - expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-[^'"]+\/invariant['"]/) - expect(stdout).toContain([ - '- id: tool-web', - " name: '@deepseek-ai/dsh-tool-web'", - ' config:', - ' fetch: false', - ' searchTimeoutMs: 60000', - ].join('\n')) - // Provenance comment separators name each section's source file. + expect(stdout).toContain('agents: []') expect(stdout).toContain('# == base.cordis.yml') - expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml') - expect(stdout).toContain('# == tui.cordis.yml') }, 30_000) - it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => { + it('composes the required raw overlay directly over the base', async () => { writeFileSync(join(home, 'config.yaml'), [ '- id: agent-loop', ' config:', ' agents:', - ' - id: main', - ' provider: custom-provider', - ' model: custom-model', - '- id: only-on-web', - ' config:', - ' value: 1', + ' - id: personal', + ' provider: personal-provider', + ' model: personal-model', '', ].join('\n')) - const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) + const { stdout, code, stderr } = await runBuiltBin( + ['--config', rawOverlay, '--dump-config'], + { DSH_HOME: home }, + ) expect(code).toBe(0) - expect(stdout).toContain('provider: custom-provider') - expect(stdout).not.toContain('model: deepseek-v4-pro') - // The personal layer appears in the patched row's provenance and the - // skipped-patch warning carries its label. - expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`) - expect(stderr).toContain('patch: entry "only-on-web" not found') - - // The shipped view ignores the personal overlay entirely. - const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) - expect(shipped.stdout).not.toContain('custom-provider') - expect(shipped.stdout).toContain('model: deepseek-v4-pro') + expect(stdout).toContain('provider: configured-provider') + expect(stdout).not.toContain('personal-provider') + expect(stdout).toContain(`patched by ${rawOverlay}`) + expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) - it('composes the web overlay for `dsh web --dump-config`', async () => { + it('keeps the Web overlay and personal layer on the Web command', async () => { + writeFileSync(join(home, 'config.yaml'), [ + '- id: agent-loop', + ' config:', + ' agents:', + ' - id: personal', + ' provider: personal-provider', + ' model: personal-model', + '', + ].join('\n')) const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home }) expect(code).toBe(0) expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'") - expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-invariants['"]/) - expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-[^'"]+\/invariant['"]/) + expect(stdout).toContain('provider: personal-provider') }, 30_000) }) }) diff --git a/apps/cli/tests/fixtures/composition-echo-llm.ts b/apps/cli/tests/fixtures/composition-echo-llm.ts deleted file mode 100644 index 9b34754e10..0000000000 --- a/apps/cli/tests/fixtures/composition-echo-llm.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Context } from 'cordis' -import type { - GenerateOptions, - LlmModelInfo, - LlmResolvedModelInfo, - StreamChunk, -} from '@deepseek-ai/dsh-llm' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' - -/** Terminal marker the preset smoke waits for before it asks the TUI to exit. */ -export const COMPOSITION_REPLY_TEXT = 'Shipped composition acknowledged.' - -// Provider id and model the keyless tail routes `main` to; that overlay is the -// only caller, so the pair lives here as plain constants. -const COMPOSITION_PROVIDER = 'composition-keyless' -const COMPOSITION_MODEL = 'composition-keyless-model' - -/** - * Network-free adapter for the shipped-composition smoke. It answers every - * request — tool-ful agent turns and the tool-less auxiliary calls alike — with - * one fixed text and never calls a tool, because the assertion under test is the - * assembled tool catalog the loop logs, not any tool's behavior. - */ -class CompositionEchoAdapter extends LlmAdapter { - override listModels(provider: string): Promise { - return Promise.resolve([{ provider, id: COMPOSITION_MODEL, name: 'Preset Keyless' }]) - } - - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: 'Preset Keyless', context: { contextWindow: 128_000 } }) - } - - override async * stream(_options: GenerateOptions): AsyncIterable { - yield { type: 'block-start', index: 0, blockType: 'text' } - for (const char of COMPOSITION_REPLY_TEXT) yield { type: 'text-delta', index: 0, text: char } - yield { type: 'block-end', index: 0, block: { type: 'text', text: COMPOSITION_REPLY_TEXT } } - yield { type: 'usage', usage: { inputTokens: 20, outputTokens: COMPOSITION_REPLY_TEXT.length } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -export const name = 'composition-echo-llm' -export const inject = ['llm'] - -/** - * Register the network-free adapter the shipped-composition smoke routes through. - * @param ctx - the loader-mounted plugin context. - */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter([COMPOSITION_PROVIDER], new CompositionEchoAdapter()) -} diff --git a/apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml b/apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml deleted file mode 100644 index 443fed6be7..0000000000 --- a/apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml +++ /dev/null @@ -1,52 +0,0 @@ -# Keyless tail for the shipped-composition smoke, applied as `--config` so the -# launcher boots `base.cordis.yml` + `tui.cordis.yml` and then this file. -# -# Everything below is test isolation, never composition under test: the model is -# replaced so no request leaves the process, the settle marker gates the smoke's -# first prompt, and the session artifacts move into the smoke's temporary -# workspace so the log inspection can read them. - -# A patch's `name` is an assertion rather than a replacement, so the base -# adapter row is disabled and the scripted one inserted. Relative specifiers -# resolve against the INCLUDED file's directory (apps/cli/config), not this -# file's, because the include moves baseUrl there. -- id: llm-deepseek - disabled: true - -- insert: - - id: composition-echo-llm - name: '../tests/fixtures/composition-echo-llm.ts' - - id: composition-settled - name: '../tests/fixtures/composition-settled.ts' - -- id: agent-loop - config: - agents: - - id: main - provider: composition-keyless - model: composition-keyless-model - cwd: !!js process.cwd() - -- id: session-persistence-jsonl - config: - root: './.sessions' - compression: none - -- id: session-query-sqlite - config: - path: './.sessions/session-query.db' - -# The title call is a second, tool-less request that would race the log -# inspection for no coverage: the catalog under test rides the agent turn. -- id: session-title-llm - disabled: true - -- id: tui - config: - sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' - welcome: 'composition smoke ready.' - showReasoning: true - -# HMR watches the repository; a PTY subprocess test must not start a watcher. -- id: hmr - disabled: true diff --git a/apps/cli/tests/fixtures/composition-settled.ts b/apps/cli/tests/fixtures/composition-settled.ts deleted file mode 100644 index 0e3aff9c4e..0000000000 --- a/apps/cli/tests/fixtures/composition-settled.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Context } from 'cordis' - -/** - * Marker the shipped-composition smoke gates its first prompt on. The TUI renders as soon as - * its own fiber starts, so a prompt typed at the banner can reach the loop while - * later rows — tool plugins, persistence — are still activating, and would - * assemble a partial catalog. Waiting for this line makes the turn observe the - * settled tree. - */ -export const COMPOSITION_SETTLED_MARKER = 'COMPOSITION_TREE_SETTLED' - -export const name = 'composition-settled' - -/** - * Announce settled Loader activation on the terminal byte stream, after every - * entry in the booted tree has started. The write is detached: awaiting the - * Loader from inside an entry would wait on this entry's own activation. - * @param ctx - the loader-mounted plugin context. - */ -export function apply(ctx: Context): void { - void ctx.loader.await().then(() => { - process.stdout.write(`\n${COMPOSITION_SETTLED_MARKER}\n`) - }) -} diff --git a/apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml new file mode 100644 index 0000000000..159d300aba --- /dev/null +++ b/apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml @@ -0,0 +1,7 @@ +# Invalid raw overlay used to prove boot failures settle and exit. + +- id: llm-pi-ai + config: + providers: + - provider: openai + apiKey: keyless-invalid-shape diff --git a/apps/cli/tests/fixtures/raw-overlay.cordis.yml b/apps/cli/tests/fixtures/raw-overlay.cordis.yml new file mode 100644 index 0000000000..f205972450 --- /dev/null +++ b/apps/cli/tests/fixtures/raw-overlay.cordis.yml @@ -0,0 +1,12 @@ +# Raw CLI overlay used by the built config-dump acceptance test. + +- id: agent-loop + config: + agents: + - id: configured + provider: configured-provider + model: configured-model + +- id: absent-row + config: + value: unmatched diff --git a/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml deleted file mode 100644 index f03a58d5d7..0000000000 --- a/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml +++ /dev/null @@ -1,10 +0,0 @@ -# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a -# dict keyed by provider name, and a list is the shape users reach for. The -# entry rejects while `ui-tui` — mounted concurrently by the Loader — already -# holds the terminal, which is the boot failure the fail-loud release hook -# exists for. -- id: llm-pi-ai - config: - providers: - - provider: openai - apiKey: keyless-invalid-shape diff --git a/apps/cli/tests/fixtures/tui-scripted-llm.ts b/apps/cli/tests/fixtures/tui-scripted-llm.ts deleted file mode 100644 index c9bd2e1fe6..0000000000 --- a/apps/cli/tests/fixtures/tui-scripted-llm.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { Context } from 'cordis' -import type { - GenerateOptions, - LlmModelInfo, - LlmResolvedModelInfo, - StreamChunk, -} from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' - -const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' -const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` -const FINAL_TEXT = 'Decision received. Scripted TUI run complete.' -const DEFAULT_MODE_PROBE = 'Confirm the scripted run left plan mode.' -const DEFAULT_MODE_TEXT = 'Default mode confirmed.' -// The `skill` scenario types `/skill:scripted-skill`; the manual-invocation front -// door delivers the loaded skill as a user turn wrapped in ``. The -// body marker below lives in the fixture skill, so echoing it back proves the whole -// block (name attribute plus body) reached the model, not just the command text. -const SKILL_BLOCK_OPEN = '' -const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER' -const SKILL_RECEIVED_TEXT = 'Scripted skill body received.' -const TITLE_TEXT = 'scripted session title' -// The failing-bash scenario proves the terminal card reports a non-zero exit -// exactly once: the model-facing result carries the `[exit code: N]` marker, and -// the card turns it into its own `[exit N]` pill instead of showing both. -const BASH_FAILURE_PROBE = 'Run the failing scripted command.' -const BASH_FAILURE_COMMAND = 'printf "SCRIPTED_BASH_FAILED\\n"; exit 3' -const BASH_FAILURE_TEXT = 'Scripted bash failure observed.' -const BASH_FAILURE_CALL_ID = CallId('call-bash-failure') - -function textChunks(text: string): StreamChunk[] { - return [ - { type: 'block-start', index: 0, blockType: 'text' }, - ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), - { type: 'block-end', index: 0, block: { type: 'text', text } }, - { type: 'usage', usage: { inputTokens: 20, outputTokens: text.length } }, - { type: 'finish', reason: { kind: 'stop' } }, - ] -} - -/** Keyless adapter for the real-PTY TUI tests: the two-step conversation and the `/skill:` round-trip. */ -class ScriptedTuiAdapter extends LlmAdapter { - override listModels(provider: string): Promise { - return Promise.resolve([ - { provider, id: 'tui-scripted-model', name: 'Scripted Base' }, - { provider, id: 'tui-scripted-model-pro', name: 'Scripted Pro' }, - ]) - } - - override resolveModel( - provider: string, - model: string, - ): Promise { - return Promise.resolve({ - provider, - id: model, - name: model === 'tui-scripted-model-pro' ? 'Scripted Pro' : 'Scripted Base', - context: { contextWindow: 128_000 }, - ...model !== 'tui-scripted-model-pro' - ? {} - : { - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, - ], - defaultEffort: ReasoningEffortId('high'), - }, - }, - }) - } - - override async * stream(options: GenerateOptions): AsyncIterable { - // The session-title provider's auxiliary request carries no tool schemas, - // unlike every agent turn; answer it with a fixed title so the PTY test can - // assert the logged title reaches the terminal window title. - if ((options.tools?.length ?? 0) === 0) { - for (const chunk of textChunks(TITLE_TEXT)) yield chunk - return - } - if ( - options.model !== 'tui-scripted-model-pro' - || !options.system?.includes('tui-scripted-model-pro') - || options.reasoningEffort !== ReasoningEffortId('max') - ) { - throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') - } - const lastMessage = options.messages.at(-1) - // The loop appends plugin-sourced context (the plan-mode notice, the - // tool-skill catalog) AFTER the admitted prompt, so the scripted trigger - // may sit one or more user messages back: scan the whole trailing run of - // user-role messages since the last assistant message. - const trailingUserTexts: string[] = [] - for (let index = options.messages.length - 1; index >= 0; index--) { - const message = options.messages[index] - if (message?.role !== 'user') break - for (const block of message.content) { - if (block.type === 'text') trailingUserTexts.push(block.text) - } - } - const lastText = trailingUserTexts.join('\n') - if (lastText.includes(DEFAULT_MODE_PROBE)) { - if (options.system?.includes('Stay in plan mode for this scripted TUI test.')) { - throw new Error('the scripted TUI request retained plan guidance after /plan off') - } - for (const chunk of textChunks(DEFAULT_MODE_TEXT)) yield chunk - return - } - if (lastText.includes(SKILL_BLOCK_OPEN)) { - const ack = lastText.includes(SKILL_BODY_MARKER) - ? SKILL_RECEIVED_TEXT - : 'Scripted skill block arrived without its body.' - for (const chunk of textChunks(ack)) yield chunk - return - } - - const blocks = lastMessage?.content ?? [] - if (blocks.some(block => block.type === 'tool-result')) { - const answeredBash = blocks.some(block => - block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) - if (answeredBash) { - for (const chunk of textChunks(BASH_FAILURE_TEXT)) yield chunk - return - } - const toolResultText = blocks.flatMap(block => block.type === 'tool-result' - ? block.content.flatMap(content => content.type === 'text' ? [content.text] : []) - : []).join('\n') - if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') { - throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`) - } - for (const chunk of textChunks(FINAL_TEXT)) yield chunk - return - } - if (lastText.includes(BASH_FAILURE_PROBE)) { - const bashArgs = JSON.stringify({ command: BASH_FAILURE_COMMAND, description: 'Run the failing scripted command' }) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 0, id: BASH_FAILURE_CALL_ID, name: 'bash', argumentsDelta: bashArgs } - yield { - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: BASH_FAILURE_CALL_ID, name: 'bash', arguments: bashArgs }, - } - yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - return - } - - const args = JSON.stringify({ - questions: [{ - id: 'mode', - header: 'Execution mode', - question: 'How should the scripted run proceed?', - multi_select: true, - options: [ - { label: 'Safe', description: 'Use the guarded path.' }, - { label: 'Fast', description: 'Use the shorter path.' }, - ], - }], - }) - const callId = CallId('call-ask-mode') - yield { type: 'block-start', index: 0, blockType: 'text' } - for (const char of INITIAL_TEXT) yield { type: 'text-delta', index: 0, text: char } - yield { type: 'block-end', index: 0, block: { type: 'text', text: INITIAL_TEXT } } - yield { type: 'block-start', index: 1, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 1, id: callId, name: 'ask_user_question', argumentsDelta: args } - yield { - type: 'block-end', - index: 1, - block: { type: 'tool-call', id: callId, name: 'ask_user_question', arguments: args }, - } - yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - } -} - -export const name = 'tui-scripted-llm' -export const inject = ['llm'] - -/** Register the network-free adapter used by the PTY fixture. */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['tui-scripted'], new ScriptedTuiAdapter()) -} diff --git a/apps/cli/tests/fixtures/tui-scripted.cordis.yml b/apps/cli/tests/fixtures/tui-scripted.cordis.yml deleted file mode 100644 index 69921c7e86..0000000000 --- a/apps/cli/tests/fixtures/tui-scripted.cordis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# Overlay for the keyless conversational PTY test: the shipped composition with -# only the model replaced, so the terminal interaction is deterministic and -# network-free while the agent/TUI/user-question stack stays the production one. -# -# Passed as `--config`, so the launcher includes `base.cordis.yml`, applies -# `tui.cordis.yml`, then this file — all sibling patch lists at one include -# level. A patch replaces the targeted row's whole `config`, so each row below -# restates every key it owns. - -# The scripted adapter replaces the DeepSeek one: no key, no network. A patch's -# `name` is an assertion rather than a replacement, so the base row is disabled -# and the adapter inserted. Relative specifiers resolve against the INCLUDED -# file's directory (apps/cli/config), because the include moves baseUrl there. -- id: llm-deepseek - disabled: true - -- insert: - - id: scripted-llm - name: '../tests/fixtures/tui-scripted-llm.ts' - -- id: agent-loop - config: - agents: - - id: main - provider: tui-scripted - model: tui-scripted-model - # `cwd` scopes the session to this workspace, which is what `/resume` - # filters on; dropping it would hide the seeded session. - cwd: !!js process.cwd() - -- id: system-prompt - config: - persona: 'Scripted model {{model}}.' - -# The smoke's log inspection reads plain `.jsonl` under the workspace, so this -# fixture pins a project-local root instead of the launcher's shared store, and -# keeps the artifacts uncompressed like the other snapshot-facing configs. -- id: session-persistence-jsonl - config: - root: './.sessions' - compression: none - -# The derived index must sit under the same root as the logs it indexes; this -# fixture pins both to the workspace instead of the launcher's shared store. -- id: session-query-sqlite - config: - path: './.sessions/session-query.db' - -- id: plan-mode - config: - section: 'Stay in plan mode for this scripted TUI test.' - -# The scripted adapter answers the tool-less title request with a fixed string, -# so the PTY test can assert the logged title reaches the terminal window title. -- id: session-title-llm - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 10000 - -- id: tui - config: - sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' - welcome: 'scripted TUI ready.' - showReasoning: true - -# HMR watches the repository; a PTY subprocess test must not start a watcher. -- id: hmr - disabled: true diff --git a/apps/cli/tests/install-script.spec.ts b/apps/cli/tests/install-script.spec.ts index 7b616958a1..871f72d056 100644 --- a/apps/cli/tests/install-script.spec.ts +++ b/apps/cli/tests/install-script.spec.ts @@ -125,31 +125,16 @@ async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promi return result.stdout } -describe.runIf(process.platform !== 'win32')('one-line installer interface choice', { timeout: 25_000 }, () => { - it('builds and launches the Web UI when the default choice is accepted', async () => { +describe.runIf(process.platform !== 'win32')('one-line installer launch', { timeout: 25_000 }, () => { + it('builds and launches the Web UI', async () => { const fixture = await createFixture() const output = await runInstaller(fixture, [ { waitFor: 'Replace it?', send: '\n' }, - { waitFor: 'Choose an interface [1/2]:', send: '\n' }, ]) expect(output).toContain('launching Web UI') expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n') expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n') }) - - it('rejects an unknown choice, then launches the TUI without building', async () => { - const fixture = await createFixture() - - const output = await runInstaller(fixture, [ - { waitFor: 'Replace it?', send: '\n' }, - { waitFor: 'Choose an interface [1/2]:', send: 'terminal\n' }, - { waitFor: 'choose 1 for Web UI or 2 for TUI', send: '2\n' }, - ]) - - expect(output).toContain('launching TUI') - expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n') - expect(readFileSync(fixture.launchLog, 'utf8')).toBe('\n') - }) }) diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts deleted file mode 100644 index 07361cf654..0000000000 --- a/apps/cli/tests/pty-harness.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { execa } from 'execa' -import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' - -const POSIX_PTY_DRIVER = String.raw` -import errno, fcntl, json, os, pty, select, signal, struct, sys, termios, time -node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds, columns, rows = sys.argv[1:] -env = os.environ.copy() -env.update(json.loads(launch_env_json)) -env.update({"COLUMNS": columns, "LINES": rows}) -# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the -# banner to the per-letter gradient (one SGR per letter), breaking literal -# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage. -env.pop("COLORTERM", None) -actions = json.loads(actions_json) -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd) - os.execvpe(node, [node, *json.loads(launch_args_json)], env) -fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(columns), 0, 0)) - -output = bytearray() -action_index = 0 -deadline = time.monotonic() + float(timeout_seconds) -status = None -while time.monotonic() < deadline: - ready, _, _ = select.select([fd], [], [], 0.05) - if ready: - try: - chunk = os.read(fd, 65536) - except OSError as error: - if error.errno != errno.EIO: - raise - chunk = b"" - if chunk: - output.extend(chunk) - while action_index < len(actions): - marker = actions[action_index]["waitFor"].encode() - if output.count(marker) < actions[action_index].get("occurrence", 1): - break - action = actions[action_index] - if "signal" in action: - os.kill(pid, getattr(signal, action["signal"])) - elif "writeFile" in action: - target = os.path.join(cwd, action["writeFile"]["path"]) - os.makedirs(os.path.dirname(target), exist_ok=True) - with open(target, "w", encoding="utf-8") as handle: - handle.write(action["writeFile"]["content"]) - if "send" in action: - os.write(fd, action["send"].encode()) - else: - os.write(fd, action["send"].encode()) - action_index += 1 - waited, candidate = os.waitpid(pid, os.WNOHANG) - if waited == pid: - status = candidate - break - -if status is None: - os.kill(pid, signal.SIGKILL) - _, status = os.waitpid(pid, 0) -sys.stdout.buffer.write(output) -if action_index != len(actions): - sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n") - sys.exit(124) -actual_exit = os.waitstatus_to_exitcode(status) -if actual_exit != int(expected_exit): - sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n") - sys.exit(125) -` - -/** One terminal input or workspace mutation performed after its marker renders. */ -type TuiPtyAction = - | { - readonly waitFor: string - readonly occurrence?: number - readonly send: string - } - | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM' } - | { - readonly waitFor: string - readonly occurrence?: number - readonly writeFile: { readonly path: string; readonly content: string } - readonly send?: string - } - -/** Inputs for a keyless real-Loader TUI process smoke. */ -export interface TuiPtySmokeOptions { - readonly label: string - readonly tempDirPrefix: string - readonly binScript: string - /** Config argument; ignored when {@link configArgs} is set. */ - readonly configPath?: string - /** Full argument vector for the bin (e.g. `[]` for a bin with a built-in default config). */ - readonly configArgs?: readonly string[] - readonly tsconfigPath: string - readonly actions?: readonly TuiPtyAction[] - readonly env?: Readonly - readonly expectedExitCode?: number - readonly timeoutMs?: number - /** Existing isolated workspace to reuse; when omitted the harness creates and removes one. */ - readonly cwd?: string - /** Pseudo-terminal columns; defaults to 100. */ - readonly columns?: number - /** Pseudo-terminal rows; defaults to 30. */ - readonly rows?: number - /** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */ - readonly prepare?: (cwd: string) => Promise - /** Inspect the workspace after a passing run, before the temp dir is removed. */ - readonly inspect?: (cwd: string) => Promise -} - -function definedEnv(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ) -} - -async function runPosixPtySmoke( - launch: ExampleLaunch, - cwd: string, - options: TuiPtySmokeOptions, - timeoutMs: number, -): Promise { - // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline - // only backstops a wedged python3 process itself. - const result = await execa('python3', [ - '-c', - POSIX_PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - String(options.columns ?? 100), - String(options.rows ?? 30), - ], { - stdin: 'ignore', - timeout: timeoutMs + 5_000, - killSignal: 'SIGKILL', - reject: false, - stripFinalNewline: false, - }) - if (result.timedOut) { - throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) - } - if (result.failed) { - throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) - } - return result.stdout -} - -async function runWindowsPtySmoke( - launch: ExampleLaunch, - cwd: string, - options: TuiPtySmokeOptions, - timeoutMs: number, -): Promise { - const pty = await import('node-pty') - return await new Promise((resolve, reject) => { - const actions = options.actions ?? [] - const expectedExitCode = options.expectedExitCode ?? 0 - let output = '' - let actionIndex = 0 - let timedOut = false - const terminal = pty.spawn(launch.command, launch.args, { - name: 'xterm-256color', - cols: options.columns ?? 100, - rows: options.rows ?? 30, - cwd, - env: definedEnv({ - ...process.env, - ...launch.env, - // Match the POSIX driver: no COLORTERM, so the banner never takes the - // truecolor gradient path under a developer's shell. - COLORTERM: undefined, - COLUMNS: String(options.columns ?? 100), - LINES: String(options.rows ?? 30), - }), - }) - const timer = setTimeout(() => { - timedOut = true - terminal.kill() - }, timeoutMs) - terminal.onData((chunk) => { - output += chunk - while ( - actionIndex < actions.length - && output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1) - ) { - const action = actions[actionIndex]! - if ('signal' in action) { - terminal.kill(action.signal) - } else if ('writeFile' in action) { - const target = join(cwd, action.writeFile.path) - mkdirSync(dirname(target), { recursive: true }) - writeFileSync(target, action.writeFile.content) - const input = action.send - if (input !== undefined) terminal.write(input) - } else { - terminal.write(action.send) - } - actionIndex += 1 - } - }) - terminal.onExit(({ exitCode, signal }) => { - clearTimeout(timer) - if (timedOut) { - reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`)) - } else if (actionIndex !== actions.length) { - reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`)) - } else if (exitCode !== expectedExitCode) { - reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`)) - } else { - resolve(output) - } - }) - }) -} - -/** - * Boot an example in a real pseudo-terminal (ConPTY on Windows), drive - * marker-gated input, and return captured bytes after the expected process exit. - * @param options - launch paths, environment, actions, and expected exit code. - * @returns complete pseudo-terminal output. - */ -export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise { - const ownedCwd = options.cwd === undefined - const cwd = options.cwd ?? await mkdtemp(join(tmpdir(), options.tempDirPrefix)) - const timeoutMs = options.timeoutMs ?? 25_000 - try { - await options.prepare?.(cwd) - const launch = resolveExampleLaunch({ - srcBin: options.binScript, - // `configPath` is the dsh `--config ` tree override; `configArgs` - // is the raw-args escape (e.g. `['--resume', ]`) for other flags. - configArgs: options.configArgs !== undefined - ? [...options.configArgs] - /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */ - : options.configPath !== undefined ? ['--config', options.configPath] : [], - tsconfigPath: options.tsconfigPath, - env: { - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - ...options.env, - }, - }) - const output = process.platform === 'win32' - ? await runWindowsPtySmoke(launch, cwd, options, timeoutMs) - : await runPosixPtySmoke(launch, cwd, options, timeoutMs) - // Inspect the workspace before `finally` removes it (e.g. the session log). - await options.inspect?.(cwd) - return output - } finally { - if (ownedCwd) await rm(cwd, { recursive: true, force: true }) - } -} diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts deleted file mode 100644 index b7ba80daed..0000000000 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { readdir, readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { COMPOSITION_REPLY_TEXT } from './fixtures/composition-echo-llm.ts' -import { COMPOSITION_SETTLED_MARKER } from './fixtures/composition-settled.ts' -import { runTuiPtySmoke } from './pty-harness.ts' -import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-run-welcome.ts' - -const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const PERMISSION_SUMMARY = 'current preset workspace-write (available: read-only, workspace-write, danger-full-access)' -// An overlay over the shipped tree, so the catalog under test is the one -// `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model -// and redirects session artifacts. -const keylessTail = fileURLToPath(new URL('./fixtures/composition-keyless-tail.cordis.yml', import.meta.url)) - -/** - * The catalog the shipped `dsh` TUI puts in front of the model, as the loop - * logged it, minus the ripgrep-dependent pair below. - * The absences are the composition's security decisions, not incidental gaps: - * the `cordis_*` toolset executes model-written JavaScript that no sandbox row - * confines, `web_fetch` chooses its own request target, and `mcp_*` servers - * spawn outside `ctx.bash`. The composition Agent Note owns the rationale and - * its sources. - */ -const EXPECTED_TUI_TOOLS = [ - 'ask_user_question', - 'bash', - 'create_goal', - 'edit', - 'exit_plan_mode', - 'get_goal', - 'list_agents', - 'ralph', - 'read', - 'send_message', - 'skill', - 'str_replace_editor', - 'subagent', - 'subagent_fork', - 'task_kill', - 'task_list', - 'task_output', - 'todo_write', - 'update_goal', - 'web_search', - 'workflow', - 'write', -] - -/** - * `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED - * ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair - * is always present on every host — asserted as fixed members, not a host - * dependency. - */ -const RIPGREP_TOOLS = ['glob', 'grep'] - -/** The assembled request header the smoke asserts on. */ -interface LoggedHeader { - /** Assembled tool names, sorted. */ - names: string[] - /** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */ - bashArguments: Record - /** Initial permission facts pinned by the shipped composition. */ - permissionEvents: Array<[string, unknown]> -} - -/** - * Read the request header the loop assembled for its first request from the - * session log the smoke's workspace persisted — the model-visible composition - * itself, not a registry projection taken beside it. - * @param cwd - the smoke's temporary workspace. - * @returns the assembled catalog, system prompt, and `bash` argument shape. - */ -async function loggedHeader(cwd: string): Promise { - const sessionsDir = join(cwd, '.sessions') - const entries = await readdir(sessionsDir, { recursive: true }) - // A single keyless run writes one session log. - const logRelPath = entries.find(name => name.endsWith('.jsonl')) - if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) - const events = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) - .map(line => JSON.parse(line) as SessionEvent) - const header = events.find(event => event.type === 'request/header') - if (header === undefined || header.type !== 'request/header') { - throw new Error(`session log ${logRelPath} has no request/header event`) - } - const tools = header.data.header.tools ?? [] - const bash = tools.find(schema => schema.name === 'bash') - return { - names: tools.map(schema => schema.name).sort(), - bashArguments: (bash?.parameters as { properties?: Record } | undefined)?.properties ?? {}, - permissionEvents: events.flatMap(event => - event.type === 'permission/preset' || event.type === 'sandbox/mode' || event.type === 'approval/policy' - ? [[event.type, event.data] as [string, unknown]] - : []), - } -} - -describe('shipped dsh composition (real Loader tree in a PTY)', () => { - it('assembles exactly the shipped TUI catalog', async () => { - let observed: LoggedHeader | undefined - const output = await runTuiPtySmoke({ - label: 'dsh shipped composition', - tempDirPrefix: 'dsh-shipped-tui-', - binScript: dshBinScript, - tsconfigPath, - configPath: keylessTail, - env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' }, - prepare: cwd => acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh')), - // Artifact CI builds and smokes concurrently on a contended runner. - ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), - actions: [ - { waitFor: COMPOSITION_SETTLED_MARKER, send: '/permission\r' }, - { waitFor: PERMISSION_SUMMARY, send: 'Describe the shipped composition.\r' }, - { waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' }, - ], - inspect: async (cwd) => { observed = await loggedHeader(cwd) }, - }) - expect(output).toContain(COMPOSITION_REPLY_TEXT) - expect(output).toContain(PERMISSION_SUMMARY) - expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS) - // The packaged ripgrep binary ships with the dependency, so the pair is a - // fixed roster member on every host. - expect(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) - expect(observed?.bashArguments).toHaveProperty('sandbox_permissions') - expect(observed?.bashArguments).toHaveProperty('justification') - expect(observed?.permissionEvents).toEqual([ - ['permission/preset', { preset: 'workspace-write' }], - ['sandbox/mode', { mode: 'workspace-write' }], - ['approval/policy', { policy: 'ask' }], - ]) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/apps/cli/tests/snapshots/bash-terminal-card/session.jsonl b/apps/cli/tests/snapshots/bash-terminal-card/session.jsonl deleted file mode 100644 index 1c8da1c8dd..0000000000 --- a/apps/cli/tests/snapshots/bash-terminal-card/session.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352050756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352051421,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":5,"time0":1783352051422,"data":{"turn":1,"step":1,"index":0,"dt":[168,28,0,1,0,0,26,30,0,0,1,0,27,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":23,"time":1783352051790,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":24,"time0":1783352051791,"data":{"turn":1,"step":1,"index":1,"dt":[29,0,0,0,0,28,0,0,0,29,0,0,28,1,0,29,0,0,0,32,0,0,0,0,0,74,0,0,13,0],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," TER","MIN","AL","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," TER","MIN","AL","_OK"," to"," verify"," terminal"," access","\"","}"]}} -{"type":"assistant/chunk","seq":55,"time":1783352052117,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":56,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}} -{"type":"assistant/chunk","seq":57,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":58,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":59,"time":1783352052121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} -{"type":"tool/call","seq":60,"time":1783352052121,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}} -{"type":"tool/result","seq":61,"time":1783352052136,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} -{"type":"step/end","seq":62,"time":1783352052137,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":63,"time":1783352052137,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":64,"time":1783352052701,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":65,"time0":1783352052702,"data":{"turn":1,"step":2,"index":0,"dt":[78,29,29,0,0,29,0,0,0,0,0,28,1,28,1,0,0,32,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":87,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":88,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":89,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":90,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} -{"type":"assistant/chunk","seq":91,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":94,"time":1783352052987,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} -{"type":"step/end","seq":95,"time":1783352052987,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":96,"time":1783352052987,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt b/apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt deleted file mode 100644 index 2efc357338..0000000000 --- a/apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ /dev/null @@ -1,55 +0,0 @@ -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "Use the bash tool to — DSH TUI snapshot" -cursor hidden column=7 viewportRow=24 bufferRow=24 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Use the bash tool to" - style 1-20 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop. " -6| -7| "Assistant " - style 0-8 fg=bright-magenta bold underline -8| "Reasoning " - style 0-8 dim italic -9| "The user wants me to run a simple bash command and then reply with \"DONE\". " - style 0-73 dim italic -10| -11| "● Tool / bash / Echo TERMINAL_OK to verify terminal access" - style 0-57 fg=green -12| "$ echo TERMINAL_OK " - style 0-17 dim -13| "TERMINAL_OK " - style 0-10 dim -14| "[exit 0] " - style 0-7 dim -15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -16| -17| "Assistant " - style 0-8 fg=bright-magenta bold underline -18| "Reasoning " - style 0-8 dim italic -19| "The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " - style 0-90 dim italic -20| "DONE " -21| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -22| -23| "/workspace/project deepseek-v4-flash ↑3.0k ↓115 cache 48% 3% contex" - style 0-46 fg=bright-magenta bold - style 49-65 dim - style 68-88 dim - style 91-99 dim -24| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -25-35| diff --git a/apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl deleted file mode 100644 index a93b2c57d5..0000000000 --- a/apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl +++ /dev/null @@ -1,32 +0,0 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":1785052797817,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785052797818,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool exactly once with the command `seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'`, then return ONLY the number of lines in its output. Reply with just that number and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785052797825,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785052797826,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785052797827,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785052798220,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785052798221,"data":{"turn":1,"step":1,"index":0,"dt":[170,30,0,0,0,30,1,0,0,28,0,0,0,29,30,0,30,0,30,0,0,0,30,0,0,0,0,0,30,30,1],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," run","_code"," program"," that"," calls"," bash"," exactly"," once"," with"," a"," specific"," command",","," then"," returns"," only"," the"," number"," of"," lines"," in"," its"," output","."]}} -{"type":"assistant/chunk","seq":38,"time":1785052798781,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":39,"time0":1785052798781,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,28,0,0,0,30,29,1,0,30,30,1,0,30,0,0,28,1,0,0,30,0,0,0,0,1,28,1,0,0,0,0,58,1,0,15,0,0,0,0,39,0,1,0,28,0,0,29,0,0,0,0,0,30,0,0,0,0,1,29,1,0,0,0,29,0,0,40,0,0,20,1,29,0,30,0,30,0,0,31,0,0,30,0,0,30,0,0,30,0,1,0,29,0,0,30,31,0,0,0,0,0,30,0,0,0,1,0,28,0,0],"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","args":["","{","\"","description","\"",": ","\"","Count"," lines"," in"," seq","/","awk"," output","\"",", ","\"","code","\"",": ","\"","const"," result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","seq"," ","1"," ","200"," |"," awk"," '{","printf"," \\\\\\\"","line"," %","04","d",":"," the"," quick"," brown"," fox"," jumps"," over"," the"," lazy"," dog","\\\\\\\\","n","\\\\\\\","," $","1","}'","\\\",\\n"," "," description",":"," \\\"","Generate"," ","200"," lines"," of"," text","\\\"\\n","});\\n\\n","//"," Count"," lines"," in"," stdout","\\n","const"," lines"," ="," result",".k","ind"," ==="," \\\"","fore","ground","\\\""," ?"," result",".stdout",".text",".trim","().","split","(\\\"\\\\","n","\\\").","length"," :"," ","0",";\\n","return"," lines",";","\"","}"]}} -{"type":"assistant/chunk","seq":152,"time":1785052799793,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."}}}} -{"type":"assistant/chunk","seq":153,"time":1785052799793,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}}}} -{"type":"assistant/chunk","seq":154,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":155,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"} -{"type":"tool/call","seq":157,"time":1785052799799,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}} -{"type":"tool/code-dispatch-start","seq":158,"time":1785052799893,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"}}} -{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: {{cwd}}/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} -{"type":"tool/result","seq":160,"time":1785052799925,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","content":[{"type":"text","text":"200"}],"isError":false},"sourceEventSeqs":[157],"surfaceOp":"append"} -{"type":"step/end","seq":161,"time":1785052799926,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":162,"time":1785052799928,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":163,"time":1785052800414,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":164,"time0":1785052800415,"data":{"turn":1,"step":2,"index":0,"dt":[157,32,0,0,0,1,30,1,30,0,0,0,33,1,0,0,0,31,0],"texts":["The"," result"," is"," ","200"," lines","."," The"," user"," wants"," me"," to"," reply"," with"," just"," that"," number"," and"," stop","."]}} -{"type":"assistant/chunk","seq":184,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":185,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"200"}}} -{"type":"assistant/chunk","seq":186,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."}}}} -{"type":"assistant/chunk","seq":187,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"200"}}}} -{"type":"assistant/chunk","seq":188,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":189,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":190,"time":1785052800733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."},{"type":"text","text":"200"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} -{"type":"step/end","seq":191,"time":1785052800733,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":192,"time":1785052800733,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt deleted file mode 100644 index f4208bf650..0000000000 --- a/apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt +++ /dev/null @@ -1,59 +0,0 @@ -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=7 viewportRow=26 bufferRow=26 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Using ONE run_code program: call" - style 1-32 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " - style 77-99 fg=cyan -6| "'{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " - style 0-72 fg=cyan -7| "number of lines in its output. Reply with just that number and stop. " -8| -9| "Assistant " - style 0-8 fg=bright-magenta bold underline -10| "Reasoning " - style 0-8 dim italic -11| "The user wants me to write a single run_code program that calls bash exactly once with a specific " - style 0-99 dim italic -12| "command, then returns only the number of lines in its output. " - style 0-60 dim italic -13| -14| "● Tool / run_code" - style 0-16 fg=green -15| "Count lines in seq/awk output " - style 0-99 dim -16| "200 " - style 0-99 dim -17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -18| -19| "Assistant " - style 0-8 fg=bright-magenta bold underline -20| "Reasoning " - style 0-8 dim italic -21| "The result is 200 lines. The user wants me to reply with just that number and stop. " - style 0-82 dim italic -22| "200 " -23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -24| -25| "/workspace/project deepseek-v4-flash ↑123 ↓208 cache 99% 3% c" - style 0-52 fg=bright-magenta bold - style 55-71 dim - style 74-93 dim - style 96-99 dim -26| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -27-35| diff --git a/apps/cli/tests/snapshots/code-mode/session.jsonl b/apps/cli/tests/snapshots/code-mode/session.jsonl deleted file mode 100644 index 42c6d902ba..0000000000 --- a/apps/cli/tests/snapshots/code-mode/session.jsonl +++ /dev/null @@ -1,34 +0,0 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785014512062,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":1785014512139,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785014512140,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785014512146,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785014512147,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785014512148,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785014512526,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785014512527,"data":{"turn":1,"step":1,"index":0,"dt":[92,26,0,0,0,27,0,1,20,1,0,0,0,25,1,0,0,0,24,1,24,26,0,24,1,25,0,0,0,1,0,24,0,1,0,0,0,24,1,0,0,0,24,0,1,0,24,1,0,0,0,0,24,1,0,24,0,0,0,1,1,23,0,0,0,0,1,24,25,1,24,1,0,0,0,25,0,25,1,0,0,25,0,0,24,1,0,0,0,25,0,0,24,1,0,25,1,0,0,25,23,26,1,0,0,25,0,0,24,1,0,0,24,0,1,0,24,1,0,0,25,0,0,1,0,0,23,0,1,0,0,0,24,1,0,0,0,0,24,0,0,0,0,1,24,1,0,0,0,0,25,0,0,0,0,1,24,0,0,24,1,0,0,0,24,0,1,0,0,0,33,0,0,0,16,1,0,0,24,1,0,0,0,26,1,0,23,25,0,0,25,1,0,24,0,1,0,0,24,1,0],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Calls"," `","bash","`"," tool"," twice"," -"," first"," with"," `","echo"," CODE","_","ONE","`,"," then"," with"," `","echo"," CODE","_T","WO","`\n","2","."," `","console",".log","`"," exactly"," `","capt","ured"," output","`\n","3","."," Returns"," the"," two"," outputs"," joined"," with"," a"," plus"," sign","\n\n","Let"," me"," think"," about"," the"," structure","."," The"," `","bash","`"," tool"," returns"," an"," object"," with"," stdout","/st","derr","."," I"," need"," to"," extract"," the"," stdout"," text"," from"," each"," call",".\n\n","Looking"," at"," the"," bash"," output"," type",":\n","```\n","{\n"," "," kind",":"," \"","fore","ground","\";\n"," "," exit","Code",":"," number"," |"," null",";\n"," "," signal",":"," string"," |"," null",";\n"," "," timed","Out",":"," boolean",";\n"," "," ab","orted",":"," boolean",";\n"," "," timeout","Ms",":"," number",";\n"," "," stdout",":"," {\n"," "," text",":"," string",";\n"," "," truncated",":"," boolean",";\n"," "," spill","Path","?:"," string",";\n"," "," };\n"," "," st","derr",":"," {"," ..."," };\n"," "," sand","box","?:"," {"," ..."," };\n","}\n","```\n\n","So"," I"," need"," to"," access"," `.","std","out",".text","`"," from"," each"," result",".\n\n","Let"," me"," write"," the"," program","."]}} -{"type":"assistant/chunk","seq":208,"time":1785014513974,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":209,"time0":1785014513974,"data":{"turn":1,"step":1,"index":1,"dt":[24,1,0,0,0,24,1,0,24,0,0,40,10,26,0,25,1,0,0,24,1,0,0,0,29,0,0,0,0,0,20,1,0,0,0,24,0,0,0,0,1,25,0,1,0,26,0,0,0,0,0,23,0,0,0,0,0,25,0,0,0,0,0,24,0,0,0,0,1,25,0,0,0,0,0,24,1,0,0,0,0,31,1,17,1,24,0,25,1,0,0,0,24,1,0,0,0,25,25,25,0,0,0,0,0,26,0,0,0,0,1,23,0,1,0,0,0,24,1,24,1,0,24,1,0,0,0,25,0],"id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","args":["","{","\"","description","\"",": ","\"","Call"," bash"," twice",","," log",","," join"," outputs","\"",", ","\"","code","\"",": ","\"","\\n","const"," r","1"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_","ONE","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_","ONE","\\\"\\n","});\\n\\n","const"," r","2"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_T","WO","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_T","WO","\\\"\\n","});\\n\\n","//"," Extract"," stdout"," text"," and"," trim"," trailing"," new","lines","\\n","const"," out","1"," ="," r","1",".stdout",".text",".trim","();\\n","const"," out","2"," ="," r","2",".stdout",".text",".trim","();\\n\\n","console",".log","(\\\"","capt","ured"," output","\\\");\\n\\n","return"," out","1"," +"," \\\"+","\\\""," +"," out","2",";\\n","\"","}"]}} -{"type":"assistant/chunk","seq":339,"time":1785014514829,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. `console.log` exactly `captured output`\n3. Returns the two outputs joined with a plus sign\n\nLet me think about the structure. The `bash` tool returns an object with stdout/stderr. I need to extract the stdout text from each call.\n\nLooking at the bash output type:\n```\n{\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: { ... };\n sandbox?: { ... };\n}\n```\n\nSo I need to access `.stdout.text` from each result.\n\nLet me write the program."}}}} -{"type":"assistant/chunk","seq":340,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}}}} -{"type":"assistant/chunk","seq":341,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}}}} -{"type":"assistant/chunk","seq":342,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":343,"time":1785014514837,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. `console.log` exactly `captured output`\n3. Returns the two outputs joined with a plus sign\n\nLet me think about the structure. The `bash` tool returns an object with stdout/stderr. I need to extract the stdout text from each call.\n\nLooking at the bash output type:\n```\n{\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: { ... };\n sandbox?: { ... };\n}\n```\n\nSo I need to access `.stdout.text` from each result.\n\nLet me write the program."},{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342],"surfaceOp":"append"} -{"type":"tool/call","seq":344,"time":1785014514839,"data":{"turn":1,"step":1,"callId":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}} -{"type":"tool/code-dispatch-start","seq":345,"time":1785014514956,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"}}} -{"type":"tool/code-dispatch","seq":346,"time":1785014514990,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} -{"type":"tool/code-dispatch-start","seq":347,"time":1785014514991,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO"}}} -{"type":"tool/code-dispatch","seq":348,"time":1785014515013,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} -{"type":"tool/result","seq":349,"time":1785014515017,"data":{"turn":1,"step":1,"callId":"call_00_D5QaUXWyA2cPRIFIT6o05977","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false},"sourceEventSeqs":[344],"surfaceOp":"append"} -{"type":"step/end","seq":350,"time":1785014515018,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":351,"time":1785014515022,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":352,"time":1785014515610,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":353,"time0":1785014515611,"data":{"turn":1,"step":2,"index":0,"dt":[116,25,0,26,1,0,25,26,1,0,0,26,1,0,0,25,0,0,26,0,0,26,0,0,0,1,1,24,0,1,0,0,25,26,0,0,0,0,2,24,0,1,0,25,1,0,0,0,25,1,0,0,0,25,0,0,0,0,0,26,1,0,0,0],"texts":["The"," program"," ran"," successfully","."," The"," output"," shows",":\n","-"," `","capt","ured"," output","`"," (","from"," console",".log",")\n","-"," `","CODE","_","ONE","+","CODE","_T","WO","`"," (","the"," returned"," joined"," string",")\n\n","The"," user"," asked"," me"," to"," reply"," with"," that"," joined"," string"," only"," and"," stop","."," So"," I","'ll"," reply"," with"," just"," `","CODE","_","ONE","+","CODE","_T","WO","`."]}} -{"type":"assistant/chunk","seq":418,"time":1785014516169,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":419,"time0":1785014516169,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,1,27],"texts":["CODE","_","ONE","+","CODE","_T","WO"]}} -{"type":"assistant/chunk","seq":426,"time":1785014516199,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran successfully. The output shows:\n- `captured output` (from console.log)\n- `CODE_ONE+CODE_TWO` (the returned joined string)\n\nThe user asked me to reply with that joined string only and stop. So I'll reply with just `CODE_ONE+CODE_TWO`."}}}} -{"type":"assistant/chunk","seq":427,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":428,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}}}} -{"type":"assistant/chunk","seq":429,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":430,"time":1785014516201,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. The output shows:\n- `captured output` (from console.log)\n- `CODE_ONE+CODE_TWO` (the returned joined string)\n\nThe user asked me to reply with that joined string only and stop. So I'll reply with just `CODE_ONE+CODE_TWO`."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}},"sourceEventSeqs":[352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429],"surfaceOp":"append"} -{"type":"step/end","seq":431,"time":1785014516202,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":432,"time":1785014516202,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/code-mode/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode/terminal.expected.txt deleted file mode 100644 index 03d65530b1..0000000000 --- a/apps/cli/tests/snapshots/code-mode/terminal.expected.txt +++ /dev/null @@ -1,143 +0,0 @@ -terminal 100x36 buffer=normal length=62 base=26 viewport=26 -lifecycle started=1 stopped=0 progress=inactive -title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=61 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Using ONE run_code program: call" - style 1-32 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " - style 63-75 fg=cyan - style 90-99 fg=cyan -6| "CODE_TWO. Inside that same program, console.log exactly captured output, then return the two outputs" - style 0-7 fg=cyan - style 56-70 fg=cyan -7| "joined with a plus sign. Reply with that joined string only and stop. " -8| -9| "Assistant " - style 0-8 fg=bright-magenta bold underline -10| "Reasoning " - style 0-8 dim italic -11| "The user wants me to write a single run_code program that: " - style 0-35 dim italic - style 36-43 fg=cyan - style 44-57 dim italic -12| "1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " - style 0-2 fg=bright-magenta - style 3-8 dim italic - style 9-12 fg=cyan - style 13-37 dim italic - style 38-50 fg=cyan - style 51-62 dim italic - style 63-75 fg=cyan -13| "2. console.log exactly captured output " - style 0-2 fg=bright-magenta - style 3-13 fg=cyan - style 14-22 dim italic - style 23-37 fg=cyan -14| "3. Returns the two outputs joined with a plus sign " - style 0-2 fg=bright-magenta - style 3-49 dim italic -15| " " -16| "Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " - style 0-37 dim italic - style 38-41 fg=cyan - style 42-99 dim italic -17| "extract the stdout text from each call. " - style 0-38 dim italic -18| " " -19| "Looking at the bash output type: " - style 0-31 dim italic -20| " " -21| " " -22| " { " - style 2-2 fg=cyan -23| " kind: \"foreground\"; " - style 2-22 fg=cyan -24| " exitCode: number | null; " - style 2-27 fg=cyan -25| " signal: string | null; " - style 2-25 fg=cyan -26| " timedOut: boolean; " - style 2-21 fg=cyan -27| " aborted: boolean; " - style 2-20 fg=cyan -28| " timeoutMs: number; " - style 2-21 fg=cyan -29| " stdout: { " - style 2-12 fg=cyan -30| " text: string; " - style 2-18 fg=cyan -31| " truncated: boolean; " - style 2-24 fg=cyan -32| " spillPath?: string; " - style 2-24 fg=cyan -33| " }; " - style 2-5 fg=cyan -34| " stderr: { ... }; " - style 2-19 fg=cyan -35| " sandbox?: { ... }; " - style 2-21 fg=cyan -36| " } " - style 2-2 fg=cyan -37| " " -38| " " -39| "So I need to access .stdout.text from each result. " - style 0-19 dim italic - style 20-31 fg=cyan - style 32-49 dim italic -40| " " -41| "Let me write the program. " - style 0-24 dim italic -42| -43| "● Tool / run_code" - style 0-16 fg=green -44| "Call bash twice, log, join outputs " - style 0-99 dim -45| "captured output " - style 0-99 dim -46| "CODE_ONE+CODE_TWO " - style 0-99 dim -47| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -48| -49| "Assistant " - style 0-8 fg=bright-magenta bold underline -50| "Reasoning " - style 0-8 dim italic -51| "The program ran successfully. The output shows: " - style 0-46 dim italic -52| "- captured output (from console.log) " - style 0-1 fg=bright-magenta - style 2-16 fg=cyan - style 17-35 dim italic -53| "- CODE_ONE+CODE_TWO (the returned joined string) " - style 0-1 fg=bright-magenta - style 2-18 fg=cyan - style 19-47 dim italic -54| " " -55| "The user asked me to reply with that joined string only and stop. So I'll reply with just " - style 0-99 dim italic -56| "CODE_ONE+CODE_TWO. " - style 0-16 fg=cyan - style 17-17 dim italic -57| "CODE_ONE+CODE_TWO " -58| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -59| -60| "/workspace/project deepseek-v4-flash ↑182 ↓446 cache 98% 4% context" - style 0-37 fg=bright-magenta bold - style 40-56 dim - style 59-78 dim - style 81-90 dim -61| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse diff --git a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl deleted file mode 100644 index 300f887178..0000000000 --- a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl +++ /dev/null @@ -1,13 +0,0 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl deleted file mode 100644 index 116ad7d42e..0000000000 --- a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl +++ /dev/null @@ -1,13 +0,0 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl deleted file mode 100644 index f6b0f49e0a..0000000000 --- a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ /dev/null @@ -1,64 +0,0 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} -{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} -{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}}} -{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} -{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}} -{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} -{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":26,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} -{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":34,"time":1783957884594,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":35,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} -{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} -{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} -{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} -{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} -{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":56,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} -{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} -{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} -{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt deleted file mode 100644 index 7d6a92ea77..0000000000 --- a/apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ /dev/null @@ -1,110 +0,0 @@ -terminal 100x36 buffer=normal length=59 base=23 viewport=23 -lifecycle started=1 stopped=0 progress=inactive -title "Run this advanced flow exactly — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=58 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Run this advanced flow exactly" - style 1-30 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use " -6| "run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a " -7| "direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply " -8| "with exactly ADVANCED_ACP_OK. " -9| -10| "Assistant " - style 0-8 fg=bright-magenta bold underline -11| -12| "● Tool / cordis_mount" - style 0-20 fg=green -13| "Mount temporary Cordis Plugin " - style 0-99 dim -14| "Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH " - style 0-99 dim -15| "restarts). " - style 0-99 dim -16| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -17| -18| "Assistant " - style 0-8 fg=bright-magenta bold underline -19| -20| "● Tool / run_code" - style 0-16 fg=green -21| "Verify the temporary marker Plugin " - style 0-99 dim -22| " " -23| "Temporary Plugins " - style 0-16 fg=bright-magenta bold dim -24| " " -25| "- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: " - style 0-1 fg=bright-magenta dim - style 2-99 dim -26| " until unmounted or DSH restarts " - style 0-99 dim -27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -28| -29| "Assistant " - style 0-8 fg=bright-magenta bold underline -30| -31| "● Tool / subagent" - style 0-16 fg=green -32| "DIRECT_CHILD_OK " - style 0-99 dim -33| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -34| -35| "Assistant " - style 0-8 fg=bright-magenta bold underline -36| -37| "● Tool / workflow" - style 0-16 fg=green -38| "workflow: advanced-acp-snapshot " - style 0-99 dim -39| "workflow \"advanced-acp-snapshot\" completed (1 agent). " - style 0-99 dim -40| "Return value: " - style 0-99 dim -41| "{ " - style 0-99 dim -42| " \"reply\": \"WORKFLOW_CHILD_OK\" " - style 0-99 dim -43| "} " - style 0-99 dim -44| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -45| -46| "Assistant " - style 0-8 fg=bright-magenta bold underline -47| -48| "● Tool / cordis_unmount" - style 0-22 fg=green -49| "Unmount temporary Cordis Plugin dyn-1 " - style 0-99 dim -50| "Temporary Plugin dyn-1 was unmounted and removed. " - style 0-99 dim -51| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -52| -53| "Assistant " - style 0-8 fg=bright-magenta bold underline -54| "ADVANCED_ACP_OK " -55| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -56| -57| "/workspace/project deepseek-v4-flash ↑18 ↓18 cache 0% 8% cont" - style 0-52 fg=bright-magenta bold - style 55-71 dim - style 74-90 dim - style 93-99 dim -58| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse diff --git a/apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl deleted file mode 100644 index 4c0cb5762a..0000000000 --- a/apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl +++ /dev/null @@ -1,16 +0,0 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":5,"time0":1783600638073,"data":{"turn":1,"step":1,"index":0,"dt":[100,16,0,0,0,0,24,0,0,0,0,29,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":24,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0],"texts":["WF","_CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":28,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/dynamic-workflow/session.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.jsonl deleted file mode 100644 index d5bb085c45..0000000000 --- a/apps/cli/tests/snapshots/dynamic-workflow/session.jsonl +++ /dev/null @@ -1,29 +0,0 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":5,"time0":1783600634643,"data":{"turn":1,"step":1,"index":0,"dt":[991,0,0,0,0,0,0,0,0,0,1,0,0,0,108,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," workflow"," tool"," exactly"," once"," with"," specific"," parameters","."," Let"," me"," carefully"," follow"," the"," instructions",":\n\n","1","."," args"," omitted"," (","so"," I"," don","'t"," include"," it",")\n","2","."," meta"," ="," {"," \"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\""," }\n","3","."," script"," ="," as"," given"," verb","atim","\n","4","."," After"," it"," returns",","," reply"," with"," \"","WORK","FL","OW","_D","ONE","\"\n\n","Let"," me"," do"," exactly"," that","."]}} -{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":94,"time0":1783600635756,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,5,0,275,0,0,0,0,206,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","args":["","{","\"","meta","\"",": ","{\"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\"","}",", ","\"","script","\"",": ","\"","\\n","phase","('","Run","')\\n","const"," reply"," ="," await"," agent","('","Reply"," with"," exactly"," the"," word"," WF","_CH","ILD","_OK"," and"," nothing"," else",".')\\n","return"," {"," reply"," }\\n","\"","}"]}} -{"type":"assistant/chunk","seq":155,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} -{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} -{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} -{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} -{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} -{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} -{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":163,"time":1783600638305,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":164,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":165,"time0":1783600640028,"data":{"turn":1,"step":2,"index":0,"dt":[106,28,33,667,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} -{"type":"assistant/chunk","seq":195,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":196,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} -{"type":"assistant/chunk","seq":201,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} -{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} -{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt b/apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt deleted file mode 100644 index ff8125c5ad..0000000000 --- a/apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ /dev/null @@ -1,86 +0,0 @@ -terminal 100x36 buffer=normal length=43 base=7 viewport=7 -lifecycle started=1 stopped=0 progress=inactive -title "Use the workflow tool exactly — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=42 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Use the workflow tool exactly" - style 1-29 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " -6| "\"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " -7| "phase('Run') " -8| "const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " -9| "return { reply } " -10| "After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other " -11| "tool. " -12| -13| "Assistant " - style 0-8 fg=bright-magenta bold underline -14| "Reasoning " - style 0-8 dim italic -15| "The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " - style 0-99 dim italic -16| "follow the instructions: " - style 0-23 dim italic -17| " " -18| "1. args omitted (so I don't include it) " - style 0-2 fg=bright-magenta - style 3-38 dim italic -19| "2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " - style 0-2 fg=bright-magenta - style 3-81 dim italic -20| "3. script = as given verbatim " - style 0-2 fg=bright-magenta - style 3-28 dim italic -21| "4. After it returns, reply with \"WORKFLOW_DONE\" " - style 0-2 fg=bright-magenta - style 3-46 dim italic -22| " " -23| "Let me do exactly that. " - style 0-22 dim italic -24| -25| "● Tool / workflow" - style 0-16 fg=green -26| "workflow: snapshot-flow " - style 0-99 dim -27| "workflow \"snapshot-flow\" completed (1 agent). " - style 0-99 dim -28| "Return value: " - style 0-99 dim -29| "{ " - style 0-99 dim -30| " \"reply\": \"WF_CHILD_OK\" " - style 0-99 dim -31| "} " - style 0-99 dim -32| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -33| -34| "Assistant " - style 0-8 fg=bright-magenta bold underline -35| "Reasoning " - style 0-8 dim italic -36| "The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " - style 0-99 dim italic -37| "\"WORKFLOW_DONE\" and stop. " - style 0-24 dim italic -38| "WORKFLOW_DONE " -39| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -40| -41| "/workspace/project deepseek-v4-flash ↑3.5k ↓227 cache 47% 3% context" - style 0-44 fg=bright-magenta bold - style 47-63 dim - style 66-86 dim - style 89-98 dim -42| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse diff --git a/apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl b/apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl deleted file mode 100644 index 5f45d65041..0000000000 --- a/apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl +++ /dev/null @@ -1,31 +0,0 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352113768,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352114428,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":5,"time0":1783352114428,"data":{"turn":1,"step":1,"index":0,"dt":[114,28,1,0,0,1,28,1,1,0,0,1,24,1,29,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]}} -{"type":"assistant/chunk","seq":23,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":25,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}}} -{"type":"assistant/chunk","seq":26,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} -{"type":"assistant/chunk","seq":27,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":28,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1783352114690,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} -{"type":"step/end","seq":30,"time":1783352114690,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":31,"time":1783352114690,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":32,"time":1783352114699,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":33,"time":1783352114699,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":34,"time":1783352114700,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":35,"time":1783352115341,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":36,"time0":1783352115341,"data":{"turn":2,"step":1,"index":0,"dt":[124,27,1,0,0,28,0,0,31,0,0,0,0,28,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","T","WO","\""," and"," no"," tools","."]}} -{"type":"assistant/chunk","seq":54,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":55,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} -{"type":"assistant/chunk","seq":56,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":57,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} -{"type":"assistant/chunk","seq":59,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":60,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":61,"time":1783352115611,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"step/end","seq":62,"time":1783352115611,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":63,"time":1783352115611,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt deleted file mode 100644 index c0c38592c3..0000000000 --- a/apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ /dev/null @@ -1,62 +0,0 @@ -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=7 viewportRow=30 bufferRow=30 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Reply with exactly the word:" - style 1-28 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Reply with exactly the word: ONE. No tools. " -6| -7| "Plan mode on. Use /plan off to leave. " - style 0-36 dim -8| -9| "Assistant " - style 0-8 fg=bright-magenta bold underline -10| "Reasoning " - style 0-8 dim italic -11| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 0-71 dim italic -12| "ONE " -13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -14| -15| "Context · plan-mode" - style 0-18 dim -16| "The user switched this session back to the default mode. " - style 0-55 dim -17| -18| "Plan mode off. " - style 0-13 dim -19| -20| "You " - style 0-2 fg=bright-magenta bold underline -21| "Reply with exactly the word: TWO. No tools. " -22| -23| "Assistant " - style 0-8 fg=bright-magenta bold underline -24| "Reasoning " - style 0-8 dim italic -25| "The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 0-67 dim italic -26| "TWO " -27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -28| -29| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% co" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-92 dim - style 95-99 dim -30| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -31-35| diff --git a/apps/cli/tests/snapshots/parallel-file-reads/session.jsonl b/apps/cli/tests/snapshots/parallel-file-reads/session.jsonl deleted file mode 100644 index bfe80d1949..0000000000 --- a/apps/cli/tests/snapshots/parallel-file-reads/session.jsonl +++ /dev/null @@ -1,28 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} -{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} -{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt b/apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt deleted file mode 100644 index 82b3048bb4..0000000000 --- a/apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ /dev/null @@ -1,58 +0,0 @@ -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "Use the read tool twice — DSH TUI snapshot" -cursor hidden column=7 viewportRow=27 bufferRow=27 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Use the read tool twice" - style 1-23 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " -6| -7| "Assistant " - style 0-8 fg=bright-magenta bold underline -8| -9| "● Tool / read" - style 0-12 fg=green -10| "Read a.txt " - style 0-99 dim -11| "1: alpha " - style 0-99 dim -12| " " -13| "(End of file - total 1 lines) " - style 0-99 dim -14| -15| "● Tool / read" - style 0-12 fg=green -16| "Read b.txt " - style 0-99 dim -17| "1: beta " - style 0-99 dim -18| " " -19| "(End of file - total 1 lines) " - style 0-99 dim -20| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -21| -22| "Assistant " - style 0-8 fg=bright-magenta bold underline -23| "DONE " -24| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -25| -26| "/workspace/project deepseek-v4-flash ↑20 ↓6 cache 0% 3% context" - style 0-47 fg=bright-magenta bold - style 50-66 dim - style 69-84 dim - style 87-96 dim -27| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -28-35| diff --git a/apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt deleted file mode 100644 index 4a58007052..0000000000 --- a/apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt +++ /dev/null @@ -1 +0,0 @@ -alpha diff --git a/apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt deleted file mode 100644 index 65b2df87f7..0000000000 --- a/apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt +++ /dev/null @@ -1 +0,0 @@ -beta diff --git a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt deleted file mode 100644 index 1d9c7048c6..0000000000 --- a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt +++ /dev/null @@ -1,132 +0,0 @@ -terminal 100x36 buffer=normal length=68 base=32 viewport=32 -lifecycle started=1 stopped=0 progress=inactive -title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=67 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Reply with exactly the word:" - style 1-28 dim -2| " main-session" - style 1-12 dim -3| -4| "Context · snapshot-seed" - style 0-22 dim -5| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -6| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -7| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -8| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -9| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -10| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -11| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -12| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -13| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -14| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -15| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -16| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -17| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -18| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-99 dim -19| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " - style 0-94 dim -20| -21| "You " - style 0-2 fg=bright-magenta bold underline -22| "Reply with exactly the word: ONE. No tools. " -23| -24| "Assistant " - style 0-8 fg=bright-magenta bold underline -25| "Reasoning " - style 0-8 dim italic -26| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 0-71 dim italic -27| "ONE " -28| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -29| -30| "Keyboard shortcuts " - style 0-17 fg=bright-magenta bold -31| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " - style 0-60 dim -32| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L " - style 0-99 dim -33| "redraw " - style 0-5 dim -34| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " - style 0-72 dim -35| " " -36| "/clear — Clear the transcript view (session history is unchanged) " - style 0-64 dim -37| "/compact — Compact older conversation history " - style 0-44 dim -38| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " - style 0-99 dim -39| "reasoning display " - style 0-16 dim -40| "/exit — Exit after the active turn reaches idle " - style 0-46 dim -41| "/help — Show keyboard shortcuts and commands " - style 0-43 dim -42| "/model [[provider/]model] — Show or switch this session's model " - style 0-62 dim -43| "/palette — Show every color and attribute role this terminal renders " - style 0-67 dim -44| "/quit — Exit after the active turn reaches idle " - style 0-46 dim -45| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " - style 0-87 dim -46| "/resume — List this workspace's resumable sessions " - style 0-49 dim -47| "/status — Show session diagnostics, system prompt, and registered tools " - style 0-70 dim -48| "/skill: [instructions] — load a skill into the conversation " - style 0-64 dim -49| -50| "Context · snapshot-injector" - style 0-26 dim -51| "Injected while compaction was running. " - style 0-37 dim -52| -53| "… earlier context was compacted … " - style 0-32 dim -54| -55| "You " - style 0-2 fg=bright-magenta bold underline -56| "Reply with exactly the word: TWO. No tools. " -57| -58| "Compacted 2 history items (~387 tokens). " - style 0-39 dim -59| -60| "Assistant " - style 0-8 fg=bright-magenta bold underline -61| "Reasoning " - style 0-8 dim italic -62| "The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 0-67 dim italic -63| "TWO " -64| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -65| -66| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" - style 0-49 fg=bright-magenta bold - style 52-68 dim - style 71-90 dim - style 93-99 dim -67| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl b/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl deleted file mode 100644 index e7d6e3aeec..0000000000 --- a/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}} -{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}} -{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt b/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt deleted file mode 100644 index 2d6068d48b..0000000000 --- a/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt +++ /dev/null @@ -1,157 +0,0 @@ -=== skill autocomplete === -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" -cursor hidden column=13 viewportRow=5 bufferRow=5 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Recorded replay: skill-invocation-policy" - style 1-40 dim -2| " main-session" - style 1-12 dim -3| -4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-77 dim - style 80-89 dim -5| " dsh > /skill " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 13-13 inverse -6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. " - style 7-78 fg=bright-magenta -7-35| - - -=== loaded exact invocation === -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title " Reference — DSH TUI snapshot" -cursor hidden column=7 viewportRow=30 bufferRow=30 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Reference" - style 1-40 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| " " -6| "References in this skill are relative to " -7| "/workspace/project/.agents/skills/user-only-skill. " -8| " " -9| "USER-ONLY BODY " -10| " " -11| -12| "Context · dsh-tool-skill" - style 0-23 dim -13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " - style 0-99 dim -14| "session: " - style 0-7 dim -15| " " -16| " " - style 0-17 dim -17| "- `model-only-skill`: Model-only assembled snapshot skill. " - style 0-57 dim -18| " " - style 0-18 dim -19| " " -20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " - style 0-99 dim -21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " - style 0-99 dim -22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " - style 0-99 dim -23| "instructions until it has been loaded. " - style 0-37 dim -24| -25| "Assistant " - style 0-8 fg=bright-magenta bold underline -26| "USER-ONLY SKILL LOADED " -27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -28| -29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-77 dim - style 80-89 dim -30| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -31-35| - - -=== denied exact invocation === -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title " Reference — DSH TUI snapshot" -cursor hidden column=7 viewportRow=32 bufferRow=32 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Reference" - style 1-40 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| " " -6| "References in this skill are relative to " -7| "/workspace/project/.agents/skills/user-only-skill. " -8| " " -9| "USER-ONLY BODY " -10| " " -11| -12| "Context · dsh-tool-skill" - style 0-23 dim -13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " - style 0-99 dim -14| "session: " - style 0-7 dim -15| " " -16| " " - style 0-17 dim -17| "- `model-only-skill`: Model-only assembled snapshot skill. " - style 0-57 dim -18| " " - style 0-18 dim -19| " " -20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " - style 0-99 dim -21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " - style 0-99 dim -22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " - style 0-99 dim -23| "instructions until it has been loaded. " - style 0-37 dim -24| -25| "Assistant " - style 0-8 fg=bright-magenta bold underline -26| "USER-ONLY SKILL LOADED " -27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -28| -29| "Skill \"model-only-skill\" is not available for user invocation. " - style 0-61 fg=yellow -30| -31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-77 dim - style 80-89 dim -32| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -33-35| diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md deleted file mode 100644 index 3608a840f5..0000000000 --- a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: model-only-skill -description: Model-only assembled snapshot skill. -user-invocable: false ---- - -MODEL-ONLY BODY MUST NOT LOAD diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md deleted file mode 100644 index fa231cd99d..0000000000 --- a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: user-only-skill -description: User-only assembled snapshot skill. -disable-model-invocation: true ---- - -USER-ONLY BODY diff --git a/apps/cli/tests/snapshots/source-checkout-workdir/session.jsonl b/apps/cli/tests/snapshots/source-checkout-workdir/session.jsonl deleted file mode 100644 index f7458c3b46..0000000000 --- a/apps/cli/tests/snapshots/source-checkout-workdir/session.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1784606400000,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":1784606400000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784606400000,"data":{"content":[{"type":"text","text":"what's the workdir?"}],"source":{"kind":"user"},"role":"user","id":"3fdc2885-1bea-4c6c-b4af-dbd5af7594f8"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784606400000,"data":{"title":"what's the workdir?","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784606400000,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784606400000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1784606400000,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," working"," directory","."," Let"," me"," check"," using"," p","wd","."]}} -{"type":"assistant/chunk","seq":23,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":24,"time0":1784606400000,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd","\"",", ","\"","description","\"",": ","\"","Print"," current"," working"," directory","\"","}"]}} -{"type":"assistant/chunk","seq":46,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."}}}} -{"type":"assistant/chunk","seq":47,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}}} -{"type":"assistant/chunk","seq":48,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":49,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":50,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."},{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"063a9245-32c3-4551-9ace-d43f10ed5582"},"usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"surfaceOp":"append"} -{"type":"tool/call","seq":51,"time":1784606400000,"data":{"turn":1,"step":1,"callId":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}} -{"type":"tool/result","seq":52,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_AqoWTncquNel5ZHsJHOo7491"},"content":[{"type":"tool-result","toolCallId":"call_00_AqoWTncquNel5ZHsJHOo7491","content":[{"type":"text","text":"{{cwd}}\n"}],"isError":false}],"role":"user","id":"16086d3b-6dfa-4970-a06e-78561475af8c"}},"sourceEventSeqs":[51],"surfaceOp":"append"} -{"type":"step/end","seq":53,"time":1784606400000,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":54,"time":1784606400000,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":55,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":56,"time0":1784606400000,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is"," `/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","`."]}} -{"type":"assistant/chunk","seq":83,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":84,"time0":1784606400000,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is",":\n\n","```\n","/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","\n","```\n\n","Note"," that"," the"," Deep","Se","ek"," Har","ness"," SDK"," checkout"," is"," at"," `/","opt","/d","sh","-source","`"," —"," that","'s"," a"," separate"," location"," used"," for"," inspecting"," or"," extending"," D","SH"," itself",","," while"," the"," working"," directory"," above"," is"," the"," current"," working"," directory"," for"," this"," session","."]}} -{"type":"assistant/chunk","seq":161,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current working directory is `{{cwd}}`."}}}} -{"type":"assistant/chunk","seq":162,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}}}} -{"type":"assistant/chunk","seq":163,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":164,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":165,"time":1784606400000,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current working directory is `{{cwd}}`."},{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"76c18b85-00aa-45f6-a32f-a2bd75412470"},"usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}},"sourceEventSeqs":[55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"} -{"type":"step/end","seq":166,"time":1784606400000,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":167,"time":1784606400000,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/source-checkout-workdir/terminal.expected.txt b/apps/cli/tests/snapshots/source-checkout-workdir/terminal.expected.txt deleted file mode 100644 index b9ecb5a553..0000000000 --- a/apps/cli/tests/snapshots/source-checkout-workdir/terminal.expected.txt +++ /dev/null @@ -1,67 +0,0 @@ -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "what's the workdir? — DSH TUI snapshot" -cursor hidden column=7 viewportRow=32 bufferRow=32 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " what's the workdir?" - style 1-19 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "what's the workdir? " -6| -7| "Assistant " - style 0-8 fg=bright-magenta bold underline -8| "Reasoning " - style 0-8 dim italic -9| "The user is asking about the current working directory. Let me check using pwd. " - style 0-78 dim italic -10| -11| "● Tool / bash / Print current working directory" - style 0-46 fg=green -12| "$ pwd " - style 0-4 dim -13| "/workspace/project " - style 0-17 dim -14| "[exit 0] " - style 0-7 dim -15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -16| -17| "Assistant " - style 0-8 fg=bright-magenta bold underline -18| "Reasoning " - style 0-8 dim italic -19| "The current working directory is /workspace/project. " - style 0-32 dim italic - style 33-84 fg=cyan - style 85-85 dim italic -20| "The current working directory is: " -21| " " -22| " " -23| " /workspace/project " - style 2-53 fg=cyan -24| " " -25| " " -26| "Note that the DeepSeek Harness SDK checkout is at /opt/dsh-source — that's a separate location used " - style 50-64 fg=cyan -27| "for inspecting or extending DSH itself, while the working directory above is the current working " -28| "directory for this session. " -29| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -30| -31| "/workspace/project deepseek-v4-flash ↑3.4k ↓185 cache 49% 3% c" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-93 dim - style 96-99 dim -32| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -33-35| diff --git a/apps/cli/tests/snapshots/todo-plan/session.jsonl b/apps/cli/tests/snapshots/todo-plan/session.jsonl deleted file mode 100644 index da2ac7dc25..0000000000 --- a/apps/cli/tests/snapshots/todo-plan/session.jsonl +++ /dev/null @@ -1,31 +0,0 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":5,"time0":1783352058320,"data":{"turn":1,"step":1,"index":0,"dt":[106,40,1,0,0,0,17,0,0,0,1,26,1,1,0,0,1,26,0,31,1,25,0,0,0,29,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," todo","_write"," tool"," to"," record"," a"," plan"," with"," exactly"," three"," todos"," in"," the"," specified"," status","es",","," then"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":36,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":37,"time0":1783352058717,"data":{"turn":1,"step":1,"index":1,"dt":[29,1,0,0,28,0,1,29,1,0,0,0,0,26,0,0,0,0,0,30,1,0,0,0,0,26,1,0,0,0,0,28,0,0,0,0,0,29,0,0,0,0,1,28,0,0,0,1,0,27,1,0,28],"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","args":["","{","\"","t","odos","\"",": ","[","{\"","content","\":"," \"","read"," the"," code","\","," \"","status","\":"," \"","in","_pro","gress","\"},"," {\"","content","\":"," \"","write"," the"," fix","\","," \"","status","\":"," \"","pending","\"},"," {\"","content","\":"," \"","run"," the"," tests","\","," \"","status","\":"," \"","pending","\"","}]","}"]}} -{"type":"assistant/chunk","seq":91,"time":1783352059095,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":92,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}} -{"type":"assistant/chunk","seq":93,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":94,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":95,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"} -{"type":"tool/call","seq":96,"time":1783352059099,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}} -{"type":"todo/write","seq":97,"time":1783352059100,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}} -{"type":"tool/result","seq":98,"time":1783352059101,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[96],"surfaceOp":"append"} -{"type":"step/end","seq":99,"time":1783352059101,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":100,"time":1783352059102,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":101,"time":1783352059732,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":102,"time0":1783352059733,"data":{"turn":1,"step":2,"index":0,"dt":[102,28,0,1,0,28,0,1,0,27,0,1,0,0,29,0,0,0,1,0],"texts":["The"," todos"," have"," been"," written"," successfully","."," Now"," I"," just"," need"," to"," reply"," with"," the"," single"," word"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":123,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":124,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":125,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":126,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":127,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":130,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} -{"type":"step/end","seq":131,"time":1783352059981,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":132,"time":1783352059981,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/snapshots/todo-plan/terminal.expected.txt b/apps/cli/tests/snapshots/todo-plan/terminal.expected.txt deleted file mode 100644 index a5d2ca07b3..0000000000 --- a/apps/cli/tests/snapshots/todo-plan/terminal.expected.txt +++ /dev/null @@ -1,65 +0,0 @@ -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "Use the todo_write tool to — DSH TUI snapshot" -cursor hidden column=7 viewportRow=31 bufferRow=31 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Use the todo_write tool to" - style 1-26 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| "Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " -6| "\"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " -7| "reply with the single word DONE and stop. " -8| -9| "Assistant " - style 0-8 fg=bright-magenta bold underline -10| "Reasoning " - style 0-8 dim italic -11| "The user wants me to use the todo_write tool to record a plan with exactly three todos in the " - style 0-99 dim italic -12| "specified statuses, then reply with \"DONE\". " - style 0-42 dim italic -13| -14| "● Tool / todo_write" - style 0-18 fg=green -15| "Update todo list " - style 0-99 dim -16| "Updated todo list: 2 pending, 1 in progress, 0 completed. " - style 0-99 dim -17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -18| -19| "Assistant " - style 0-8 fg=bright-magenta bold underline -20| "Reasoning " - style 0-8 dim italic -21| "The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " - style 0-94 dim italic -22| "DONE " -23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -24-25| -26| "Plan" - style 0-3 fg=bright-magenta bold -27| " ● read the code" - style 2-2 fg=yellow -28| " ○ write the fix" - style 2-2 dim -29| " ○ run the tests" - style 2-2 dim -30| "/workspace/project deepseek-v4-flash ↑3.1k ↓145 cache 47% 3% context" - style 0-37 fg=bright-magenta bold - style 40-56 dim - style 59-79 dim - style 82-91 dim -31| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -32-35| diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index bc6959261a..f8ee51216a 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -5,8 +5,8 @@ import { describe, expect, it } from 'vitest' /** * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts` * with the exact production launch vector (`node --import tsx/esm`, the same - * shape as `bin/dsh` and the root `dsh`/`demo:tui`/`demo:web` scripts) and - * assert the piped-stdio TTY refusal. The Node compatibility matrix runs this + * shape as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the + * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin * suite covers the published `lib/` entry, not this source chain. @@ -16,7 +16,7 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' describe('dsh SOURCE launcher (node --import tsx/esm)', () => { - it('boots the source entry and refuses pipes LOUD (non-zero exit + stderr)', async () => { + it('boots the source entry and requires the raw config overlay', async () => { const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], { cwd: repoRoot, input: '', @@ -28,9 +28,7 @@ describe('dsh SOURCE launcher (node --import tsx/esm)', () => { throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) } expect(result.exitCode).not.toBe(0) - expect(result.stderr).toContain('requires stdin and stdout to be interactive TTYs') - expect(result.stderr).toContain('dsh -p') - // The refusal happens before any plugin mounts: stdout stays silent. + expect(result.stderr).toContain('--config is required') expect(result.stdout).toBe('') }, 30_000) }) diff --git a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt deleted file mode 100644 index 9a5051ceb3..0000000000 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ /dev/null @@ -1,76 +0,0 @@ -overlay 120x30 rows=20 -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-119 dim -1| "│ ▄ DeepSeek Harness │" - style 0-0 dim - style 10-37 fg=blue - style 75-90 fg=blue bold - style 119-119 dim -2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" - style 0-0 dim - style 9-38 fg=blue - style 119-119 dim -3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能 │" - style 0-0 dim - style 4-43 fg=blue - style 119-119 dim -4| "│ ▄███████████████████▄ ████████████▀ 仍待完善,体验难免有些粗糙。 │" - style 0-0 dim - style 4-43 fg=blue - style 119-119 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ │" - style 0-0 dim - style 4-42 fg=blue - style 119-119 dim -6| "│ ▄███▀█████████████████████▄ ████▀▀ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 6-41 fg=blue - style 49-70 bold - style 119-119 dim -7| "│ ███ ▀▀█████████▀▀▀█████████▀ │" - style 0-0 dim - style 7-40 fg=blue - style 119-119 dim -8| "│ ███ ▀███████▀█ ▀███████ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │" - style 0-0 dim - style 7-39 fg=blue - style 119-119 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" - style 0-0 dim - style 7-39 fg=blue - style 119-119 dim -10| "│ ▀███ ▀██████████████ │" - style 0-0 dim - style 8-39 fg=blue - style 119-119 dim -11| "│ ▀███▄ ▀███████████▀ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 │" - style 0-0 dim - style 8-38 fg=blue - style 119-119 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外 │" - style 0-0 dim - style 9-38 fg=blue - style 119-119 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ ,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈, │" - style 0-0 dim - style 9-38 fg=blue - style 119-119 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ 都会帮助我们把它打磨得更好。 │" - style 0-0 dim - style 8-39 fg=blue - style 119-119 dim -15| "│ ▀▀███████████▀▀ │" - style 0-0 dim - style 12-34 fg=blue - style 119-119 dim -16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" - style 0-119 dim -17| "│ Enter 继续 │" - style 0-0 dim - style 54-64 fg=bright-magenta bold - style 119-119 dim -18| "│ │" - style 0-0 dim - style 119-119 dim -19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-119 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt deleted file mode 100644 index a374d14da8..0000000000 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ /dev/null @@ -1,76 +0,0 @@ -overlay 160x30 rows=20 -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-159 dim -1| "│ ▄ DeepSeek Harness │" - style 0-0 dim - style 10-37 fg=blue - style 95-110 fg=blue bold - style 159-159 dim -2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" - style 0-0 dim - style 9-38 fg=blue - style 159-159 dim -3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" - style 0-0 dim - style 4-43 fg=blue - style 159-159 dim -4| "│ ▄███████████████████▄ ████████████▀ │" - style 0-0 dim - style 4-43 fg=blue - style 159-159 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 4-42 fg=blue - style 49-70 bold - style 159-159 dim -6| "│ ▄███▀█████████████████████▄ ████▀▀ │" - style 0-0 dim - style 6-41 fg=blue - style 159-159 dim -7| "│ ███ ▀▀█████████▀▀▀█████████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │" - style 0-0 dim - style 7-40 fg=blue - style 159-159 dim -8| "│ ███ ▀███████▀█ ▀███████ 有的设计。 │" - style 0-0 dim - style 7-39 fg=blue - style 159-159 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ │" - style 0-0 dim - style 7-39 fg=blue - style 159-159 dim -10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 │" - style 0-0 dim - style 8-39 fg=blue - style 159-159 dim -11| "│ ▀███▄ ▀███████████▀ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我 │" - style 0-0 dim - style 8-38 fg=blue - style 159-159 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 们把它打磨得更好。 │" - style 0-0 dim - style 9-38 fg=blue - style 159-159 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ │" - style 0-0 dim - style 9-38 fg=blue - style 159-159 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ │" - style 0-0 dim - style 8-39 fg=blue - style 159-159 dim -15| "│ ▀▀███████████▀▀ │" - style 0-0 dim - style 12-34 fg=blue - style 159-159 dim -16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" - style 0-159 dim -17| "│ Enter 继续 │" - style 0-0 dim - style 74-84 fg=bright-magenta bold - style 159-159 dim -18| "│ │" - style 0-0 dim - style 159-159 dim -19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-159 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt deleted file mode 100644 index bdf8a46a6f..0000000000 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt +++ /dev/null @@ -1,31 +0,0 @@ -overlay 60x12 rows=10 -0| "╭──────────────────────────────────────────────────────────╮" - style 0-59 dim -1| "│ DeepSeek Harness │" - style 0-0 dim - style 22-37 fg=blue bold - style 59-59 dim -2| "│ │" - style 0-0 dim - style 59-59 dim -3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │" - style 0-0 dim - style 59-59 dim -4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │" - style 0-0 dim - style 59-59 dim -5| "│ │" - style 0-0 dim - style 59-59 dim -6| "├──────────────────────────────────────────────────────────┤" - style 0-59 dim -7| "│ Enter 继续 │" - style 0-0 dim - style 24-34 fg=bright-magenta bold - style 59-59 dim -8| "│ ↑/↓ 滚动 ↓ │" - style 0-0 dim - style 24-35 dim - style 59-59 dim -9| "╰──────────────────────────────────────────────────────────╯" - style 0-59 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt deleted file mode 100644 index ae53f4bfda..0000000000 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ /dev/null @@ -1,64 +0,0 @@ -overlay 60x30 rows=21 -0| "╭──────────────────────────────────────────────────────────╮" - style 0-59 dim -1| "│ DeepSeek Harness │" - style 0-0 dim - style 22-37 fg=blue bold - style 59-59 dim -2| "│ │" - style 0-0 dim - style 59-59 dim -3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │" - style 0-0 dim - style 59-59 dim -4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │" - style 0-0 dim - style 59-59 dim -5| "│ │" - style 0-0 dim - style 59-59 dim -6| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 59-59 dim -7| "│ │" - style 0-0 dim - style 59-59 dim -8| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" - style 0-0 dim - style 59-59 dim -9| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │" - style 0-0 dim - style 59-59 dim -10| "│ 设计。 │" - style 0-0 dim - style 59-59 dim -11| "│ │" - style 0-0 dim - style 59-59 dim -12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │" - style 0-0 dim - style 59-59 dim -13| "│ 会上传所有 Session Log;如需关闭,请设置环境变量 │" - style 0-0 dim - style 59-59 dim -14| "│ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议, │" - style 0-0 dim - style 59-59 dim -15| "│ 请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把 │" - style 0-0 dim - style 59-59 dim -16| "│ 它打磨得更好。 │" - style 0-0 dim - style 59-59 dim -17| "├──────────────────────────────────────────────────────────┤" - style 0-59 dim -18| "│ Enter 继续 │" - style 0-0 dim - style 24-34 fg=bright-magenta bold - style 59-59 dim -19| "│ │" - style 0-0 dim - style 59-59 dim -20| "╰──────────────────────────────────────────────────────────╯" - style 0-59 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt deleted file mode 100644 index c3a8b5d496..0000000000 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ /dev/null @@ -1,89 +0,0 @@ -overlay 80x30 rows=27 -0| "╭──────────────────────────────────────────────────────────────────────────────╮" - style 0-79 dim -1| "│ ▄▄▄▄▄▄ ▄▄ │" - style 0-0 dim - style 33-46 fg=blue - style 79-79 dim -2| "│ ▄████████▄ ▀████▀ │" - style 0-0 dim - style 31-48 fg=blue - style 79-79 dim -3| "│ █▀▀▀▀███████▄██▀ │" - style 0-0 dim - style 32-47 fg=blue - style 79-79 dim -4| "│ █▄ ▀███ ▀███ │" - style 0-0 dim - style 32-46 fg=blue - style 79-79 dim -5| "│ ▀█▄ ▀█████ │" - style 0-0 dim - style 33-46 fg=blue - style 79-79 dim -6| "│ ▀█▄▄ █▄▄▀███▄ │" - style 0-0 dim - style 33-46 fg=blue - style 79-79 dim -7| "│ ▀▀▀▀▀▀ │" - style 0-0 dim - style 35-44 fg=blue - style 79-79 dim -8| "│ │" - style 0-0 dim - style 79-79 dim -9| "│ DeepSeek Harness │" - style 0-0 dim - style 32-47 fg=blue bold - style 79-79 dim -10| "│ │" - style 0-0 dim - style 79-79 dim -11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完 │" - style 0-0 dim - style 79-79 dim -12| "│ 善,体验难免有些粗糙。 │" - style 0-0 dim - style 79-79 dim -13| "│ │" - style 0-0 dim - style 79-79 dim -14| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 79-79 dim -15| "│ │" - style 0-0 dim - style 79-79 dim -16| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │" - style 0-0 dim - style 79-79 dim -17| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" - style 0-0 dim - style 79-79 dim -18| "│ │" - style 0-0 dim - style 79-79 dim -19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │" - style 0-0 dim - style 79-79 dim -20| "│ Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反 │" - style 0-0 dim - style 79-79 dim -21| "│ 馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │" - style 0-0 dim - style 79-79 dim -22| "│ 好。 │" - style 0-0 dim - style 79-79 dim -23| "├──────────────────────────────────────────────────────────────────────────────┤" - style 0-79 dim -24| "│ Enter 继续 │" - style 0-0 dim - style 34-44 fg=bright-magenta bold - style 79-79 dim -25| "│ │" - style 0-0 dim - style 79-79 dim -26| "╰──────────────────────────────────────────────────────────────────────────────╯" - style 0-79 dim diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts deleted file mode 100644 index 984ab15a03..0000000000 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Context } from 'cordis' -import { visibleWidth } from '@earendil-works/pi-tui' -import { - type TuiOverlayHost, - type TuiOverlayRequest, - type TuiTheme, -} from '@deepseek-ai/dsh-tui' -import { - acknowledgeTuiFirstRunWelcome, - apply, - hasTuiFirstRunWelcomeAcknowledgement, - needsTuiFirstRunWelcomeAsciiArt, - TuiFirstRunWelcomeComponent, - tuiFirstRunWelcomeAcknowledgementPath, - tuiFirstRunWelcomeArtTier, -} from '../src/tui-onboarding/tui-first-run-welcome.ts' -import { - TUI_FIRST_RUN_WELCOME_NOTICE_COPY, - TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, - TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, -} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts' -import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts' - -const mockDisposeRootAndExit = vi.hoisted(() => vi.fn()) -vi.mock('@deepseek-ai/dsh-tui', async importOriginal => ({ - ...await importOriginal(), - disposeRootAndExit: mockDisposeRootAndExit, -})) - -const identityTheme: TuiTheme = Object.freeze({ - text: (value: string) => value, - brand: (value: string) => value, - dim: (value: string) => value, - accent: (value: string) => value, - success: (value: string) => value, - warning: (value: string) => value, - error: (value: string) => value, - bold: (value: string) => value, -}) - -function hostFixture(rows: number): { - host: TuiOverlayHost - closed: () => boolean - invalidations: () => number -} { - let closed = false - let invalidations = 0 - const controller = new AbortController() - return { - host: Object.freeze({ - signal: controller.signal, - viewport: Object.freeze({ columns: 160, rows }), - theme: identityTheme, - display: (value: string) => value, - invalidate: () => { invalidations += 1 }, - close: () => { closed = true }, - }), - closed: () => closed, - invalidations: () => invalidations, - } -} - -const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] -const openingSentence = `${copy.paragraphs[0]!.split('。', 1)[0]}。` -const temporaryHomes: string[] = [] - -function artAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string { - return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim() -} - -function withoutWhitespace(value: string): string { - return value.replace(/\s/gu, '') -} - -async function temporaryHome(prefix: string): Promise { - const home = await mkdtemp(join(tmpdir(), prefix)) - temporaryHomes.push(home) - return home -} - -afterEach(async () => { - mockDisposeRootAndExit.mockClear() - await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true }))) -}) - -describe('TUI first-run welcome acknowledgement', () => { - it('publishes one immutable per-version marker safely across concurrent acknowledgements', async () => { - const home = await temporaryHome('dsh-tui-welcome-ack-') - expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(false) - - await Promise.all(Array.from({ length: 8 }, () => acknowledgeTuiFirstRunWelcome(home))) - - expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) - const info = await stat(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION)) - expect(info.isFile()).toBe(true) - if (process.platform !== 'win32') expect(info.mode & 0o777).toBe(0o600) - }) - - it('treats a notice-version bump as a new one-time acknowledgement', async () => { - const home = await temporaryHome('dsh-tui-welcome-version-') - await acknowledgeTuiFirstRunWelcome(home) - const nextVersion = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION + 1 - - expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(false) - await acknowledgeTuiFirstRunWelcome(home, nextVersion) - expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(true) - }) - - it('rejects a malformed marker instead of silently acknowledging it', async () => { - const home = await temporaryHome('dsh-tui-welcome-malformed-') - await mkdir(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION), { - recursive: true, - }) - await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file') - await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow() - }) - - it('detects only explicit ASCII-only terminal environments', () => { - expect(needsTuiFirstRunWelcomeAsciiArt({ TERM: 'dumb' })).toBe(true) - expect(needsTuiFirstRunWelcomeAsciiArt({ LC_ALL: 'C' })).toBe(true) - expect(needsTuiFirstRunWelcomeAsciiArt({ LC_CTYPE: 'POSIX' })).toBe(true) - expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'C' })).toBe(true) - expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'en_US.UTF-8' })).toBe(false) - expect(typeof needsTuiFirstRunWelcomeAsciiArt()).toBe('boolean') - }) -}) - -describe('TUI first-run welcome composition', () => { - it('pins the supplied official icon and exact Chinese copy at their owner boundaries', async () => { - const icon = (await readFile(new URL('../assets/deepseek-color.svg', import.meta.url), 'utf8')).trimEnd() - expect(createHash('sha256').update(icon).digest('hex')) - .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') - expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('99f9a828b4f083b28de21bf5e03f939c00238531e765db78911957c44c6e98da') - expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy) - }) - - it.each([ - { columns: 60, inner: 50, rows: 30, tier: undefined }, - { columns: 80, inner: 68, rows: 30, tier: 'minimal' }, - { columns: 100, inner: 84, rows: 34, tier: 'compact' }, - { columns: 120, inner: 104, rows: 30, tier: 'full' }, - { columns: 160, inner: 140, rows: 30, tier: 'full' }, - ] as const)('renders the responsive composition at $columns columns without overdraw', ({ inner, rows, tier }) => { - const fixture = hostFixture(rows) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}) - const renderWidth = inner + 4 - const lines = component.render(renderWidth) - - expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) - expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true) - if (tier === undefined) { - expect(lines.join('\n')).not.toMatch(/[▀▄█]/u) - } else { - expect(lines.join('\n')).toContain(artAnchor(tier)) - } - const rendered = lines.join('\n') - const optOut = copy.paragraphs.at(-1)!.match(/[A-Z_]+=1/u)![0] - expect(rendered).not.toContain(copy.scrollHint) - expect(rendered).toContain(copy.paragraphs.at(-1)!.match(/[A-Za-z]+ [A-Za-z]+/u)![0]) - expect(rendered).toContain(optOut) - expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) - expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) - expect(lines.length).toBeGreaterThan(5) - }) - - it.each([ - { inner: 68, rows: 14, tier: undefined }, - { inner: 68, rows: 17, tier: undefined }, - { inner: 68, rows: 18, tier: 'minimal' }, - { inner: 84, rows: 21, tier: 'minimal' }, - { inner: 84, rows: 22, tier: 'compact' }, - ] as const)('degrades art to preserve the action at $rows rows', ({ inner, rows, tier }) => { - const fixture = hostFixture(rows) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}) - const lines = component.render(inner + 4) - expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) - expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) - expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) - }) - - it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => { - const fixture = hostFixture(10) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}) - const initial = component.render(54).join('\n') - expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined() - expect(initial).toContain(openingSentence) - expect(initial).toContain(`Enter ${copy.continueLabel}`) - - component.handleInput('\x1b[F') - const end = component.render(54).join('\n') - expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-7))) - expect(end).toContain(`Enter ${copy.continueLabel}`) - - for (const key of ['\x1b[A', '\x1b[B', '\x1b[5~', '\x1b[6~', '\x1b[H', 'x']) { - component.handleInput(key) - } - component.invalidate() - }) - - it('renders a tiny viewport and a quotation-only paragraph without overdraw', () => { - const fixture = hostFixture(5) - const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] } - const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}, () => {}) - const lines = component.render(2) - expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true) - }) - - it('keeps the side-by-side composition aligned when prose outgrows the full raster', () => { - const fixture = hostFixture(40) - const longCopy = { ...copy, paragraphs: [copy.paragraphs.join(' ').repeat(4)] } - const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}, () => {}) - const lines = component.render(100) - expect(lines.length).toBeGreaterThan(TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length) - expect(lines.every(line => visibleWidth(line) <= 100)).toBe(true) - component.handleInput('\x1b[F') - expect(component.render(100).join('\n')).toContain(copy.title) - }) - - it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { - const fixture = hostFixture(30) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}, true) - const rendered = component.render(72).join('\n') - expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim()) - expect(rendered).not.toMatch(/[▀▄█]/u) - }) - - it.each(['full', 'compact', 'minimal'] as const)('keeps the $tier ASCII raster bit-equivalent', (tier) => { - const mapped = TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode.map(line => Array.from(line).map((cell) => { - if (cell === '▀') return "'" - if (cell === '▄') return '_' - if (cell === '█') return '#' - return cell - }).join('')) - expect(mapped).toEqual(TUI_FIRST_RUN_WELCOME_WHALE[tier].ascii) - }) - - it('ignores Escape and acknowledges only Enter before closing', async () => { - const fixture = hostFixture(30) - const acknowledge = vi.fn(async () => {}) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {}) - component.render(72) - - component.handleInput('\x1b') - await Promise.resolve() - expect(acknowledge).not.toHaveBeenCalled() - expect(fixture.closed()).toBe(false) - - component.handleInput('\r') - await vi.waitFor(() => { expect(fixture.closed()).toBe(true) }) - expect(acknowledge).toHaveBeenCalledOnce() - }) - - it('keeps the notice eligible when Ctrl+C or Ctrl+D requests a normal exit', async () => { - const fixture = hostFixture(30) - const acknowledge = vi.fn(async () => {}) - const exit = vi.fn() - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, exit) - component.handleInput('\x03') - component.handleInput('\x04') - expect(exit).toHaveBeenCalledTimes(2) - expect(acknowledge).not.toHaveBeenCalled() - expect(fixture.closed()).toBe(false) - }) - - it('does not start a second acknowledgement while the first Enter is pending', async () => { - const fixture = hostFixture(30) - const pending = Promise.withResolvers() - const acknowledge = vi.fn(async () => pending.promise) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {}) - component.render(72) - - component.handleInput('\r') - component.handleInput('\r') - component.handleInput('\x1b[B') - expect(component.render(72).join('\n')).toContain(copy.saving) - expect(acknowledge).toHaveBeenCalledOnce() - - pending.resolve(undefined) - await vi.waitFor(() => { expect(fixture.closed()).toBe(true) }) - }) - - it('keeps the overlay open after a persistence failure and lets Enter retry', async () => { - const fixture = hostFixture(30) - let attempts = 0 - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => { - attempts += 1 - if (attempts === 1) throw new Error('disk unavailable') - }, () => {}) - component.render(72) - - component.handleInput('\r') - await vi.waitFor(() => { - expect(component.render(72).join('\n')).toContain(copy.saveError) - }) - expect(fixture.closed()).toBe(false) - - component.handleInput('\r') - await vi.waitFor(() => { expect(fixture.closed()).toBe(true) }) - expect(attempts).toBe(2) - expect(fixture.invalidations()).toBeGreaterThanOrEqual(3) - }) - - it('opens through the TUI extension and uses the launcher-owned acknowledgement closure', async () => { - const home = await temporaryHome('dsh-tui-welcome-apply-') - let request: TuiOverlayRequest | undefined - let disposePending: (() => Promise) | undefined - const ctx = { - effect(register: () => () => Promise) { - disposePending = register() - return () => {} - }, - tui: { - openOverlay(value: TuiOverlayRequest) { - request = value - return {} as never - }, - }, - } as unknown as Context - apply(ctx, { dshHome: home }) - expect(request?.options).toEqual({ - width: '100%', - maxHeight: '90%', - anchor: 'center', - margin: 0, - }) - - const fixture = hostFixture(30) - const component = request?.create(fixture.host) - expect(component).toBeInstanceOf(TuiFirstRunWelcomeComponent) - component?.handleInput?.('\x03') - expect(mockDisposeRootAndExit).toHaveBeenCalledWith(ctx, 0) - component?.handleInput?.('\r') - await disposePending?.() - expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) - - apply(ctx, { dshHome: home, asciiArt: true }) - expect(request?.create(fixture.host).render(72).join('\n')) - .toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim()) - }) -}) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts deleted file mode 100644 index 17b33f37ce..0000000000 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ /dev/null @@ -1,879 +0,0 @@ -import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' -import { createHash } from 'node:crypto' -import { realpathSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' -import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin' -import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' -import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' -import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' -import { - acknowledgeTuiFirstRunWelcome, - hasTuiFirstRunWelcomeAcknowledgement, -} from '../src/tui-onboarding/tui-first-run-welcome.ts' -import { - TUI_FIRST_RUN_WELCOME_NOTICE_COPY, - TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, -} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts' -import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts' - -const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) -// `--config` layers an overlay over the shared base, so the default surface -// needs no config argument at all; these are the overlays under test. -const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) -// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects -// while the TUI already holds the terminal. -const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) -const synchronizedFrameEnd = '\x1b[?2026l' -// Artifact mode gives the inner PTY driver 60 seconds and its execa owner a -// five-second backstop. Keep Vitest outside both deadlines so the harness can -// report its own marker, exit, and cleanup failure instead of being cut off. -const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib' - ? 75_000 - : LOADER_SMOKE_TEST_TIMEOUT_MS - -/** - * Seed the isolated process workspace: ordinary files land in `cwd`, personal - * files in the Harness home (`.dsh`), and skill bundles under the agents - * home's `skills/` root — the same trees `$DSH_HOME` / - * `$DSH_AGENTS_HOME` point the child at. - */ -function seedWorkspace( - files: { - workspace?: Record - personal?: Record - skills?: Record - }, -): (cwd: string) => Promise { - return async (cwd) => { - for (const [name, content] of Object.entries(files.workspace ?? {})) { - const file = join(cwd, name) - await mkdir(dirname(file), { recursive: true }) - await writeFile(file, content) - } - for (const [name, content] of Object.entries(files.personal ?? {})) { - const file = join(cwd, '.dsh', name) - await mkdir(dirname(file), { recursive: true }) - await writeFile(file, content) - } - for (const [name, content] of Object.entries(files.skills ?? {})) { - const file = join(cwd, '.agents', 'skills', name) - await mkdir(dirname(file), { recursive: true }) - await writeFile(file, content) - } - } -} - -/** - * Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin` - * package and return the generated wrapper text, so the smoke's cache-seeded - * wrapper can never drift from the generator's template. - */ -async function generatePreparedWrapper(pluginName: string): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-')) - try { - const plugin = join(root, '.dsh-plugin') - await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true }) - await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [ - '---', - 'name: config-only-repository', - 'description: Generator input; the seeded cache copy owns the visible text.', - '---', - '', - 'Repository instructions.', - '', - ].join('\n')) - await mkdir(plugin, { recursive: true }) - await writeFile(join(plugin, 'package.json'), `${JSON.stringify({ - name: pluginName, - version: '0.0.0', - dsh: { skills: ['../skills'] }, - }, undefined, 2)}\n`) - await prepareDshPlugin(plugin) - return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8') - } finally { - await rm(root, { recursive: true, force: true }) - } -} - -/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */ -async function seedResumeSession(cwd: string): Promise { - const sessionCwd = realpathSync.native(cwd) - const id = SessionId('resume-target') - const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd } - const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'user/message', seq: 1, time: 1_700_000_000_002, data: createUserMessage({ - content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' }, - }), surfaceOp: 'append' }, - { type: 'step/start', seq: 2, time: 1_700_000_000_003, data: { turn: 1, step: 1 } }, - { type: 'request/header', seq: 3, time: 1_700_000_000_004, data: { header: { config: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, reason: 'initial' } }, - { type: 'assistant/message', seq: 4, time: 1_700_000_000_005, data: { - turn: 1, step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'persisted answer' }], - source: { - kind: 'model', - ...{ provider: 'tui-scripted', model: 'tui-scripted-model' }, - }, - }), - }, surfaceOp: 'append' }, - { type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } }, - { type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } }, - { type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } }, - { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none') - await mkdir(dirname(file), { recursive: true }) - await writeFile(file, [ - JSON.stringify(toHeaderLine(meta)), - ...packChunkRuns(events).map(record => JSON.stringify(record)), - '', - ].join('\n')) -} - -/** Model-visible startup context from the first request in the workspace's persisted session log. */ -interface LoggedRequestContext { - /** The system prompt string the launcher sends. */ - system: string - /** The durable skill-catalog message serialized to text. */ - skillCatalog: string -} - -async function readLoggedRequestContext(cwd: string): Promise { - const sessionsDir = join(cwd, '.sessions') - const entries = await readdir(sessionsDir, { recursive: true }) - // A single keyless run writes one session log; the source section is global, so any log carries it. - const logRelPath = entries.find(name => name.endsWith('.jsonl')) - if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) - const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) - let skillCatalog = '' - for (const line of lines) { - const event = JSON.parse(line) as SessionEvent - if ( - event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill' - ) { - skillCatalog = JSON.stringify(event.data.content) - } - if (event.type === 'request/header') { - return { - system: event.data.header.system ?? '', - skillCatalog, - } - } - } - throw new Error(`session log ${logRelPath} has no request/header event`) -} - -/** - * Shared defaults: the keyless key and the dsh bin. Each case supplies either - * `configArgs: []` (boot the shipped composition, `base.cordis.yml` + - * `tui.cordis.yml`, with no flags) or `configPath` (an overlay layered over that - * same base through `--config`). - */ -function smoke(overrides: Partial & { - label: string - showFirstRunWelcome?: boolean -}): Promise { - const { showFirstRunWelcome = false, prepare, ...options } = overrides - return runTuiPtySmoke({ - tempDirPrefix: 'dsh-tui-smoke-', - binScript: dshBinScript, - tsconfigPath, - env: { - DEEPSEEK_API_KEY: 'keyless-tui-no-call', - DSH_TELEMETRY_DISABLED: '1', - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - LC_CTYPE: 'en_US.UTF-8', - TERM: 'xterm-256color', - }, - // Artifact CI builds and smokes concurrently on a contended runner. - ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), - ...options, - prepare: async (cwd) => { - if (!showFirstRunWelcome) await acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh')) - await prepare?.(cwd) - }, - }) -} - -const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] -const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}。` - -function firstRunArtAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string { - return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim() -} - -/** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */ -function overlaySnapshot(snapshot: string, columns: number, rows: number): string { - const blocks: string[][] = [] - for (const line of snapshot.split('\n')) { - if (/^\d+(?:-\d+)?~?\| /u.test(line)) blocks.push([line]) - else if (line.startsWith(' style ') && blocks.length > 0) blocks.at(-1)?.push(line) - } - const first = blocks.findIndex(block => block[0]?.includes('╭') === true) - const last = blocks.findIndex((block, index) => index >= first && block[0]?.includes('╰') === true) - if (first < 0 || last < first) throw new Error('first-run PTY snapshot has no complete overlay frame') - const overlay = blocks.slice(first, last + 1).flatMap((block, index) => [ - block[0]!.replace(/^\d+(?:-\d+)?(~)?\|/u, `${String(index)}$1|`), - ...block.slice(1), - ]) - return [`overlay ${String(columns)}x${String(rows)} rows=${String(last - first + 1)}`, ...overlay, ''].join('\n') -} - -/** Project the first synchronized PTY frame containing `marker` into an overlay-only snapshot. */ -async function firstRunFrameSnapshot( - output: string, - marker: string, - columns: number, - rows: number, -): Promise { - const markerIndex = output.indexOf(marker) - if (markerIndex < 0) throw new Error(`first-run PTY output has no marker ${JSON.stringify(marker)}`) - const frameEnd = output.indexOf(synchronizedFrameEnd, markerIndex) - if (frameEnd < 0) throw new Error(`first-run PTY output has no complete frame after ${JSON.stringify(marker)}`) - const terminal = new HeadlessTerminal(columns, rows) - try { - terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length)) - return overlaySnapshot(await terminal.snapshot(), columns, rows) - } finally { - await terminal.dispose() - } -} - -// The scripted conversation switches to the pro model first: the scripted -// adapter proves routing + prompt variables by rejecting tool-ful calls on any -// other route (see fixtures/tui-scripted-llm.ts). -const SELECT_PRO_MODEL = [ - { waitFor: 'scripted TUI ready.', send: '/model\r' }, - { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, -] as const -const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r' - -describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { - it.each([ - { columns: 60, tier: undefined }, - { columns: 80, tier: 'minimal' }, - { columns: 120, tier: 'full' }, - { columns: 160, tier: 'full' }, - ] as const)('renders and acknowledges the responsive first-run composition at $columns columns', async ({ columns, tier }) => { - const output = await smoke({ - label: `dsh first-run welcome ${String(columns)} columns`, - tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, - configPath: scriptedConfigPath, - showFirstRunWelcome: true, - expectedExitCode: 0, - columns, - rows: 30, - actions: [ - { - waitFor: `Enter ${firstRunCopy.continueLabel}`, - send: '\r\x03', - }, - ], - inspect: async (cwd) => { - expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) - const entries = await readdir(join(cwd, '.sessions'), { recursive: true }) - const logs = entries.filter(name => name.endsWith('.jsonl')) - for (const log of logs) { - const stored = await readFile(join(cwd, '.sessions', log), 'utf8') - expect(stored).not.toContain(firstRunCopy.paragraphs[0]) - } - }, - }) - await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, columns, 30)) - .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) - if (tier === undefined) { - expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) - } else { - expect(output).toContain(firstRunArtAnchor(tier)) - } - expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => { - const output = await smoke({ - label: 'dsh low-height first-run welcome', - tempDirPrefix: 'dsh-tui-welcome-low-', - configPath: scriptedConfigPath, - showFirstRunWelcome: true, - expectedExitCode: 0, - columns: 60, - rows: 12, - actions: [ - { waitFor: firstRunOpeningSentence, send: '\x1b[F' }, - { - waitFor: `Enter ${firstRunCopy.continueLabel}`, - occurrence: 2, - send: '\r\x03', - }, - ], - }) - await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, 60, 12)) - .toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt')) - expect(output).toContain(firstRunCopy.title) - expect(output).toContain(firstRunOpeningSentence) - expect(output).toContain('企业微信群') - expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) - expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('shows once and skips the second launch under the same DSH_HOME', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-twice-')) - try { - const first = await smoke({ - label: 'dsh first welcome launch', - tempDirPrefix: 'unused-', - cwd, - configPath: scriptedConfigPath, - showFirstRunWelcome: true, - expectedExitCode: 0, - actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, - ], - }) - expect(first).toContain(firstRunCopy.title) - - const second = await smoke({ - label: 'dsh second welcome launch', - tempDirPrefix: 'unused-', - cwd, - configPath: scriptedConfigPath, - showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, - actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }], - }) - expect(second).not.toContain(firstRunOpeningSentence) - expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`) - } finally { - await rm(cwd, { recursive: true, force: true }) - } - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it.skipIf(process.platform === 'win32')('keeps the notice eligible when the process exits before Enter', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-abort-')) - try { - await smoke({ - label: 'dsh aborted welcome launch', - tempDirPrefix: 'unused-', - cwd, - configPath: scriptedConfigPath, - showFirstRunWelcome: true, - expectedExitCode: -15, - actions: [{ waitFor: firstRunOpeningSentence, signal: 'SIGTERM' }], - inspect: async (workspace) => { - expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false) - }, - }) - - const next = await smoke({ - label: 'dsh welcome after aborted launch', - tempDirPrefix: 'unused-', - cwd, - configPath: scriptedConfigPath, - showFirstRunWelcome: true, - expectedExitCode: 0, - actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, - ], - }) - expect(next).toContain(firstRunOpeningSentence) - } finally { - await rm(cwd, { recursive: true, force: true }) - } - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { - // With no configured welcome the borderless banner sweeps in left-to-right; - // the detail line's session id (`main-session-`) renders only once - // the sweep reaches it, so it marks a settled banner. - const output = await smoke({ - label: 'dsh boot', - configArgs: [], - actions: [ - { waitFor: 'main-session-', send: '/plan' }, - { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' }, - { waitFor: 'Plan mode on. Use /plan off to leave.', send: '/exit\r' }, - ], - }) - expect(output).toContain('DEEPSEEK') - expect(output).toContain('HARNESS') - expect(output).toContain('main-session-') - expect(output).toContain('[off|message] — Enter or leave plan mode') - expect(output).toContain('Plan mode on. Use /plan off to leave.') - // Borderless: no box-drawing frame around the banner. - expect(output).not.toContain('╭') - expect(output).not.toContain('╮') - expect(output).toContain('\u001B[?2004l') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - // The Loader mounts entries concurrently, so `ui-tui` can already own the - // terminal when a sibling entry rejects on its config. Exiting without the - // tree's own teardown left raw mode and bracketed paste set on the user's - // shell, and the pending Device Attributes reply landed there as literal - // text. The transactional mount must settle (an HMR initial-scan refresh - // once deadlocked its rollback into a silent exit 13) so `boot` disposes - // the tree — reaching the TUI's own shutdown — and rejects with the - // labelled diagnostic. - it('restores the terminal when a sibling entry fails to validate during boot', async () => { - const output = await smoke({ - label: 'dsh invalid provider config', - tempDirPrefix: 'dsh-tui-invalid-config-', - configPath: invalidProviderConfigPath, - expectedExitCode: 1, - }) - expect(output).toContain('dsh: plugin tree failed to load:') - expect(output).toContain('$.providers') - // Bracketed paste is disabled again, which only `ProcessTerminal.stop()` - // writes — proof the tree was disposed rather than exited out from under. - expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { - const output = await smoke({ - label: 'dsh conversation', - tempDirPrefix: 'dsh-tui-conversation-', - configPath: scriptedConfigPath, - actions: [ - ...SELECT_PRO_MODEL, - { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan exercise the TUI\r' }, - // The question text first appears in the streamed tool-call card. Wait - // for the dialog's input legend so Enter cannot arrive before it owns - // terminal input when pre-dispatch policy yields. - { - waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt', - send: ANSWER_MULTI_WITH_CUSTOM, - }, - { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, - // Session title: the first user message drives the first-message-llm - // provider's tool-less title call; the scripted adapter answers it, the - // accepted title lands in the log, and the TUI renders the terminal - // window title as `` via OSC 0. - // Gating /status on it keeps the assertion race-free; the diagnostics - // card is then exercised through the same real Loader/PTY composition. - { waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' }, - { waitFor: 'Plan mode off.', send: 'Confirm the scripted run left plan mode.\r' }, - { waitFor: 'Default mode confirmed.', send: '/status\r' }, - { waitFor: 'Session status', send: '/exit\r' }, - ], - }) - expect(output).toContain('I need one decision before I continue.') - expect(output).toContain('Reasoning effort: Max.') - expect(output).toContain('Plan mode on. Use /plan off to leave.') - expect(output).toContain('Plan mode off.') - expect(output).toContain('Default mode confirmed.') - expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) - expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) - expect(output).toContain(String.raw`\x9b31mMODEL_C1`) - expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007') - expect(output).not.toContain('\u001B[999CMODEL_CURSOR') - expect(output).not.toContain('\u009B31mMODEL_C1') - expect(output).toContain('Safe') - expect(output).toContain('Release notes') - expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') - expect(output).toContain('Session status') - expect(output).toContain('Title') - expect(output).toContain('scripted session title') - expect(output).toContain('Model') - expect(output).toContain('tui-scripted/tui-scripted-model-pro') - expect(output).toContain('KV cache') - expect(output).toContain('Context') - expect(output).toContain('128,000') - expect(output).toContain('System prompt') - expect(output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') - expect(output).toContain('Registered tools') - expect(output).toContain('ask_user_question') - expect(output).toContain('\u001B[?2004l') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => { - // The whole user-only invocation path in one keyless boot: `ctx.get('skills')` - // resolves in the shipped tree, the client-side `/skill:` command parses, - // and the local provider admits a model-disabled skill by the omitted - // `user-invocable` default. The rendered `` block reaches - // the model — proven by the scripted adapter echoing the fixture's body - // marker only when it arrives. - const output = await smoke({ - label: 'dsh skill', - tempDirPrefix: 'dsh-tui-skill-', - configPath: scriptedConfigPath, - prepare: seedWorkspace({ - skills: { - 'scripted-skill/SKILL.md': [ - '---', - 'name: scripted-skill', - 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.', - 'disable-model-invocation: true', - '---', - '', - 'SCRIPTED SKILL BODY MARKER', - '', - ].join('\n'), - }, - }), - actions: [ - ...SELECT_PRO_MODEL, - { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/skill:scripted-skill\r' }, - { waitFor: 'Scripted skill body received.', send: '/exit\r' }, - ], - }) - expect(output).not.toContain('[instructions]') - expect(output).toContain('Scripted skill body received.') - expect(output).toContain('\u001B[?2004l') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('adds a watched local skill to live /skill: autocomplete without restarting', async () => { - const skill = [ - '---', - 'name: hot-added-skill', - 'description: HOT_ADDED_COMPLETION_MARKER', - '---', - '', - 'Hot-added body.', - '', - ].join('\n') - const output = await smoke({ - label: 'tui-agent hot-added skill autocomplete', - tempDirPrefix: 'tui-agent-hot-skill-', - configPath: scriptedConfigPath, - actions: [ - { - waitFor: 'scripted TUI ready.', - writeFile: { - path: '.agents/skills/hot-added-skill/SKILL.md', - content: skill, - }, - send: '/skill:hot', - }, - { waitFor: 'HOT_ADDED_COMPLETION_MARKER', send: '\x03/exit\r' }, - ], - }) - expect(output).toContain('HOT_ADDED_COMPLETION_MARKER') - expect(output).toContain('\u001B[?2004l') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => { - const output = await smoke({ - label: 'dsh file autocomplete', - tempDirPrefix: 'dsh-tui-file-autocomplete-', - // Source-plane PTY coverage complements the deterministic package-level - // autocomplete tests. Artifact CI omits this timing-sensitive terminal - // rendering assertion; built boot is covered by the neighboring cases. - configArgs: [], - prepare: seedWorkspace({ - workspace: { - 'src/terminal-special-case.ts': 'export const marker = true\n', - 'src/other.ts': 'export const other = true\n', - }, - }), - actions: [ - { waitFor: 'main-session-', send: '@tsc' }, - { waitFor: 'File · terminal-special-case.t', send: '\t' }, - { waitFor: '@src/terminal-special-case.ts', send: '\x03/exit\r' }, - ], - }) - expect(output).toContain('File · terminal-special-case.t') - expect(output).toContain('@src/terminal-special-case.ts') - expect(output).toContain('\u001B[?2004l') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - -}) - -describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { - it('shows the terminal-local notice over a resumed session without changing its log', async () => { - let originalLineCount = 0 - const output = await smoke({ - label: 'dsh first-run notice on resume', - tempDirPrefix: 'dsh-tui-welcome-resume-', - binScript: dshBinScript, - configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath], - showFirstRunWelcome: true, - expectedExitCode: 0, - prepare: async (cwd) => { - await seedResumeSession(cwd) - const before = await readFile(logPath( - join(cwd, '.sessions'), - realpathSync.native(cwd), - SessionId('resume-target'), - 'none', - ), 'utf8') - originalLineCount = before.split('\n').filter(Boolean).length - }, - actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, - ], - inspect: async (cwd) => { - const after = await readFile(logPath( - join(cwd, '.sessions'), - realpathSync.native(cwd), - SessionId('resume-target'), - 'none', - ), 'utf8') - expect(after).not.toContain(firstRunCopy.paragraphs[0]) - const appended = after.split('\n').filter(Boolean).slice(originalLineCount) - .map(line => JSON.parse(line) as SessionEvent) - expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' })) - expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' })) - }, - }) - expect(output).toContain(firstRunOpeningSentence) - expect(output).toContain('Resume selector design — DeepSeek Harness') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('exec-replaces the TUI for /resume and restores the same session state', async () => { - const output = await smoke({ - label: 'dsh in-place resume', - tempDirPrefix: 'dsh-in-place-resume-', - binScript: dshBinScript, - configPath: scriptedConfigPath, - prepare: seedResumeSession, - actions: [ - { waitFor: 'scripted TUI ready.', send: '/resume\r' }, - { waitFor: 'Resume selector design', send: 'Resume selector design' }, - { waitFor: '⌕ Resume selector design', send: '\r' }, - { waitFor: 'Preserve restored state', send: '/exit\r' }, - ], - }) - const released = output.indexOf('\u001B[?2004l') - const restored = output.indexOf('Resume selector design — DeepSeek Harness') - expect(released).toBeGreaterThanOrEqual(0) - expect(restored).toBeGreaterThan(released) - expect(output).toContain('Preserve restored state') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('boots the shipped default config with no arguments and no personal overlay', async () => { - const output = await smoke({ - label: 'dsh default boot', - tempDirPrefix: 'dsh-default-boot-', - binScript: dshBinScript, - configArgs: [], - actions: [{ waitFor: 'main-session-', send: '/exit\r' }], - }) - expect(output).toContain('DEEPSEEK') - expect(output).toContain('main-session-') - expect(output).not.toContain('╭') - expect(output).not.toContain('╮') - expect(output).toContain('\u001B[?2004l') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => { - // The whole personal-config chain in one boot, plus the environment layer - // it deliberately excludes. config.yaml patches the `tui` row — a row the - // SURFACE OVERLAY inserted, not one the base declares — proving a later - // patch list reaches a row an earlier one inserted. The single `!!js` - // expression prefers the PERSONAL variable, so the welcome can only render - // the project value while the harness home's .env — the credential store - // of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting - // it would make every stored key read as a read-only launch override on - // the next run and hand it to every subprocess the agent starts. - const output = await smoke({ - label: 'dsh personal overlay', - tempDirPrefix: 'dsh-personal-overlay-', - binScript: dshBinScript, - configArgs: [], - prepare: seedWorkspace({ - workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' }, - personal: { - '.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n', - 'config.yaml': [ - '- id: workspace-context', - ' disabled: true', - '- id: tui', - ' config:', - " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", - ' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME', - '', - ].join('\n'), - }, - }), - actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }], - }) - expect(output).toContain('PROJECT OVERLAY READY.') - expect(output).not.toContain('HOME ENV LEAKED.') - expect(output).toContain('\u001B[?2004l') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('loads a cached repository Plugin from personal config alone', async () => { - const source = 'github:fixture/repository#fixed-ref' - const specifier = `${source}&path:/.dsh-plugin` - const key = createHash('sha256').update(specifier).digest('hex') - const packageRoot = `cache/repository-plugins/${key}/node_modules/repository` - // Produced by the real generator (prepareDshPlugin over an equivalent - // .dsh-plugin package) rather than hand-written, so a wrapper-template - // change cannot leave this smoke exercising a stale shape. The cache - // LAYOUT below (sha256 key, marker, node_modules/repository) remains a - // deliberate external pin of the durable on-disk format. - const wrapper = await generatePreparedWrapper('config-only-fixture') - const output = await smoke({ - label: 'dsh personal repository Plugin', - tempDirPrefix: 'dsh-personal-repository-plugin-', - binScript: dshBinScript, - configArgs: [], - prepare: seedWorkspace({ - personal: { - 'config.yaml': [ - '- id: repository-plugins', - " name: '@deepseek-ai/dsh-repository-plugin'", - ' config:', - ' repositories:', - ` - '${source}'`, - '', - ].join('\n'), - [`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`, - [`${packageRoot}/dsh-plugin.mjs`]: wrapper, - [`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [ - '---', - 'name: config-only-repository', - 'description: CONFIG_ONLY_REPOSITORY_SKILL', - '---', - '', - 'Repository instructions.', - '', - ].join('\n'), - }, - }), - actions: [ - { waitFor: 'main-session-', send: '/skill:config-only' }, - { waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' }, - ], - }) - expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL') - expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('fails loud instead of booting when the personal config.yaml is invalid', async () => { - const output = await smoke({ - label: 'dsh invalid personal config', - tempDirPrefix: 'dsh-invalid-personal-', - binScript: dshBinScript, - configArgs: [], - prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }), - expectedExitCode: 1, - }) - expect(output).toContain('must be a top-level YAML array of loader patch entries') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => { - // The flag path end to end: apps/cli parses `--resume missing-session`, - // provides it as the launcher-owned identity on the boot context, and the - // resume fails loud — proving the printed hint reaches the app's resume - // intake with no config key and no environment variable. - const output = await smoke({ - label: 'dsh resume flag failure', - tempDirPrefix: 'dsh-resume-flag-', - binScript: dshBinScript, - configArgs: ['--resume', 'missing-session'], - expectedExitCode: 1, - }) - expect(output).toContain('ui-tui: session "missing-session" failed to start:') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('prints the launcher-owned resume command on exit, naming the booted config', async () => { - // The exit line is built by apps/cli from this invocation, so it must carry - // `--config`: a hint that omitted it would resume into the default tree. - const output = await smoke({ - label: 'dsh goodbye message', - tempDirPrefix: 'dsh-goodbye-', - binScript: dshBinScript, - configPath: scriptedConfigPath, - actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }], - }) - expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => { - // Loader patches replace a targeted `config` key wholesale, so a personal - // overlay repointing the model route drops every identity key the shipped - // row declared. Launcher-owned identity makes that unreachable: agent-loop - // applies the launcher's id over whatever route survives. - const output = await smoke({ - label: 'dsh overlay keeps resume', - tempDirPrefix: 'dsh-overlay-resume-', - binScript: dshBinScript, - configArgs: [], - prepare: seedWorkspace({ - personal: { - 'config.yaml': [ - '- id: workspace-context', - ' disabled: true', - '- id: agent-loop', - ' config:', - ' agents:', - ' - id: main', - ' provider: deepseek-official', - ' model: deepseek-v4-flash', - ' cwd: !!js process.cwd()', - '- id: tui', - ' config:', - " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", - ' welcome: OVERLAY REPLACED THE CONFIG.', - '', - ].join('\n'), - }, - }), - actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }], - }) - expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/) - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('reports a failing bash command exactly once, as the terminal card exit pill', async () => { - // The model-facing result ends in `[exit code: 3]`, which the terminal card - // consumes into its own `[exit 3]` pill. Rendering both would report the same - // exit twice, so the marker must not survive into the card body. - const output = await smoke({ - label: 'dsh bash exit pill', - tempDirPrefix: 'dsh-bash-exit-pill-', - configPath: scriptedConfigPath, - actions: [ - ...SELECT_PRO_MODEL, - { - waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', - send: 'Run the failing scripted command.\r', - }, - { waitFor: 'Scripted bash failure observed.', send: '/exit\r' }, - ], - }) - // The command really ran: its stdout is in the card body. - expect(output).toContain('SCRIPTED_BASH_FAILED') - expect(output).toContain('[exit 3]') - expect(output).not.toContain('[exit code: 3]') - }, PTY_SMOKE_TEST_TIMEOUT_MS) - - it('distinguishes its source path from the current workdir and offers the bundled maintenance skills', async () => { - // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; - // this test file sits an equal depth under the same root, so the same hop applies. - // The source-path line explicitly distinguishes that checkout from the current workdir; - // bundled skills reach the model through a durable user message, so each assertion - // targets its own field. - const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) - let context: LoggedRequestContext = { system: '', skillCatalog: '' } - await smoke({ - label: 'dsh source-path prompt', - tempDirPrefix: 'dsh-source-path-', - binScript: dshBinScript, - configPath: scriptedConfigPath, - actions: [ - ...SELECT_PRO_MODEL, - { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM }, - { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, - ], - inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, - }) - expect(context.system).toContain(`The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`) - expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") - expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') - expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') - }, PTY_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/apps/cli/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts deleted file mode 100644 index 1fdda75424..0000000000 --- a/apps/cli/tests/tui.snapshot.ts +++ /dev/null @@ -1,852 +0,0 @@ -import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' -import type { Agent } from '@deepseek-ai/dsh-agent' -import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' -import CommandService from '@deepseek-ai/dsh-commands' -import * as CommandCompact from '@deepseek-ai/dsh-command-compact' -import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' -import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' -import LocalFileSystem from '@deepseek-ai/dsh-fs-local' -import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import PlanModeService from '@deepseek-ai/dsh-plan-mode' -import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import { packChunkRuns, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' -import SubagentService from '@deepseek-ai/dsh-subagent' -import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' -import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' -import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' -import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' -import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' -import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { createTuiChat, FILE_REFERENCE_PROMPT, TuiPromptService } from '@deepseek-ai/dsh-tui' -import LocalSpillStore from '@deepseek-ai/dsh-spill-local' -import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' -import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' - -const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') -// Keep pre-normalization layout widths identical across macOS and Linux. -const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp' -const PROVIDERS = [{ id: 'deepseek-official', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] -const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi - -type SnapshotMode = 'replay' | 'record' | 'refresh' -type Composition = 'native' | 'code' | 'advanced' -type ScenarioInteraction = 'skill-invocation-policy' - -interface Scenario { - name: string - /** Replay fixture owned by an earlier scenario, for a derived presentation case. */ - fixture?: string - composition: Composition - expectedTools: string[] - expectedEventCounts?: Record - childSessions?: number - enterPlanMode?: boolean - leavePlanModeAfterFirstTurn?: boolean - recorded: boolean - seedWorkspace?: boolean - /** Add the launcher's model-visible DSH source checkout at this fixed path. */ - harnessSourceRoot?: string - /** Replace the real `pwd` result with a portable fixed-length workspace path. */ - normalizePwdResult?: boolean - /** - * Load the opt-in `todo_write` tool for this scenario. The shipped TUI - * config omits it, so only the todo-plan scenario (the enabled-path proof) - * mounts it; the rest cover the default, todo-free composition. - */ - enableTodo?: boolean - /** - * Mount the spill stack (local backend + policy) with this inline cap, as the - * shipped configs do. The dispatch-spill scenario proves the durable - * `tool/code-dispatch` copy of an oversized sub-result is bounded to a - * preview + locator while the program value stays whole. - */ - spillMaxInlineBytes?: number - /** Run scenario-specific terminal input instead of replaying recorded user prompts. */ - interaction?: ScenarioInteraction - /** - * Mount a deterministic compaction backend plus `/compact`, then run the - * human command with a held summary while a prompt and injected context - * arrive. Proves queued input waits for the standalone bracket's durability - * checkpoint instead of racing the replacement. - */ - manualCompact?: boolean -} - -const SCENARIOS: Scenario[] = [ - { - name: 'multi-turn-conversation', - composition: 'native', - expectedTools: [], - expectedEventCounts: { 'plan/mode': 2 }, - enterPlanMode: true, - leavePlanModeAfterFirstTurn: true, - recorded: true, - }, - { - name: 'queued-manual-compact', - fixture: 'multi-turn-conversation', - composition: 'native', - expectedTools: [], - recorded: false, - manualCompact: true, - }, - { - name: 'todo-plan', - composition: 'native', - expectedTools: ['todo_write'], - expectedEventCounts: { 'todo/write': 1 }, - recorded: true, - enableTodo: true, - }, - { - name: 'bash-terminal-card', - composition: 'native', - expectedTools: ['bash'], - recorded: true, - }, - { - name: 'source-checkout-workdir', - composition: 'native', - expectedTools: ['bash'], - recorded: true, - harnessSourceRoot: '/opt/dsh-source', - normalizePwdResult: true, - }, - { - name: 'parallel-file-reads', - composition: 'native', - expectedTools: ['read', 'read'], - recorded: true, - seedWorkspace: true, - }, - { - name: 'skill-invocation-policy', - composition: 'native', - expectedTools: [], - recorded: false, - seedWorkspace: true, - interaction: 'skill-invocation-policy', - }, - { - name: 'code-mode', - composition: 'code', - expectedTools: ['run_code'], - expectedEventCounts: { 'tool/code-dispatch': 2 }, - recorded: true, - }, - { - name: 'code-mode-dispatch-spill', - composition: 'code', - expectedTools: ['run_code'], - expectedEventCounts: { 'tool/code-dispatch-start': 1, 'tool/code-dispatch': 1 }, - recorded: true, - spillMaxInlineBytes: 600, - }, - { - name: 'dynamic-workflow', - composition: 'native', - expectedTools: ['workflow'], - childSessions: 1, - recorded: true, - }, - { - name: 'cordis-dynamic-toolchain', - composition: 'advanced', - expectedTools: ['cordis_mount', 'run_code', 'subagent', 'workflow', 'cordis_unmount'], - expectedEventCounts: { 'tool/code-dispatch': 1 }, - childSessions: 2, - recorded: false, - }, -] - -function snapshotModeFromEnv(value: string | undefined): SnapshotMode { - if (value === undefined || value === '' || value === 'replay') return 'replay' - if (value === 'record' || value === 'refresh') return value - throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) -} - -const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT) -const observedScenarios = new Set() -const workerState = Reflect.get(globalThis, '__vitest_worker__') as - | { readonly config?: { readonly testNamePattern?: RegExp } } - | undefined -// Worker argv omits the parent CLI's `-t`; the serialized runner config is the -// authoritative distinction between a focused replay and the full suite. -const TEST_NAME_FILTERED = workerState?.config?.testNamePattern !== undefined - -/** - * Deterministic keyless summary that pauses so the scenario can submit a real - * prompt and inject context while manual compaction holds turn admission. - */ -class DeferredSnapshotCompactService extends BasicCompactService { - readonly summaryStarted = Promise.withResolvers() - readonly releaseSummary = Promise.withResolvers() - - override async summarize( - _input: SummarizationInput, - _agent: Agent, - signal?: AbortSignal, - ): Promise<{ summary: [{ type: 'text'; text: string }]; provider: string; model: string }> { - this.summaryStarted.resolve(undefined) - await this.releaseSummary.promise - signal?.throwIfAborted() - return { - summary: [{ type: 'text', text: 'Keyless manual compaction checkpoint.' }], - provider: 'snapshot', - model: 'snapshot-compactor', - } - } -} - -/** Seed between-turn model-visible history without inventing a loop execution. */ -function seedCompactableHistory(agent: Agent): void { - agent.inject(createUserMessage({ - content: [{ type: 'text', text: 'Older snapshot context. '.repeat(60) }], - source: { kind: 'plugin', plugin: 'snapshot-seed' }, - })) -} - -function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string { - const rel = relative(cwd, displayPath) - if (rel === '') return displayCwd - if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return displayPath - return `${displayCwd}/${rel.split(sep).join('/')}` -} - -function scenarioDir(scenario: Scenario): string { - return join(SNAPSHOTS_DIR, scenario.name) -} - -/** Directory owning the replay fixture: the scenario's own, or the one it derives from. */ -function fixtureDir(scenario: Scenario): string { - return join(SNAPSHOTS_DIR, scenario.fixture ?? scenario.name) -} - -function childFixturePaths(scenario: Scenario): string[] { - return Array.from( - { length: scenario.childSessions ?? 0 }, - (_, index) => join(fixtureDir(scenario), `session.${index + 1}.jsonl`), - ) -} - -function userPrompts(rawLog: string): string[] { - return parseSessionLog(rawLog).flatMap((event) => { - if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] - const text = event.data.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') - return text.length > 0 ? [text] : [] - }) -} - -function rawSessionLog(session: Session): string { - return [ - JSON.stringify({ type: 'session', ...session.header }), - ...packChunkRuns(session.events).map(record => JSON.stringify(record)), - '', - ].join('\n') -} - -async function materializeFixtureCwd(fixtureFile: string, cwd: string, replayRoot: string): Promise { - const realized = join(replayRoot, basename(fixtureFile)) - await writeFile(realized, (await readFile(fixtureFile, 'utf8')).split('{{cwd}}').join(cwd)) - return realized -} - -function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string { - return snapshot - .split(`/private${cwd}`).join('/workspace/project') - .split(displayCwd).join('/workspace/project') - .split(cwd).join('/workspace/project') - .replace(UUID_RE, '{{uuid}}') -} - -async function settleTerminal(terminal: HeadlessTerminal): Promise { - let stable = 0 - for (let attempt = 0; attempt < 20 && stable < 3; attempt++) { - const before = terminal.frames - await new Promise(resolve => setTimeout(resolve, 10)) - await terminal.flush() - stable = terminal.frames === before ? stable + 1 : 0 - } - if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms') -} - -/** Bound deterministic in-process coordination waits with actionable state. */ -async function snapshotDeadline( - operation: Promise, - detail: () => string, -): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - operation, - new Promise((_resolve, reject) => { - timer = setTimeout(() => { reject(new Error(detail())) }, 5_000) - }), - ]) - } finally { - if (timer !== undefined) clearTimeout(timer) - } -} - -async function mountScenarioContext( - scenario: Scenario, - cwd: string, - displayCwd: string, - fixtureFile: string, - childFiles: string[], - replayRoot: string | undefined, -): Promise { - class SnapshotLocalFileSystem extends LocalFileSystem { - override async resolve( - path: string, - opts?: { cwd?: string; signal?: AbortSignal }, - ): Promise>> { - const target = await super.resolve(path, opts) - return { ...target, displayPath: snapshotDisplayPath(target.displayPath, cwd, displayCwd) } - } - } - - const ctx = new Context() - await ctx.plugin(AgentCore, { - agents: [], - dshHome: join(cwd, '.dsh'), - workspaceContext: false, - tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, - skills: { local: { agentsHome: join(cwd, '.agents') } }, - }) - if (scenario.harnessSourceRoot !== undefined) addHarnessSourceSection(ctx, scenario.harnessSourceRoot) - await ctx.plugin(TokenMeterService) - if (scenario.manualCompact === true) { - await ctx.plugin(DeferredSnapshotCompactService, { auto: false }) - } - await ctx.plugin(LocalSubprocessService) - await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) - await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) - await ctx.plugin(FsPolicy) - await ctx.plugin(ToolFs) - await ctx.plugin(UserInteractionService) - await ctx.plugin(TuiPromptService) - // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped - // config that omits it. The other scenarios prove the default todo-free composition. - if (scenario.enableTodo === true) await ctx.plugin(ToolTodo) - await ctx.plugin(SubagentService) - await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) - await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) - await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) - await ctx.plugin(ToolWorkflow) - await ctx.plugin(ToolRalph) - await ctx.plugin(CommandService) - if (scenario.manualCompact === true) await ctx.plugin(CommandCompact) - if (scenario.enterPlanMode === true) { - await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' }) - } - if (scenario.composition === 'code' || scenario.composition === 'advanced') { - await ctx.plugin(WorkerCodeRuntime, {}) - } - if (scenario.spillMaxInlineBytes !== undefined) { - await ctx.plugin(LocalSpillStore, { root: join(cwd, '.spill') }) - await ctx.plugin(SpillPolicy, { maxInlineBytes: scenario.spillMaxInlineBytes }) - } - if (scenario.composition === 'advanced') await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 }) - if (MODE === 'record' && scenario.recorded) { - await ctx.plugin(LlmDeepSeek) - } else { - if (replayRoot === undefined) throw new Error('replay mode requires an isolated fixture directory') - // Recorded model text may name the generated cwd. Realize the portable token - // outside that cwd so tools see only the scenario workspace during replay. - const replayFile = await materializeFixtureCwd(fixtureFile, cwd, replayRoot) - const replayChildFiles = await Promise.all(childFiles.map(file => materializeFixtureCwd(file, cwd, replayRoot))) - installLlmReplay(ctx, { file: replayFile, childFiles: replayChildFiles, providers: PROVIDERS }) - } - return ctx -} - -interface ScenarioResult { - terminal: string - parent: Session - children: Session[] - workflowEvents: string[] -} - -async function runScenario(scenario: Scenario): Promise { - const snapshotTime = new Date(2026, 6, 21, 12, 0, 0).getTime() - const clock = vi.spyOn(Date, 'now').mockReturnValue(snapshotTime) - const fixtureFile = join(fixtureDir(scenario), 'session.jsonl') - const childFiles = childFixturePaths(scenario) - const prompts = userPrompts(await readFile(fixtureFile, 'utf8')) - if (scenario.interaction === undefined) { - expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) - } - - const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) - const displayCwd = `/tmp/${basename(cwd)}` - let replayRoot: string | undefined - let ctx: Context | undefined - let controller: ReturnType | undefined - const terminal = new HeadlessTerminal(100, 36) - try { - if (!(MODE === 'record' && scenario.recorded)) { - replayRoot = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-replay-${scenario.name}-`)) - } - if (scenario.seedWorkspace === true) { - const source = join(fixtureDir(scenario), 'workspace') - await cp(source, cwd, { recursive: true }) - } - ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot) - if (scenario.normalizePwdResult === true) { - ctx.on('tools/post-execute', async (exec, result, next) => { - const args = exec.arguments as { command?: unknown } - return exec.name === 'bash' && args.command === 'pwd' && !result.isError - ? { kind: 'accept', content: [{ type: 'text', text: '/workspace/project\n' }] } - : next() - }) - } - const disposedSessions: Session[] = [] - ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) - const workflowEvents: string[] = [] - for (const name of ['workflow/start', 'workflow/phase', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { - ctx.on(name, () => { workflowEvents.push(name) }) - } - const handle = await ctx.agents.create({ - sessionId: SessionId('main-session'), - meta: { cwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - const agent: Agent = handle.agent - if (scenario.manualCompact === true) seedCompactableHistory(agent) - controller = createTuiChat(ctx, { - sessionId: 'main-session', - theme: { color: true }, - showReasoning: true, - title: 'DSH TUI snapshot', - welcome: `Recorded replay: ${scenario.name}`, - maxToolOutputLines: 8, - }, { - terminal, - exit: () => {}, - formatCwd: () => displayCwd, - }) - await settleTerminal(terminal) - - let interactionSnapshot: string | undefined - if (scenario.interaction === 'skill-invocation-policy') { - terminal.send('/skill') - await settleTerminal(terminal) - const discovery = normalizeTerminalSnapshot( - await terminal.snapshot({ includeScrollback: true }), - cwd, - displayCwd, - ) - expect(discovery).toContain('user-only-skill') - expect(discovery).not.toContain('model-only-skill') - - terminal.send('\x03') - await settleTerminal(terminal) - const skillContext = ctx - const skillTurnEnded = new Promise((resolve) => { - const detach = skillContext.on('session/event', (session, event) => { - if (session !== agent.session || event.type !== 'turn/end') return - detach() - resolve() - }) - }) - terminal.send('/skill:user-only-skill') - terminal.send('\r') - await skillTurnEnded - await agent.whenIdle() - await settleTerminal(terminal) - const loaded = normalizeTerminalSnapshot( - await terminal.snapshot({ includeScrollback: true }), - cwd, - displayCwd, - ) - expect(loaded).toContain('USER-ONLY SKILL LOADED') - - terminal.send('/skill:model-only-skill') - terminal.send('\r') - await settleTerminal(terminal) - const denied = normalizeTerminalSnapshot( - await terminal.snapshot({ includeScrollback: true }), - cwd, - displayCwd, - ) - expect(denied).toContain('model-only-skill') - expect(denied).toContain('not available for user invocation.') - expect(denied).not.toContain('MODEL-ONLY BODY MUST NOT LOAD') - interactionSnapshot = [ - '=== skill autocomplete ===', - discovery, - '', - '=== loaded exact invocation ===', - loaded, - '', - '=== denied exact invocation ===', - denied, - ].join('\n') - } - - let remainingPrompts = prompts - let queuedPrompt: string | undefined - let manualOrder: string[] | undefined - let manualCommandId: string | undefined - if (scenario.manualCompact === true) { - expect(prompts.length, 'queued manual compaction needs a second replayed prompt').toBeGreaterThanOrEqual(2) - queuedPrompt = prompts.at(-1) - remainingPrompts = prompts.slice(0, -1) - } - if (scenario.enterPlanMode === true) { - const firstPrompt = prompts[0]! - terminal.send(`/plan ${firstPrompt}`) - terminal.send('\r') - await agent.whenIdle() - await settleTerminal(terminal) - remainingPrompts = prompts.slice(1) - } - - if (scenario.leavePlanModeAfterFirstTurn === true) { - terminal.send('/plan off') - terminal.send('\r') - await settleTerminal(terminal) - } - - for (const prompt of remainingPrompts) { - const admitted = agent.session.events.filter(event => - event.type === 'user/message' && event.data.source.kind === 'user').length - terminal.send(prompt) - terminal.send('\r') - await terminal.flush() - await expect.poll(() => agent.session.events.filter(event => - event.type === 'user/message' && event.data.source.kind === 'user').length).toBe(admitted + 1) - await agent.whenIdle() - await settleTerminal(terminal) - } - - if (scenario.manualCompact === true && queuedPrompt !== undefined) { - terminal.send('/help') - terminal.send('\r') - await settleTerminal(terminal) - expect(await terminal.snapshot({ includeScrollback: true })) - .toContain('/compact — Compact older conversation history') - - const compact = ctx.compact as DeferredSnapshotCompactService - const inbox: string[] = [] - manualOrder = [] - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`) - }) - ctx.on('agent/inbox/dequeue', (subject, message) => { - if (subject === agent) inbox.push(`dequeue:${message.id}`) - }) - ctx.on('session/event', (session, event) => { - if (session !== agent.session) return - if (event.type === 'command/run' && event.data.name === 'compact') { - manualCommandId = event.data.commandId - manualOrder?.push('command/run') - } - if (event.type === 'command/done' && event.data.commandId === manualCommandId) { - manualOrder?.push('command/done') - } - if (event.type.startsWith('compact/')) manualOrder?.push(event.type) - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'compact') manualOrder?.push('checkpoint') - if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`) - }) - ctx.on('session/flush', (session) => { - if (session === agent.session) manualOrder?.push('flush') - }) - - terminal.send('/compact') - terminal.send('\r') - await terminal.flush() - await snapshotDeadline(compact.summaryStarted.promise, () => - `manual summary did not start; status=${agent.status}; tail=${ - agent.session.events.slice(-8).map(event => event.type).join(',') - }`) - clock.mockReturnValue(snapshotTime + 1_000) - await settleTerminal(terminal) - await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙') - await expect.poll(() => terminal.snapshot()).toContain('Context being compacted 1.0s') - const liveCompaction = await terminal.snapshot() - expect(liveCompaction.indexOf('Context being compacted 1.0s')).toBeLessThan(liveCompaction.indexOf('dsh ⊙')) - clock.mockReturnValue(snapshotTime) - - // Real keystrokes: the prompt keeps its ordinary queue identity while - // admission is reserved, and an injection appends immediately. - terminal.send(queuedPrompt) - terminal.send('\r') - await terminal.flush() - await expect.poll(() => inbox.length).toBe(1) - agent.inject(createUserMessage({ - content: [{ type: 'text', text: 'Injected while compaction was running.' }], - source: { kind: 'plugin', plugin: 'snapshot-injector' }, - })) - expect(inbox[0]).toMatch(/^enqueue:queued:/u) - expect(agent.status).toBe('idle') - expect(agent.session.events.some(event => event.type === 'user/message' - && event.data.source.kind === 'user' - && event.data.content.some(block => block.type === 'text' && block.text === queuedPrompt))).toBe(false) - - const idle = agent.whenIdle() - compact.releaseSummary.resolve(undefined) - await snapshotDeadline(idle, () => - `manual compaction did not reach idle; status=${agent.status}; order=${manualOrder?.join(',') ?? ''}; tail=${ - agent.session.events.slice(-12).map(event => event.type).join(',') - }`) - await settleTerminal(terminal) - expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`]) - } - - const events: SessionEvent[] = [...agent.session.events] - const firstHeader = events.find(event => event.type === 'request/header') - expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) - .toContain(FILE_REFERENCE_PROMPT) - if (scenario.harnessSourceRoot !== undefined) { - expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) - .toContain(`The DeepSeek Harness implementation checkout is at ${scenario.harnessSourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`) - } - expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) - for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { - expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) - } - if (scenario.enterPlanMode === true) { - expect(ctx.planMode.get(agent)).toEqual({ - active: scenario.leavePlanModeAfterFirstTurn !== true, - }) - const planMode = events.find(event => event.type === 'plan/mode') - if (planMode === undefined || firstHeader === undefined) { - throw new Error('plan-mode command snapshot needs plan/mode before its first request/header') - } - expect(planMode.seq).toBeLessThan(firstHeader.seq) - expect(firstHeader.data.header.system).toContain('Snapshot plan mode instructions.') - const firstMessage = events.find(event => event.type === 'user/message') - expect(firstMessage?.data.content).toEqual([{ type: 'text', text: prompts[0] }]) - } - if (scenario.leavePlanModeAfterFirstTurn === true) { - const planModes = events.filter(event => event.type === 'plan/mode') - expect(planModes.map(event => event.data.active)).toEqual([true, false]) - const headers = events.filter(event => event.type === 'request/header') - const exit = planModes[1] - const afterExit = headers[1] - if (exit === undefined || afterExit === undefined) { - throw new Error('active plan exit snapshot needs a committed exit and changed request header') - } - expect(exit.seq).toBeLessThan(afterExit.seq) - expect(afterExit.data.header.system).not.toContain('Snapshot plan mode instructions.') - expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content)) - .toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }]) - } - if (scenario.manualCompact === true) { - const compactStart = events.find(event => event.type === 'compact/start') - const compactSummary = events.find(event => event.type === 'compact/summary') - const compactCheckpoint = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' && event.data.source.plugin === 'compact') - const injectedEvent = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' && event.data.source.plugin === 'snapshot-injector') - const compactEnd = events.find(event => event.type === 'compact/end') - expect(compactStart?.data.turn).toBeNull() - expect(compactEnd?.data.turn).toBeNull() - expect(events.filter(event => event.type === 'compact/summary')).toHaveLength(1) - if (compactStart === undefined || compactSummary === undefined - || compactCheckpoint === undefined || injectedEvent === undefined - || compactEnd === undefined) { - throw new Error('manual compaction snapshot is missing its durable marker, summary, checkpoint, or injection') - } - // The markers are time points, not an exclusive container: unrelated - // idle injection is allowed between them while the selected span stays stable. - expect(compactStart.seq).toBeLessThan(injectedEvent.seq) - expect(injectedEvent.seq).toBeLessThan(compactSummary.seq) - expect(compactSummary.seq).toBeLessThan(compactCheckpoint.seq) - expect(compactCheckpoint.seq).toBeLessThan(compactEnd.seq) - - const manualTimeline = manualOrder ?? [] - const commandRunIndex = manualTimeline.indexOf('command/run') - const compactStartIndex = manualTimeline.indexOf('compact/start') - const compactEndIndex = manualTimeline.indexOf('compact/end') - const firstFlushIndex = manualTimeline.indexOf('flush') - const queuedTurnIndex = manualTimeline.indexOf('turn/start:message') - const commandDoneIndex = manualTimeline.indexOf('command/done') - expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1) - expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1) - expect(compactStartIndex).toBeGreaterThan(commandRunIndex) - expect(compactEndIndex).toBeGreaterThan(compactStartIndex) - expect(firstFlushIndex).toBeGreaterThan(compactEndIndex) - expect(queuedTurnIndex).toBeGreaterThan(firstFlushIndex) - expect(commandDoneIndex).toBeGreaterThan(firstFlushIndex) - - const commandRun = events.find(event => event.type === 'command/run' - && event.data.name === 'compact') - const commandRunId = commandRun?.type === 'command/run' - ? commandRun.data.commandId - : undefined - const commandDone = events.find(event => event.type === 'command/done' - && event.data.commandId === commandRunId) - expect(commandRun?.type === 'command/run' && commandRun.data).toEqual({ - commandId: commandRunId, - name: 'compact', - args: '', - source: { kind: 'user' }, - }) - expect(commandDone?.type === 'command/done' && commandDone.data).toEqual({ - commandId: commandRunId, - kind: 'success', - text: 'Compacted 2 history items (~387 tokens).', - }) - expect(commandRun !== undefined && commandRun.seq < compactStart.seq).toBe(true) - expect(commandDone !== undefined && commandDone.seq > compactEnd.seq).toBe(true) - expect(agent.session.surface.nodes).not.toContain(commandRun?.seq) - expect(agent.session.surface.nodes).not.toContain(commandDone?.seq) - - // The command line itself never becomes a prompt. - expect(events.some(event => event.type === 'user/message' - && event.data.source.kind === 'user' - && event.data.content.some(block => block.type === 'text' && block.text.trim() === '/compact'))).toBe(false) - const derived = agent.session.deriveMessages().map(message => message.content - .map(block => block.type === 'text' ? block.text : '') - .join('')) - const checkpoint = derived.findIndex(text => text.includes('Keyless manual compaction checkpoint.')) - const injected = derived.findIndex(text => text.includes('Injected while compaction was running.')) - const queued = derived.findIndex(text => text === queuedPrompt) - expect(checkpoint).toBe(0) - expect(injected).toBeGreaterThan(checkpoint) - expect(queued).toBeGreaterThan(injected) - expect(derived).not.toContain('/compact') - expect(derived).not.toContain('Compacted 2 history items (~387 tokens).') - expect(derived.filter(text => text.includes('Injected while compaction was running.'))).toHaveLength(1) - expect(compactSummary.data.shadowedSeqs).not.toContain(injectedEvent.seq) - const queuedTurn = events.findLast(event => event.type === 'turn/start') - expect(queuedTurn !== undefined && compactEnd.seq < queuedTurn.seq).toBe(true) - } - if (scenario.spillMaxInlineBytes !== undefined) { - // The REAL pipeline ran (tools execute on replay too): the durable - // dispatch copy is bounded to a preview + locator under the run cwd, - // while the outer result still carries the program's whole value. - const dispatch = events.find(event => (event.type as string) === 'tool/code-dispatch') - const content = (dispatch?.data as { content: { type: string; text?: string }[] }).content - const text = content.filter(block => block.type === 'text').map(block => block.text ?? '').join('') - expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(scenario.spillMaxInlineBytes) - expect(text).toContain('Full formatted result stored at:') - expect(text).toContain('.spill') - } - expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.message.content[0].isError)).toBe(true) - expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true) - if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') { - expect(workflowEvents).toEqual([ - 'workflow/start', - 'workflow/phase', - 'workflow/agent-start', - 'workflow/agent-end', - 'workflow/end', - ]) - } - - expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([]) - const snapshot = interactionSnapshot ?? normalizeTerminalSnapshot( - await terminal.snapshot({ includeScrollback: true }), - cwd, - displayCwd, - ) - await handle.dispose() - const children = disposedSessions - .filter(session => session !== agent.session) - .sort((a, b) => a.header.createdAt - b.header.createdAt) - expect(children).toHaveLength(scenario.childSessions ?? 0) - return { terminal: snapshot, parent: agent.session, children, workflowEvents } - } finally { - await controller?.dispose() - await ctx?.fiber.dispose() - await terminal.dispose() - await rm(cwd, { recursive: true, force: true }) - if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true }) - clock.mockRestore() - } -} - -async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise { - const dir = scenarioDir(scenario) - await mkdir(dir, { recursive: true }) - await writeFile( - join(dir, 'session.jsonl'), - scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(result.parent))), - ) - expect(result.children).toHaveLength(scenario.childSessions ?? 0) - for (const [index, child] of result.children.entries()) { - await writeFile( - join(dir, `session.${index + 1}.jsonl`), - scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(child))), - ) - } -} - -describe('TUI recorded-session terminal snapshots', () => { - for (const scenario of SCENARIOS) { - it(scenario.name, async () => { - observedScenarios.add(scenario.name) - const result = await runScenario(scenario) - const terminalFile = join(scenarioDir(scenario), 'terminal.expected.txt') - if (MODE === 'record' || MODE === 'refresh') { - await mkdir(scenarioDir(scenario), { recursive: true }) - await writeFile(terminalFile, result.terminal) - } - if (MODE === 'record' && scenario.recorded) await writeRecording(scenario, result) - await expect(result.terminal).toMatchFileSnapshot(terminalFile) - }, 120_000) - } -}) - -afterAll(async () => { - const scenarioNames = SCENARIOS.map(scenario => scenario.name).sort() - const observedNames = [...observedScenarios].sort() - if (TEST_NAME_FILTERED) { - expect(observedNames).not.toHaveLength(0) - expect(scenarioNames).toEqual(expect.arrayContaining(observedNames)) - } else { - expect(observedNames).toEqual(scenarioNames) - } - for (const [index, scenario] of SCENARIOS.entries()) { - if (scenario.fixture === undefined) continue - const sourceIndex = SCENARIOS.findIndex(candidate => candidate.name === scenario.fixture) - expect(sourceIndex, `${scenario.name} fixture source ${scenario.fixture} must exist`).toBeGreaterThanOrEqual(0) - expect(sourceIndex, `${scenario.name} fixture source must precede it`).toBeLessThan(index) - const source = SCENARIOS[sourceIndex] - expect(source?.fixture, `${scenario.name} fixture source must own its replay files`).toBeUndefined() - expect(source?.recorded, `${scenario.name} fixture source must be recordable`).toBe(true) - } - const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true })) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - .sort() - expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort()) - for (const scenario of SCENARIOS) { - const expected = [ - ...scenario.fixture === undefined ? ['session.jsonl'] : [], - 'terminal.expected.txt', - ...scenario.seedWorkspace === true && scenario.fixture === undefined ? ['workspace'] : [], - ...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`), - ].sort() - expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected) - for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) { - const content = await readFile(join(fixtureDir(scenario), fixture), 'utf8') - expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content) - } - } -}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 2f995abf87..b9b2eeea1f 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../../packages/bash/tool-bash" }, - { - "path": "../../packages/ui/tui" - }, { "path": "../../packages/util/paths" }, diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index 68ca2254e0..51dec0dc6c 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from 'tsdown' /** * The dsh CLI ships one entry: the `bin` referenced by package.json `bin`. * The root tsdown builds only `lib/types/index.js`, so this override points at - * `lib/types/bin.js` instead; the statically imported surface modules bundle - * into it. Declarations come from `tsc -b` (dts: false), matching every package. + * `lib/types/bin.js` instead; its reachable mode modules bundle with it. + * Declarations come from `tsc -b` (dts: false), matching every package. */ export default defineConfig({ entry: ['lib/types/bin.js'], diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index bded8b85c8..b5136c31df 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: b11ab9bc3060aea668d142139e0a25f491a777d1 -architecture.zh.md: 5eb1cab7e453dc0423cbb42348e918de798f1f9b +architecture.md: ee50e249f8e588a3f92f57db929c6bbfb1c853dd +architecture.zh.md: c2e596cd10c21be2bcaad11323277d04ef9e74a1 diff --git a/docs/architecture.md b/docs/architecture.md index b11ab9bc30..ee50e249f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -173,7 +173,7 @@ Exceptions combine LLM interface/consumer, filesystem policy, web registries, an ### Bundles And Apps -`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own TUI, CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes @@ -191,7 +191,7 @@ New behavior attaches to a documented extension point; a loop change updates thi | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | | Add model-facing context | call `agent.inject()` to append a sourced `user/message` without a turn | -| Add UI or editor integration | drive `ctx.agents`, render from `session/event`; terminal-only overlays use `ctx.tui` | +| Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 5eb1cab7e4..c2e596cd10 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -173,7 +173,7 @@ idle inject: ### 组合包与应用 -`dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 TUI、CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 +`dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 ### 新行为的归属位置 @@ -191,7 +191,7 @@ idle inject: | 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成前包装 argv | | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | | 添加模型可见上下文 | 调用 `agent.inject()`,追加带来源的 `user/message`,但不创建轮次 | -| 添加 UI 或编辑器集成 | 驱动 `ctx.agents`,从 `session/event` 渲染;仅终端浮层使用 `ctx.tui` | +| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 561b59e10e..3976c1fb24 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -60,7 +60,6 @@ flowchart LR pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] - pkg_tui["tui"] pkg_session_title["session-title"] svc_sessionTitle["ctx.sessionTitle
Log-backed session titles"] pkg_session_title_first_message_llm["session-title-first-message-llm"] @@ -88,13 +87,11 @@ flowchart LR pkg_host_apiproxy["host-apiproxy"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] - svc_tui["ctx.tui
Mounted-terminal interaction service"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] - pkg_tui_demo["tui-demo"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] @@ -236,8 +233,6 @@ flowchart LR pkg_token_meter --> svc_tokenMeter pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools - pkg_tui --> svc_tui - pkg_tui --> svc_userInteraction pkg_typert_registry --> svc_typert pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -254,7 +249,6 @@ flowchart LR svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo svc_agents --> pkg_subagent_inprocess - svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_bash --> pkg_hooks_claude @@ -262,7 +256,6 @@ flowchart LR svc_bash --> pkg_tool_bash svc_clientModuleHost --> pkg_hmr svc_codeRuntime --> pkg_tools - svc_commands --> pkg_tui svc_compact --> pkg_compact_basic svc_credentials --> pkg_apiproxy svc_credentials --> pkg_llm_deepseek @@ -296,7 +289,6 @@ flowchart LR svc_sessionProjections --> pkg_tool_todo svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query - svc_sessionReferences --> pkg_tui svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo @@ -342,7 +334,6 @@ flowchart LR svc_tools --> pkg_tool_web svc_typert --> pkg_typert_loader svc_userInteraction --> pkg_tool_ask_user - svc_userInteraction --> pkg_tui svc_web --> pkg_tool_web svc_workflows --> pkg_tool_ralph svc_workflows --> pkg_tool_workflow @@ -366,18 +357,17 @@ flowchart LR | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | -| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | +| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | - | [`tool-ask-user`](../packages/ui/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | -| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui) | - | Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model. | +| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | -| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), `tui-demo` | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f07a12f4a8..f85d39dcd7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2045,85 +2045,6 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' Source: [`packages/core/tools/src/index.ts:592`](../packages/core/tools/src/index.ts) -## `@deepseek-ai/dsh-tui` - -Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` · `tuiPrompt` - -```ts config-catalog -/** 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:`. 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 -} - -/** 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 -} - -/** 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 -} -``` - -Source: [`packages/ui/tui/src/config.ts:129`](../packages/ui/tui/src/config.ts) - ## `@deepseek-ai/dsh-typert-loader` Requires: `typert` · `loader` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 25611512c3..e317b64965 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -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 docs/cookbook/adding-a-tool.md -adding-a-tool.md: 80625b5ec64aca8f8cda8a39048ba1c13fe57b2b -adding-a-tool.zh.md: 7d426ed7f0e5c9147d29ac7f6deb15ec27e36288 +adding-a-tool.md: 0688e4a46b1eb2ca282a7ee970546a34b3c1cc68 +adding-a-tool.zh.md: e12d5e23a93d08f15fb0d1ac40229c5c4ab26e38 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 80625b5ec6..0688e4a46b 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -87,8 +87,8 @@ Hard rules (they bite if broken): - **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the adapter adds any fallback framing. - **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. -The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. The TUI and host/client runtime map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. +The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. ## Tests every tool needs -Cover argument rejection, every canonical value and Native rendering shape, output-schema rejection, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and projected `tool/result` session events; prove the canonical value itself is not persisted. For a UI card, assert the exact `presentCall` and `presentResult` views and exercise the owning TUI or host/client projection. Add an assembled snapshot for the shipped model or UI behavior the tool changes. +Cover argument rejection, every canonical value and Native rendering shape, output-schema rejection, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and projected `tool/result` session events; prove the canonical value itself is not persisted. For a UI card, assert the exact `presentCall` and `presentResult` views and exercise the owning host/client projection. Add an assembled snapshot for the shipped model or UI behavior the tool changes. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 7d426ed7f0..e12d5e23a9 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -87,8 +87,8 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务 UI 而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由适配器按需添加回退格式。 - **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 -中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。TUI 和 host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 +中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 ## 每个工具必须的测试 -覆盖参数拒绝、每种规范值和 Native 渲染形态、输出 schema 拒绝以及 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和投影后的 `tool/result` 会话事件;同时证明规范值本身未被持久化。对于 UI 卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并实际运行所属 TUI 或 host/client 投影。如果工具改变了已交付的模型或 UI 行为,请添加组装应用快照。 +覆盖参数拒绝、每种规范值和 Native 渲染形态、输出 schema 拒绝以及 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和投影后的 `tool/result` 会话事件;同时证明规范值本身未被持久化。对于 UI 卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并实际运行所属 host/client 投影。如果工具改变了已交付的模型或 UI 行为,请添加组装应用快照。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index b971e0ce51..a4c20672c0 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -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 docs/cookbook/extension-cookbook.md -extension-cookbook.md: 3fed79372e284df8d2cf87a3b1ca6fac6e2131ae -extension-cookbook.zh.md: 1d422712592c7427fde785f1419633a13a55cca3 +extension-cookbook.md: 07073c39f8a9b998b09b0815257d995b174c8be7 +extension-cookbook.zh.md: 10664af9a39f0a1663c316869ba8ef02f67c29fe diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 3fed79372e..07073c39f8 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui`](../../packages/ui/tui), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), JSON-RPC leaves use [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 1d42271259..10664af9a3 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui`](../../packages/ui/tui),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 +可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),JSON-RPC 叶子使用 [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 ## 功能→机制映射 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6180984b3c..a713efa09c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2430,29 +2430,6 @@ Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core- Source: [`packages/core/tools/src/index.ts:714`](../../packages/core/tools/src/index.ts) -## `ctx.tui` — `TuiExtensionService` (abstract seam) - -Optional terminal-local interaction service provided by one mounted TUI. - -The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugins receive only effect-owned overlay sessions. - -```ts cordis-catalog -/** - * Queue an interactive overlay owned by the calling plugin fiber. - * - * The TUI displays one overlay at a time in FIFO order. Disposing the caller - * removes a queued overlay or closes an active one before plugin teardown - * settles. This live presentation is neither logged nor replayed. - * - * @param request - component factory, layout constraints, and cancellation. - * @returns the effect-owned overlay session. - * @throws when the TUI has begun shutting down. - */ -abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession -``` - -Source: [`packages/ui/tui/src/index.ts:246`](../../packages/ui/tui/src/index.ts) - ## `ctx.typert` — `TypertRegistry` Registry of generated schemas and package reflection. diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index a1ee89f99e..d958eeac32 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -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 docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 39f140b91d4c9f8cc197df4d08bd2c4c168cc858 -01-first-plugin.zh.md: 0715cd525f980ab237e65baf6982192d2e0c286c +01-first-plugin.md: c9f4889398222793005fce6832d0917a20d0be30 +01-first-plugin.zh.md: b9d6994fad8e26cdfc52db0fbcef5631d5d53b03 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 39f140b91d..c9f4889398 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -48,7 +48,7 @@ The process exits on its own once nothing is left running. What happened: 2. The Loader read `cordis.yml`, resolved `./hello.ts`, and mounted it as a child plugin. 3. Cordis called your `apply(ctx)`. -There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../apps/cli/config/tui.cordis.yml), for example, is a longer plugin composition. +There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [`dsh` base](../../apps/cli/config/base.cordis.yml), for example, is a longer plugin composition that deployment overlays patch. ## The two other plugin shapes diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 0715cd525f..b9d6994fad 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -48,7 +48,7 @@ hello from my first plugin 2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。 3. Cordis 调用你的 `apply(ctx)`。 -你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../apps/cli/config/tui.cordis.yml) 就是一个更长的插件组合。 +你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[`dsh` base](../../apps/cli/config/base.cordis.yml) 就是一份更长的插件组合,由部署 overlay 对它进行修补。 ## 其他两种插件形态 diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index a955adc963..cd2add6a82 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -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 docs/core-data-structures/tools.md -tools.md: acaf5d32dd5481aec495ac49f727c9b64f25211e -tools.zh.md: 5c39226fdf5d1406dab7383d40227c26ff1e447c +tools.md: 853eed16cff451edcc32bc3aa5c6bc7cabb0f518 +tools.zh.md: ec8809f6bebd185404828c5c5879f8eff832ea9a diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index acaf5d32dd..853eed16cf 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -452,6 +452,6 @@ How a tool wants its call shown in a UI (an editor tool-call card, a CLI log lin - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). - `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search → grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob); `truncated`/`total` report whether the inline result was capped so a UI never presents a partial result as complete; the view carries no result text — a UI without a search card falls back to the raw result content), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet; a search and a web retrieval have no `card` call-time analogue (their pending state stays a generic card, since the structured result exists only after `execute`). -`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`), `FileDiff` (`{ path, oldText, newText }`), and `ReadFileLine` (`{ number, text }`, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views. +`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`), `FileDiff` (`{ path, oldText, newText }`), and `ReadFileLine` (`{ number, text }`, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); host/client runtimes project this neutral vocabulary into their own views. The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts). The `bash` schema and executor are on [bash.md](bash.md); generic background controls are on [tasks.md](tasks.md). diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 5c39226fdf..ec8809f6be 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -452,6 +452,6 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 - `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索→`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表;`truncated`/`total` 报告内联结果是否被截断,使 UI 永不把部分结果当作完整结果呈现;该视图不携带结果文本——无 search 卡片的 UI 回退到原始结果内容)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果;搜索和 web 检索都没有 `card` 的调用时对应视图(其 pending 状态保持为 generic 卡片,因为结构化结果只在 `execute` 之后才存在)。 -`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)、`FileDiff`(`{ path, oldText, newText }`)与 `ReadFileLine`(`{ number, text }`,读取窗口中一行带 1-based 行号的内容)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。 +`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)、`FileDiff`(`{ path, oldText, newText }`)与 `ReadFileLine`(`{ number, text }`,读取窗口中一行带 1-based 行号的内容)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;host/client 运行时将这套中性词汇投影为各自的视图。 完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。`bash` schema 与执行器见 [bash.md](bash.md);通用后台控制见 [tasks.md](tasks.md)。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 36352bb747..50087c9f56 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -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 docs/core-data-structures/user-interaction.md -user-interaction.md: 16478168c8fcfeafbd3fdbbf30e732de81a3a8dd -user-interaction.zh.md: 7c8baaf189d759ca609c1d51652120f973ae48b6 +user-interaction.md: dd9dc4f98e6517e016d224e6bed25a511c2a4256 +user-interaction.zh.md: 669fbe04c76039e637a162e4de817dc5704fedeb diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 16478168c8..dd9dc4f98e 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -2,7 +2,7 @@ English | [中文](user-interaction.zh.md) -The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`; `dsh-tui` uses keyboard-driven overlays and the host runtime relays requests to its connected client. +The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`; the host runtime relays requests to its connected client. Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 7c8baaf189..669fbe04c7 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -2,7 +2,7 @@ [English](user-interaction.md) | 中文 -[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。UI surface 提供活跃的 `UserInteractionProvider`;`dsh-tui` 使用键盘驱动的 overlay,host 运行时把请求转发给它连接的客户端。 +[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。UI surface 提供活跃的 `UserInteractionProvider`;host 运行时把请求转发给它连接的客户端。 源码:[`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 7d71b529fa..619aa92d76 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 docs/development.md -development.md: 22eb7915f621883a84688d70e2ccad2fee2dbbba -development.zh.md: 480cd323d4d2325974f472c734edab9ce7459e89 +development.md: 77087a95fdb4cfbeb02111ef2560d6989cde5686 +development.zh.md: 18e130e2f3aa1c53f6efa70366cdb629df71ad85 diff --git a/docs/development.md b/docs/development.md index 22eb7915f6..77087a95fd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ This onboarding guide helps project contributors get started with the local envi - Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. - Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension. -- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests. +- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests. ## First-time setup @@ -137,12 +137,6 @@ The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment o pnpm run demo:headless "summarize this workspace" ``` -The full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: - -```sh -pnpm run demo:tui -``` - The self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`): ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index 480cd323d4..18e130e2f3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -9,7 +9,7 @@ - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 - Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。 -- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。 +- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。 ## 首次搭建 @@ -137,12 +137,6 @@ pnpm run hygiene # knip, publint, workspace constraints, and NodeNext dec pnpm run demo:headless "summarize this workspace" ``` -全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: - -```sh -pnpm run demo:tui -``` - 自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`): ```sh diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b59b8733d5..33a4704ee5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,40 +7,40 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:467`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:467`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:380`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:380`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:454`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/step` | `serial` | [`packages/core/agent/src/types.ts:393`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm), [`tui`](../packages/ui/tui) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 909794c428..e783d5fddf 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -12,7 +12,7 @@ The process decision behind this index is recorded in [the documentation graph A | [module dependency graph](module-graph.md) | `generated` | | [tool schema catalog and package map](tool-catalog.md) | `generated` | | [capability seams and core services](capability-seams.md) | `hybrid generated` | -| [../apps/cli/composition.md](../apps/cli/composition.md) | `generated` | +| [dsh shared base composition](../apps/cli/composition.md) | `hybrid generated` | | [headless-agent app composition](../examples/headless-agent/composition.md) | `hybrid generated` | | [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` | | [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 93b12fa5de..b2a70d7ab7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -138,7 +138,6 @@ flowchart TD pkg_jsonrpc["jsonrpc"] pkg_permission["permission"] pkg_tool_ask_user["tool-ask-user"] - pkg_tui["tui"] pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end @@ -929,27 +928,6 @@ flowchart TD pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools - pkg_tui --> pkg_agent - pkg_tui --> pkg_agent_loop - pkg_tui --> pkg_commands - pkg_tui --> pkg_compact - pkg_tui --> pkg_goal - pkg_tui --> pkg_invariants - pkg_tui --> pkg_llm - pkg_tui --> pkg_llm_retry - pkg_tui --> pkg_session - pkg_tui --> pkg_session_persistence - pkg_tui --> pkg_session_projection - pkg_tui --> pkg_session_projection_cache - pkg_tui --> pkg_session_query - pkg_tui --> pkg_session_reference - pkg_tui --> pkg_session_title - pkg_tui --> pkg_skill - pkg_tui --> pkg_subprocess - pkg_tui --> pkg_system_prompt - pkg_tui --> pkg_token_meter - pkg_tui --> pkg_tools - pkg_tui --> pkg_user_interaction pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime @@ -1239,7 +1217,6 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index df4b94e6ce..e91b6e3c34 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -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 docs/testing.md -testing.md: 8c16dea5e90ff330992a2d0e38f47abc9da20b26 -testing.zh.md: f99f02e2a733a94cadeffddc2c5033242bfde59b +testing.md: 89d495e05c7521becea052ad67ac602ba28c22ef +testing.zh.md: 8e19734d09d5b0bdbeffe9426bed7c12f23fbc41 diff --git a/docs/testing.md b/docs/testing.md index 8c16dea5e9..89d495e05c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge. @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index f99f02e2a7..8e19734d09 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -9,7 +9,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。浏览器渲染的 Web GUI 旅程使用 `apps/web/tests/snapshots/`。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 4fd91343ac..7d99e45437 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -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 docs/user/guide/config.md -config.md: 0e2e0e7e7077adcacfaada1d038a0b1e63fcc0cd -config.zh.md: 850a841286fe77db9169738b0b155f008205a1a8 +config.md: 4e438cc3a400de71934d047108024ff5be4ef7d2 +config.zh.md: e0b0285b110a808b1284209a84f78e114634df52 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 0e2e0e7e70..4e438cc3a4 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -8,7 +8,8 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: -- [the shared `dsh` base](../../../apps/cli/config/base.cordis.yml) plus the [`tui.cordis.yml`](../../../apps/cli/config/tui.cordis.yml) overlay combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. +- [the shared `dsh` base](../../../apps/cli/config/base.cordis.yml) provides the common model, tools, persistence, policy, and telemetry rows; raw `dsh --config ` requires a patch list that selects deployment-specific agents and front doors. +- [the Web overlay](../../../apps/cli/config/web.cordis.yml) adds the browser host, Workspace management, browser interaction, and client plugins. - [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) exposes fresh sessions to programmatic ACP clients. @@ -50,7 +51,7 @@ Plugins load in file order. Place plugins that depend on services after the appl ## CLI overlays -The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before Web profile and CLI-flag patches. +Raw `dsh --config ` requires a patch list and applies it directly over `base.cordis.yml`. It does not add a surface overlay or `~/.dsh/config.yaml`, and the named file is not a complete replacement tree. `dsh web` composes `base.cordis.yml` and `web.cordis.yml`, then applies `~/.dsh/config.yaml`; `dsh web --config ` replaces that personal layer with the named overlay. Web profile and CLI-flag patches follow the user layer. A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 850a841286..e0b0285b11 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -8,7 +8,8 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [共享的 `dsh` base](../../../apps/cli/config/base.cordis.yml) 叠加 [`tui.cordis.yml`](../../../apps/cli/config/tui.cordis.yml) overlay,组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 +- [共享的 `dsh` base](../../../apps/cli/config/base.cordis.yml) 提供通用的模型、工具、持久化、策略与遥测配置项;原始 `dsh --config ` 要求传入一份 patch 列表,用于选择部署特定的 agent 和前端入口。 +- [Web overlay](../../../apps/cli/config/web.cordis.yml) 添加浏览器宿主、Workspace 管理、浏览器交互与客户端插件。 - [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 向程序化 ACP(Agent Client Protocol)客户端提供全新会话。 @@ -50,7 +51,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 ## CLI 覆盖层 -TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web profile 与命令行标志补丁之前添加覆盖。 +原始 `dsh --config ` 要求传入一份 patch 列表,并将其直接应用在 `base.cordis.yml` 之上。它不会添加 surface overlay 或 `~/.dsh/config.yaml`,指定文件也不是完整替换树。`dsh web` 先组合 `base.cordis.yml` 与 `web.cordis.yml`,再应用 `~/.dsh/config.yaml`;`dsh web --config ` 会以指定 overlay 替代该个人层。Web profile 与 CLI(命令行界面)标志 patch 位于用户层之后。 补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index af6b6012d2..137a9697c1 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -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 docs/user/guide/index.md -index.md: cefb978019c21a24e9fb57f96ab8e0fee82dc812 -index.zh.md: 2f1298b5bf5ce44f7d653154b263cb533a3b3ba4 +index.md: 4bb9f2e0056792a160877515f142eb36d4f680ac +index.zh.md: 2792547a146b5ca6186bcb69c1c026744e80b326 diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index cefb978019..4bb9f2e005 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -14,17 +14,13 @@ Harness implements every capability an AI agent needs—including LLM calls, too config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the agent the interactive front door drives -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +# Select the one-shot application +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash - -# Select the interactive front door -- name: '@deepseek-ai/dsh-tui' + provider: deepseek-official + model: deepseek-v4-flash + workspaceContext: false ``` ## Who it is for diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 2f1298b5bf..2792547a14 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -14,17 +14,13 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the agent the interactive front door drives -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +# Select the one-shot application +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash - -# Select the interactive front door -- name: '@deepseek-ai/dsh-tui' + provider: deepseek-official + model: deepseek-v4-flash + workspaceContext: false ``` ## 适合谁 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 703e6ec391..2cf494f71a 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -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 docs/user/guide/quickstart.md -quickstart.md: 1f01c7f4c9935c5a4772f6c702f023e691d911fc -quickstart.zh.md: 8b5d4608525a603725136e1568aef7217756293f +quickstart.md: 199b3f092159fa6fbaf3ae298151487c924ac6f1 +quickstart.zh.md: 9327ed646ba211bcce6426beb6bf76fca50acbf6 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 1f01c7f4c9..199b3f0921 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -40,19 +40,20 @@ pnpm run demo:headless "summarize the architecture of this workspace" Headless runs one complete model/tool turn, persists the session, prints the result, and exits. Use `--output-format stream-json` when you need the canonical event stream. -## Step 3: use the TUI +## Step 3: use the Web UI -Start the interactive coding agent: +Build and start the browser interface: ```sh -pnpm run demo:tui +pnpm run build +pnpm run dsh web ``` -The full-screen agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`. +Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`. ## What happened -headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; the interactive `dsh` surface instead composes [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) with the `tui.cordis.yml` overlay and no app bundle. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. +headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead composes [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) with [`apps/cli/config/web.cordis.yml`](../../../apps/cli/config/web.cordis.yml) and no app bundle. Both select the DeepSeek model and capability plugins appropriate to their entry mode. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 8b5d460852..9327ed646b 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -40,19 +40,20 @@ pnpm run demo:headless "summarize the architecture of this workspace" Headless 运行一个完整的模型/工具轮次,持久化会话,打印结果后退出。需要规范事件流时可使用 `--output-format stream-json`。 -## 第三步:使用 TUI +## 第三步:使用 Web UI -启动交互式 coding agent: +构建并启动浏览器界面: ```sh -pnpm run demo:tui +pnpm run build +pnpm run dsh web ``` -这个全屏 Agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`。 +打开 `http://127.0.0.1:3080`。agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`。 ## 回头看 -headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app;交互式 `dsh` surface 则以 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) 叠加 `tui.cordis.yml` overlay 组合而成,不使用 app 组合包。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 +headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) 与 [`apps/cli/config/web.cordis.yml`](../../../apps/cli/config/web.cordis.yml),不使用 app 组合包。二者都会根据各自入口模式选择 DeepSeek 模型和能力插件。 ## 下一步 diff --git a/examples/package.json b/examples/package.json index b1a32fc381..849d8fc3ff 100644 --- a/examples/package.json +++ b/examples/package.json @@ -93,7 +93,6 @@ "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-tui": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-interaction": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl b/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl deleted file mode 100644 index e7d6e3aeec..0000000000 --- a/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}} -{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}} -{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt b/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt deleted file mode 100644 index 2d6068d48b..0000000000 --- a/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt +++ /dev/null @@ -1,157 +0,0 @@ -=== skill autocomplete === -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" -cursor hidden column=13 viewportRow=5 bufferRow=5 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Recorded replay: skill-invocation-policy" - style 1-40 dim -2| " main-session" - style 1-12 dim -3| -4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-77 dim - style 80-89 dim -5| " dsh > /skill " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 13-13 inverse -6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. " - style 7-78 fg=bright-magenta -7-35| - - -=== loaded exact invocation === -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title " Reference — DSH TUI snapshot" -cursor hidden column=7 viewportRow=30 bufferRow=30 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Reference" - style 1-40 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| " " -6| "References in this skill are relative to " -7| "/workspace/project/.agents/skills/user-only-skill. " -8| " " -9| "USER-ONLY BODY " -10| " " -11| -12| "Context · dsh-tool-skill" - style 0-23 dim -13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " - style 0-99 dim -14| "session: " - style 0-7 dim -15| " " -16| " " - style 0-17 dim -17| "- `model-only-skill`: Model-only assembled snapshot skill. " - style 0-57 dim -18| " " - style 0-18 dim -19| " " -20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " - style 0-99 dim -21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " - style 0-99 dim -22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " - style 0-99 dim -23| "instructions until it has been loaded. " - style 0-37 dim -24| -25| "Assistant " - style 0-8 fg=bright-magenta bold underline -26| "USER-ONLY SKILL LOADED " -27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -28| -29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-77 dim - style 80-89 dim -30| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -31-35| - - -=== denied exact invocation === -terminal 100x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title " Reference — DSH TUI snapshot" -cursor hidden column=7 viewportRow=32 bufferRow=32 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Reference" - style 1-40 dim -2| " main-session" - style 1-12 dim -3| -4| "You " - style 0-2 fg=bright-magenta bold underline -5| " " -6| "References in this skill are relative to " -7| "/workspace/project/.agents/skills/user-only-skill. " -8| " " -9| "USER-ONLY BODY " -10| " " -11| -12| "Context · dsh-tool-skill" - style 0-23 dim -13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " - style 0-99 dim -14| "session: " - style 0-7 dim -15| " " -16| " " - style 0-17 dim -17| "- `model-only-skill`: Model-only assembled snapshot skill. " - style 0-57 dim -18| " " - style 0-18 dim -19| " " -20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " - style 0-99 dim -21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " - style 0-99 dim -22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " - style 0-99 dim -23| "instructions until it has been loaded. " - style 0-37 dim -24| -25| "Assistant " - style 0-8 fg=bright-magenta bold underline -26| "USER-ONLY SKILL LOADED " -27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " - style 0-46 dim -28| -29| "Skill \"model-only-skill\" is not available for user invocation. " - style 0-61 fg=yellow -30| -31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" - style 0-51 fg=bright-magenta bold - style 54-70 dim - style 73-77 dim - style 80-89 dim -32| " dsh ◍ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse -33-35| diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md deleted file mode 100644 index 3608a840f5..0000000000 --- a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: model-only-skill -description: Model-only assembled snapshot skill. -user-invocable: false ---- - -MODEL-ONLY BODY MUST NOT LOAD diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md deleted file mode 100644 index fa231cd99d..0000000000 --- a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: user-only-skill -description: User-only assembled snapshot skill. -disable-model-invocation: true ---- - -USER-ONLY BODY diff --git a/knip.json b/knip.json index 71ee4faa3c..4b60130715 100644 --- a/knip.json +++ b/knip.json @@ -488,16 +488,6 @@ "tests/**/*.ts" ] }, - "packages/ui/tui": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.snapshot.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/examples/jsonrpc-demo": { "project": [ "src/**/*.ts" @@ -618,11 +608,7 @@ "apps/cli": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts", - "tests/**/*.snapshot.ts", - "tests/fixtures/tui-scripted-llm.ts", - "tests/fixtures/composition-echo-llm.ts", - "tests/fixtures/composition-settled.ts" + "tests/**/*.e2e.ts" ], "project": [ "src/**/*.ts", diff --git a/package.json b/package.json index c1013c23c6..c26f217329 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 5491ce5432..88bcc06368 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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 diff --git a/packages/README.md b/packages/README.md index c8984bfa65..4832fffbc8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -46,11 +46,11 @@ Packages live at `packages///`; 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`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/README.zh.md b/packages/README.zh.md index 2a59a63d22..076b4f8770 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -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`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7091f95447..5f9af11105 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1084,16 +1084,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.', diff --git a/packages/sdk/create-sdk/src/args.ts b/packages/sdk/create-sdk/src/args.ts index 0f1ba0d3a5..da69fec25e 100644 --- a/packages/sdk/create-sdk/src/args.ts +++ b/packages/sdk/create-sdk/src/args.ts @@ -61,7 +61,7 @@ function createProgram(): Command { .option('--base-url ') .option('--api-key ') .option('--model ') - .addOption(new Option('--interface ').choices(['acp', 'tui', 'embed'])) + .addOption(new Option('--interface ').choices(['acp', 'embed'])) .addOption(new Option('--pm ').choices(['npm', 'pnpm', 'yarn'])) .addOption(new Option('--install').default(undefined)) .addOption(new Option('--no-install').default(undefined)) diff --git a/packages/sdk/create-sdk/src/create-questions.ts b/packages/sdk/create-sdk/src/create-questions.ts index a45d46d503..42a18907ce 100644 --- a/packages/sdk/create-sdk/src/create-questions.ts +++ b/packages/sdk/create-sdk/src/create-questions.ts @@ -169,10 +169,9 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep[] = [ 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 }, diff --git a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl index 8972c1c7df..307cc23398 100644 --- a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl +++ b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl @@ -6,7 +6,7 @@ Options: --base-url --api-key --model - --interface + --interface --pm --install / --no-install --config diff --git a/packages/sdk/create-sdk/tests/create.snapshot.ts b/packages/sdk/create-sdk/tests/create.snapshot.ts index db60939050..dcf0b791a5 100644 --- a/packages/sdk/create-sdk/tests/create.snapshot.ts +++ b/packages/sdk/create-sdk/tests/create.snapshot.ts @@ -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", ], }, diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index b3ec9f0909..a099b38b23 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -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, diff --git a/packages/sdk/helper/README.i18n.yaml b/packages/sdk/helper/README.i18n.yaml index 8842e34d26..bb5acbdcc2 100644 --- a/packages/sdk/helper/README.i18n.yaml +++ b/packages/sdk/helper/README.i18n.yaml @@ -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 diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md index 8c6ed9e87b..416fcab981 100644 --- a/packages/sdk/helper/README.md +++ b/packages/sdk/helper/README.md @@ -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. diff --git a/packages/sdk/helper/README.zh.md b/packages/sdk/helper/README.zh.md index e8844aab14..c50ec7aafa 100644 --- a/packages/sdk/helper/README.zh.md +++ b/packages/sdk/helper/README.zh.md @@ -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 可以补充缺失且名称不同的变量,但绝不会更新或删除现有内容。 diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index 88c07a0853..106cf07097 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -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'), ] diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 2aa6c40e71..278b609dd8 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -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) } diff --git a/packages/sdk/helper/src/features/define-feature.ts b/packages/sdk/helper/src/features/define-feature.ts index 6b726d42d6..84b020591c 100644 --- a/packages/sdk/helper/src/features/define-feature.ts +++ b/packages/sdk/helper/src/features/define-feature.ts @@ -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[] { diff --git a/packages/sdk/helper/src/features/feature.ts b/packages/sdk/helper/src/features/feature.ts index b77deb8093..5eb3d2bc5a 100644 --- a/packages/sdk/helper/src/features/feature.ts +++ b/packages/sdk/helper/src/features/feature.ts @@ -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. diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index d027d74e08..e8b88f91f3 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -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 } } diff --git a/packages/sdk/helper/src/project/sdk-project.ts b/packages/sdk/helper/src/project/sdk-project.ts index a24a08b3df..e9302aa428 100644 --- a/packages/sdk/helper/src/project/sdk-project.ts +++ b/packages/sdk/helper/src/project/sdk-project.ts @@ -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 { const absolute = resolve(root) diff --git a/packages/sdk/helper/src/project/types.ts b/packages/sdk/helper/src/project/types.ts index 11fca01b8d..0c66bb5ac2 100644 --- a/packages/sdk/helper/src/project/types.ts +++ b/packages/sdk/helper/src/project/types.ts @@ -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 { diff --git a/packages/sdk/helper/src/templates/assets/README.md.tpl b/packages/sdk/helper/src/templates/assets/README.md.tpl index c9843a2d15..619849d8cd 100644 --- a/packages/sdk/helper/src/templates/assets/README.md.tpl +++ b/packages/sdk/helper/src/templates/assets/README.md.tpl @@ -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 diff --git a/packages/sdk/helper/src/templates/assets/index.ts.tpl b/packages/sdk/helper/src/templates/assets/index.ts.tpl index 311c6746cf..584fadf9a1 100644 --- a/packages/sdk/helper/src/templates/assets/index.ts.tpl +++ b/packages/sdk/helper/src/templates/assets/index.ts.tpl @@ -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=') - const resume = boot.args.resume - if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) { - throw new Error('TUI startup requires --resume=') - } - 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 } diff --git a/packages/sdk/helper/src/templates/project-template.ts b/packages/sdk/helper/src/templates/project-template.ts index afcf820ec2..97408b345f 100644 --- a/packages/sdk/helper/src/templates/project-template.ts +++ b/packages/sdk/helper/src/templates/project-template.ts @@ -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> { - const modelArg = context.isTui ? ` -- --model=${JSON.stringify(context.model)}` : '' +export function createAppPackageScripts(): Readonly> { 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', } } diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 6ef18b12a2..86e50ca8ad 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -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') diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 763558afab..20446216ab 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -51,7 +51,7 @@ function selection(id: string, options: readonly string[], secrets?: Record { 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 => diff --git a/packages/sdk/helper/tests/questions.spec.ts b/packages/sdk/helper/tests/questions.spec.ts index e4ac919f8d..3cfc7ae029 100644 --- a/packages/sdk/helper/tests/questions.spec.ts +++ b/packages/sdk/helper/tests/questions.spec.ts @@ -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', } diff --git a/packages/sdk/scripts/src/config/config-workflow.ts b/packages/sdk/scripts/src/config/config-workflow.ts index 408a9b9639..9c578f3297 100644 --- a/packages/sdk/scripts/src/config/config-workflow.ts +++ b/packages/sdk/scripts/src/config/config-workflow.ts @@ -56,7 +56,7 @@ function targetRunInterface( desired: ReadonlyMap>, ): 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)) } diff --git a/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap b/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap index d4ce43908a..6ba55fd1a9 100644 --- a/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap +++ b/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap @@ -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, }, diff --git a/packages/sdk/scripts/tests/config.snapshot.ts b/packages/sdk/scripts/tests/config.snapshot.ts index e0e755d517..10f1a6ab28 100644 --- a/packages/sdk/scripts/tests/config.snapshot.ts +++ b/packages/sdk/scripts/tests/config.snapshot.ts @@ -94,7 +94,7 @@ async function baseProject(): Promise { 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: [], diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index d9f1f6f936..35df90ce98 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -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 { 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', () => { diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index 015b8e1ceb..859de5e095 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -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 diff --git a/packages/todo/README.md b/packages/todo/README.md index e16d1a3ff4..da85a55735 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -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. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index e3307d4f0a..5b83fe4d2d 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -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) 拥有);本包是追加快照的轻量消费方。宿主/客户端运行时会根据会话事件渲染该持久化列表。 diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 3072b330b8..e0a1a5ea25 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -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 diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 91f4bc6abd..456d4a08d8 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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 diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index b9582307ff..ec37682773 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -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))。 ## 会话投影 diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 7a6175f898..541de5b199 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/README.i18n.yaml @@ -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 diff --git a/packages/ui/README.md b/packages/ui/README.md index f08157d411..76ca80e568 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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. diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index ed4fbf5762..3958b0bdfb 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -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` 则提供后端与可选工具。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index d565f6f11c..1577917eb8 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -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 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 7e0466c405..dfb8b45b1c 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -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. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index abb796aaa9..db69e7609b 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -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(测试前置数据)中。 diff --git a/packages/ui/tui/AGENTS.md b/packages/ui/tui/AGENTS.md deleted file mode 100644 index 2e73212a58..0000000000 --- a/packages/ui/tui/AGENTS.md +++ /dev/null @@ -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. diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml deleted file mode 100644 index 39aabe4da4..0000000000 --- a/packages/ui/tui/README.i18n.yaml +++ /dev/null @@ -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: 78d56a6cacd040fdd32b73f779bab3fb4c77fcce -README.zh.md: c403605bb13d252eec00a2b0ebafb5f953c884f8 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md deleted file mode 100644 index 78d56a6cac..0000000000 --- a/packages/ui/tui/README.md +++ /dev/null @@ -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 ``. 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:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. - -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/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 ` 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: ` 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 ` still selects an unambiguous model id directly, while `/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: [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 `↑`, followed by `cache %` 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:`, 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: [instructions]` submission loads the named skill and delivers one text block: a `` 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. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md deleted file mode 100644 index c403605bb1..0000000000 --- a/packages/ui/tui/README.zh.md +++ /dev/null @@ -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`,终端窗口标题则变为 ``。持久 `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:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 - -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted ` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 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 ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 - -`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill: [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill,按精确名称调用时也会拒绝用户策略禁用的 skill。 - -Footer 将会话报告的用量汇总为 `↑`;任何输入计费后,后面会显示 `cache %`,表示提供方缓存服务的已计费提示词 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:` 一样自动调用它。随附的 `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: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 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 不会遍历。 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json deleted file mode 100644 index 135774a846..0000000000 --- a/packages/ui/tui/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/ui/tui/src/chat/autocomplete.ts b/packages/ui/tui/src/chat/autocomplete.ts deleted file mode 100644 index d63ed3614d..0000000000 --- a/packages/ui/tui/src/chat/autocomplete.ts +++ /dev/null @@ -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 { - 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) - } -} diff --git a/packages/ui/tui/src/chat/channel.ts b/packages/ui/tui/src/chat/channel.ts deleted file mode 100644 index bb46aad780..0000000000 --- a/packages/ui/tui/src/chat/channel.ts +++ /dev/null @@ -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 -} diff --git a/packages/ui/tui/src/chat/file-autocomplete.ts b/packages/ui/tui/src/chat/file-autocomplete.ts deleted file mode 100644 index 5bd4282d5d..0000000000 --- a/packages/ui/tui/src/chat/file-autocomplete.ts +++ /dev/null @@ -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 -} - -/** - * 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 - 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 { - 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 { - 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 { - 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 { - 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 { - 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(promise: Promise, signal: AbortSignal): Promise { - /* 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((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 }) -} diff --git a/packages/ui/tui/src/chat/helpers.ts b/packages/ui/tui/src/chat/helpers.ts deleted file mode 100644 index d96203ecd3..0000000000 --- a/packages/ui/tui/src/chat/helpers.ts +++ /dev/null @@ -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 { - const ids = new Set() - 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 - 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 - 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 diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts deleted file mode 100644 index c86b3b7e3d..0000000000 --- a/packages/ui/tui/src/chat/model-command.ts +++ /dev/null @@ -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 | 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 = 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 => { - 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 /.`, '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() - }, - } -} diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts deleted file mode 100644 index 5d96282860..0000000000 --- a/packages/ui/tui/src/chat/questions.ts +++ /dev/null @@ -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((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, - } -} diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts deleted file mode 100644 index f2173862ae..0000000000 --- a/packages/ui/tui/src/chat/resume.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - 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(records.length) - let cursor = 0 - const worker = async (): Promise => { - 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 => { - 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 => { - // 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') - }) - }, - } -} diff --git a/packages/ui/tui/src/chat/skill-invocation.ts b/packages/ui/tui/src/chat/skill-invocation.ts deleted file mode 100644 index 7eb7a555ae..0000000000 --- a/packages/ui/tui/src/chat/skill-invocation.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Manual `/skill: [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: [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: [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 - * `` 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:`; empty when absent. - * @returns the user-message text delivered to the agent. - */ -export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { - const lines = [``] - const reference = skillResourceReference(skill.resourceBase) - if (reference !== undefined) lines.push(reference, '') - lines.push(skill.content, '') - const block = lines.join('\n') - return instructions === '' ? block : `${block}\n\n${instructions}` -} diff --git a/packages/ui/tui/src/chat/timing.ts b/packages/ui/tui/src/chat/timing.ts deleted file mode 100644 index 5bd7e4082b..0000000000 --- a/packages/ui/tui/src/chat/timing.ts +++ /dev/null @@ -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 = { - 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, -): 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() - - /** - * 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 = { - 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 | 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}` -} diff --git a/packages/ui/tui/src/chat/tokens.ts b/packages/ui/tui/src/chat/tokens.ts deleted file mode 100644 index ab54292a1d..0000000000 --- a/packages/ui/tui/src/chat/tokens.ts +++ /dev/null @@ -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 -} - -/** - * 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` -} diff --git a/packages/ui/tui/src/components/content.ts b/packages/ui/tui/src/components/content.ts deleted file mode 100644 index a4536a6afd..0000000000 --- a/packages/ui/tui/src/components/content.ts +++ /dev/null @@ -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 } - } -} diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts deleted file mode 100644 index d427b906e1..0000000000 --- a/packages/ui/tui/src/components/dialogs.ts +++ /dev/null @@ -1,1253 +0,0 @@ -/** - * pi-tui dialog and selector components for the terminal front door: the status - * card, prompt-context line, model selector, resume picker, and user-question - * dialog, plus the model-choice and resume-candidate data they present. - * @module @deepseek-ai/dsh-tui/components/dialogs - */ - -import { - Input, - Key, - SelectList, - matchesKey, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, - type Component, - type Focusable, - type SelectItem, -} from '@earendil-works/pi-tui' -import type { Context } from 'cordis' -import { - type Agent, - type AgentLlmTarget, -} from '@deepseek-ai/dsh-agent' -import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionRecord } from '@deepseek-ai/dsh-session-query' -import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' -import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' -import { dialogSelectTheme, type Palette } from './theme.ts' -import type { ToolCardVisibility } from './transcript.ts' -import { - renderTuiPromptTemplate, - type TuiPromptTemplateToken, -} from '../prompt.ts' - -/** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */ -export interface ModelChoice extends AgentLlmTarget { - modelName: string - description?: string - reasoning?: LlmModelReasoningInfo -} - -/** - * The provider/model route and selected reasoning effort resolved from a model dialog. - */ -export interface ModelDialogSelection { - choice: ModelChoice - reasoningEffort: ReasoningEffortId | undefined -} - -/** - * Format a provider/model target as its `provider/model` label. - * @param target - The LLM target. - * @returns The `provider/model` label. - */ -export function targetLabel(target: AgentLlmTarget): string { - return `${target.provider}/${target.model}` -} - -/** - * Format a target compactly as its model name with any selected reasoning effort appended. - * @param target - The LLM target. - * @returns The compact `model [effort]` label. - */ -export function compactTargetLabel(target: AgentLlmTarget): string { - return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` -} - -/** - * Resolve the display label for a choice's reasoning effort. - * @param choice - The model choice carrying advertised reasoning metadata. - * @param effort - The selected effort, or `undefined` for provider default. - * @returns The effort's display name, `Default`, or `undefined` when the model has no reasoning metadata. - */ -export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { - if (effort === undefined) return choice.reasoning === undefined ? undefined : 'Default' - return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort -} - -/** - * Derive the agent's initial LLM target from its logged request header or options. - * @param agent - The driven agent. - * @returns The initial target, or `undefined` when unset. - */ -export function initialTarget(agent: Agent): AgentLlmTarget | undefined { - const logged = agent.session.requestHeader()?.config - if (logged !== undefined) { - if (logged.reasoningEffort === undefined) { - return { provider: logged.provider, model: logged.model } - } - return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort } - } - if (agent.options.provider === undefined || agent.options.model === undefined) return undefined - return { provider: agent.options.provider, model: agent.options.model } -} - -/** - * List every advertised model across registered providers, appending the current - * target when a provider does not advertise it. - * @param ctx - Context supplying the LLM service. - * @param current - The current target, appended when unadvertised. - * @returns The model choices, flattened across providers. - */ -export async function readModelChoices( - ctx: Context, - current: AgentLlmTarget | undefined, -): Promise { - const providers = ctx.llm.listProviders() - const groups = await Promise.all(providers.map(async (provider) => { - const advertised = await ctx.llm.listModels(provider.id) - const models: LlmModelInfo[] = [...advertised] - if ( - current?.provider === provider.id - && !models.some(model => model.id === current.model) - ) { - models.push({ provider: provider.id, id: current.model, name: current.model }) - } - return Promise.all(models.map(async (model): Promise => { - const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning - return { - provider: provider.id, - model: model.id, - modelName: model.name, - ...model.description === undefined ? {} : { description: model.description }, - ...reasoning === undefined ? {} : { reasoning }, - } - })) - })) - return groups.flat() -} - -/** - * Format a diagnostic integer with grouping separators. - * @param value - Integer to format. - * @returns The grouped decimal string. - */ -export function formatDiagnosticNumber(value: number): string { - return value.toLocaleString('en-US') -} - -/** - * Format a diagnostic timestamp as an ISO date-time in UTC. - * @param value - Epoch milliseconds. - * @returns The formatted UTC timestamp. - */ -export function formatDiagnosticTime(value: number): string { - return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') -} - -/** - * Format a pluralized count for a diagnostic row. - * @param value - Count. - * @param singular - Singular noun; an `s` is appended for other counts. - * @returns The formatted count. - */ -export function formatDiagnosticCount(value: number, singular: string): string { - return `${String(value)} ${singular}${value === 1 ? '' : 's'}` -} - -/** - * Render a fixed-width filled meter bar for a percentage. - * @param percent - Percentage in [0, 100]. - * @param palette - Active role palette. - * @returns The rendered meter. - */ -export function diagnosticMeter(percent: number, palette: Palette): string { - const width = 16 - const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) - return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` -} - -/** One `label: value` row of a status card group. */ -export type StatusCardRow = readonly [label: string, value: string] - -/** Bordered, grouped field card for one point-in-time status snapshot. */ -export class StatusCardComponent implements Component { - constructor( - private readonly groups: readonly (readonly StatusCardRow[])[], - private readonly palette: Palette, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) - const naturalLabelWidth = Math.max(...labels.map(label => label.length)) - const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => - 1 + naturalLabelWidth + 2 + visibleWidth(value)))) - const cardWidth = Math.min( - Math.max(8, width), - Math.max('Session status'.length + 5, naturalBodyWidth + 4), - ) - const innerWidth = Math.max(1, cardWidth - 4) - const labelWidth = Math.min( - naturalLabelWidth, - Math.max(1, Math.floor(innerWidth / 3)), - ) - const body: string[] = [] - for (const [groupIndex, group] of this.groups.entries()) { - if (groupIndex > 0) body.push('') - for (const [label, value] of group) { - const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') - const prefix = ` ${this.palette.dim(plainLabel.padEnd(labelWidth))} ` - const continuation = ' '.repeat(1 + labelWidth + 2) - const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) - const wrapped = wrapTextWithAnsi(value, valueWidth) - for (const [lineIndex, line] of wrapped.entries()) { - body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) - } - } - } - - const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') - const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) - const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` - const lines = [top] - for (const line of body) { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) - } - lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) - return lines - } -} - -/** The left/right template line rendered above the editor. */ -export class PromptContextComponent implements Component { - constructor( - private readonly leftTemplate: readonly TuiPromptTemplateToken[], - private readonly rightTemplate: readonly TuiPromptTemplateToken[], - private readonly resolve: (name: string) => string | undefined, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '') - const rightWidth = visibleWidth(right) - const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2)) - const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '') - if (rightWidth === 0) return [left] - const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth)) - return [`${left}${gap}${right}`] - } -} - -/** A user's answer to one question: chosen option labels and an optional custom answer. */ -export interface QuestionSelection { - selected: string[] - custom?: string -} - -/** - * Render a bordered dialog frame around body lines with a titled top edge. - * @param title - Dialog title shown in the top border. - * @param body - Body lines. - * @param width - Dialog width in columns. - * @param palette - Active role palette. - * @returns The framed dialog lines. - */ -export function renderDialog( - title: string, - body: readonly string[], - width: number, - palette: Palette, -): string[] { - const innerWidth = Math.max(1, width - 4) - const topLabel = ` ${displayText(title)} ` - const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` - const lines: string[] = [palette.accent(top)] - for (const line of body) { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) - } - lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) - return lines -} - -/** Keyboard model selector rendered as a bordered overlay, with a filter box and per-model reasoning-effort cycling. */ -export class ModelDialog implements Component { - private list: SelectList - private readonly filter = new Input() - private readonly items: Map - private readonly choices: Map - private readonly efforts: Map - private readonly currentValue: string | undefined - - constructor( - choices: readonly ModelChoice[], - current: AgentLlmTarget | undefined, - private readonly maxVisible: number, - private readonly palette: Palette, - private readonly done: (selection: ModelDialogSelection) => void, - private readonly cancel: () => void, - ) { - this.items = new Map() - this.choices = new Map() - this.efforts = new Map() - this.currentValue = current === undefined ? undefined : targetLabel(current) - for (const choice of choices) { - const value = targetLabel(choice) - const isCurrent = current?.provider === choice.provider && current.model === choice.model - this.choices.set(value, choice) - this.efforts.set( - value, - isCurrent - ? current.reasoningEffort ?? choice.reasoning?.defaultEffort - : choice.reasoning?.defaultEffort, - ) - this.items.set(value, { - value, - label: displayText(value), - description: this.describeChoice(choice, isCurrent), - }) - } - this.list = this.buildList(this.currentValue) - } - - /** Build a SelectList over the currently filtered items, selecting `selectValue` when present. */ - private buildList(selectValue: string | undefined): SelectList { - const items = this.filteredItems() - const list = new SelectList(items, this.maxVisible, dialogSelectTheme(this.palette)) - const index = selectValue === undefined ? 0 : items.findIndex(item => item.value === selectValue) - list.setSelectedIndex(Math.max(0, index)) - list.onSelect = (item) => { this.confirm(item) } - list.onCancel = this.cancel - return list - } - - /** Items matching the filter box, as a case-insensitive substring over the label, model name, and description. */ - private filteredItems(): SelectItem[] { - const query = this.filter.getValue().trim().toLocaleLowerCase() - if (query === '') return [...this.items.values()] - return [...this.items.values()].filter((item) => { - const choice = this.choices.get(item.value) - /* v8 ignore next -- items and choices share the same keys. */ - if (choice === undefined) return false - return [item.value, choice.modelName, choice.description ?? ''] - .some(field => field.toLocaleLowerCase().includes(query)) - }) - } - - private confirm(item: SelectItem): void { - const selected = this.choices.get(item.value) - /* v8 ignore next -- SelectList only returns values built from `choices`. */ - if (selected === undefined) return - this.done({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) - } - - private describeChoice(choice: ModelChoice, isCurrent: boolean): string { - const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice))) - return [ - displayText(choice.modelName), - ...choice.description === undefined ? [] : [displayText(choice.description)], - ...effortLabel === undefined ? [] : [displayText(effortLabel)], - ...isCurrent ? ['current'] : [], - ].join(' — ') - } - - private cycleReasoningEffort(): void { - const selectedItem = this.list.getSelectedItem() - /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ - if (selectedItem === null) return - const choice = this.choices.get(selectedItem.value) - if (choice?.reasoning === undefined) return - const current = this.efforts.get(selectedItem.value) - const efforts: Array = [ - ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], - ...choice.reasoning.efforts.map(effort => effort.id), - ] - const currentIndex = efforts.indexOf(current) - const next = efforts[(currentIndex + 1) % efforts.length] - this.efforts.set(selectedItem.value, next) - const item = this.items.get(selectedItem.value) - /* v8 ignore next -- items and choices are constructed from the same values. */ - if (item === undefined) return - item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) - } - - invalidate(): void { - this.filter.invalidate() - this.list.invalidate() - } - - handleInput(data: string): void { - if (matchesKey(data, Key.shift(Key.tab))) { - this.cycleReasoningEffort() - } else if (matchesKey(data, Key.escape)) { - if (this.filter.getValue() === '') this.cancel() - else { - this.filter.setValue('') - this.list = this.buildList(undefined) - } - } else if ( - matchesKey(data, Key.up) - || matchesKey(data, Key.down) - || matchesKey(data, Key.enter) - ) { - this.list.handleInput(data) - } else { - const previous = this.filter.getValue() - this.filter.focused = true - this.filter.handleInput(data) - if (this.filter.getValue() !== previous) { - const selected = this.list.getSelectedItem() - this.list = this.buildList(selected?.value) - } - } - this.invalidate() - } - - render(width: number): string[] { - const innerWidth = Math.max(1, width - 4) - this.filter.focused = true - const results = this.filteredItems() - const filterContent = truncateToWidth(this.filter.render(innerWidth).join(''), innerWidth, '') - return renderDialog('Select model', [ - filterContent, - '', - ...results.length === 0 - ? [this.palette.dim(' No models match the filter')] - : this.list.render(innerWidth), - '', - this.palette.dim('type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc'), - ], width, this.palette) - } -} - -/** Both transcript-detail dimensions, applied immediately on each Tab. */ -export interface DetailsSelection { - readonly visibility: ToolCardVisibility - readonly showReasoning: boolean -} - -const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] - -/** - * Keyboard toggle over the two transcript-detail entries — tool-card - * visibility and reasoning display. Tab cycles the highlighted entry's value - * and applies it immediately, so the transcript behind the dialog is the live - * preview; Enter, Esc, or Ctrl+C closes. - */ -export class DetailsDialog implements Component { - private readonly list: SelectList - private readonly toolsItem: SelectItem - private readonly reasoningItem: SelectItem - - constructor( - private visibility: ToolCardVisibility, - private showReasoning: boolean, - private readonly palette: Palette, - private readonly apply: (selection: DetailsSelection) => void, - private readonly close: () => void, - ) { - this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility } - this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() } - this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) - this.list.onSelect = close - } - - private reasoningLabel(): string { - return this.showReasoning ? 'shown' : 'hidden' - } - - /** Cycle the highlighted entry one step and apply the new state. */ - private cycle(): void { - const selected = this.list.getSelectedItem() - /* v8 ignore next -- the two-entry list always has a selection. */ - if (selected === null) return - if (selected.value === 'tools') { - const index = TOOL_CARD_PHASES.indexOf(this.visibility) - this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility - this.toolsItem.description = this.visibility - } else { - this.showReasoning = !this.showReasoning - this.reasoningItem.description = this.reasoningLabel() - } - this.apply({ visibility: this.visibility, showReasoning: this.showReasoning }) - } - - invalidate(): void { - this.list.invalidate() - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close() - else if (matchesKey(data, Key.tab)) this.cycle() - else this.list.handleInput(data) - this.invalidate() - } - - render(width: number): string[] { - const innerWidth = Math.max(1, width - 4) - return renderDialog('Transcript details', [ - ...this.list.render(innerWidth), - '', - this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'), - ], width, this.palette) - } -} - -/** A resume selector row summarizing one session from metadata and its folded title. */ -export interface ResumeCandidate { - record: SessionRecord - title: string - /** Last observed change: live last-event time or artifact mtime, falling back to creation. */ - lastActivityAt: number - /** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */ - currentWorkspace: boolean - /** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */ - workspaceLabel: string - disabledReason?: string -} - -/** - * Build one resume selector row from a record, its batch-folded title, and a - * metadata-derived activity time, deriving the workspace scope and any reason - * the session cannot be resumed here. A workspace other than the current one - * is a scope, not a disabled reason: resuming it hands the process off into - * that directory. Rows carry no per-log detail beyond the title — route and - * replay validity are checked by the Enter-time preflight against the one - * chosen log. - * @param record - The session record. - * @param title - The session's batch-folded title, absent for an untitled log. - * @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time. - * @param currentId - The current session id. - * @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. - * @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label. - * @returns The summarized resume candidate. - */ -export function summarizeResumeCandidate( - record: SessionRecord, - title: string | undefined, - lastActivityAt: number | undefined, - currentId: SessionId, - cwd: string | undefined, - formatWorkspace: (cwd: string | undefined) => string, -): ResumeCandidate { - let disabledReason: string | undefined - if (record.header.id === currentId) disabledReason = 'current session' - else if (record.live) disabledReason = 'session is already live in this runtime' - else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace' - return { - record, - title: title ?? 'Untitled session', - lastActivityAt: lastActivityAt ?? record.header.createdAt, - currentWorkspace: record.header.cwd === cwd, - workspaceLabel: formatWorkspace(record.header.cwd), - ...disabledReason === undefined ? {} : { disabledReason }, - } -} - -/** Which workspaces the resume picker currently lists. */ -export type ResumeScope = 'workspace' | 'all' - -/** - * Full-viewport keyboard selector over detached, preflighted resume summaries. - * - * Two scopes over one candidate set: `workspace` (the default) lists only the - * current session's workspace, `all` lists every workspace and labels each row - * with its own. Tab toggles between them; the search query and selection reset - * on a scope change so the highlighted row always belongs to the visible list. - * - * The picker opens before the session scan settles: an `undefined` candidate - * set renders a loading placeholder that keeps input away from the editor, - * and `setCandidates` swaps the scanned rows in without replacing the overlay. - */ -export class ResumePicker implements Component, Focusable { - private readonly search = new Input() - private pasteBuffer: string | undefined - private selectedIndex = 0 - private error = '' - private scope: ResumeScope = 'workspace' - private candidates: readonly ResumeCandidate[] | undefined - focused = false - - constructor( - candidates: readonly ResumeCandidate[] | undefined, - private readonly maxVisible: number, - private readonly workspaceLabel: string, - private readonly viewportRows: () => number, - private readonly palette: Palette, - private readonly done: (candidate: ResumeCandidate) => void, - private readonly cancel: () => void, - ) { - this.candidates = candidates - } - - invalidate(): void { - this.search.invalidate() - } - - /** - * Replace the loading placeholder with the scanned candidate set. - * @param candidates - the summarized rows the finished scan produced. - */ - setCandidates(candidates: readonly ResumeCandidate[]): void { - this.candidates = candidates - this.selectedIndex = 0 - // A still-loading error is false the moment rows exist. - this.error = '' - this.invalidate() - } - - /** Candidates in the active scope, before the search query narrows them. */ - private scoped(): ResumeCandidate[] { - const candidates = this.candidates ?? [] - return this.scope === 'all' - ? [...candidates] - : candidates.filter(candidate => candidate.currentWorkspace) - } - - private filtered(): ResumeCandidate[] { - const query = this.search.getValue().trim().toLocaleLowerCase() - const scoped = this.scoped() - if (query === '') return scoped - // The workspace label only distinguishes rows once it is on screen, so it - // joins the searchable text exactly in the scope that shows it. - return scoped.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) - || candidate.record.header.id.toLocaleLowerCase().includes(query) - || (this.scope === 'all' && candidate.workspaceLabel.toLocaleLowerCase().includes(query))) - } - - private visibleCandidateCount(): number { - // The all-workspaces scope adds a per-row workspace line, so a row costs - // one more terminal row there than in the single-workspace scope. - const rowHeight = this.scope === 'all' ? 4 : 3 - const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight)) - return Math.min(this.maxVisible, candidateBudget) - } - - private handleBracketedPaste(data: string): boolean { - const start = data.indexOf(BRACKETED_PASTE_START) - if (this.pasteBuffer === undefined && start < 0) return false - if (this.pasteBuffer === undefined) { - const prefix = data.slice(0, start) - if (prefix !== '') this.handleInput(prefix) - this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) - } else { - this.pasteBuffer += data - } - const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) - if (end < 0) return true - const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) - const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) - this.pasteBuffer = undefined - const previous = this.search.getValue() - this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - if (remaining !== '') this.handleInput(remaining) - this.invalidate() - return true - } - - handleInput(data: string): void { - if (this.handleBracketedPaste(data)) return - const filtered = this.filtered() - if (matchesKey(data, Key.ctrl('c'))) { - this.cancel() - return - } - if (matchesKey(data, Key.escape)) { - if (this.search.getValue() === '') this.cancel() - else { - this.search.setValue('') - this.selectedIndex = 0 - this.error = '' - } - } else if (matchesKey(data, Key.up)) { - this.selectedIndex = filtered.length === 0 - ? 0 - : (this.selectedIndex + filtered.length - 1) % filtered.length - } else if (matchesKey(data, Key.down)) { - this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length - } else if (matchesKey(data, Key.pageUp)) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) - } else if (matchesKey(data, Key.pageDown)) { - this.selectedIndex = Math.min( - Math.max(0, filtered.length - 1), - this.selectedIndex + this.visibleCandidateCount(), - ) - } else if (matchesKey(data, Key.tab)) { - this.scope = this.scope === 'workspace' ? 'all' : 'workspace' - this.search.setValue('') - this.selectedIndex = 0 - this.error = '' - } else if (matchesKey(data, Key.enter)) { - const selected = filtered[this.selectedIndex] - if (this.candidates === undefined) this.error = 'Sessions are still loading.' - else if (selected === undefined) this.error = 'No session matches this search.' - else if (selected.disabledReason !== undefined) this.error = selected.disabledReason - else this.done(selected) - } else { - const previous = this.search.getValue() - this.search.focused = this.focused - this.search.handleInput(data) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - } - this.invalidate() - } - - /** - * The scope line under the search box: the active scope with the current - * workspace it means, and the inactive scope with the count Tab would reveal. - */ - private renderScopeLine(): string { - const candidates = this.candidates ?? [] - const inWorkspace = candidates.filter(candidate => candidate.currentWorkspace).length - const active = this.scope === 'workspace' - ? `this workspace ${displayText(this.workspaceLabel)}` - : `all workspaces (${candidates.length})` - const other = this.scope === 'workspace' - ? `all workspaces (${candidates.length})` - : `this workspace (${inWorkspace})` - return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}` - } - - render(width: number): string[] { - this.search.focused = this.focused - const height = Math.max(1, this.viewportRows()) - const horizontalPadding = width >= 12 ? 2 : 0 - const contentWidth = Math.max(1, width - horizontalPadding * 2) - const indent = ' '.repeat(horizontalPadding) - const filtered = this.filtered() - if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) - const selected = filtered[this.selectedIndex] - const position = selected === undefined ? 0 : this.selectedIndex + 1 - const title = this.candidates === undefined - ? 'Resume session' - : `Resume session (${position} of ${filtered.length})` - const lines: string[] = [ - '', - `${indent}${this.palette.bold(this.palette.accent(title))}`, - '', - ] - - const searchInnerWidth = Math.max(1, contentWidth - 4) - lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) - const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') - const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') - lines.push( - `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, - `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, - '', - `${indent}${this.renderScopeLine()}`, - '', - ) - - const visibleCount = this.visibleCandidateCount() - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(visibleCount / 2), - filtered.length - visibleCount, - )) - const end = Math.min(filtered.length, start + visibleCount) - const push = (line: string): void => { - lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) - } - for (let index = start; index < end; index += 1) { - const candidate = filtered[index] as ResumeCandidate - const active = index === this.selectedIndex - const status = [ - candidate.disabledReason === 'current session' ? 'current' : undefined, - candidate.record.live ? 'live' : undefined, - candidate.record.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(' · ') - const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` - push(active ? this.palette.bold(this.palette.accent(lead)) : lead) - push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`)) - // Only the all-workspaces scope mixes directories, so the per-row - // workspace is redundant in the scope that already names one. - if (this.scope === 'all') { - push(this.palette.dim(` workspace ${displayText(candidate.workspaceLabel)}`)) - } - if (candidate.disabledReason !== undefined) { - push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) - } - } - if (this.candidates === undefined) push(this.palette.dim('Loading sessions…')) - else if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) - if (this.error !== '') { - lines.push('') - push(this.palette.error(displayText(this.error))) - } - - const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel')}` - while (lines.length < height - 2) lines.push('') - lines.push(footer, '') - return lines.slice(0, height) - } -} - -interface SelectedBlockPage { - offset: number - size: number - maxOffset: number -} - -/** Inline dialog for one user question with option or custom-answer modes. */ -export class QuestionDialog implements Component, Focusable { - private selectedIndex = 0 - private selected = new Set() - private headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } - private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } - private mode: 'options' | 'custom' - private error = '' - private readonly input = new Input() - private readonly options: NonNullable - focused = false - - constructor( - private readonly question: AskUserQuestionItem, - private readonly position: number, - private readonly total: number, - private readonly unanswered: number, - private readonly maxVisible: number, - private readonly maxHeight: () => number, - private readonly palette: Palette, - private readonly done: (selection: QuestionSelection) => void, - private readonly cancel: () => void, - ) { - this.options = question.options ?? [] - this.mode = this.options.length > 0 ? 'options' : 'custom' - this.input.onSubmit = (value) => { this.submitCustom(value) } - this.input.onEscape = () => { - if (this.options.length > 0) { - this.mode = 'options' - this.error = '' - } else { - this.cancel() - } - } - } - - invalidate(): void { - this.input.invalidate() - } - - handleInput(data: string): void { - this.invalidate() - if (matchesKey(data, Key.pageUp)) { - this.pageBackward() - return - } - if (matchesKey(data, Key.pageDown)) { - this.pageForward() - return - } - if (this.mode === 'custom') { - this.input.focused = this.focused - this.input.handleInput(data) - return - } - const options = this.options - if (matchesKey(data, Key.up)) { - this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } - this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 - } else if (matchesKey(data, Key.down)) { - this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } - this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 - } else if (matchesKey(data, Key.space) && this.question.multiSelect) { - if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) - else this.selected.add(this.selectedIndex) - } else if (matchesKey(data, Key.enter)) { - const selected = this.question.multiSelect - ? this.selectedOptionLabels() - : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) - const custom = this.question.multiSelect ? this.input.getValue().trim() : '' - if (selected.length === 0 && custom === '') { - this.error = 'Select at least one option, or press Tab for a custom answer.' - return - } - this.done({ selected, ...(custom === '' ? {} : { custom }) }) - } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { - this.mode = 'custom' - this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } - this.error = '' - } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { - this.cancel() - } - } - - private submitCustom(value: string): void { - const custom = value.trim() - if (custom === '') { - this.error = 'Enter an answer before submitting.' - return - } - this.done({ - selected: this.question.multiSelect ? this.selectedOptionLabels() : [], - custom, - }) - } - - private selectedOptionLabels(): string[] { - return [...this.selected] - .sort((a, b) => a - b) - .map(index => this.options[index]?.label) - .filter((label): label is string => label !== undefined) - } - - /** Page backward through an oversized option, then through question detail. */ - private pageBackward(): void { - if (this.mode === 'options' && this.selectedBlockPage.offset > 0) { - this.selectedBlockPage = { - ...this.selectedBlockPage, - offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size), - } - return - } - this.headerPage = { - ...this.headerPage, - offset: Math.max(0, this.headerPage.offset - this.headerPage.size), - } - } - - /** Page forward through question detail, then through an oversized option. */ - private pageForward(): void { - if (this.headerPage.offset < this.headerPage.maxOffset) { - this.headerPage = { - ...this.headerPage, - offset: Math.min( - this.headerPage.maxOffset, - this.headerPage.offset + this.headerPage.size, - ), - } - return - } - if (this.mode === 'custom') return - this.selectedBlockPage = { - ...this.selectedBlockPage, - offset: Math.min( - this.selectedBlockPage.maxOffset, - this.selectedBlockPage.offset + this.selectedBlockPage.size, - ), - } - } - - render(width: number): string[] { - this.input.focused = this.focused - const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2))) - const innerWidth = Math.max(1, width - horizontalPadding * 2) - const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const questionLines = wrapTextWithAnsi( - this.palette.text(displayText(this.question.question)), - innerWidth, - ) - const contentLines = [...questionLines] - const headerLines: string[] = [ - ...wrapTextWithAnsi(this.palette.dim(header), innerWidth), - ...questionLines, - ] - // Supporting detail (e.g. the full plan under review) renders between the - // question and the answer surface, kept out of option labels. - if (this.question.detail !== undefined) { - headerLines.push('') - contentLines.push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) { - headerLines.push(line) - contentLines.push(line) - } - } - headerLines.push('') - - const customControls = [ - ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), - 'Enter submit', - this.options.length > 0 ? 'Esc options' : 'Esc cancel', - ] - const customHint = this.palette.dim(customControls.join(' • ')) - const footerLines: string[] = [] - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) footerLines.push(line) - for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line) - } else { - const controls = [ - 'Tab custom answer', - ...(this.options.length > 1 ? ['↑/↓ navigate'] : []), - ...(this.question.multiSelect ? ['Space toggle'] : []), - 'Enter submit', - 'Esc interrupt', - ] - const hint = this.palette.dim(controls.join(' • ')) - for (const line of wrapTextWithAnsi(hint, innerWidth)) footerLines.push(line) - } - if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line) - } - const positionLines = this.mode === 'options' && this.options.length > this.maxVisible - ? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)] - : [] - - // Options receive only the rows left after fixed chrome and outer padding. - // The final height window handles fixed chrome that cannot fit even alone. - const paddingRows = 2 - const maxHeight = this.maxHeight() - const availableForOptions = Math.max( - this.mode === 'options' ? 4 : 1, - maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length, - ) - - const body: string[] = [...headerLines] - const optionLines: string[] = [] - if (this.mode === 'custom') { - for (const line of footerLines) body.push(line) - } else { - const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth)) - const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth) - if (hiddenBefore > 0) optionLines.push(this.palette.dim(`↑ ${hiddenBefore} more`)) - for (const block of visibleBlocks) { - for (const line of block) optionLines.push(line) - } - if (hiddenAfter > 0) optionLines.push(this.palette.dim(`↓ ${hiddenAfter} more`)) - for (const line of optionLines) body.push(line) - for (const line of positionLines) body.push(line) - for (const line of footerLines) body.push(line) - } - - const rows = ['', ...body, ''] - let visibleRows = rows - if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 } - if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) { - const headerBudget = Math.max( - 0, - maxHeight - optionLines.length - (this.error === '' ? 1 : 2), - ) - const compactFooter = [ - ...this.error === '' - ? [] - : [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')], - this.compactOptionControls( - innerWidth, - headerBudget === 1 && contentLines.length > headerBudget, - ), - ] - const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth) - visibleRows = [...compactHeader, ...optionLines, ...compactFooter] - } else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) { - const compactFooterSource = [ - ...this.input.render(innerWidth), - this.compactCustomControls(innerWidth), - ...this.error === '' - ? [] - : [truncateToWidth(this.palette.error(this.error), innerWidth, '…')], - ] - const footerBudget = Math.max(1, maxHeight - 1) - const compactFooter = compactFooterSource.length <= footerBudget - ? compactFooterSource - : footerBudget === 1 - ? compactFooterSource.slice(0, 1) - : [ - ...compactFooterSource.slice(0, 1), - ...compactFooterSource.slice(-(footerBudget - 1)), - ] - const compactHeader = this.compactQuestionHeader( - contentLines, - Math.max(0, maxHeight - compactFooter.length), - innerWidth, - ) - visibleRows = [...compactHeader, ...compactFooter] - } - if (visibleRows.length > maxHeight) { - visibleRows = maxHeight === 1 - ? [this.palette.dim(`↑ ${visibleRows.length} lines hidden`)] - : [ - this.palette.dim(`↑ ${visibleRows.length - maxHeight + 1} lines hidden`), - ...visibleRows.slice(-(maxHeight - 1)), - ] - } - return visibleRows.map((line) => { - const bounded = truncateToWidth(line, innerWidth, '…') - const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded))) - const outerPad = ' '.repeat(horizontalPadding) - return `${outerPad}${bounded}${pad}${outerPad}` - }) - } - - /** Render one option as wrapped label and indented description lines. */ - private renderOptionBlock( - option: NonNullable[number], - index: number, - innerWidth: number, - ): string[] { - const cursor = index === this.selectedIndex ? '›' : ' ' - const number = `${index + 1}. ` - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const labelPrefixPlain = ` ${cursor} ${number}${mark}` - const labelPrefixWidth = visibleWidth(labelPrefixPlain) - const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) - const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth) - const continuation = ' '.repeat(labelPrefixWidth) - const lines: string[] = [] - for (const [lineIndex, labelLine] of labelLines.entries()) { - const prefix = lineIndex === 0 ? labelPrefixPlain : continuation - const composed = `${prefix}${labelLine}` - lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed) - } - if (option.description !== undefined) { - const descIndent = ' '.repeat(labelPrefixWidth) - const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) - const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth) - for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`) - } - return lines - } - - /** Keep the question visible when fixed chrome must be compacted. */ - private compactQuestionHeader( - contentLines: readonly string[], - budget: number, - innerWidth: number, - ): string[] { - if (budget <= 0) return [] - if (contentLines.length <= budget) { - this.headerPage = { offset: 0, size: 1, maxOffset: 0 } - return [...contentLines] - } - const pageSize = Math.max(1, budget - 1) - const maxOffset = Math.max(0, contentLines.length - pageSize) - const offset = Math.min(this.headerPage.offset, maxOffset) - this.headerPage = { offset, size: pageSize, maxOffset } - const keptLines = contentLines.slice(offset, offset + pageSize) - if (budget === 1) { - // A page is non-empty because pageSize is one and offset is clamped inside contentLines. - return [keptLines[0] as string] - } - return [ - ...keptLines, - this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth), - ] - } - - /** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */ - private pagerStatus(first: number, last: number, total: number, innerWidth: number): string { - const full = `… lines ${first}-${last}/${total} • PgUp/PgDn` - const compact = `PgUp/PgDn ${first}/${total}` - return this.palette.dim(truncateToWidth( - visibleWidth(full) <= innerWidth ? full : compact, - innerWidth, - '…', - )) - } - - /** Render custom-mode controls on one row when the header must compact. */ - private compactCustomControls(innerWidth: number): string { - const controls = this.options.length > 0 - ? 'Enter submit • Esc options' - : 'Enter submit • Esc cancel' - const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel' - const line = visibleWidth(controls) <= innerWidth ? controls : fallback - return this.palette.dim(truncateToWidth(line, innerWidth, '…')) - } - - /** Render a one-row option footer that retains every mode-specific control. */ - private compactOptionControls(innerWidth: number, showPager = false): string { - const controls = [ - ...(this.options.length > 1 ? ['↑/↓'] : []), - 'Tab custom', - ...(this.question.multiSelect ? ['Space toggle'] : []), - 'Enter', - 'Esc interrupt', - ...(showPager ? ['PgUp/PgDn'] : []), - ].join(' • ') - const optionNavigation = this.options.length > 1 ? '↑↓ ' : '' - const fallback = showPager - ? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc` - : this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc` - const line = visibleWidth(controls) <= innerWidth ? controls : fallback - return this.palette.dim(truncateToWidth(line, innerWidth, '…')) - } - - /** - * Choose option blocks that fit while keeping the selected option visible. - * Omitted blocks are counted at each end for explicit overflow markers. - */ - private windowBlocks( - blocks: readonly string[][], - budget: number, - innerWidth: number, - ): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } { - const totalLines = blocks.reduce((sum, block) => sum + block.length, 0) - if (totalLines <= budget && blocks.length <= this.maxVisible) { - return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 } - } - // `blocks` is dense and selectedIndex is derived from the same options. - let start = this.selectedIndex - let end = this.selectedIndex + 1 - /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ - let used = blocks[this.selectedIndex]?.length ?? 0 - const markerLines = (before: number, after: number): number => - (before > 0 ? 1 : 0) + (after > 0 ? 1 : 0) - const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean => - nextEnd - nextStart <= this.maxVisible - && nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget - const selectedMarkers = markerLines(start, blocks.length - end) - if (used + selectedMarkers > budget) { - /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ - const selectedBlock = blocks[this.selectedIndex] ?? [] - const hiddenBefore = start - const hiddenAfter = blocks.length - end - const pageSize = budget - selectedMarkers - 1 - const maxOffset = Math.max(0, selectedBlock.length - pageSize) - const offset = Math.min(this.selectedBlockPage.offset, maxOffset) - this.selectedBlockPage = { offset, size: pageSize, maxOffset } - const keptLines = selectedBlock.slice(offset, offset + pageSize) - const first = offset + 1 - const last = offset + keptLines.length - const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth) - return { - visibleBlocks: [[...keptLines, overflow]], - hiddenBefore, - hiddenAfter, - } - } - this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } - let expanded = true - while (expanded && (start > 0 || end < blocks.length)) { - expanded = false - if (end < blocks.length) { - /* v8 ignore next -- guarded by `end < blocks.length` above. */ - const next = blocks[end]?.length ?? 0 - if (fits(start, end + 1, used + next)) { - used += next - end += 1 - expanded = true - continue - } - } - if (start > 0) { - /* v8 ignore next -- guarded by `start > 0` above. */ - const previous = blocks[start - 1]?.length ?? 0 - if (fits(start - 1, end, used + previous)) { - used += previous - start -= 1 - expanded = true - } - } - } - return { - visibleBlocks: blocks.slice(start, end), - hiddenBefore: start, - hiddenAfter: blocks.length - end, - } - } -} diff --git a/packages/ui/tui/src/components/text.ts b/packages/ui/tui/src/components/text.ts deleted file mode 100644 index 876893adfd..0000000000 --- a/packages/ui/tui/src/components/text.ts +++ /dev/null @@ -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, '') -} diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts deleted file mode 100644 index 43630e27d8..0000000000 --- a/packages/ui/tui/src/components/theme.ts +++ /dev/null @@ -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 = (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> - readonly attributes: Readonly> -} { - 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 - 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 -} diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts deleted file mode 100644 index edf5a5a661..0000000000 --- a/packages/ui/tui/src/components/transcript.ts +++ /dev/null @@ -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 => 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 / ` 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() - 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(([, 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['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 / ` 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 - // `/ ` 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 `/ ` 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 / ` 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 / ` 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 ·