From 890343751a356273128416eba6be6b09ece7d463 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 22:29:35 +0800 Subject: [PATCH 01/16] feat(tui): add versioned first-run welcome --- ...-versioned-tui-first-run-welcome.i18n.yaml | 6 + ...6-07-30-versioned-tui-first-run-welcome.md | 43 +++ ...7-30-versioned-tui-first-run-welcome.zh.md | 43 +++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 1 + apps/cli/README.zh.md | 1 + apps/cli/assets/deepseek-color.svg | 1 + apps/cli/package.json | 2 + apps/cli/src/tui-first-run-welcome-art.ts | 111 ++++++ apps/cli/src/tui-first-run-welcome-copy.ts | 62 +++ apps/cli/src/tui-first-run-welcome.ts | 352 ++++++++++++++++++ apps/cli/src/tui.ts | 29 +- apps/cli/tests/pty-harness.ts | 52 ++- .../120-columns.expected.txt | 106 ++++++ .../160-columns.expected.txt | 106 ++++++ .../60-columns.expected.txt | 100 +++++ .../80-columns.expected.txt | 103 +++++ apps/cli/tests/tui-first-run-welcome.spec.ts | 189 ++++++++++ apps/cli/tests/tui-keyless-smoke.e2e.ts | 190 +++++++++- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 6 +- packages/ui/tui/README.zh.md | 6 +- packages/ui/tui/src/components/theme.ts | 32 +- packages/ui/tui/src/extension/types.ts | 2 + packages/ui/tui/src/index.ts | 5 +- packages/ui/tui/tests/extension.spec.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 7 +- pnpm-lock.yaml | 3 + 28 files changed, 1527 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md create mode 100644 apps/cli/assets/deepseek-color.svg create mode 100644 apps/cli/src/tui-first-run-welcome-art.ts create mode 100644 apps/cli/src/tui-first-run-welcome-copy.ts create mode 100644 apps/cli/src/tui-first-run-welcome.ts create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt create mode 100644 apps/cli/tests/tui-first-run-welcome.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml new file mode 100644 index 0000000000..ff8ce65c14 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.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/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +2026-07-30-versioned-tui-first-run-welcome.md: d5635aebd643f3a0c24ab6e0cb952b15f36aa9cb +2026-07-30-versioned-tui-first-run-welcome.zh.md: 7760ebde0e117e0f03d572b8263330b24c5a81ab diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md new file mode 100644 index 0000000000..d5635aebd6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -0,0 +1,43 @@ +# Agent Note: Versioned TUI first-run welcome + +Status: implemented + +English | [中文](2026-07-30-versioned-tui-first-run-welcome.zh.md) + +## Problem + +The shipped `dsh` terminal starts directly in the editor and gives first-time internal testers no durable orientation about the product's maturity or feedback channel. The existing one-line `welcome` banner subtitle cannot carry the supplied four-paragraph notice without crowding the normal session header, and putting onboarding in the session log would create a user turn or model-visible context that is unrelated to the user's work. + +The notice also needs a recognizable DeepSeek composition without copying another product's startup art or maintaining a hand-drawn approximation that drifts from the official mark. + +## Decision + +The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and every other non-navigation input leave the overlay open; disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. + +The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Exclusive file creation makes concurrent Enter actions idempotent without a read-modify-write race or dependency on the independently evolving settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. + +The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. + +The overlay is centered and consumes the available terminal width. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. + +## Verification + +Focused unit coverage pins the supplied SVG and Chinese copy hashes, version bumps, exclusive concurrent acknowledgement, malformed markers, persistence retry, Escape behavior, ASCII fallback, width-tier selection, bounded rendering, and low-height scrolling. Real Loader/PTY cases cover 60, 80, 120, and 160 columns plus a low-height viewport, emit semantic terminal snapshots, prove first launch then second-launch suppression under one `DSH_HOME`, and prove a resumed session appends no notice-derived user message or turn; ordinary terminal-exit lifecycle events remain unchanged. + +## Alternatives considered + +**Reuse the TUI `welcome` subtitle.** It is one transient header line whose normal job is to identify an untitled session. The required prose and action would either be clipped or permanently crowd ordinary launches. + +**Copy Claude Code's startup art or composition.** Its strong hierarchy is useful product evidence, but its graphic, layout, and brand treatment belong to another product. The official DeepSeek SVG provides a direct brand source, and the terminal composition is derived independently around this notice's copy and responsive constraints. + +**Hand-draw an original whale.** A freehand silhouette can be recognizable yet still disagree with the official mark's body, internal negative space, fin, and tail. Exact-path raster sampling keeps the terminal limitation explicit and makes every tier traceable to one source asset. + +**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; a version-named exclusive marker has no lost-update shape. + +**Allow Escape or a later-reminder action.** Either would make dismissal indistinguishable from acknowledgement or introduce reminder policy that the notice does not need. Normal process exit remains the abort path and leaves the version unacknowledged. + +## Consequences + +Each Harness home receives the notice once per copy version, only after a successful Enter acknowledgement. Maintainers can edit all bilingual wording and the version in one small owner file, and can update the official SVG and derived static rasters in their separate visual owner without chasing snapshots for full prose copies. + +The terminal cannot display SVG vectors directly, so its faithful representation is resolution-bounded. Smaller tiers preserve the sampled silhouette but necessarily lose fine detail; low-height terminals prefer readable prose and an always-reachable action over brand art. The marker format is intentionally one-file-per-version during the pre-release period; old markers are harmless and no compatibility reader is required. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md new file mode 100644 index 0000000000..7760ebde0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 版本化 TUI 首次运行欢迎页 + +Status: implemented + +[English](2026-07-30-versioned-tui-first-run-welcome.md) | 中文 + +## 问题 + +已交付的 `dsh` 终端会直接进入编辑器,没有为首次使用的内部测试者提供持久的产品成熟度说明或反馈渠道指引。现有单行 `welcome` banner 副标题无法容纳指定的四段通知,否则会挤占正常的会话 header;如果把 onboarding 写入会话日志,还会创建一个与用户工作无关的用户轮次或模型可见上下文。 + +该通知还需要形成具有辨识度的 DeepSeek 视觉构图,同时不能复制其他产品的启动图形,也不能维护一份会偏离官方标志的手绘近似图。 + +## 决策 + +官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和其他所有非导航输入都会让浮层保持打开;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 + +该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。独占文件创建使并发 Enter 操作保持幂等,无需承担读取、修改、写入竞态,也不依赖独立演进的设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 + +指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 + +浮层居中显示,并使用可用的终端宽度。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 + +## 验证 + +聚焦单元测试固定指定 SVG 与中文文案的 hash、版本递增、并发独占确认、格式错误的标记、持久化重试、Escape 行为、ASCII 回退、宽度分级选择、有界渲染和低高度滚动。真实 Loader/PTY 用例覆盖 60、80、120、160 列以及一个低高度 viewport,产出语义终端快照,证明同一个 `DSH_HOME` 下首次启动后再次启动会抑制展示,并证明恢复会话不会追加任何由通知衍生的用户消息或轮次;普通终端退出的生命周期事件保持不变。 + +## 曾考虑的替代方案 + +**复用 TUI 的 `welcome` 副标题。** 它是一行瞬态 header,正常职责是标识无标题会话。所需正文和操作要么会被裁剪,要么会永久挤占普通启动界面。 + +**复制 Claude Code 的启动图形或构图。** 其清晰的视觉层级是有用的产品证据,但图形、布局和品牌处理属于另一个产品。官方 DeepSeek SVG 提供直接的品牌来源,而终端构图围绕本通知的文案与响应式约束独立推导。 + +**手绘原创鲸鱼。** 自由绘制的轮廓可以具有辨识度,却仍可能与官方标志的身体、内部负空间、鳍和尾部不一致。对精确路径进行栅格取样,可以明确呈现终端限制,并让每个分级都能追溯到同一个源资产。 + +**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;按版本命名并独占创建的标记不存在更新丢失问题。 + +**允许 Escape 或稍后提醒操作。** 两者都会让取消与确认无法区分,或引入本通知并不需要的提醒策略。正常退出进程仍是中止路径,并会让该版本保持未确认状态。 + +## 后果 + +每个 Harness 主目录在每个文案版本中都会收到一次通知,且仅在用户成功按 Enter 确认后停止展示。维护者可以在一个小型 owner 文件中编辑所有双语文案和版本,也可以在独立的视觉 owner 中更新官方 SVG 与衍生静态栅格图,无需到各个快照中查找完整正文副本。 + +终端无法直接显示 SVG 矢量图,因此其忠实呈现受分辨率限制。较小分级会保留取样后的轮廓,但必然损失精细细节;低高度终端优先保障正文可读和操作始终可达,而非展示品牌图形。在预发布阶段,标记格式有意采用每个版本一个文件;旧标记无害,也无需兼容性读取器。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 7e4aacef29..8488b75bfc 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: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215 -README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c +README.md: 3f421fa7a47a8a56ccbe969497e7bfc4b69dcf10 +README.zh.md: fc7fe373070a0dc62b547a76e661669dadf12f69 diff --git a/apps/cli/README.md b/apps/cli/README.md index 2bc36cce62..3f421fa7a4 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -12,6 +12,7 @@ The TUI surface: - 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)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +- 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, bilingual copy, and notice version are static local owners; the overlay never writes a session event or model context. `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 both `.env` layers are loaded, so environment 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. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0e0771658c..fc7fe37307 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -12,6 +12,7 @@ TUI 界面: - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 +- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、双语文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 `dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 diff --git a/apps/cli/assets/deepseek-color.svg b/apps/cli/assets/deepseek-color.svg new file mode 100644 index 0000000000..52eec25cd3 --- /dev/null +++ b/apps/cli/assets/deepseek-color.svg @@ -0,0 +1 @@ +DeepSeek diff --git a/apps/cli/package.json b/apps/cli/package.json index e3d1976c52..c707b50ad3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,6 +9,7 @@ }, "files": [ "lib/bin.js", + "assets", "config", "src" ], @@ -114,6 +115,7 @@ "@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" diff --git a/apps/cli/src/tui-first-run-welcome-art.ts b/apps/cli/src/tui-first-run-welcome-art.ts new file mode 100644 index 0000000000..eac41d3e19 --- /dev/null +++ b/apps/cli/src/tui-first-run-welcome-art.ts @@ -0,0 +1,111 @@ +/** + * 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-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-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts new file mode 100644 index 0000000000..7999b26e64 --- /dev/null +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -0,0 +1,62 @@ +/** + * Centrally owned version and bilingual 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-first-run-welcome-copy + */ + +/** Copy version persisted after the user explicitly continues. */ +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 1 + +/** 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 notice copy. The shipped TUI currently presents the supplied + * Simplified Chinese locale; English remains its reviewed locale counterpart. + */ +export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ + 'zh-CN': Object.freeze({ + title: 'DeepSeek Harness', + paragraphs: Object.freeze([ + '感谢您愿意拨冗试用 DeepSeek Harness。', + '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。', + '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', + '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + ]), + continueLabel: '继续', + scrollHint: '↑/↓ 滚动', + saving: '正在保存确认…', + saveError: '无法保存确认,请按 Enter 重试。', + }), + en: Object.freeze({ + title: 'DeepSeek Harness', + paragraphs: Object.freeze([ + 'Thank you for taking the time to try DeepSeek Harness.', + 'This release is still in internal testing. Some features remain unfinished, and parts of the experience will inevitably feel rough.', + '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems uncovered in real use may lead us to re-examine, or even overturn, existing designs.', + 'We especially want to hear about failures, confusion, and friction. If DeepSeek Harness did not help—or made your work harder—please tell us in the company WeChat group. Every piece of feedback helps us refine it.', + ]), + continueLabel: 'Continue', + scrollHint: '↑/↓ Scroll', + saving: 'Saving acknowledgement…', + saveError: 'Could not save the acknowledgement. Press Enter to retry.', + }), +}) + +/** 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-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts new file mode 100644 index 0000000000..5d7cd63198 --- /dev/null +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -0,0 +1,352 @@ +/** + * 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-first-run-welcome + */ + +import { lstat, mkdir, open, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { Context } from 'cordis' +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from '@earendil-works/pi-tui' +import type { + TuiComponent, + TuiFocusable, + 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' + +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 as an immutable exclusive marker. + * Concurrent launches race only on file creation: the winner syncs the marker, + * and every loser accepts the same already-published regular file. + * @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) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await syncDirectory(dirname(directory)) + let handle: Awaited> | undefined + try { + handle = await open(path, 'wx', 0o600) + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') { + if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return + } + throw error + } + try { + await handle.sync() + const created = handle + handle = undefined + await created.close() + await syncDirectory(directory) + } catch (error) { + await rm(path, { force: true }) + throw error + } finally { + await handle?.close() + } +} + +/** 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 { + if (innerWidth >= 96 && viewportRows >= 22) return 'full' + if (innerWidth >= 64 && viewportRows >= 18) return 'compact' + if (innerWidth >= 48 && viewportRows >= 14) 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 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 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) + body = [centered(title, contentWidth), '', ...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) + } + + this.bodyCapacity = Math.max(1, availableRows - 5 - fixedHeader.length) + 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) + while (visibleBody.length < this.bodyCapacity) visibleBody.push('') + + 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 composition = fullArt === undefined + ? [...fixedHeader, ...visibleBody] + : visibleBody.map((line, index) => { + const art = fullArt[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.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] + ctx.tui.openOverlay({ + create: host => new TuiFirstRunWelcomeComponent( + host, + copy, + () => acknowledgeTuiFirstRunWelcome(config.dshHome), + config.asciiArt ?? false, + ), + options: { + width: '100%', + maxHeight: '90%', + anchor: 'center', + margin: 1, + }, + }) +} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f13451c04e..d56f14c083 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -43,6 +43,16 @@ import { type MainSessionIdentity, type TuiResumeHost, } from '@deepseek-ai/dsh-tui' +import { + apply as applyTuiFirstRunWelcome, + hasTuiFirstRunWelcomeAcknowledgement, + inject as tuiFirstRunWelcomeInject, + name as tuiFirstRunWelcomeName, + needsTuiFirstRunWelcomeAsciiArt, +} from './tui-first-run-welcome.ts' +import { + TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, +} from './tui-first-run-welcome-copy.ts' const NAME = 'dsh' @@ -126,7 +136,12 @@ export async function runTui( installFailLoud(NAME) // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). - loadEnv(NAME, resolveDshHome()) + const dshHome = resolveDshHome() + loadEnv(NAME, dshHome) + const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement( + dshHome, + TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, + ) // Both .env layers are loaded, so switching the workspace here cannot alter // environment 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 @@ -200,7 +215,7 @@ export async function runTui( const patches = replaceTree ? [] : [ ...loadOverlayPatches(NAME, TUI_OVERLAY), ...resolvedConfig === undefined - ? loadPersonalPatches(NAME) ?? [] + ? loadPersonalPatches(NAME, dshHome) ?? [] : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), ] const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) @@ -241,5 +256,15 @@ export async function runTui( ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) + if (showFirstRunWelcome && ctx.get('tui') !== undefined) { + await ctx.plugin({ + name: tuiFirstRunWelcomeName, + inject: tuiFirstRunWelcomeInject, + apply: applyTuiFirstRunWelcome, + }, { + dshHome, + asciiArt: needsTuiFirstRunWelcomeAsciiArt(), + }) + } } /* v8 ignore stop */ diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index a6c33160fa..202fb20470 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -6,11 +6,11 @@ import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` -import errno, json, os, pty, select, signal, sys, time -node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:] +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": "100", "LINES": "30"}) +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. @@ -20,6 +20,7 @@ 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 @@ -36,8 +37,13 @@ while time.monotonic() < deadline: chunk = b"" if chunk: output.extend(chunk) - while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: + 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 action.get("delayMs", 0) > 0: + time.sleep(action["delayMs"] / 1000) if "writeFile" in action: target = os.path.join(cwd, action["writeFile"]["path"]) os.makedirs(os.path.dirname(target), exist_ok=True) @@ -68,11 +74,13 @@ if actual_exit != int(expected_exit): /** One terminal input or workspace mutation performed after its marker renders. */ type TuiPtyAction = - | { readonly waitFor: string; readonly send: string } + | { readonly waitFor: string; readonly occurrence?: number; readonly send: string; readonly delayMs?: number } | { readonly waitFor: string + readonly occurrence?: number readonly writeFile: { readonly path: string; readonly content: string } readonly send?: string + readonly delayMs?: number } /** Inputs for a keyless real-Loader TUI process smoke. */ @@ -89,6 +97,12 @@ export interface TuiPtySmokeOptions { 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. */ @@ -119,6 +133,8 @@ async function runPosixPtySmoke( 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, @@ -150,8 +166,8 @@ async function runWindowsPtySmoke( let timedOut = false const terminal = pty.spawn(launch.command, launch.args, { name: 'xterm-256color', - cols: 100, - rows: 30, + cols: options.columns ?? 100, + rows: options.rows ?? 30, cwd, env: definedEnv({ ...process.env, @@ -159,8 +175,8 @@ async function runWindowsPtySmoke( // Match the POSIX driver: no COLORTERM, so the banner never takes the // truecolor gradient path under a developer's shell. COLORTERM: undefined, - COLUMNS: '100', - LINES: '30', + COLUMNS: String(options.columns ?? 100), + LINES: String(options.rows ?? 30), }), }) const timer = setTimeout(() => { @@ -169,16 +185,23 @@ async function runWindowsPtySmoke( }, timeoutMs) terminal.onData((chunk) => { output += chunk - while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) { + while ( + actionIndex < actions.length + && output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1) + ) { const action = actions[actionIndex]! 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) + if (input !== undefined) { + if (action.delayMs === undefined) terminal.write(input) + else setTimeout(() => { terminal.write(input) }, action.delayMs) + } } else { - terminal.write(action.send) + if (action.delayMs === undefined) terminal.write(action.send) + else setTimeout(() => { terminal.write(action.send) }, action.delayMs) } actionIndex += 1 } @@ -205,7 +228,8 @@ async function runWindowsPtySmoke( * @returns complete pseudo-terminal output. */ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise { - const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) + 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) @@ -231,6 +255,6 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt new file mode 100644 index 0000000000..14689f9b69 --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt @@ -0,0 +1,106 @@ +terminal 160x30 buffer=normal length=32 base=2 viewport=2 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=29 bufferRow=31 +viewport +2| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ " + style 1-158 dim +4| " │ ▄ DeepSeek Harness │ " + style 1-1 dim + style 11-38 fg=blue + style 95-110 fg=blue bold + style 158-158 dim +5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ " + style 1-1 dim + style 10-39 fg=blue + style 158-158 dim +6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " + style 0-0 fg=bright-magenta bold + style 1-1 dim + style 5-44 fg=blue + style 158-158 dim +7| " │ ▄███████████████████▄ ████████████▀ │ " + style 1-1 dim + style 5-44 fg=blue + style 158-158 dim +8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " + style 1-1 dim + style 5-43 fg=blue + style 158-158 dim +9| " │ ▄███▀█████████████████████▄ ████▀▀ │ " + style 1-1 dim + style 7-42 fg=blue + style 158-158 dim +10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ " + style 1-1 dim + style 8-41 fg=blue + style 50-71 bold + style 158-158 dim +11| " │ ███ ▀███████▀█ ▀███████ │ " + style 1-1 dim + style 8-40 fg=blue + style 158-158 dim +12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻 │ " + style 1-1 dim + style 8-40 fg=blue + style 158-158 dim +13| " │ ▀███ ▀██████████████ 已有的设计。 │ " + style 1-1 dim + style 9-40 fg=blue + style 158-158 dim +14| " │ ▀███▄ ▀███████████▀ │ " + style 1-1 dim + style 9-39 fg=blue + style 158-158 dim +15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中 │ " + style 1-1 dim + style 10-39 fg=blue + style 158-158 dim +16| " │ █████▄ ███▄▄ ▀█████▄▄ 留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │ " + style 1-1 dim + style 10-39 fg=blue + style 158-158 dim +17| " │ ▀█████████████▄▄▄▄█▀█████▀ │ " + style 1-1 dim + style 9-40 fg=blue + style 158-158 dim +18| " │ ▀▀███████████▀▀ │ " + style 1-1 dim + style 13-35 fg=blue + style 158-158 dim +19| " │ │ " + style 1-1 dim + style 158-158 dim +20| " │ │ " + style 1-1 dim + style 158-158 dim +21| " │ │ " + style 1-1 dim + style 158-158 dim +22| " │ │ " + style 1-1 dim + style 158-158 dim +23| " │ │ " + style 1-1 dim + style 158-158 dim +24| " │ │ " + style 1-1 dim + style 158-158 dim +25| " │ │ " + style 1-1 dim + style 158-158 dim +26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ " + style 1-158 dim +27| " │ Enter 继续 │ " + style 1-1 dim + style 74-84 fg=bright-magenta bold + style 158-158 dim +28| " │ │ " + style 1-1 dim + style 158-158 dim +29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ " + style 1-158 dim +30-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt new file mode 100644 index 0000000000..47a3b3c8a0 --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt @@ -0,0 +1,100 @@ +terminal 60x30 buffer=normal length=34 base=4 viewport=4 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=29 bufferRow=33 +viewport +4| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +5| " ╭────────────────────────────────────────────────────────╮ " + style 1-58 dim +6| " │ ▄▄▄▄▄▄ ▄▄ │ " + style 1-1 dim + style 23-36 fg=blue + style 58-58 dim +7| " │ ▄████████▄ ▀████▀ │ " + style 1-1 dim + style 21-38 fg=blue + style 58-58 dim +8| "/│ █▀▀▀▀███████▄██▀ │h" + style 0-0 fg=bright-magenta bold + style 1-1 dim + style 22-37 fg=blue + style 58-58 dim + style 59-59 fg=bright-magenta bold +9| " │ █▄ ▀███ ▀███ │ " + style 1-1 dim + style 22-36 fg=blue + style 58-58 dim +10| " │ ▀█▄ ▀█████ │ " + style 1-1 dim + style 23-36 fg=blue + style 58-58 dim +11| " │ ▀█▄▄ █▄▄▀███▄ │ " + style 1-1 dim + style 23-36 fg=blue + style 58-58 dim +12| " │ ▀▀▀▀▀▀ │ " + style 1-1 dim + style 25-34 fg=blue + style 58-58 dim +13| " │ │ " + style 1-1 dim + style 58-58 dim +14| " │ DeepSeek Harness │ " + style 1-1 dim + style 22-37 fg=blue bold + style 58-58 dim +15| " │ │ " + style 1-1 dim + style 58-58 dim +16| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " + style 1-1 dim + style 58-58 dim +17| " │ │ " + style 1-1 dim + style 58-58 dim +18| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体 │ " + style 1-1 dim + style 58-58 dim +19| " │ 验难免粗粝。 │ " + style 1-1 dim + style 58-58 dim +20| " │ │ " + style 1-1 dim + style 58-58 dim +21| " │ “如切如磋,如琢如磨。” │ " + style 1-1 dim + style 3-24 bold + style 58-58 dim +22| " │ │ " + style 1-1 dim + style 58-58 dim +23| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真 │ " + style 1-1 dim + style 58-58 dim +24| " │ 实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │ " + style 1-1 dim + style 58-58 dim +25| " │ 有的设计。 │ " + style 1-1 dim + style 58-58 dim +26| " │ │ " + style 1-1 dim + style 58-58 dim +27| " │ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未 │ " + style 1-1 dim + style 58-58 dim +28| " ├────────────────────────────────────────────────────────┤ " + style 1-58 dim +29| " │ Enter 继续 │ " + style 1-1 dim + style 24-34 fg=bright-magenta bold + style 58-58 dim +30| " │ ↑/↓ 滚动 ↓ │ " + style 1-1 dim + style 24-35 dim + style 58-58 dim +31| " ╰────────────────────────────────────────────────────────╯ " + style 1-58 dim +32-33| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt new file mode 100644 index 0000000000..887a00d0cb --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt @@ -0,0 +1,103 @@ +terminal 80x30 buffer=normal length=33 base=3 viewport=3 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=29 bufferRow=32 +viewport +3| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +4| " ╭────────────────────────────────────────────────────────────────────────────╮ " + style 1-78 dim +5| " │ ▄▄▄▄▄▄▄██▀ █▄ ▄ │ " + style 1-1 dim + style 26-53 fg=blue + style 78-78 dim +6| " │ ▄███████████▄▄ ███▄▄████ │ " + style 1-1 dim + style 26-53 fg=blue + style 78-78 dim +7| "/│ ████████████████▄ ▀██████▀ │F" + style 0-0 fg=bright-magenta bold + style 1-1 dim + style 26-52 fg=blue + style 78-78 dim + style 79-79 fg=bright-magenta bold +8| " │ ██▀▀▀▀▀████████████▄▄██▀ │ " + style 1-1 dim + style 28-51 fg=blue + style 78-78 dim +9| " │ ██ ▀█████▄ ▀█████ │ " + style 1-1 dim + style 28-50 fg=blue + style 78-78 dim +10| " │ ██▄ ▀████▄ ▄████ │ " + style 1-1 dim + style 28-50 fg=blue + style 78-78 dim +11| " │ ██▄ ████████▀ │ " + style 1-1 dim + style 29-50 fg=blue + style 78-78 dim +12| " │ ██▄ ▄▄ ▀█████▀ │ " + style 1-1 dim + style 29-49 fg=blue + style 78-78 dim +13| " │ ▀███▄▄▄███▄ ████▄▄ │ " + style 1-1 dim + style 29-50 fg=blue + style 78-78 dim +14| " │ ▀▀▀███████▀▀ │ " + style 1-1 dim + style 31-47 fg=blue + style 78-78 dim +15| " │ │ " + style 1-1 dim + style 78-78 dim +16| " │ DeepSeek Harness │ " + style 1-1 dim + style 32-47 fg=blue bold + style 78-78 dim +17| " │ │ " + style 1-1 dim + style 78-78 dim +18| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " + style 1-1 dim + style 78-78 dim +19| " │ │ " + style 1-1 dim + style 78-78 dim +20| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " + style 1-1 dim + style 78-78 dim +21| " │ │ " + style 1-1 dim + style 78-78 dim +22| " │ “如切如磋,如琢如磨。” │ " + style 1-1 dim + style 3-24 bold + style 78-78 dim +23| " │ │ " + style 1-1 dim + style 78-78 dim +24| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题, │ " + style 1-1 dim + style 78-78 dim +25| " │ 也可能促使我们重新审视,甚至推翻已有的设计。 │ " + style 1-1 dim + style 78-78 dim +26| " │ │ " + style 1-1 dim + style 78-78 dim +27| " ├────────────────────────────────────────────────────────────────────────────┤ " + style 1-78 dim +28| " │ Enter 继续 │ " + style 1-1 dim + style 34-44 fg=bright-magenta bold + style 78-78 dim +29| " │ ↑/↓ 滚动 ↓ │ " + style 1-1 dim + style 34-45 dim + style 78-78 dim +30| " ╰────────────────────────────────────────────────────────────────────────────╯ " + style 1-78 dim +31-32| diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts new file mode 100644 index 0000000000..6039c43640 --- /dev/null +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -0,0 +1,189 @@ +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 { visibleWidth } from '@earendil-works/pi-tui' +import type { TuiOverlayHost, TuiTheme } from '@deepseek-ai/dsh-tui' +import { + acknowledgeTuiFirstRunWelcome, + hasTuiFirstRunWelcomeAcknowledgement, + TuiFirstRunWelcomeComponent, + tuiFirstRunWelcomeAcknowledgementPath, + tuiFirstRunWelcomeArtTier, +} from '../src/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-first-run-welcome-copy.ts' +import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-first-run-welcome-art.ts' + +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 temporaryHomes: string[] = [] + +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 () => { + 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') + }) +}) + +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('c75e395999f572ee231688ef70d5b7f553de3809b57ce8160b4406bd7650f2ec') + }) + + it.each([ + { columns: 60, inner: 50, rows: 30, tier: 'minimal' }, + { columns: 80, inner: 68, rows: 30, tier: 'compact' }, + { columns: 120, inner: 104, rows: 30, tier: 'full' }, + { columns: 160, inner: 140, rows: 30, tier: 'full' }, + ] as const)('renders the $tier 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) + expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) + expect(lines).toHaveLength(Math.floor(rows * 0.9)) + }) + + 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(copy.paragraphs[0]) + 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(-10))) + expect(end).toContain(`Enter ${copy.continueLabel}`) + }) + + 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.compact.ascii[0]!.trim()) + expect(rendered).not.toMatch(/[▀▄█]/u) + }) + + 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 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) + }) +}) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 59042892e1..80d58a498c 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -1,6 +1,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { realpathSync } from 'node:fs' -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +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' @@ -8,12 +9,24 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' 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-first-run-welcome.ts' +import { + TUI_FIRST_RUN_WELCOME_NOTICE_COPY, + TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, +} from '../src/tui-first-run-welcome-copy.ts' +import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/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)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const firstRunSnapshots = fileURLToPath(new URL('./snapshots/tui-first-run-welcome/', import.meta.url)) +const synchronizedFrameEnd = '\x1b[?2026l' /** * Seed the isolated process workspace: ordinary files land in `cwd`, personal @@ -125,18 +138,54 @@ async function readLoggedRequestContext(cwd: string): Promise & { label: string }): Promise { +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' }, + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + 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 } : {}), - ...overrides, + ...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] + +/** Project the first synchronized PTY frame containing `marker` into the stable terminal snapshot format. */ +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 await terminal.snapshot() + } 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). @@ -146,6 +195,94 @@ const SELECT_PRO_MODEL = [ ] as const describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { + it.each([ + { columns: 60, tier: 'minimal' }, + { columns: 80, tier: 'compact' }, + { columns: 120, tier: 'full' }, + { columns: 160, tier: 'full' }, + ] as const)('renders and acknowledges the $tier 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, + columns, + rows: 30, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + ], + 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, firstRunCopy.paragraphs[0]!, columns, 30)) + .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) + expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) + expect(output).toContain('\u001B[?2004l') + }, LOADER_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, + columns: 60, + rows: 12, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, + { waitFor: '企业微信群', send: '\r' }, + { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + ], + }) + await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) + .toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt')) + expect(output).toContain(firstRunCopy.title) + expect(output).toContain(firstRunCopy.paragraphs[0]) + expect(output).toContain('企业微信群') + expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) + expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) + }, LOADER_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, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + ], + }) + expect(first).toContain(firstRunCopy.title) + + const second = await smoke({ + label: 'dsh second welcome launch', + tempDirPrefix: 'unused-', + cwd, + configPath: scriptedConfigPath, + showFirstRunWelcome: true, + actions: [{ waitFor: 'main-session-', send: '/exit\r', delayMs: 1_500 }], + }) + expect(second).not.toContain(firstRunCopy.paragraphs[0]) + expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, LOADER_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 @@ -317,6 +454,51 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { }) 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, + 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: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: 'resume-target', occurrence: 2, send: '/exit\r' }, + ], + 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.map(event => event.type)).toEqual([ + 'session/end-seed', + 'command/run', + 'command/done', + ]) + expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' })) + expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' })) + }, + }) + expect(output).toContain(firstRunCopy.paragraphs[0]) + expect(output).toContain('Resume selector design — DeepSeek Harness') + }, LOADER_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', diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..d1a1bbb3c5 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/tui/README.md -README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e -README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d +README.md: 86e54ab76d07e32fad93965fcbb585d2b5fdfe06 +README.zh.md: a8d072a82c9cff8db7cfe436957a9c1391fb8d8f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..86e54ab76d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p 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, 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. +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 in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. 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. @@ -79,9 +79,9 @@ Startup fails before mounting when either process stream is not a TTY. The compo ## Color -Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade. +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 and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. +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's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..a8d072a82c 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 本包(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、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 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)持有该边界和未采用的替代方案。 +终端成功启动后,本包会提供终端本地的 `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` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 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 消息——不渲染任何内容。 @@ -79,9 +79,9 @@ Footer 将会话报告的用量汇总为 `↑`;任 ## 颜色 -TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。 +TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读。启动 banner 渐变与官方标志使用的精确 `#4D6BFE` 色值是两处有意保留的真彩色品牌例外。正文使用终端默认前景色,而非固定色调。 -每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 +每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts index 269a7c28e8..43630e27d8 100644 --- a/packages/ui/tui/src/components/theme.ts +++ b/packages/ui/tui/src/components/theme.ts @@ -46,6 +46,8 @@ export type AttributeRole = (text: T) => T */ 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. */ @@ -63,7 +65,7 @@ export interface Palette { } /** Names of the palette's color roles, in the order `/palette` prints them. */ -export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const +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 @@ -86,8 +88,9 @@ export interface RoleSpec { * * Only the standard 16-color set and SGR attributes appear here. Terminals remap * those to the user's active theme, so the TUI stays legible on any background; - * a fixed 24-bit color would not. The brand gradient is the one deliberate - * exception ({@link gradientText}). + * 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. @@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): { // 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' @@ -168,6 +172,19 @@ const BRAND_GRADIENT = [ [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. @@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] { * @returns `text` wrapped in truecolor SGR foreground codes. */ export function gradientText(text: string): string { - // The sole caller passes the ASCII product name, so UTF-16 unit iteration - // samples exactly one color per visible letter. - const last = Math.max(1, text.length - 1) + const glyphs = Array.from(text) + const last = Math.max(1, glyphs.length - 1) let painted = '' - for (let index = 0; index < text.length; index += 1) { + for (let index = 0; index < glyphs.length; index += 1) { const [r, g, b] = brandColorAt(index / last) - painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` + painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}` } return `${painted}\x1b[39m` } diff --git a/packages/ui/tui/src/extension/types.ts b/packages/ui/tui/src/extension/types.ts index cb0e7e434f..aa24a7b14e 100644 --- a/packages/ui/tui/src/extension/types.ts +++ b/packages/ui/tui/src/extension/types.ts @@ -37,6 +37,8 @@ export interface TuiFocusable { export interface TuiTheme { /** Render ordinary foreground text. */ readonly text: (value: string) => string + /** Render trusted static brand art with the host's configured brand treatment. */ + readonly brand: (value: string) => string /** Render secondary information and low-emphasis hints, the one tone below `text`. */ readonly dim: (value: string) => string /** Render the active accent role. */ diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..8448d79f8e 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -69,7 +69,7 @@ import type { TuiTheme, } from './extension/types.ts' import { displayInlineText, displayText } from './components/text.ts' -import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts' +import { brandText, createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts' import { contentText, parseArguments } from './components/content.ts' import { cacheHitRate, @@ -486,6 +486,9 @@ export function createTuiChat( const extensionTheme: TuiTheme = Object.freeze({ text: (value: string) => palette.text(value), + brand: (value: string) => resolved.theme.color + ? resolved.theme.truecolor ? brandText(value) : palette.brand(value) + : value, dim: (value: string) => palette.dim(value), accent: (value: string) => palette.accent(value), success: (value: string) => palette.success(value), diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index a7eec5589e..15bfee33f6 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -20,6 +20,7 @@ import { const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, + brand: (value: string) => `brand:${value}`, muted: (value: string) => `muted:${value}`, dim: (value: string) => `dim:${value}`, accent: (value: string) => `accent:${value}`, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 445487065f..a85bd0ec9f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -41,7 +41,7 @@ import { type TuiRuntime, } from '../src/index.ts' import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts' -import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts' +import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts' import { appendAssistant, appendUser, @@ -4993,6 +4993,7 @@ describe('TUI extension service', () => { host.theme.accent(`${label} plugin overlay`), [ host.theme.text('text'), + host.theme.brand('brand'), host.theme.dim('dim'), host.theme.success('success'), host.theme.warning('warning'), @@ -5370,6 +5371,10 @@ describe('terminal mounting', () => { await dispose(result) }) + it('uses the official DeepSeek SVG ink for truecolor brand art', () => { + expect(brandText('mark')).toBe('\x1b[38;2;77;107;254mmark\x1b[39m') + }) + it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => { const result = await setup({ config: { theme: { color: true } } }) // `dim` is scheme-independent (SGR 2 over the default foreground), so the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 000058ca32..54587eef3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,6 +417,9 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context + '@earendil-works/pi-tui': + specifier: 0.80.7 + version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) commander: specifier: ^15.0.0 version: 15.0.0 From 31f66dbd547bc52969d7bdef66e863fd4234e0e0 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 23:09:58 +0800 Subject: [PATCH 02/16] fix(tui): tighten welcome layout and acceptance --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +- ...6-07-30-versioned-tui-first-run-welcome.md | 2 +- ...7-30-versioned-tui-first-run-welcome.zh.md | 2 +- apps/cli/src/tui-first-run-welcome.ts | 25 ++- apps/cli/tests/pty-harness.ts | 9 +- .../120-columns.expected.txt | 180 ++++++++---------- .../160-columns.expected.txt | 180 ++++++++---------- .../60-columns-low-height.expected.txt | 39 ++++ .../60-columns.expected.txt | 160 ++++++++-------- .../80-columns.expected.txt | 160 ++++++++-------- apps/cli/tests/tui-first-run-welcome.spec.ts | 80 +++++++- apps/cli/tests/tui-keyless-smoke.e2e.ts | 61 ++++-- 12 files changed, 520 insertions(+), 382 deletions(-) create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index ff8ce65c14..278a28bba3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/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: d5635aebd643f3a0c24ab6e0cb952b15f36aa9cb -2026-07-30-versioned-tui-first-run-welcome.zh.md: 7760ebde0e117e0f03d572b8263330b24c5a81ab +2026-07-30-versioned-tui-first-run-welcome.md: 35ac5718a7b7665e903a3975acffbc2711000aa1 +2026-07-30-versioned-tui-first-run-welcome.zh.md: f6edf354e42f2797e1227ed04a3fbc9701a04b8b diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index d5635aebd6..35ac5718a7 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -18,7 +18,7 @@ The marker is launcher state rather than session persistence because eligibility The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. -The overlay is centered and consumes the available terminal width. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. +The overlay is centered and consumes the available terminal width, while its height follows actual content and treats 90% of the viewport only as an upper bound. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. ## Verification diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index 7760ebde0e..f6edf354e4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -18,7 +18,7 @@ Status: implemented 指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 -浮层居中显示,并使用可用的终端宽度。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 +浮层居中显示,并使用可用的终端宽度;高度则跟随实际内容,仅将 viewport 的 90% 作为上限。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 ## 验证 diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index 5d7cd63198..d69d35a30a 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -115,9 +115,12 @@ export async function acknowledgeTuiFirstRunWelcome( try { handle = await open(path, 'wx', 0o600) } catch (error) { + /* v8 ignore else -- the only expected race is another creator publishing this exact marker */ if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') { + /* v8 ignore else -- EEXIST is accepted only after the winner is verified as a regular marker */ if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return } + /* v8 ignore next -- unexpected filesystem failures pass through unchanged */ throw error } try { @@ -127,10 +130,14 @@ export async function acknowledgeTuiFirstRunWelcome( await created.close() await syncDirectory(directory) } catch (error) { - await rm(path, { force: true }) + /* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */ + try { + await handle?.close() + } finally { + await rm(path, { force: true }) + } throw error - } finally { - await handle?.close() + /* v8 ignore stop */ } } @@ -170,7 +177,7 @@ export function tuiFirstRunWelcomeArtTier( innerWidth: number, viewportRows: number, ): TuiFirstRunWelcomeArtTier | undefined { - if (innerWidth >= 96 && viewportRows >= 22) return 'full' + if (innerWidth >= 96 && viewportRows >= 23) return 'full' if (innerWidth >= 64 && viewportRows >= 18) return 'compact' if (innerWidth >= 48 && viewportRows >= 14) return 'minimal' return undefined @@ -250,12 +257,13 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { body = proseLines(this.copy, innerWidth, this.host) } - this.bodyCapacity = Math.max(1, availableRows - 5 - fixedHeader.length) + const compositionCapacity = Math.max(1, availableRows - 5) + const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.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) - while (visibleBody.length < this.bodyCapacity) visibleBody.push('') const top = this.host.theme.dim(`╭${'─'.repeat(Math.max(0, frameWidth - 2))}╮`) const separator = this.host.theme.dim(`├${'─'.repeat(Math.max(0, frameWidth - 2))}┤`) @@ -274,8 +282,9 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { const composition = fullArt === undefined ? [...fixedHeader, ...visibleBody] - : visibleBody.map((line, index) => { + : Array.from({ length: Math.max(fullArt.length, visibleBody.length) }, (_, index) => { const art = fullArt[index] ?? '' + const line = visibleBody[index] ?? '' const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` return `${left} ${line}` }) @@ -346,7 +355,7 @@ export function apply(ctx: Context, config: Config): void { width: '100%', maxHeight: '90%', anchor: 'center', - margin: 1, + margin: 0, }, }) } diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index 202fb20470..fb926bd7c1 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -44,7 +44,9 @@ while time.monotonic() < deadline: action = actions[action_index] if action.get("delayMs", 0) > 0: time.sleep(action["delayMs"] / 1000) - if "writeFile" in action: + 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: @@ -75,6 +77,7 @@ if actual_exit != int(expected_exit): /** One terminal input or workspace mutation performed after its marker renders. */ type TuiPtyAction = | { readonly waitFor: string; readonly occurrence?: number; readonly send: string; readonly delayMs?: number } + | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } | { readonly waitFor: string readonly occurrence?: number @@ -190,7 +193,9 @@ async function runWindowsPtySmoke( && output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1) ) { const action = actions[actionIndex]! - if ('writeFile' in action) { + 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) diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt index 7790624f12..a22b548e5a 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt @@ -6,101 +6,89 @@ viewport 2| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ " - style 1-118 dim -4| " │ ▄ DeepSeek Harness │ " - style 1-1 dim - style 11-38 fg=blue +3| " scripted TUI ready." + style 1-19 dim +4| " main-session-{{uuid}}" + style 1-49 dim +5| +6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% conte" + style 0-82 fg=bright-magenta bold + style 85-102 dim + style 105-109 dim + style 112-119 dim +7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-119 dim +8| "│ ▄ DeepSeek Harness │" + style 0-0 dim + style 10-37 fg=blue style 75-90 fg=blue bold - style 118-118 dim -5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ " - style 1-1 dim - style 10-39 fg=blue - style 118-118 dim -6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │e" - style 0-0 fg=bright-magenta bold - style 1-1 dim - style 5-44 fg=blue - style 118-119 dim -7| " │ ▄███████████████████▄ ████████████▀ │ " - style 1-1 dim - style 5-44 fg=blue - style 118-118 dim -8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " - style 1-1 dim - style 5-43 fg=blue - style 118-118 dim -9| " │ ▄███▀█████████████████████▄ ████▀▀ │ " - style 1-1 dim - style 7-42 fg=blue - style 118-118 dim -10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 8-41 fg=blue - style 50-71 bold - style 118-118 dim -11| " │ ███ ▀███████▀█ ▀███████ │ " - style 1-1 dim - style 8-40 fg=blue - style 118-118 dim -12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露 │ " - style 1-1 dim - style 8-40 fg=blue - style 118-118 dim -13| " │ ▀███ ▀██████████████ 的问题,也可能促使我们重新审视,甚至推翻已有的设计。 │ " - style 1-1 dim - style 9-40 fg=blue - style 118-118 dim -14| " │ ▀███▄ ▀███████████▀ │ " - style 1-1 dim - style 9-39 fg=blue - style 118-118 dim -15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚 │ " - style 1-1 dim - style 10-39 fg=blue - style 118-118 dim -16| " │ █████▄ ███▄▄ ▀█████▄▄ 至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。 │ " - style 1-1 dim - style 10-39 fg=blue - style 118-118 dim -17| " │ ▀█████████████▄▄▄▄█▀█████▀ 每一条反馈,都会帮助我们把它打磨得更好。 │ " - style 1-1 dim - style 9-40 fg=blue - style 118-118 dim -18| " │ ▀▀███████████▀▀ │ " - style 1-1 dim - style 13-35 fg=blue - style 118-118 dim -19| " │ │ " - style 1-1 dim - style 118-118 dim -20| " │ │ " - style 1-1 dim - style 118-118 dim -21| " │ │ " - style 1-1 dim - style 118-118 dim -22| " │ │ " - style 1-1 dim - style 118-118 dim -23| " │ │ " - style 1-1 dim - style 118-118 dim -24| " │ │ " - style 1-1 dim - style 118-118 dim -25| " │ │ " - style 1-1 dim - style 118-118 dim -26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ " - style 1-118 dim -27| " │ Enter 继续 │ " - style 1-1 dim + style 119-119 dim +9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" + style 0-0 dim + style 9-38 fg=blue + style 119-119 dim +10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 4-43 fg=blue + style 119-119 dim +11| "│ ▄███████████████████▄ ████████████▀ │" + style 0-0 dim + style 4-43 fg=blue + style 119-119 dim +12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" + style 0-0 dim + style 4-42 fg=blue + style 119-119 dim +13| "│ ▄███▀█████████████████████▄ ████▀▀ │" + style 0-0 dim + style 6-41 fg=blue + style 119-119 dim +14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 7-40 fg=blue + style 49-70 bold + style 119-119 dim +15| "│ ███ ▀███████▀█ ▀███████ │" + style 0-0 dim + style 7-39 fg=blue + style 119-119 dim +16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" + style 0-0 dim + style 7-39 fg=blue + style 119-119 dim +17| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" + style 0-0 dim + style 8-39 fg=blue + style 119-119 dim +18| "│ ▀███▄ ▀███████████▀ │" + style 0-0 dim + style 8-38 fg=blue + style 119-119 dim +19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" + style 0-0 dim + style 9-38 fg=blue + style 119-119 dim +20| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" + style 0-0 dim + style 9-38 fg=blue + style 119-119 dim +21| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" + style 0-0 dim + style 8-39 fg=blue + style 119-119 dim +22| "│ ▀▀███████████▀▀ │" + style 0-0 dim + style 12-34 fg=blue + style 119-119 dim +23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" + style 0-119 dim +24| "│ Enter 继续 │" + style 0-0 dim style 54-64 fg=bright-magenta bold - style 118-118 dim -28| " │ │ " - style 1-1 dim - style 118-118 dim -29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ " - style 1-118 dim -30-31| + style 119-119 dim +25| "│ │" + style 0-0 dim + style 119-119 dim +26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-119 dim +27-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt index 14689f9b69..4c22bfd6cb 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt @@ -6,101 +6,89 @@ viewport 2| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ " - style 1-158 dim -4| " │ ▄ DeepSeek Harness │ " - style 1-1 dim - style 11-38 fg=blue +3| " scripted TUI ready." + style 1-19 dim +4| " main-session-{{uuid}}" + style 1-49 dim +5| +6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% context" + style 0-82 fg=bright-magenta bold + style 85-102 dim + style 105-109 dim + style 112-121 dim +7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-159 dim +8| "│ ▄ DeepSeek Harness │" + style 0-0 dim + style 10-37 fg=blue style 95-110 fg=blue bold - style 158-158 dim -5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ " - style 1-1 dim - style 10-39 fg=blue - style 158-158 dim -6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " - style 0-0 fg=bright-magenta bold - style 1-1 dim - style 5-44 fg=blue - style 158-158 dim -7| " │ ▄███████████████████▄ ████████████▀ │ " - style 1-1 dim - style 5-44 fg=blue - style 158-158 dim -8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " - style 1-1 dim - style 5-43 fg=blue - style 158-158 dim -9| " │ ▄███▀█████████████████████▄ ████▀▀ │ " - style 1-1 dim - style 7-42 fg=blue - style 158-158 dim -10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 8-41 fg=blue - style 50-71 bold - style 158-158 dim -11| " │ ███ ▀███████▀█ ▀███████ │ " - style 1-1 dim - style 8-40 fg=blue - style 158-158 dim -12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻 │ " - style 1-1 dim - style 8-40 fg=blue - style 158-158 dim -13| " │ ▀███ ▀██████████████ 已有的设计。 │ " - style 1-1 dim - style 9-40 fg=blue - style 158-158 dim -14| " │ ▀███▄ ▀███████████▀ │ " - style 1-1 dim - style 9-39 fg=blue - style 158-158 dim -15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中 │ " - style 1-1 dim - style 10-39 fg=blue - style 158-158 dim -16| " │ █████▄ ███▄▄ ▀█████▄▄ 留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │ " - style 1-1 dim - style 10-39 fg=blue - style 158-158 dim -17| " │ ▀█████████████▄▄▄▄█▀█████▀ │ " - style 1-1 dim - style 9-40 fg=blue - style 158-158 dim -18| " │ ▀▀███████████▀▀ │ " - style 1-1 dim - style 13-35 fg=blue - style 158-158 dim -19| " │ │ " - style 1-1 dim - style 158-158 dim -20| " │ │ " - style 1-1 dim - style 158-158 dim -21| " │ │ " - style 1-1 dim - style 158-158 dim -22| " │ │ " - style 1-1 dim - style 158-158 dim -23| " │ │ " - style 1-1 dim - style 158-158 dim -24| " │ │ " - style 1-1 dim - style 158-158 dim -25| " │ │ " - style 1-1 dim - style 158-158 dim -26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ " - style 1-158 dim -27| " │ Enter 继续 │ " - style 1-1 dim + style 159-159 dim +9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" + style 0-0 dim + style 9-38 fg=blue + style 159-159 dim +10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 4-43 fg=blue + style 159-159 dim +11| "│ ▄███████████████████▄ ████████████▀ │" + style 0-0 dim + style 4-43 fg=blue + style 159-159 dim +12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" + style 0-0 dim + style 4-42 fg=blue + style 159-159 dim +13| "│ ▄███▀█████████████████████▄ ████▀▀ │" + style 0-0 dim + style 6-41 fg=blue + style 159-159 dim +14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 7-40 fg=blue + style 49-70 bold + style 159-159 dim +15| "│ ███ ▀███████▀█ ▀███████ │" + style 0-0 dim + style 7-39 fg=blue + style 159-159 dim +16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" + style 0-0 dim + style 7-39 fg=blue + style 159-159 dim +17| "│ ▀███ ▀██████████████ 有的设计。 │" + style 0-0 dim + style 8-39 fg=blue + style 159-159 dim +18| "│ ▀███▄ ▀███████████▀ │" + style 0-0 dim + style 8-38 fg=blue + style 159-159 dim +19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" + style 0-0 dim + style 9-38 fg=blue + style 159-159 dim +20| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" + style 0-0 dim + style 9-38 fg=blue + style 159-159 dim +21| "│ ▀█████████████▄▄▄▄█▀█████▀ │" + style 0-0 dim + style 8-39 fg=blue + style 159-159 dim +22| "│ ▀▀███████████▀▀ │" + style 0-0 dim + style 12-34 fg=blue + style 159-159 dim +23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" + style 0-159 dim +24| "│ Enter 继续 │" + style 0-0 dim style 74-84 fg=bright-magenta bold - style 158-158 dim -28| " │ │ " - style 1-1 dim - style 158-158 dim -29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ " - style 1-158 dim -30-31| + style 159-159 dim +25| "│ │" + style 0-0 dim + style 159-159 dim +26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-159 dim +27-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt new file mode 100644 index 0000000000..dc0459ab43 --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt @@ -0,0 +1,39 @@ +terminal 60x12 buffer=normal length=16 base=4 viewport=4 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=11 bufferRow=15 +viewport +4| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +5| "╭──────────────────────────────────────────────────────────╮" + style 0-59 dim +6| "│ DeepSeek Harness │" + style 0-0 dim + style 22-37 fg=blue bold + style 59-59 dim +7| "│ │" + style 0-0 dim + style 59-59 dim +8| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + 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-59 dim +12| "│ Enter 继续 │" + style 0-0 dim + style 24-34 fg=bright-magenta bold + style 59-59 dim +13| "│ ↑/↓ 滚动 ↓ │" + style 0-0 dim + style 24-35 dim + style 59-59 dim +14| "╰──────────────────────────────────────────────────────────╯" + style 0-59 dim +15| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt index 47a3b3c8a0..8cbafd5d27 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt @@ -6,95 +6,93 @@ viewport 4| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -5| " ╭────────────────────────────────────────────────────────╮ " - style 1-58 dim -6| " │ ▄▄▄▄▄▄ ▄▄ │ " - style 1-1 dim +5| "╭──────────────────────────────────────────────────────────╮" + style 0-59 dim +6| "│ ▄▄▄▄▄▄ ▄▄ │" + style 0-0 dim style 23-36 fg=blue - style 58-58 dim -7| " │ ▄████████▄ ▀████▀ │ " - style 1-1 dim + style 59-59 dim +7| "│ ▄████████▄ ▀████▀ │" + style 0-0 dim style 21-38 fg=blue - style 58-58 dim -8| "/│ █▀▀▀▀███████▄██▀ │h" - style 0-0 fg=bright-magenta bold - style 1-1 dim + style 59-59 dim +8| "│ █▀▀▀▀███████▄██▀ │" + style 0-0 dim style 22-37 fg=blue - style 58-58 dim - style 59-59 fg=bright-magenta bold -9| " │ █▄ ▀███ ▀███ │ " - style 1-1 dim + style 59-59 dim +9| "│ █▄ ▀███ ▀███ │" + style 0-0 dim style 22-36 fg=blue - style 58-58 dim -10| " │ ▀█▄ ▀█████ │ " - style 1-1 dim + style 59-59 dim +10| "│ ▀█▄ ▀█████ │" + style 0-0 dim style 23-36 fg=blue - style 58-58 dim -11| " │ ▀█▄▄ █▄▄▀███▄ │ " - style 1-1 dim + style 59-59 dim +11| "│ ▀█▄▄ █▄▄▀███▄ │" + style 0-0 dim style 23-36 fg=blue - style 58-58 dim -12| " │ ▀▀▀▀▀▀ │ " - style 1-1 dim + style 59-59 dim +12| "│ ▀▀▀▀▀▀ │" + style 0-0 dim style 25-34 fg=blue - style 58-58 dim -13| " │ │ " - style 1-1 dim - style 58-58 dim -14| " │ DeepSeek Harness │ " - style 1-1 dim + style 59-59 dim +13| "│ │" + style 0-0 dim + style 59-59 dim +14| "│ DeepSeek Harness │" + style 0-0 dim style 22-37 fg=blue bold - style 58-58 dim -15| " │ │ " - style 1-1 dim - style 58-58 dim -16| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " - style 1-1 dim - style 58-58 dim -17| " │ │ " - style 1-1 dim - style 58-58 dim -18| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体 │ " - style 1-1 dim - style 58-58 dim -19| " │ 验难免粗粝。 │ " - style 1-1 dim - style 58-58 dim -20| " │ │ " - style 1-1 dim - style 58-58 dim -21| " │ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 3-24 bold - style 58-58 dim -22| " │ │ " - style 1-1 dim - style 58-58 dim -23| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真 │ " - style 1-1 dim - style 58-58 dim -24| " │ 实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │ " - style 1-1 dim - style 58-58 dim -25| " │ 有的设计。 │ " - style 1-1 dim - style 58-58 dim -26| " │ │ " - style 1-1 dim - style 58-58 dim -27| " │ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未 │ " - style 1-1 dim - style 58-58 dim -28| " ├────────────────────────────────────────────────────────┤ " - style 1-58 dim -29| " │ Enter 继续 │ " - style 1-1 dim + style 59-59 dim +15| "│ │" + style 0-0 dim + style 59-59 dim +16| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 59-59 dim +17| "│ │" + style 0-0 dim + style 59-59 dim +18| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" + style 0-0 dim + style 59-59 dim +19| "│ 难免粗粝。 │" + style 0-0 dim + style 59-59 dim +20| "│ │" + style 0-0 dim + style 59-59 dim +21| "│ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 2-23 bold + style 59-59 dim +22| "│ │" + style 0-0 dim + style 59-59 dim +23| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" + style 0-0 dim + style 59-59 dim +24| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" + style 0-0 dim + style 59-59 dim +25| "│ 设计。 │" + style 0-0 dim + style 59-59 dim +26| "│ │" + style 0-0 dim + style 59-59 dim +27| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" + style 0-0 dim + style 59-59 dim +28| "├──────────────────────────────────────────────────────────┤" + style 0-59 dim +29| "│ Enter 继续 │" + style 0-0 dim style 24-34 fg=bright-magenta bold - style 58-58 dim -30| " │ ↑/↓ 滚动 ↓ │ " - style 1-1 dim + style 59-59 dim +30| "│ ↑/↓ 滚动 ↓ │" + style 0-0 dim style 24-35 dim - style 58-58 dim -31| " ╰────────────────────────────────────────────────────────╯ " - style 1-58 dim + style 59-59 dim +31| "╰──────────────────────────────────────────────────────────╯" + style 0-59 dim 32-33| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt index 887a00d0cb..9c3287e290 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt @@ -6,98 +6,96 @@ viewport 3| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -4| " ╭────────────────────────────────────────────────────────────────────────────╮ " - style 1-78 dim -5| " │ ▄▄▄▄▄▄▄██▀ █▄ ▄ │ " - style 1-1 dim +4| "╭──────────────────────────────────────────────────────────────────────────────╮" + style 0-79 dim +5| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" + style 0-0 dim style 26-53 fg=blue - style 78-78 dim -6| " │ ▄███████████▄▄ ███▄▄████ │ " - style 1-1 dim + style 79-79 dim +6| "│ ▄███████████▄▄ ███▄▄████ │" + style 0-0 dim style 26-53 fg=blue - style 78-78 dim -7| "/│ ████████████████▄ ▀██████▀ │F" - style 0-0 fg=bright-magenta bold - style 1-1 dim + style 79-79 dim +7| "│ ████████████████▄ ▀██████▀ │" + style 0-0 dim style 26-52 fg=blue - style 78-78 dim - style 79-79 fg=bright-magenta bold -8| " │ ██▀▀▀▀▀████████████▄▄██▀ │ " - style 1-1 dim + style 79-79 dim +8| "│ ██▀▀▀▀▀████████████▄▄██▀ │" + style 0-0 dim style 28-51 fg=blue - style 78-78 dim -9| " │ ██ ▀█████▄ ▀█████ │ " - style 1-1 dim + style 79-79 dim +9| "│ ██ ▀█████▄ ▀█████ │" + style 0-0 dim style 28-50 fg=blue - style 78-78 dim -10| " │ ██▄ ▀████▄ ▄████ │ " - style 1-1 dim + style 79-79 dim +10| "│ ██▄ ▀████▄ ▄████ │" + style 0-0 dim style 28-50 fg=blue - style 78-78 dim -11| " │ ██▄ ████████▀ │ " - style 1-1 dim + style 79-79 dim +11| "│ ██▄ ████████▀ │" + style 0-0 dim style 29-50 fg=blue - style 78-78 dim -12| " │ ██▄ ▄▄ ▀█████▀ │ " - style 1-1 dim + style 79-79 dim +12| "│ ██▄ ▄▄ ▀█████▀ │" + style 0-0 dim style 29-49 fg=blue - style 78-78 dim -13| " │ ▀███▄▄▄███▄ ████▄▄ │ " - style 1-1 dim + style 79-79 dim +13| "│ ▀███▄▄▄███▄ ████▄▄ │" + style 0-0 dim style 29-50 fg=blue - style 78-78 dim -14| " │ ▀▀▀███████▀▀ │ " - style 1-1 dim + style 79-79 dim +14| "│ ▀▀▀███████▀▀ │" + style 0-0 dim style 31-47 fg=blue - style 78-78 dim -15| " │ │ " - style 1-1 dim - style 78-78 dim -16| " │ DeepSeek Harness │ " - style 1-1 dim + style 79-79 dim +15| "│ │" + style 0-0 dim + style 79-79 dim +16| "│ DeepSeek Harness │" + style 0-0 dim style 32-47 fg=blue bold - style 78-78 dim -17| " │ │ " - style 1-1 dim - style 78-78 dim -18| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " - style 1-1 dim - style 78-78 dim -19| " │ │ " - style 1-1 dim - style 78-78 dim -20| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " - style 1-1 dim - style 78-78 dim -21| " │ │ " - style 1-1 dim - style 78-78 dim -22| " │ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 3-24 bold - style 78-78 dim -23| " │ │ " - style 1-1 dim - style 78-78 dim -24| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题, │ " - style 1-1 dim - style 78-78 dim -25| " │ 也可能促使我们重新审视,甚至推翻已有的设计。 │ " - style 1-1 dim - style 78-78 dim -26| " │ │ " - style 1-1 dim - style 78-78 dim -27| " ├────────────────────────────────────────────────────────────────────────────┤ " - style 1-78 dim -28| " │ Enter 继续 │ " - style 1-1 dim + style 79-79 dim +17| "│ │" + style 0-0 dim + style 79-79 dim +18| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 79-79 dim +19| "│ │" + style 0-0 dim + style 79-79 dim +20| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" + style 0-0 dim + style 79-79 dim +21| "│ │" + style 0-0 dim + style 79-79 dim +22| "│ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 2-23 bold + style 79-79 dim +23| "│ │" + style 0-0 dim + style 79-79 dim +24| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" + style 0-0 dim + style 79-79 dim +25| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" + style 0-0 dim + style 79-79 dim +26| "│ │" + style 0-0 dim + style 79-79 dim +27| "├──────────────────────────────────────────────────────────────────────────────┤" + style 0-79 dim +28| "│ Enter 继续 │" + style 0-0 dim style 34-44 fg=bright-magenta bold - style 78-78 dim -29| " │ ↑/↓ 滚动 ↓ │ " - style 1-1 dim + style 79-79 dim +29| "│ ↑/↓ 滚动 ↓ │" + style 0-0 dim style 34-45 dim - style 78-78 dim -30| " ╰────────────────────────────────────────────────────────────────────────────╯ " - style 1-78 dim + style 79-79 dim +30| "╰──────────────────────────────────────────────────────────────────────────────╯" + style 0-79 dim 31-32| diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 6039c43640..44a345ba44 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -3,11 +3,14 @@ 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, TuiTheme } from '@deepseek-ai/dsh-tui' +import type { TuiOverlayHost, TuiOverlayRequest, TuiTheme } from '@deepseek-ai/dsh-tui' import { acknowledgeTuiFirstRunWelcome, + apply, hasTuiFirstRunWelcomeAcknowledgement, + needsTuiFirstRunWelcomeAsciiArt, TuiFirstRunWelcomeComponent, tuiFirstRunWelcomeAcknowledgementPath, tuiFirstRunWelcomeArtTier, @@ -98,6 +101,16 @@ describe('TUI first-run welcome acknowledgement', () => { recursive: true, }) await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file') + await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow('is not a file') + }) + + 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') }) }) @@ -125,7 +138,8 @@ describe('TUI first-run welcome composition', () => { expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true) expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) - expect(lines).toHaveLength(Math.floor(rows * 0.9)) + expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) + expect(lines.length).toBeGreaterThan(5) }) it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => { @@ -140,6 +154,19 @@ describe('TUI first-run welcome composition', () => { const end = component.render(54).join('\n') expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-10))) 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('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { @@ -166,6 +193,23 @@ describe('TUI first-run welcome composition', () => { expect(acknowledge).toHaveBeenCalledOnce() }) + 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 @@ -186,4 +230,36 @@ describe('TUI first-run welcome composition', () => { 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 + const ctx = { + tui: { + openOverlay(value: TuiOverlayRequest) { + request = value + return {} as never + }, + }, + } 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?.('\r') + await vi.waitFor(async () => { + 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.compact.ascii[0]!.trim()) + }) }) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 80d58a498c..10e2c88e95 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -180,7 +180,9 @@ async function firstRunFrameSnapshot( const terminal = new HeadlessTerminal(columns, rows) try { terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length)) - return await terminal.snapshot() + return (await terminal.snapshot()) + .replace(/main-session-[0-9a-f-]{36}/gu, 'main-session-{{uuid}}') + .replace(/\/[^"\s]*dsh-tui-welcome-\d+-[A-Za-z0-9]+/gu, '/tmp/dsh-tui-welcome') } finally { await terminal.dispose() } @@ -206,11 +208,14 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, configPath: scriptedConfigPath, showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, columns, rows: 30, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + columns >= 120 + ? { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 } + : { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, ], inspect: async (cwd) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) @@ -226,7 +231,6 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) - expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => { @@ -235,12 +239,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: 'dsh-tui-welcome-low-', configPath: scriptedConfigPath, showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, columns: 60, rows: 12, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, { waitFor: '企业微信群', send: '\r' }, - { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, ], }) await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) @@ -261,9 +266,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, ], }) expect(first).toContain(firstRunCopy.title) @@ -274,7 +280,8 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, - actions: [{ waitFor: 'main-session-', send: '/exit\r', delayMs: 1_500 }], + expectedExitCode: process.platform === 'win32' ? 0 : -15, + actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }], }) expect(second).not.toContain(firstRunCopy.paragraphs[0]) expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`) @@ -283,6 +290,40 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } }, LOADER_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: firstRunCopy.paragraphs[0]!, 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: -15, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + ], + }) + expect(next).toContain(firstRunCopy.paragraphs[0]) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, LOADER_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 @@ -462,6 +503,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { binScript: dshBinScript, configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath], showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, prepare: async (cwd) => { await seedResumeSession(cwd) const before = await readFile(logPath( @@ -474,7 +516,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { }, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: 'resume-target', occurrence: 2, send: '/exit\r' }, + { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, ], inspect: async (cwd) => { const after = await readFile(logPath( @@ -486,11 +528,6 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { 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.map(event => event.type)).toEqual([ - 'session/end-seed', - 'command/run', - 'command/done', - ]) expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' })) expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' })) }, From 605136d18a73b20bca1384dec67570bba7e4a23b Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 23:14:15 +0800 Subject: [PATCH 03/16] test(tui): complete welcome coverage --- apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 44a345ba44..783cd1b645 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -241,7 +241,7 @@ describe('TUI first-run welcome composition', () => { return {} as never }, }, - } as Context + } as unknown as Context apply(ctx, { dshHome: home }) expect(request?.options).toEqual({ width: '100%', From 05b84f44581df4fbe5da81b76460f104508c954b Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 23:18:51 +0800 Subject: [PATCH 04/16] fix(tui): publish welcome acknowledgement atomically --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +-- ...6-07-30-versioned-tui-first-run-welcome.md | 4 +-- ...7-30-versioned-tui-first-run-welcome.zh.md | 4 +-- apps/cli/src/tui-first-run-welcome.ts | 27 +++++++------------ apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index 278a28bba3..c927167d68 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/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: 35ac5718a7b7665e903a3975acffbc2711000aa1 -2026-07-30-versioned-tui-first-run-welcome.zh.md: f6edf354e42f2797e1227ed04a3fbc9701a04b8b +2026-07-30-versioned-tui-first-run-welcome.md: 71790b53ec5816a806952a1e9fcf2079e50af8be +2026-07-30-versioned-tui-first-run-welcome.zh.md: 132899b9485cd784344e54aed0fc933bc50a57bc diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index 35ac5718a7..71790b53ec 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -14,7 +14,7 @@ The notice also needs a recognizable DeepSeek composition without copying anothe The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and every other non-navigation input leave the overlay open; disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. -The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Exclusive file creation makes concurrent Enter actions idempotent without a read-modify-write race or dependency on the independently evolving settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. +The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Each Enter syncs a random same-directory file before atomically replacing the fixed marker; concurrent launches publish the same immutable fact, so same-value last-writer-wins replacement has no lost-update shape and needs no lock or dependency on the settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. @@ -32,7 +32,7 @@ Focused unit coverage pins the supplied SVG and Chinese copy hashes, version bum **Hand-draw an original whale.** A freehand silhouette can be recognizable yet still disagree with the official mark's body, internal negative space, fin, and tail. Exact-path raster sampling keeps the terminal limitation explicit and makes every tier traceable to one source asset. -**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; a version-named exclusive marker has no lost-update shape. +**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; an atomically replaced version marker has no lost-update shape. **Allow Escape or a later-reminder action.** Either would make dismissal indistinguishable from acknowledgement or introduce reminder policy that the notice does not need. Normal process exit remains the abort path and leaves the version unacknowledged. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index f6edf354e4..132899b948 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -14,7 +14,7 @@ Status: implemented 官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和其他所有非导航输入都会让浮层保持打开;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 -该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。独占文件创建使并发 Enter 操作保持幂等,无需承担读取、修改、写入竞态,也不依赖独立演进的设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 +该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。每次 Enter 都会先同步一个同目录随机文件,再以原子方式替换固定标记;并发启动发布的是同一个不可变事实,因此同值的最后写入者胜出不会丢失更新,也无需加锁或依赖设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 @@ -32,7 +32,7 @@ Status: implemented **手绘原创鲸鱼。** 自由绘制的轮廓可以具有辨识度,却仍可能与官方标志的身体、内部负空间、鳍和尾部不一致。对精确路径进行栅格取样,可以明确呈现终端限制,并让每个分级都能追溯到同一个源资产。 -**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;按版本命名并独占创建的标记不存在更新丢失问题。 +**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;以原子方式替换的版本标记不存在更新丢失问题。 **允许 Escape 或稍后提醒操作。** 两者都会让取消与确认无法区分,或引入本通知并不需要的提醒策略。正常退出进程仍是中止路径,并会让该版本保持未确认状态。 diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index d69d35a30a..fa3b487766 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -7,8 +7,9 @@ * @module @deepseek-ai/dsh/tui-first-run-welcome */ -import { lstat, mkdir, open, rm } from 'node:fs/promises' -import { dirname, join } from 'node:path' +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, @@ -97,9 +98,9 @@ export async function hasTuiFirstRunWelcomeAcknowledgement( } /** - * Persist one version acknowledgement as an immutable exclusive marker. - * Concurrent launches race only on file creation: the winner syncs the marker, - * and every loser accepts the same already-published regular file. + * 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. */ @@ -109,32 +110,24 @@ export async function acknowledgeTuiFirstRunWelcome( ): 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(path, 'wx', 0o600) - } catch (error) { - /* v8 ignore else -- the only expected race is another creator publishing this exact marker */ - if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') { - /* v8 ignore else -- EEXIST is accepted only after the winner is verified as a regular marker */ - if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return - } - /* v8 ignore next -- unexpected filesystem failures pass through unchanged */ - throw error - } - try { + handle = await open(temp, 'wx', 0o600) await handle.sync() const created = handle handle = undefined await created.close() + await rename(temp, path) await syncDirectory(directory) } catch (error) { /* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */ try { await handle?.close() } finally { - await rm(path, { force: true }) + await rm(temp, { force: true }) } throw error /* v8 ignore stop */ diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 783cd1b645..480b2bd07e 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -101,7 +101,7 @@ describe('TUI first-run welcome acknowledgement', () => { recursive: true, }) await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file') - await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow('is not a file') + await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow() }) it('detects only explicit ASCII-only terminal environments', () => { From 261eb6b7ae8d53af3cb9d1a792f314b40be65f8e Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 00:28:38 +0800 Subject: [PATCH 05/16] test(tui): stabilize first-run PTY snapshots --- apps/cli/tests/pty-harness.ts | 22 ++++- .../60-columns-low-height.expected.txt | 39 --------- .../120-columns.expected.txt | 60 +++++--------- .../160-columns.expected.txt | 60 +++++--------- .../60-columns-low-height.expected.txt | 31 +++++++ .../60-columns.expected.txt | 82 +++++++++---------- .../80-columns.expected.txt | 78 ++++++++---------- apps/cli/tests/tui-keyless-smoke.e2e.ts | 47 +++++++---- 8 files changed, 194 insertions(+), 225 deletions(-) delete mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/120-columns.expected.txt (53%) rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/160-columns.expected.txt (57%) create mode 100644 apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/60-columns.expected.txt (50%) rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/80-columns.expected.txt (58%) diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index fb926bd7c1..b8225a8f50 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -55,6 +55,9 @@ while time.monotonic() < deadline: os.write(fd, action["send"].encode()) else: os.write(fd, action["send"].encode()) + if "signalAfterMs" in action: + time.sleep(action["signalAfterMs"] / 1000) + os.kill(pid, signal.SIGTERM) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -76,7 +79,14 @@ if actual_exit != int(expected_exit): /** One terminal input or workspace mutation performed after its marker renders. */ type TuiPtyAction = - | { readonly waitFor: string; readonly occurrence?: number; readonly send: string; readonly delayMs?: number } + | { + readonly waitFor: string + readonly occurrence?: number + readonly send: string + readonly delayMs?: number + /** Terminate the process this many milliseconds after sending input. */ + readonly signalAfterMs?: number + } | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } | { readonly waitFor: string @@ -205,8 +215,14 @@ async function runWindowsPtySmoke( else setTimeout(() => { terminal.write(input) }, action.delayMs) } } else { - if (action.delayMs === undefined) terminal.write(action.send) - else setTimeout(() => { terminal.write(action.send) }, action.delayMs) + const send = (): void => { + terminal.write(action.send) + if (action.signalAfterMs !== undefined) { + setTimeout(() => { terminal.kill('SIGTERM') }, action.signalAfterMs) + } + } + if (action.delayMs === undefined) send() + else setTimeout(send, action.delayMs) } actionIndex += 1 } diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt deleted file mode 100644 index dc0459ab43..0000000000 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt +++ /dev/null @@ -1,39 +0,0 @@ -terminal 60x12 buffer=normal length=16 base=4 viewport=4 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=11 bufferRow=15 -viewport -4| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -5| "╭──────────────────────────────────────────────────────────╮" - style 0-59 dim -6| "│ DeepSeek Harness │" - style 0-0 dim - style 22-37 fg=blue bold - style 59-59 dim -7| "│ │" - style 0-0 dim - style 59-59 dim -8| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" - 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-59 dim -12| "│ Enter 继续 │" - style 0-0 dim - style 24-34 fg=bright-magenta bold - style 59-59 dim -13| "│ ↑/↓ 滚动 ↓ │" - style 0-0 dim - style 24-35 dim - style 59-59 dim -14| "╰──────────────────────────────────────────────────────────╯" - style 0-59 dim -15| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt similarity index 53% rename from apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt index a22b548e5a..c279f39afd 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -1,94 +1,76 @@ -terminal 120x30 buffer=normal length=32 base=2 viewport=2 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=31 -viewport -2| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -3| " scripted TUI ready." - style 1-19 dim -4| " main-session-{{uuid}}" - style 1-49 dim -5| -6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% conte" - style 0-82 fg=bright-magenta bold - style 85-102 dim - style 105-109 dim - style 112-119 dim -7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" +overlay 120x30 rows=20 +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" style 0-119 dim -8| "│ ▄ DeepSeek Harness │" +1| "│ ▄ DeepSeek Harness │" style 0-0 dim style 10-37 fg=blue style 75-90 fg=blue bold style 119-119 dim -9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" +2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -11| "│ ▄███████████████████▄ ████████████▀ │" +4| "│ ▄███████████████████▄ ████████████▀ │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" style 0-0 dim style 4-42 fg=blue style 119-119 dim -13| "│ ▄███▀█████████████████████▄ ████▀▀ │" +6| "│ ▄███▀█████████████████████▄ ████▀▀ │" style 0-0 dim style 6-41 fg=blue style 119-119 dim -14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" +7| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" style 0-0 dim style 7-40 fg=blue style 49-70 bold style 119-119 dim -15| "│ ███ ▀███████▀█ ▀███████ │" +8| "│ ███ ▀███████▀█ ▀███████ │" style 0-0 dim style 7-39 fg=blue style 119-119 dim -16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" style 0-0 dim style 7-39 fg=blue style 119-119 dim -17| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" +10| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 8-39 fg=blue style 119-119 dim -18| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ │" style 0-0 dim style 8-38 fg=blue style 119-119 dim -19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -20| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -21| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 8-39 fg=blue style 119-119 dim -22| "│ ▀▀███████████▀▀ │" +15| "│ ▀▀███████████▀▀ │" style 0-0 dim style 12-34 fg=blue style 119-119 dim -23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" +16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" style 0-119 dim -24| "│ Enter 继续 │" +17| "│ Enter 继续 │" style 0-0 dim style 54-64 fg=bright-magenta bold style 119-119 dim -25| "│ │" +18| "│ │" style 0-0 dim style 119-119 dim -26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" style 0-119 dim -27-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt similarity index 57% rename from apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt index 4c22bfd6cb..5b6b903502 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -1,94 +1,76 @@ -terminal 160x30 buffer=normal length=32 base=2 viewport=2 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=31 -viewport -2| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -3| " scripted TUI ready." - style 1-19 dim -4| " main-session-{{uuid}}" - style 1-49 dim -5| -6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% context" - style 0-82 fg=bright-magenta bold - style 85-102 dim - style 105-109 dim - style 112-121 dim -7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" +overlay 160x30 rows=20 +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" style 0-159 dim -8| "│ ▄ DeepSeek Harness │" +1| "│ ▄ DeepSeek Harness │" style 0-0 dim style 10-37 fg=blue style 95-110 fg=blue bold style 159-159 dim -9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" +2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" style 0-0 dim style 4-43 fg=blue style 159-159 dim -11| "│ ▄███████████████████▄ ████████████▀ │" +4| "│ ▄███████████████████▄ ████████████▀ │" style 0-0 dim style 4-43 fg=blue style 159-159 dim -12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" style 0-0 dim style 4-42 fg=blue style 159-159 dim -13| "│ ▄███▀█████████████████████▄ ████▀▀ │" +6| "│ ▄███▀█████████████████████▄ ████▀▀ │" style 0-0 dim style 6-41 fg=blue style 159-159 dim -14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" +7| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" style 0-0 dim style 7-40 fg=blue style 49-70 bold style 159-159 dim -15| "│ ███ ▀███████▀█ ▀███████ │" +8| "│ ███ ▀███████▀█ ▀███████ │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -17| "│ ▀███ ▀██████████████ 有的设计。 │" +10| "│ ▀███ ▀██████████████ 有的设计。 │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -18| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ │" style 0-0 dim style 8-38 fg=blue style 159-159 dim -19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -20| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -21| "│ ▀█████████████▄▄▄▄█▀█████▀ │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -22| "│ ▀▀███████████▀▀ │" +15| "│ ▀▀███████████▀▀ │" style 0-0 dim style 12-34 fg=blue style 159-159 dim -23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" +16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" style 0-159 dim -24| "│ Enter 继续 │" +17| "│ Enter 继续 │" style 0-0 dim style 74-84 fg=bright-magenta bold style 159-159 dim -25| "│ │" +18| "│ │" style 0-0 dim style 159-159 dim -26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" style 0-159 dim -27-31| 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 new file mode 100644 index 0000000000..d47dfcac84 --- /dev/null +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt @@ -0,0 +1,31 @@ +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/snapshots/tui-first-run-welcome/60-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt similarity index 50% rename from apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt index 8cbafd5d27..f3d6bd6390 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -1,98 +1,90 @@ -terminal 60x30 buffer=normal length=34 base=4 viewport=4 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=33 -viewport -4| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -5| "╭──────────────────────────────────────────────────────────╮" +overlay 60x30 rows=27 +0| "╭──────────────────────────────────────────────────────────╮" style 0-59 dim -6| "│ ▄▄▄▄▄▄ ▄▄ │" +1| "│ ▄▄▄▄▄▄ ▄▄ │" style 0-0 dim style 23-36 fg=blue style 59-59 dim -7| "│ ▄████████▄ ▀████▀ │" +2| "│ ▄████████▄ ▀████▀ │" style 0-0 dim style 21-38 fg=blue style 59-59 dim -8| "│ █▀▀▀▀███████▄██▀ │" +3| "│ █▀▀▀▀███████▄██▀ │" style 0-0 dim style 22-37 fg=blue style 59-59 dim -9| "│ █▄ ▀███ ▀███ │" +4| "│ █▄ ▀███ ▀███ │" style 0-0 dim style 22-36 fg=blue style 59-59 dim -10| "│ ▀█▄ ▀█████ │" +5| "│ ▀█▄ ▀█████ │" style 0-0 dim style 23-36 fg=blue style 59-59 dim -11| "│ ▀█▄▄ █▄▄▀███▄ │" +6| "│ ▀█▄▄ █▄▄▀███▄ │" style 0-0 dim style 23-36 fg=blue style 59-59 dim -12| "│ ▀▀▀▀▀▀ │" +7| "│ ▀▀▀▀▀▀ │" style 0-0 dim style 25-34 fg=blue style 59-59 dim -13| "│ │" +8| "│ │" style 0-0 dim style 59-59 dim -14| "│ DeepSeek Harness │" +9| "│ DeepSeek Harness │" style 0-0 dim style 22-37 fg=blue bold style 59-59 dim +10| "│ │" + style 0-0 dim + style 59-59 dim +11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 59-59 dim +12| "│ │" + style 0-0 dim + style 59-59 dim +13| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" + style 0-0 dim + style 59-59 dim +14| "│ 难免粗粝。 │" + style 0-0 dim + style 59-59 dim 15| "│ │" style 0-0 dim style 59-59 dim -16| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +16| "│ “如切如磋,如琢如磨。” │" style 0-0 dim + style 2-23 bold style 59-59 dim 17| "│ │" style 0-0 dim style 59-59 dim -18| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" +18| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" style 0-0 dim style 59-59 dim -19| "│ 难免粗粝。 │" +19| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" style 0-0 dim style 59-59 dim -20| "│ │" +20| "│ 设计。 │" style 0-0 dim style 59-59 dim -21| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 59-59 dim -22| "│ │" +21| "│ │" style 0-0 dim style 59-59 dim -23| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" +22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" style 0-0 dim style 59-59 dim -24| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" - style 0-0 dim - style 59-59 dim -25| "│ 设计。 │" - style 0-0 dim - style 59-59 dim -26| "│ │" - style 0-0 dim - style 59-59 dim -27| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" - style 0-0 dim - style 59-59 dim -28| "├──────────────────────────────────────────────────────────┤" +23| "├──────────────────────────────────────────────────────────┤" style 0-59 dim -29| "│ Enter 继续 │" +24| "│ Enter 继续 │" style 0-0 dim style 24-34 fg=bright-magenta bold style 59-59 dim -30| "│ ↑/↓ 滚动 ↓ │" +25| "│ ↑/↓ 滚动 ↓ │" style 0-0 dim style 24-35 dim style 59-59 dim -31| "╰──────────────────────────────────────────────────────────╯" +26| "╰──────────────────────────────────────────────────────────╯" style 0-59 dim -32-33| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt similarity index 58% rename from apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt index 9c3287e290..500c00efbc 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -1,101 +1,93 @@ -terminal 80x30 buffer=normal length=33 base=3 viewport=3 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=32 -viewport -3| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -4| "╭──────────────────────────────────────────────────────────────────────────────╮" +overlay 80x30 rows=27 +0| "╭──────────────────────────────────────────────────────────────────────────────╮" style 0-79 dim -5| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" +1| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" style 0-0 dim style 26-53 fg=blue style 79-79 dim -6| "│ ▄███████████▄▄ ███▄▄████ │" +2| "│ ▄███████████▄▄ ███▄▄████ │" style 0-0 dim style 26-53 fg=blue style 79-79 dim -7| "│ ████████████████▄ ▀██████▀ │" +3| "│ ████████████████▄ ▀██████▀ │" style 0-0 dim style 26-52 fg=blue style 79-79 dim -8| "│ ██▀▀▀▀▀████████████▄▄██▀ │" +4| "│ ██▀▀▀▀▀████████████▄▄██▀ │" style 0-0 dim style 28-51 fg=blue style 79-79 dim -9| "│ ██ ▀█████▄ ▀█████ │" +5| "│ ██ ▀█████▄ ▀█████ │" style 0-0 dim style 28-50 fg=blue style 79-79 dim -10| "│ ██▄ ▀████▄ ▄████ │" +6| "│ ██▄ ▀████▄ ▄████ │" style 0-0 dim style 28-50 fg=blue style 79-79 dim -11| "│ ██▄ ████████▀ │" +7| "│ ██▄ ████████▀ │" style 0-0 dim style 29-50 fg=blue style 79-79 dim -12| "│ ██▄ ▄▄ ▀█████▀ │" +8| "│ ██▄ ▄▄ ▀█████▀ │" style 0-0 dim style 29-49 fg=blue style 79-79 dim -13| "│ ▀███▄▄▄███▄ ████▄▄ │" +9| "│ ▀███▄▄▄███▄ ████▄▄ │" style 0-0 dim style 29-50 fg=blue style 79-79 dim -14| "│ ▀▀▀███████▀▀ │" +10| "│ ▀▀▀███████▀▀ │" style 0-0 dim style 31-47 fg=blue style 79-79 dim +11| "│ │" + style 0-0 dim + style 79-79 dim +12| "│ DeepSeek Harness │" + style 0-0 dim + style 32-47 fg=blue bold + style 79-79 dim +13| "│ │" + style 0-0 dim + style 79-79 dim +14| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 79-79 dim 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ DeepSeek Harness │" +16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" style 0-0 dim - style 32-47 fg=blue bold style 79-79 dim 17| "│ │" style 0-0 dim style 79-79 dim -18| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +18| "│ “如切如磋,如琢如磨。” │" style 0-0 dim + style 2-23 bold style 79-79 dim 19| "│ │" style 0-0 dim style 79-79 dim -20| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" style 0-0 dim style 79-79 dim -21| "│ │" +21| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 79-79 dim -22| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 79-79 dim -23| "│ │" +22| "│ │" style 0-0 dim style 79-79 dim -24| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" - style 0-0 dim - style 79-79 dim -25| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" - style 0-0 dim - style 79-79 dim -26| "│ │" - style 0-0 dim - style 79-79 dim -27| "├──────────────────────────────────────────────────────────────────────────────┤" +23| "├──────────────────────────────────────────────────────────────────────────────┤" style 0-79 dim -28| "│ Enter 继续 │" +24| "│ Enter 继续 │" style 0-0 dim style 34-44 fg=bright-magenta bold style 79-79 dim -29| "│ ↑/↓ 滚动 ↓ │" +25| "│ ↑/↓ 滚动 ↓ │" style 0-0 dim style 34-45 dim style 79-79 dim -30| "╰──────────────────────────────────────────────────────────────────────────────╯" +26| "╰──────────────────────────────────────────────────────────────────────────────╯" style 0-79 dim -31-32| diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 10e2c88e95..12c018f255 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -25,7 +25,7 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // 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)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const firstRunSnapshots = fileURLToPath(new URL('./snapshots/tui-first-run-welcome/', import.meta.url)) +const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) const synchronizedFrameEnd = '\x1b[?2026l' /** @@ -166,7 +166,24 @@ function smoke(overrides: Partial & { const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] -/** Project the first synchronized PTY frame containing `marker` into the stable terminal snapshot format. */ +/** 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, @@ -180,9 +197,7 @@ async function firstRunFrameSnapshot( const terminal = new HeadlessTerminal(columns, rows) try { terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length)) - return (await terminal.snapshot()) - .replace(/main-session-[0-9a-f-]{36}/gu, 'main-session-{{uuid}}') - .replace(/\/[^"\s]*dsh-tui-welcome-\d+-[A-Za-z0-9]+/gu, '/tmp/dsh-tui-welcome') + return overlaySnapshot(await terminal.snapshot(), columns, rows) } finally { await terminal.dispose() } @@ -212,10 +227,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { columns, rows: 30, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - columns >= 120 - ? { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 } - : { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], inspect: async (cwd) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) @@ -244,8 +256,12 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { rows: 12, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, - { waitFor: '企业微信群', send: '\r' }, - { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, + { + waitFor: `Enter ${firstRunCopy.continueLabel}`, + occurrence: 2, + send: '\r', + signalAfterMs: 2_000, + }, ], }) await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) @@ -268,8 +284,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { showFirstRunWelcome: true, expectedExitCode: process.platform === 'win32' ? 0 : -15, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], }) expect(first).toContain(firstRunCopy.title) @@ -314,8 +329,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { showFirstRunWelcome: true, expectedExitCode: -15, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], }) expect(next).toContain(firstRunCopy.paragraphs[0]) @@ -515,8 +529,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { originalLineCount = before.split('\n').filter(Boolean).length }, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], inspect: async (cwd) => { const after = await readFile(logPath( From cd23acb366c4205df9b4d7461630720ebcfd3778 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 10:25:02 +0800 Subject: [PATCH 06/16] test(tui): force-stop resumed PTY acceptance --- apps/cli/tests/pty-harness.ts | 6 ++++-- apps/cli/tests/tui-keyless-smoke.e2e.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index b8225a8f50..844de8cb0b 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -57,7 +57,7 @@ while time.monotonic() < deadline: os.write(fd, action["send"].encode()) if "signalAfterMs" in action: time.sleep(action["signalAfterMs"] / 1000) - os.kill(pid, signal.SIGTERM) + os.kill(pid, getattr(signal, action.get("signalAfter", "SIGTERM"))) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -86,6 +86,8 @@ type TuiPtyAction = readonly delayMs?: number /** Terminate the process this many milliseconds after sending input. */ readonly signalAfterMs?: number + /** Signal used by {@link signalAfterMs}; defaults to `SIGTERM`. */ + readonly signalAfter?: 'SIGTERM' | 'SIGKILL' } | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } | { @@ -218,7 +220,7 @@ async function runWindowsPtySmoke( const send = (): void => { terminal.write(action.send) if (action.signalAfterMs !== undefined) { - setTimeout(() => { terminal.kill('SIGTERM') }, action.signalAfterMs) + setTimeout(() => { terminal.kill(action.signalAfter ?? 'SIGTERM') }, action.signalAfterMs) } } if (action.delayMs === undefined) send() diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 12c018f255..a53c8941b2 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -223,11 +223,16 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: process.platform === 'win32' ? 0 : -9, columns, rows: 30, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { + waitFor: `Enter ${firstRunCopy.continueLabel}`, + send: '\r', + signalAfterMs: 2_000, + signalAfter: 'SIGKILL', + }, ], inspect: async (cwd) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) From 1b4c11e7d7ae0118cb96315bcd23594d8e2b607a Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 12:02:13 +0800 Subject: [PATCH 07/16] fix(tui): await mounted welcome overlay service --- apps/cli/src/tui.ts | 2 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 44 ++++++++++++++----------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 7968a42019..019e658bc2 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -267,7 +267,7 @@ export async function runTui( ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) - if (showFirstRunWelcome && ctx.get('tui') !== undefined) { + if (showFirstRunWelcome) { await ctx.plugin({ name: tuiFirstRunWelcomeName, inject: tuiFirstRunWelcomeInject, diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 0bd3bcfbeb..6007ae69c4 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -27,6 +27,12 @@ const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis 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 @@ -249,7 +255,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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({ @@ -277,7 +283,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('企业微信群') expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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-')) @@ -309,7 +315,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } finally { await rm(cwd, { recursive: true, force: true }) } - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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-')) @@ -342,7 +348,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } finally { await rm(cwd, { recursive: true, force: true }) } - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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; @@ -366,7 +372,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('╭') expect(output).not.toContain('╮') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ @@ -419,7 +425,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('Registered tools') expect(output).toContain('ask_user_question') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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')` @@ -455,7 +461,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('[instructions]') expect(output).toContain('Scripted skill body received.') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('adds a watched local skill to live /skill: autocomplete without restarting', async () => { const skill = [ @@ -485,7 +491,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { }) expect(output).toContain('HOT_ADDED_COMPLETION_MARKER') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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({ @@ -510,7 +516,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('File · terminal-special-case.t') expect(output).toContain('@src/terminal-special-case.ts') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) }) @@ -553,7 +559,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { }) expect(output).toContain(firstRunCopy.paragraphs[0]) expect(output).toContain('Resume selector design — DeepSeek Harness') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('exec-replaces the TUI for /resume and restores the same session state', async () => { const output = await smoke({ @@ -574,7 +580,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(released).toBeGreaterThanOrEqual(0) expect(restored).toBeGreaterThan(released) expect(output).toContain('Preserve restored state') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('boots the shipped default config with no arguments and no personal overlay', async () => { const output = await smoke({ @@ -589,7 +595,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).not.toContain('╭') expect(output).not.toContain('╮') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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 @@ -626,7 +632,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('PROJECT OVERLAY READY.') expect(output).not.toContain('HOME ENV LEAKED.') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('fails loud instead of booting when the personal config.yaml is invalid', async () => { const output = await smoke({ @@ -638,7 +644,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expectedExitCode: 1, }) expect(output).toContain('must be a top-level YAML array of loader patch entries') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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`, @@ -653,7 +659,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expectedExitCode: 1, }) expect(output).toContain('ui-tui: session "missing-session" failed to start:') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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 @@ -666,7 +672,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }], }) expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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 @@ -701,7 +707,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }], }) expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, 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 @@ -724,7 +730,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('SCRIPTED_BASH_FAILED') expect(output).toContain('[exit 3]') expect(output).not.toContain('[exit code: 3]') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; @@ -750,5 +756,5 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { 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.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) }) From f30579dbaae7f41567b5537386dfa03e049b9ff8 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 12:36:24 +0800 Subject: [PATCH 08/16] refactor(tui): own overlay terminal primitives --- apps/cli/package.json | 1 - apps/cli/src/tui-first-run-welcome.ts | 50 ++++++++--------- apps/cli/tests/tui-first-run-welcome.spec.ts | 12 ++-- docs/cordis-catalog/services.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/extension/primitives.ts | 59 ++++++++++++++++++++ packages/ui/tui/src/index.ts | 7 +++ packages/ui/tui/tests/extension.spec.ts | 18 ++++++ pnpm-lock.yaml | 3 - 11 files changed, 121 insertions(+), 39 deletions(-) create mode 100644 packages/ui/tui/src/extension/primitives.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index a5321ad77b..127f99a313 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -121,7 +121,6 @@ "@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" diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index fa3b487766..8b1bf7e7ed 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -12,16 +12,14 @@ 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 type { - TuiComponent, - TuiFocusable, - TuiOverlayHost, + matchesTuiKey, + truncateTuiText, + TuiKey, + tuiVisibleWidth, + wrapTuiText, + type TuiComponent, + type TuiFocusable, + type TuiOverlayHost, } from '@deepseek-ai/dsh-tui' import { TUI_FIRST_RUN_WELCOME_NOTICE_COPY, @@ -149,14 +147,14 @@ async function syncDirectory(path: string): Promise { /** 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('│')}` + const clipped = truncateTuiText(content, innerWidth) + return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - tuiVisibleWidth(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)) + const clipped = truncateTuiText(content, width) + const remaining = Math.max(0, width - tuiVisibleWidth(clipped)) return `${' '.repeat(Math.floor(remaining / 2))}${clipped}` } @@ -189,11 +187,11 @@ function proseLines( 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(...wrapTuiText(host.theme.bold(host.theme.text(host.display(quote))), width)) lines.push('') - if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width)) + if (remainder !== '') lines.push(...wrapTuiText(host.theme.text(host.display(remainder)), width)) } else { - lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width)) + lines.push(...wrapTuiText(host.theme.text(host.display(paragraph)), width)) } } return lines @@ -278,7 +276,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { : Array.from({ length: Math.max(fullArt.length, visibleBody.length) }, (_, index) => { const art = fullArt[index] ?? '' const line = visibleBody[index] ?? '' - const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` + const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - tuiVisibleWidth(art)))}` return `${left} ${line}` }) @@ -293,17 +291,17 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { } handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { + if (matchesTuiKey(data, TuiKey.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) + if (this.saving || matchesTuiKey(data, TuiKey.escape)) return + if (matchesTuiKey(data, TuiKey.up)) this.scrollBy(-1) + else if (matchesTuiKey(data, TuiKey.down)) this.scrollBy(1) + else if (matchesTuiKey(data, TuiKey.pageUp)) this.scrollBy(-this.bodyCapacity) + else if (matchesTuiKey(data, TuiKey.pageDown)) this.scrollBy(this.bodyCapacity) + else if (matchesTuiKey(data, TuiKey.home)) this.scrollTo(0) + else if (matchesTuiKey(data, TuiKey.end)) this.scrollTo(this.maxScrollOffset) } private scrollBy(delta: number): void { diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 480b2bd07e..076b3a0302 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -4,8 +4,12 @@ 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, TuiOverlayRequest, TuiTheme } from '@deepseek-ai/dsh-tui' +import { + tuiVisibleWidth, + type TuiOverlayHost, + type TuiOverlayRequest, + type TuiTheme, +} from '@deepseek-ai/dsh-tui' import { acknowledgeTuiFirstRunWelcome, apply, @@ -135,7 +139,7 @@ describe('TUI first-run welcome composition', () => { const lines = component.render(renderWidth) expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) - expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true) + expect(lines.every(line => tuiVisibleWidth(line) <= renderWidth)).toBe(true) expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) @@ -166,7 +170,7 @@ describe('TUI first-run welcome composition', () => { 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) + expect(lines.every(line => tuiVisibleWidth(line) <= 6)).toBe(true) }) it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8eed1dab5d..28f082a0ec 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2336,7 +2336,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:248`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index d1a1bbb3c5..f327292d41 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/tui/README.md -README.md: 86e54ab76d07e32fad93965fcbb585d2b5fdfe06 -README.zh.md: a8d072a82c9cff8db7cfe436957a9c1391fb8d8f +README.md: c1ab57410469d649fd589c5038903324c60343cb +README.zh.md: 7c92136398f7a84213712d06f843a29afd8f1277 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 86e54ab76d..c1ab574104 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p 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. +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. The package exports semantic-key, ANSI-wrap, truncation, and visible-width primitives for extension components, keeping the underlying renderer dependency inside `dsh-tui`. 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 in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. 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. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index a8d072a82c..7c92136398 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 本包(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)持有该边界和未采用的替代方案。 +终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。本包还为扩展组件导出语义按键、ANSI 换行、截断和可见宽度原语,使底层 renderer 依赖始终留在 `dsh-tui` 内。插件 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` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 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 消息——不渲染任何内容。 diff --git a/packages/ui/tui/src/extension/primitives.ts b/packages/ui/tui/src/extension/primitives.ts new file mode 100644 index 0000000000..bb4fba8012 --- /dev/null +++ b/packages/ui/tui/src/extension/primitives.ts @@ -0,0 +1,59 @@ +/** + * Terminal-safe primitives for components mounted through the TUI extension service. + * + * Extensions use these wrappers instead of importing the underlying renderer, so + * `ctx.tui` remains the owner of key decoding, ANSI wrapping, and column width. + * @module @deepseek-ai/dsh-tui/extension-primitives + */ + +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type KeyId, +} from '@earendil-works/pi-tui' + +/** Key identifiers accepted by TUI extension components. */ +export const TuiKey = Key + +/** + * Test whether terminal input matches one semantic key. + * @param data - Raw terminal input delivered to the component. + * @param key - Semantic key identifier to match. + * @returns Whether the input encodes the requested key. + */ +export function matchesTuiKey(data: string, key: KeyId): boolean { + return matchesKey(data, key) +} + +/** + * Measure terminal columns after ignoring ANSI control sequences. + * @param value - Styled or plain terminal text. + * @returns Visible terminal-column width. + */ +export function tuiVisibleWidth(value: string): number { + return visibleWidth(value) +} + +/** + * Wrap styled terminal text without splitting ANSI sequences. + * @param value - Styled or plain terminal text. + * @param width - Maximum visible columns per line. + * @returns Wrapped lines preserving active ANSI styling. + */ +export function wrapTuiText(value: string, width: number): string[] { + return wrapTextWithAnsi(value, width) +} + +/** + * Truncate styled terminal text to a visible-column limit. + * @param value - Styled or plain terminal text. + * @param width - Maximum visible columns. + * @param ellipsis - Suffix used when truncation occurs. + * @returns Text whose visible width does not exceed the limit. + */ +export function truncateTuiText(value: string, width: number, ellipsis = ''): string { + return truncateToWidth(value, width, ellipsis) +} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 8448d79f8e..16f7bd9938 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -141,6 +141,13 @@ import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' export { TuiPromptService } from './prompt.ts' export { renderSkillInvocation } from './chat/skill-invocation.ts' +export { + matchesTuiKey, + truncateTuiText, + TuiKey, + tuiVisibleWidth, + wrapTuiText, +} from './extension/primitives.ts' export type { TuiResumeHost, TuiRuntime } from './runtime.ts' export { resolveTuiConfig, diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index 15bfee33f6..eb3103f5da 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -17,6 +17,13 @@ import { TuiOverlayManager, type TuiOverlayDriver, } from '../src/extension/overlay-manager.ts' +import { + matchesTuiKey, + truncateTuiText, + TuiKey, + tuiVisibleWidth, + wrapTuiText, +} from '../src/index.ts' const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, @@ -30,6 +37,17 @@ const theme: TuiTheme = Object.freeze({ bold: (value: string) => `bold:${value}`, }) +describe('TUI extension terminal primitives', () => { + it('owns semantic keys, ANSI-safe wrapping, truncation, and visible width', () => { + expect(matchesTuiKey('\r', TuiKey.enter)).toBe(true) + expect(tuiVisibleWidth('\x1b[34m鲸鱼\x1b[39m')).toBe(4) + const truncated = truncateTuiText('鲸鱼欢迎', 6) + expect(truncated).toContain('鲸鱼欢') + expect(tuiVisibleWidth(truncated)).toBe(6) + expect(wrapTuiText('\x1b[34m鲸鱼欢迎\x1b[39m', 4)).toHaveLength(2) + }) +}) + interface ShownOverlay { component: Component options: TuiOverlayOptions | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 909b380b4f..3f00cb4866 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,9 +435,6 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context - '@earendil-works/pi-tui': - specifier: 0.80.7 - version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) commander: specifier: ^15.0.0 version: 15.0.0 From ac19ba2da334d3ab8eed8b7dc10fa781469c758e Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 13:28:07 +0800 Subject: [PATCH 09/16] =?UTF-8?q?copy(tui):=20use=20=E7=B2=97=E7=B3=99=20i?= =?UTF-8?q?n=20first-run=20welcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cli/src/tui-first-run-welcome-copy.ts | 4 ++-- .../tests/tui-first-run-snapshots/120-columns.expected.txt | 2 +- .../tests/tui-first-run-snapshots/160-columns.expected.txt | 2 +- .../cli/tests/tui-first-run-snapshots/60-columns.expected.txt | 2 +- .../cli/tests/tui-first-run-snapshots/80-columns.expected.txt | 2 +- apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts index 7999b26e64..b50b2d9688 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -7,7 +7,7 @@ */ /** Copy version persisted after the user explicitly continues. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 1 +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 2 /** Locale-shaped text rendered by the first-run welcome overlay. */ export interface TuiFirstRunWelcomeNoticeCopy { @@ -34,7 +34,7 @@ export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ title: 'DeepSeek Harness', paragraphs: Object.freeze([ '感谢您愿意拨冗试用 DeepSeek Harness。', - '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。', + '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。', '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ]), 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 index c279f39afd..afa94c0348 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -18,7 +18,7 @@ overlay 120x30 rows=20 style 0-0 dim style 4-43 fg=blue style 119-119 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" style 0-0 dim style 4-42 fg=blue style 119-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 index 5b6b903502..ef1af0d547 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -18,7 +18,7 @@ overlay 160x30 rows=20 style 0-0 dim style 4-43 fg=blue style 159-159 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" style 0-0 dim style 4-42 fg=blue style 159-159 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 index f3d6bd6390..bdf12926be 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -48,7 +48,7 @@ overlay 60x30 rows=27 13| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" style 0-0 dim style 59-59 dim -14| "│ 难免粗粝。 │" +14| "│ 难免粗糙。 │" style 0-0 dim style 59-59 dim 15| "│ │" 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 index 500c00efbc..67f4ceffad 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -57,7 +57,7 @@ overlay 80x30 rows=27 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" style 0-0 dim style 79-79 dim 17| "│ │" diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 076b3a0302..1cdea4efbb 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -124,7 +124,7 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('c75e395999f572ee231688ef70d5b7f553de3809b57ce8160b4406bd7650f2ec') + .toBe('78d30473a7e3d7afe3157c2f685fd5fd6c94eb9d9d09ac726a36e922c1ff8725') }) it.each([ From e73fa2ec9f5cf38c0d408691925ecc462fb16781 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 13:34:55 +0800 Subject: [PATCH 10/16] copy(tui): refine first-run welcome message --- apps/cli/src/tui-first-run-welcome-copy.ts | 12 ++++++------ .../tui-first-run-snapshots/120-columns.expected.txt | 10 +++++----- .../tui-first-run-snapshots/160-columns.expected.txt | 8 ++++---- .../60-columns-low-height.expected.txt | 2 +- .../tui-first-run-snapshots/60-columns.expected.txt | 8 ++++---- .../tui-first-run-snapshots/80-columns.expected.txt | 4 ++-- apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts index b50b2d9688..ae5ebd8ce3 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -34,9 +34,9 @@ export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ title: 'DeepSeek Harness', paragraphs: Object.freeze([ '感谢您愿意拨冗试用 DeepSeek Harness。', - '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。', - '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + '目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', + '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', + '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ]), continueLabel: '继续', scrollHint: '↑/↓ 滚动', @@ -47,9 +47,9 @@ export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ title: 'DeepSeek Harness', paragraphs: Object.freeze([ 'Thank you for taking the time to try DeepSeek Harness.', - 'This release is still in internal testing. Some features remain unfinished, and parts of the experience will inevitably feel rough.', - '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems uncovered in real use may lead us to re-examine, or even overturn, existing designs.', - 'We especially want to hear about failures, confusion, and friction. If DeepSeek Harness did not help—or made your work harder—please tell us in the company WeChat group. Every piece of feedback helps us refine it.', + 'This release is still in internal testing. Features remain unfinished, and the experience will inevitably feel somewhat rough.', + '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may lead us to re-examine, or even overturn, existing designs.', + 'We especially want to hear about moments of failure, confusion, and friction. If you have any feedback or suggestions, please leave a message in the company WeChat group and let us know. Every piece of feedback helps us refine it.', ]), continueLabel: 'Continue', scrollHint: '↑/↓ Scroll', 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 index afa94c0348..fee357976f 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -18,7 +18,7 @@ overlay 120x30 rows=20 style 0-0 dim style 4-43 fg=blue style 119-119 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-42 fg=blue style 119-119 dim @@ -35,7 +35,7 @@ overlay 120x30 rows=20 style 0-0 dim style 7-39 fg=blue style 119-119 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │" style 0-0 dim style 7-39 fg=blue style 119-119 dim @@ -47,15 +47,15 @@ overlay 120x30 rows=20 style 0-0 dim style 8-38 fg=blue style 119-119 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ ,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 好。 │" style 0-0 dim style 8-39 fg=blue style 119-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 index ef1af0d547..d2d14a2360 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -18,7 +18,7 @@ overlay 160x30 rows=20 style 0-0 dim style 4-43 fg=blue style 159-159 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-42 fg=blue style 159-159 dim @@ -35,7 +35,7 @@ overlay 160x30 rows=20 style 0-0 dim style 7-39 fg=blue style 159-159 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │" style 0-0 dim style 7-39 fg=blue style 159-159 dim @@ -47,11 +47,11 @@ overlay 160x30 rows=20 style 0-0 dim style 8-38 fg=blue style 159-159 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 9-38 fg=blue style 159-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 index d47dfcac84..7271f27e79 100644 --- 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 @@ -14,7 +14,7 @@ overlay 60x12 rows=10 4| "│ │" style 0-0 dim style 59-59 dim -5| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" +5| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" style 0-0 dim style 59-59 dim 6| "├──────────────────────────────────────────────────────────┤" 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 index bdf12926be..3737b99fd6 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -45,10 +45,10 @@ overlay 60x30 rows=27 12| "│ │" style 0-0 dim style 59-59 dim -13| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" +13| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" style 0-0 dim style 59-59 dim -14| "│ 难免粗糙。 │" +14| "│ 粗糙。 │" style 0-0 dim style 59-59 dim 15| "│ │" @@ -64,7 +64,7 @@ overlay 60x30 rows=27 18| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" style 0-0 dim style 59-59 dim -19| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" +19| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │" style 0-0 dim style 59-59 dim 20| "│ 设计。 │" @@ -73,7 +73,7 @@ overlay 60x30 rows=27 21| "│ │" style 0-0 dim style 59-59 dim -22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" +22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任 │" style 0-0 dim style 59-59 dim 23| "├──────────────────────────────────────────────────────────┤" 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 index 67f4ceffad..bfab89be3f 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -57,7 +57,7 @@ overlay 80x30 rows=27 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" +16| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 79-79 dim 17| "│ │" @@ -70,7 +70,7 @@ overlay 80x30 rows=27 19| "│ │" style 0-0 dim style 79-79 dim -20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" +20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │" style 0-0 dim style 79-79 dim 21| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 1cdea4efbb..245db27bec 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -124,7 +124,7 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('78d30473a7e3d7afe3157c2f685fd5fd6c94eb9d9d09ac726a36e922c1ff8725') + .toBe('e1b8ea95d9f8af276c4b2b76fff23fa068bdad61b5d4a10300600ed47572900d') }) it.each([ From dbb87f02bf78f53a362c5a919a9dc03c4b36eb12 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 15:15:14 +0800 Subject: [PATCH 11/16] copy(tui): disclose Session Log upload in Chinese welcome --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +- ...6-07-30-versioned-tui-first-run-welcome.md | 6 +- ...7-30-versioned-tui-first-run-welcome.zh.md | 6 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/tui-first-run-welcome-copy.ts | 51 +++++------- apps/cli/src/tui-first-run-welcome.ts | 4 +- .../120-columns.expected.txt | 30 +++---- .../160-columns.expected.txt | 20 ++--- .../60-columns-low-height.expected.txt | 6 +- .../60-columns.expected.txt | 81 ++++++------------ .../80-columns.expected.txt | 83 +++++++++---------- apps/cli/tests/tui-first-run-welcome.spec.ts | 40 +++++++-- apps/cli/tests/tui-keyless-smoke.e2e.ts | 29 ++++--- 15 files changed, 173 insertions(+), 195 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index c927167d68..cfc68dc8f1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/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: 71790b53ec5816a806952a1e9fcf2079e50af8be -2026-07-30-versioned-tui-first-run-welcome.zh.md: 132899b9485cd784344e54aed0fc933bc50a57bc +2026-07-30-versioned-tui-first-run-welcome.md: d78e7a172198de2c61579dc5faa37a4c9f50fb58 +2026-07-30-versioned-tui-first-run-welcome.zh.md: 1d6a3da63938f6de3dfc9b85ef0548e360de9b3e diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index 71790b53ec..d78e7a1721 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -6,7 +6,7 @@ English | [中文](2026-07-30-versioned-tui-first-run-welcome.zh.md) ## Problem -The shipped `dsh` terminal starts directly in the editor and gives first-time internal testers no durable orientation about the product's maturity or feedback channel. The existing one-line `welcome` banner subtitle cannot carry the supplied four-paragraph notice without crowding the normal session header, and putting onboarding in the session log would create a user turn or model-visible context that is unrelated to the user's work. +The shipped `dsh` terminal starts directly in the editor and gives first-time internal testers no durable orientation about the product's maturity or feedback channel. The existing one-line `welcome` banner subtitle cannot carry the supplied notice without crowding the normal session header, and putting onboarding in the session log would create a user turn or model-visible context that is unrelated to the user's work. The notice also needs a recognizable DeepSeek composition without copying another product's startup art or maintaining a hand-drawn approximation that drifts from the official mark. @@ -18,7 +18,7 @@ The marker is launcher state rather than session persistence because eligibility The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. -The overlay is centered and consumes the available terminal width, while its height follows actual content and treats 90% of the viewport only as an upper bound. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. +The overlay is centered and consumes the available terminal width, while its height follows actual content and treats 90% of the viewport only as an upper bound. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. Every locale uses the same centrally owned Chinese copy, and the quotation is promoted to its own visual paragraph without changing that string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. ## Verification @@ -38,6 +38,6 @@ Focused unit coverage pins the supplied SVG and Chinese copy hashes, version bum ## Consequences -Each Harness home receives the notice once per copy version, only after a successful Enter acknowledgement. Maintainers can edit all bilingual wording and the version in one small owner file, and can update the official SVG and derived static rasters in their separate visual owner without chasing snapshots for full prose copies. +Each Harness home receives the notice once per copy version, only after a successful Enter acknowledgement. Maintainers can edit the all-locale Chinese wording and version in one small owner file, and can update the official SVG and derived static rasters in their separate visual owner without chasing snapshots for full prose copies. The terminal cannot display SVG vectors directly, so its faithful representation is resolution-bounded. Smaller tiers preserve the sampled silhouette but necessarily lose fine detail; low-height terminals prefer readable prose and an always-reachable action over brand art. The marker format is intentionally one-file-per-version during the pre-release period; old markers are harmless and no compatibility reader is required. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index 132899b948..1d6a3da639 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -已交付的 `dsh` 终端会直接进入编辑器,没有为首次使用的内部测试者提供持久的产品成熟度说明或反馈渠道指引。现有单行 `welcome` banner 副标题无法容纳指定的四段通知,否则会挤占正常的会话 header;如果把 onboarding 写入会话日志,还会创建一个与用户工作无关的用户轮次或模型可见上下文。 +已交付的 `dsh` 终端会直接进入编辑器,没有为首次使用的内部测试者提供持久的产品成熟度说明或反馈渠道指引。现有单行 `welcome` banner 副标题无法容纳指定通知,否则会挤占正常的会话 header;如果把 onboarding 写入会话日志,还会创建一个与用户工作无关的用户轮次或模型可见上下文。 该通知还需要形成具有辨识度的 DeepSeek 视觉构图,同时不能复制其他产品的启动图形,也不能维护一份会偏离官方标志的手绘近似图。 @@ -18,7 +18,7 @@ Status: implemented 指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 -浮层居中显示,并使用可用的终端宽度;高度则跟随实际内容,仅将 viewport 的 90% 作为上限。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 +浮层居中显示,并使用可用的终端宽度;高度则跟随实际内容,仅将 viewport 的 90% 作为上限。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。所有 locale 共用同一份集中持有的中文文案,引用内容会提升为独立的视觉段落,但不会改变该字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 ## 验证 @@ -38,6 +38,6 @@ Status: implemented ## 后果 -每个 Harness 主目录在每个文案版本中都会收到一次通知,且仅在用户成功按 Enter 确认后停止展示。维护者可以在一个小型 owner 文件中编辑所有双语文案和版本,也可以在独立的视觉 owner 中更新官方 SVG 与衍生静态栅格图,无需到各个快照中查找完整正文副本。 +每个 Harness 主目录在每个文案版本中都会收到一次通知,且仅在用户成功按 Enter 确认后停止展示。维护者可以在一个小型 owner 文件中编辑所有 locale 共用的中文文案和版本,也可以在独立的视觉 owner 中更新官方 SVG 与衍生静态栅格图,无需到各个快照中查找完整正文副本。 终端无法直接显示 SVG 矢量图,因此其忠实呈现受分辨率限制。较小分级会保留取样后的轮廓,但必然损失精细细节;低高度终端优先保障正文可读和操作始终可达,而非展示品牌图形。在预发布阶段,标记格式有意采用每个版本一个文件;旧标记无害,也无需兼容性读取器。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c5a38937d2..fb3b8af50f 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: e01989d18d7a1567ea0d62628d207bdebd8e27e1 -README.zh.md: 4df2b0911059c24baa5f6bec315e7fdf523213f7 +README.md: 2f7238b3c05f6b76288c5e26b14efd2e64485677 +README.zh.md: af4004be225307fc3863bf6f227ef95f7ca0c14f diff --git a/apps/cli/README.md b/apps/cli/README.md index e01989d18d..2f7238b3c0 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -12,7 +12,7 @@ The TUI surface: - 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`. -- 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, bilingual copy, and notice version are static local owners; the overlay never writes a session event or model context. +- 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. `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. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 4df2b09110..af4004be22 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -12,7 +12,7 @@ TUI 界面: - 将 **调用目录** 视为 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`。 -- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、双语文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 +- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 `dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts index ae5ebd8ce3..26ddbd17c2 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -1,5 +1,5 @@ /** - * Centrally owned version and bilingual copy for the shipped TUI first-run notice. + * 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. @@ -7,7 +7,7 @@ */ /** Copy version persisted after the user explicitly continues. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 2 +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 3 /** Locale-shaped text rendered by the first-run welcome overlay. */ export interface TuiFirstRunWelcomeNoticeCopy { @@ -25,37 +25,24 @@ export interface TuiFirstRunWelcomeNoticeCopy { readonly saveError: string } -/** - * Complete notice copy. The shipped TUI currently presents the supplied - * Simplified Chinese locale; English remains its reviewed locale counterpart. - */ +/** 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;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + ]), + 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': Object.freeze({ - title: 'DeepSeek Harness', - paragraphs: Object.freeze([ - '感谢您愿意拨冗试用 DeepSeek Harness。', - '目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', - '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', - ]), - continueLabel: '继续', - scrollHint: '↑/↓ 滚动', - saving: '正在保存确认…', - saveError: '无法保存确认,请按 Enter 重试。', - }), - en: Object.freeze({ - title: 'DeepSeek Harness', - paragraphs: Object.freeze([ - 'Thank you for taking the time to try DeepSeek Harness.', - 'This release is still in internal testing. Features remain unfinished, and the experience will inevitably feel somewhat rough.', - '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may lead us to re-examine, or even overturn, existing designs.', - 'We especially want to hear about moments of failure, confusion, and friction. If you have any feedback or suggestions, please leave a message in the company WeChat group and let us know. Every piece of feedback helps us refine it.', - ]), - continueLabel: 'Continue', - scrollHint: '↑/↓ Scroll', - saving: 'Saving acknowledgement…', - saveError: 'Could not save the acknowledgement. Press Enter to retry.', - }), + 'zh-CN': TUI_FIRST_RUN_WELCOME_CHINESE_COPY, + en: TUI_FIRST_RUN_WELCOME_CHINESE_COPY, }) /** Locale presented by the shipped first-run notice. */ diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index 8b1bf7e7ed..1b280f84d2 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -169,8 +169,8 @@ export function tuiFirstRunWelcomeArtTier( viewportRows: number, ): TuiFirstRunWelcomeArtTier | undefined { if (innerWidth >= 96 && viewportRows >= 23) return 'full' - if (innerWidth >= 64 && viewportRows >= 18) return 'compact' - if (innerWidth >= 48 && viewportRows >= 14) return 'minimal' + if (innerWidth >= 80 && viewportRows >= 34) return 'compact' + if (innerWidth >= 64 && viewportRows >= 14) return 'minimal' return undefined } 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 index fee357976f..2bc2914a52 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -10,52 +10,52 @@ overlay 120x30 rows=20 style 0-0 dim style 9-38 fg=blue style 119-119 dim -3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能 │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -4| "│ ▄███████████████████▄ ████████████▀ │" +4| "│ ▄███████████████████▄ ████████████▀ 仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ │" style 0-0 dim style 4-42 fg=blue style 119-119 dim -6| "│ ▄███▀█████████████████████▄ ████▀▀ │" +6| "│ ▄███▀█████████████████████▄ ████▀▀ “如切如磋,如琢如磨。” │" style 0-0 dim style 6-41 fg=blue - style 119-119 dim -7| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 7-40 fg=blue style 49-70 bold style 119-119 dim -8| "│ ███ ▀███████▀█ ▀███████ │" +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| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 7-39 fg=blue style 119-119 dim -10| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" +10| "│ ▀███ ▀██████████████ │" style 0-0 dim style 8-39 fg=blue style 119-119 dim -11| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 │" style 0-0 dim style 8-38 fg=blue style 119-119 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ ,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ 好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 磨得更好。 │" style 0-0 dim style 8-39 fg=blue style 119-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 index d2d14a2360..91b6610600 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -10,7 +10,7 @@ overlay 160x30 rows=20 style 0-0 dim style 9-38 fg=blue style 159-159 dim -3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-43 fg=blue style 159-159 dim @@ -18,40 +18,40 @@ overlay 160x30 rows=20 style 0-0 dim style 4-43 fg=blue style 159-159 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" +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| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" +7| "│ ███ ▀▀█████████▀▀▀█████████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │" style 0-0 dim style 7-40 fg=blue - style 49-70 bold style 159-159 dim -8| "│ ███ ▀███████▀█ ▀███████ │" +8| "│ ███ ▀███████▀█ ▀███████ 有的设计。 │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -10| "│ ▀███ ▀██████████████ 有的设计。 │" +10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以【关闭方式待补 │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -11| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ 充】。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 8-38 fg=blue style 159-159 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 馈,都会帮助我们把它打磨得更好。 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ │" style 0-0 dim style 9-38 fg=blue style 159-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 index 7271f27e79..bdf8a46a6f 100644 --- 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 @@ -8,13 +8,13 @@ overlay 60x12 rows=10 2| "│ │" style 0-0 dim style 59-59 dim -3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │" style 0-0 dim style 59-59 dim -4| "│ │" +4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 59-59 dim -5| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" +5| "│ │" style 0-0 dim style 59-59 dim 6| "├──────────────────────────────────────────────────────────┤" 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 index 3737b99fd6..daa9bbfc67 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -1,90 +1,61 @@ -overlay 60x30 rows=27 +overlay 60x30 rows=20 0| "╭──────────────────────────────────────────────────────────╮" style 0-59 dim -1| "│ ▄▄▄▄▄▄ ▄▄ │" - style 0-0 dim - style 23-36 fg=blue - style 59-59 dim -2| "│ ▄████████▄ ▀████▀ │" - style 0-0 dim - style 21-38 fg=blue - style 59-59 dim -3| "│ █▀▀▀▀███████▄██▀ │" - style 0-0 dim - style 22-37 fg=blue - style 59-59 dim -4| "│ █▄ ▀███ ▀███ │" - style 0-0 dim - style 22-36 fg=blue - style 59-59 dim -5| "│ ▀█▄ ▀█████ │" - style 0-0 dim - style 23-36 fg=blue - style 59-59 dim -6| "│ ▀█▄▄ █▄▄▀███▄ │" - style 0-0 dim - style 23-36 fg=blue - style 59-59 dim -7| "│ ▀▀▀▀▀▀ │" - style 0-0 dim - style 25-34 fg=blue - style 59-59 dim -8| "│ │" - style 0-0 dim - style 59-59 dim -9| "│ DeepSeek Harness │" +1| "│ DeepSeek Harness │" style 0-0 dim style 22-37 fg=blue bold style 59-59 dim -10| "│ │" +2| "│ │" style 0-0 dim style 59-59 dim -11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │" style 0-0 dim style 59-59 dim -12| "│ │" +4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 59-59 dim -13| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" +5| "│ │" style 0-0 dim style 59-59 dim -14| "│ 粗糙。 │" - style 0-0 dim - style 59-59 dim -15| "│ │" - style 0-0 dim - style 59-59 dim -16| "│ “如切如磋,如琢如磨。” │" +6| "│ “如切如磋,如琢如磨。” │" style 0-0 dim style 2-23 bold style 59-59 dim -17| "│ │" +7| "│ │" style 0-0 dim style 59-59 dim -18| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" +8| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" style 0-0 dim style 59-59 dim -19| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │" +9| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │" style 0-0 dim style 59-59 dim -20| "│ 设计。 │" +10| "│ 设计。 │" style 0-0 dim style 59-59 dim -21| "│ │" +11| "│ │" style 0-0 dim style 59-59 dim -22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任 │" +12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │" style 0-0 dim style 59-59 dim -23| "├──────────────────────────────────────────────────────────┤" +13| "│ 会上传所有 Session Log;如需关闭,可以【关闭方式待补充】 │" + style 0-0 dim + style 59-59 dim +14| "│ 。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉 │" + style 0-0 dim + style 59-59 dim +15| "│ 我们。每一条反馈,都会帮助我们把它打磨得更好。 │" + style 0-0 dim + style 59-59 dim +16| "├──────────────────────────────────────────────────────────┤" style 0-59 dim -24| "│ Enter 继续 │" +17| "│ Enter 继续 │" style 0-0 dim style 24-34 fg=bright-magenta bold style 59-59 dim -25| "│ ↑/↓ 滚动 ↓ │" +18| "│ │" style 0-0 dim - style 24-35 dim style 59-59 dim -26| "╰──────────────────────────────────────────────────────────╯" +19| "╰──────────────────────────────────────────────────────────╯" 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 index bfab89be3f..09fb756633 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -1,93 +1,86 @@ -overlay 80x30 rows=27 +overlay 80x30 rows=26 0| "╭──────────────────────────────────────────────────────────────────────────────╮" style 0-79 dim -1| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" +1| "│ ▄▄▄▄▄▄ ▄▄ │" style 0-0 dim - style 26-53 fg=blue + style 33-46 fg=blue style 79-79 dim -2| "│ ▄███████████▄▄ ███▄▄████ │" +2| "│ ▄████████▄ ▀████▀ │" style 0-0 dim - style 26-53 fg=blue + style 31-48 fg=blue style 79-79 dim -3| "│ ████████████████▄ ▀██████▀ │" +3| "│ █▀▀▀▀███████▄██▀ │" style 0-0 dim - style 26-52 fg=blue + style 32-47 fg=blue style 79-79 dim -4| "│ ██▀▀▀▀▀████████████▄▄██▀ │" +4| "│ █▄ ▀███ ▀███ │" style 0-0 dim - style 28-51 fg=blue + style 32-46 fg=blue style 79-79 dim -5| "│ ██ ▀█████▄ ▀█████ │" +5| "│ ▀█▄ ▀█████ │" style 0-0 dim - style 28-50 fg=blue + style 33-46 fg=blue style 79-79 dim -6| "│ ██▄ ▀████▄ ▄████ │" +6| "│ ▀█▄▄ █▄▄▀███▄ │" style 0-0 dim - style 28-50 fg=blue + style 33-46 fg=blue style 79-79 dim -7| "│ ██▄ ████████▀ │" +7| "│ ▀▀▀▀▀▀ │" style 0-0 dim - style 29-50 fg=blue + style 35-44 fg=blue style 79-79 dim -8| "│ ██▄ ▄▄ ▀█████▀ │" - style 0-0 dim - style 29-49 fg=blue - style 79-79 dim -9| "│ ▀███▄▄▄███▄ ████▄▄ │" - style 0-0 dim - style 29-50 fg=blue - style 79-79 dim -10| "│ ▀▀▀███████▀▀ │" - style 0-0 dim - style 31-47 fg=blue - style 79-79 dim -11| "│ │" +8| "│ │" style 0-0 dim style 79-79 dim -12| "│ DeepSeek Harness │" +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| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +14| "│ “如切如磋,如琢如磨。” │" style 0-0 dim + style 2-23 bold style 79-79 dim 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" +16| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │" style 0-0 dim style 79-79 dim -17| "│ │" +17| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 79-79 dim -18| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 79-79 dim -19| "│ │" +18| "│ │" style 0-0 dim style 79-79 dim -20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │" +19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │" style 0-0 dim style 79-79 dim -21| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" +20| "│ Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈与建议,请在企 │" style 0-0 dim style 79-79 dim -22| "│ │" +21| "│ 业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 79-79 dim -23| "├──────────────────────────────────────────────────────────────────────────────┤" +22| "├──────────────────────────────────────────────────────────────────────────────┤" style 0-79 dim -24| "│ Enter 继续 │" +23| "│ Enter 继续 │" style 0-0 dim style 34-44 fg=bright-magenta bold style 79-79 dim -25| "│ ↑/↓ 滚动 ↓ │" +24| "│ │" style 0-0 dim - style 34-45 dim style 79-79 dim -26| "╰──────────────────────────────────────────────────────────────────────────────╯" +25| "╰──────────────────────────────────────────────────────────────────────────────╯" 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 index 245db27bec..0f040de8d1 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -60,6 +60,7 @@ function hostFixture(rows: number): { } 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 withoutWhitespace(value: string): string { @@ -124,15 +125,17 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('e1b8ea95d9f8af276c4b2b76fff23fa068bdad61b5d4a10300600ed47572900d') + .toBe('54389347f93109c7cb17baa4312ae55eaefe77cbbf2ffe3e7579a4538e9f5738') + expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy) }) it.each([ - { columns: 60, inner: 50, rows: 30, tier: 'minimal' }, - { columns: 80, inner: 68, rows: 30, tier: 'compact' }, + { 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 $tier composition at $columns columns without overdraw', ({ inner, rows, tier }) => { + ] 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 @@ -140,7 +143,17 @@ describe('TUI first-run welcome composition', () => { expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) expect(lines.every(line => tuiVisibleWidth(line) <= renderWidth)).toBe(true) - expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + if (tier === undefined) { + expect(lines.join('\n')).not.toMatch(/[▀▄█]/u) + } else { + expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + } + const rendered = lines.join('\n') + const placeholder = copy.paragraphs.at(-1)!.match(/【[^】]+】/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(placeholder.slice(0, 3)) + expect(rendered).toContain(placeholder.slice(-3)) expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) expect(lines.length).toBeGreaterThan(5) @@ -151,12 +164,12 @@ describe('TUI first-run welcome composition', () => { const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}) const initial = component.render(54).join('\n') expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined() - expect(initial).toContain(copy.paragraphs[0]) + 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(-10))) + 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']) { @@ -173,11 +186,20 @@ describe('TUI first-run welcome composition', () => { expect(lines.every(line => tuiVisibleWidth(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 => tuiVisibleWidth(line) <= 100)).toBe(true) + }) + 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.compact.ascii[0]!.trim()) + expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim()) expect(rendered).not.toMatch(/[▀▄█]/u) }) @@ -264,6 +286,6 @@ describe('TUI first-run welcome composition', () => { apply(ctx, { dshHome: home, asciiArt: true }) expect(request?.create(fixture.host).render(72).join('\n')) - .toContain(TUI_FIRST_RUN_WELCOME_WHALE.compact.ascii[0]!.trim()) + .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 index 6007ae69c4..7901a79555 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -172,6 +172,7 @@ function smoke(overrides: Partial & { } const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] +const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}。` /** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */ function overlaySnapshot(snapshot: string, columns: number, rows: number): string { @@ -220,11 +221,11 @@ const SELECT_PRO_MODEL = [ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it.each([ - { columns: 60, tier: 'minimal' }, - { columns: 80, tier: 'compact' }, + { columns: 60, tier: undefined }, + { columns: 80, tier: 'minimal' }, { columns: 120, tier: 'full' }, { columns: 160, tier: 'full' }, - ] as const)('renders and acknowledges the $tier first-run composition at $columns columns', async ({ columns, tier }) => { + ] 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)}-`, @@ -251,9 +252,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } }, }) - await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, columns, 30)) + await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, columns, 30)) .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) - expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + if (tier === undefined) { + expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) + } else { + expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + } expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) }, PTY_SMOKE_TEST_TIMEOUT_MS) @@ -267,7 +272,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { columns: 60, rows: 12, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, + { waitFor: firstRunOpeningSentence, send: '\x1b[F' }, { waitFor: `Enter ${firstRunCopy.continueLabel}`, occurrence: 2, @@ -276,10 +281,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { }, ], }) - await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) + 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(firstRunCopy.paragraphs[0]) + 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()) @@ -310,7 +315,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expectedExitCode: process.platform === 'win32' ? 0 : -15, actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }], }) - expect(second).not.toContain(firstRunCopy.paragraphs[0]) + expect(second).not.toContain(firstRunOpeningSentence) expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`) } finally { await rm(cwd, { recursive: true, force: true }) @@ -327,7 +332,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { configPath: scriptedConfigPath, showFirstRunWelcome: true, expectedExitCode: -15, - actions: [{ waitFor: firstRunCopy.paragraphs[0]!, signal: 'SIGTERM' }], + actions: [{ waitFor: firstRunOpeningSentence, signal: 'SIGTERM' }], inspect: async (workspace) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false) }, @@ -344,7 +349,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], }) - expect(next).toContain(firstRunCopy.paragraphs[0]) + expect(next).toContain(firstRunOpeningSentence) } finally { await rm(cwd, { recursive: true, force: true }) } @@ -557,7 +562,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' })) }, }) - expect(output).toContain(firstRunCopy.paragraphs[0]) + expect(output).toContain(firstRunOpeningSentence) expect(output).toContain('Resume selector design — DeepSeek Harness') }, PTY_SMOKE_TEST_TIMEOUT_MS) From 346d1e4c74852faddbb2565849d19e2ff8528718 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 17:47:07 +0800 Subject: [PATCH 12/16] fix(tui): address onboarding review feedback --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +- ...6-07-30-versioned-tui-first-run-welcome.md | 2 +- ...7-30-versioned-tui-first-run-welcome.zh.md | 2 +- apps/cli/package.json | 1 + .../tui-first-run-welcome-art.ts | 4 +- .../tui-first-run-welcome-copy.ts | 6 +- .../tui-first-run-welcome.ts | 92 ++++++++++------ apps/cli/src/tui.ts | 14 +-- apps/cli/tests/pty-harness.ts | 27 +---- .../120-columns.expected.txt | 6 +- .../160-columns.expected.txt | 6 +- .../60-columns.expected.txt | 19 ++-- .../80-columns.expected.txt | 17 +-- apps/cli/tests/tui-first-run-welcome.spec.ts | 101 ++++++++++++++---- apps/cli/tests/tui-keyless-smoke.e2e.ts | 35 +++--- docs/cordis-catalog/services.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/extension/primitives.ts | 59 ---------- packages/ui/tui/src/index.ts | 7 -- packages/ui/tui/tests/extension.spec.ts | 18 ---- pnpm-lock.yaml | 3 + 23 files changed, 212 insertions(+), 221 deletions(-) rename apps/cli/src/{ => tui-onboarding}/tui-first-run-welcome-art.ts (96%) rename apps/cli/src/{ => tui-onboarding}/tui-first-run-welcome-copy.ts (86%) rename apps/cli/src/{ => tui-onboarding}/tui-first-run-welcome.ts (78%) delete mode 100644 packages/ui/tui/src/extension/primitives.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index cfc68dc8f1..5a414af614 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/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: d78e7a172198de2c61579dc5faa37a4c9f50fb58 -2026-07-30-versioned-tui-first-run-welcome.zh.md: 1d6a3da63938f6de3dfc9b85ef0548e360de9b3e +2026-07-30-versioned-tui-first-run-welcome.md: 5270c239f0bccbf58e68364e195ff2175355a816 +2026-07-30-versioned-tui-first-run-welcome.zh.md: cd132fde5ff0601cef6cd3cd433fdd15dc05f7f0 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index d78e7a1721..5270c239f0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -12,7 +12,7 @@ The notice also needs a recognizable DeepSeek composition without copying anothe ## Decision -The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and every other non-navigation input leave the overlay open; disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. +The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and unrecognized input leave the overlay open; Ctrl+C and Ctrl+D use the normal exit path without acknowledging. Disposal waits for an acknowledgement already started by Enter, while disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Each Enter syncs a random same-directory file before atomically replacing the fixed marker; concurrent launches publish the same immutable fact, so same-value last-writer-wins replacement has no lost-update shape and needs no lock or dependency on the settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index 1d6a3da639..cd132fde5f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和其他所有非导航输入都会让浮层保持打开;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 +官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和无法识别的输入会让浮层保持打开;Ctrl+C 与 Ctrl+D 通过普通退出路径离开且不确认。资源释放会等待已经由 Enter 启动的确认任务;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。每次 Enter 都会先同步一个同目录随机文件,再以原子方式替换固定标记;并发启动发布的是同一个不可变事实,因此同值的最后写入者胜出不会丢失更新,也无需加锁或依赖设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 127f99a313..a5321ad77b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -121,6 +121,7 @@ "@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" diff --git a/apps/cli/src/tui-first-run-welcome-art.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts similarity index 96% rename from apps/cli/src/tui-first-run-welcome-art.ts rename to apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts index eac41d3e19..afaf56db3d 100644 --- a/apps/cli/src/tui-first-run-welcome-art.ts +++ b/apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts @@ -1,13 +1,13 @@ /** * Static terminal rasters derived from the official 24x24 DeepSeek icon. * - * Source: `../assets/deepseek-color.svg`, whose path data is copied exactly + * 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-first-run-welcome-art + * @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-art */ /** Responsive official-icon raster tier. */ diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts similarity index 86% rename from apps/cli/src/tui-first-run-welcome-copy.ts rename to apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts index 26ddbd17c2..60cec25c52 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts @@ -3,11 +3,11 @@ * * 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-first-run-welcome-copy + * @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 = 3 +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 4 /** Locale-shaped text rendered by the first-run welcome overlay. */ export interface TuiFirstRunWelcomeNoticeCopy { @@ -31,7 +31,7 @@ const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze { /** Render one visible-width-padded line inside the notice frame. */ function framed(content: string, innerWidth: number, host: TuiOverlayHost): string { - const clipped = truncateTuiText(content, innerWidth) - return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - tuiVisibleWidth(clipped)))} ${host.theme.dim('│')}` + 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 = truncateTuiText(content, width) - const remaining = Math.max(0, width - tuiVisibleWidth(clipped)) + const clipped = truncateToWidth(content, width, '') + const remaining = Math.max(0, width - visibleWidth(clipped)) return `${' '.repeat(Math.floor(remaining / 2))}${clipped}` } @@ -168,9 +177,10 @@ export function tuiFirstRunWelcomeArtTier( innerWidth: number, viewportRows: number, ): TuiFirstRunWelcomeArtTier | undefined { - if (innerWidth >= 96 && viewportRows >= 23) return 'full' - if (innerWidth >= 80 && viewportRows >= 34) return 'compact' - if (innerWidth >= 64 && viewportRows >= 14) return 'minimal' + 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 } @@ -187,11 +197,11 @@ function proseLines( if (quoteEnd > 0) { const quote = paragraph.slice(0, quoteEnd + 1) const remainder = paragraph.slice(quoteEnd + 1).trimStart() - lines.push(...wrapTuiText(host.theme.bold(host.theme.text(host.display(quote))), width)) + lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width)) lines.push('') - if (remainder !== '') lines.push(...wrapTuiText(host.theme.text(host.display(remainder)), width)) + if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width)) } else { - lines.push(...wrapTuiText(host.theme.text(host.display(paragraph)), width)) + lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width)) } } return lines @@ -221,6 +231,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { private readonly host: TuiOverlayHost, private readonly copy: TuiFirstRunWelcomeNoticeCopy, private readonly acknowledge: () => Promise, + private readonly exit: () => void, private readonly asciiArt = false, ) {} @@ -234,6 +245,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { 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 @@ -241,7 +253,8 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { if (tier === 'full') { fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt) const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3) - body = [centered(title, contentWidth), '', ...proseLines(this.copy, contentWidth, this.host)] + 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), ''] @@ -249,7 +262,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { } const compositionCapacity = Math.max(1, availableRows - 5) - const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length) + 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 @@ -271,12 +284,13 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { ? 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, visibleBody.length) }, (_, index) => { + : Array.from({ length: Math.max(fullArt.length, fullContent.length) }, (_, index) => { const art = fullArt[index] ?? '' - const line = visibleBody[index] ?? '' - const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - tuiVisibleWidth(art)))}` + const line = fullContent[index] ?? '' + const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` return `${left} ${line}` }) @@ -291,17 +305,21 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { } handleInput(data: string): void { - if (matchesTuiKey(data, TuiKey.enter)) { + 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 || matchesTuiKey(data, TuiKey.escape)) return - if (matchesTuiKey(data, TuiKey.up)) this.scrollBy(-1) - else if (matchesTuiKey(data, TuiKey.down)) this.scrollBy(1) - else if (matchesTuiKey(data, TuiKey.pageUp)) this.scrollBy(-this.bodyCapacity) - else if (matchesTuiKey(data, TuiKey.pageDown)) this.scrollBy(this.bodyCapacity) - else if (matchesTuiKey(data, TuiKey.home)) this.scrollTo(0) - else if (matchesTuiKey(data, TuiKey.end)) this.scrollTo(this.maxScrollOffset) + 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 { @@ -335,11 +353,23 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { */ 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, - () => acknowledgeTuiFirstRunWelcome(config.dshHome), + acknowledge, + () => { disposeRootAndExit(ctx, 0) }, config.asciiArt ?? false, ), options: { diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 019e658bc2..207e98e206 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -49,10 +49,10 @@ import { inject as tuiFirstRunWelcomeInject, name as tuiFirstRunWelcomeName, needsTuiFirstRunWelcomeAsciiArt, -} from './tui-first-run-welcome.ts' +} from './tui-onboarding/tui-first-run-welcome.ts' import { TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, -} from './tui-first-run-welcome-copy.ts' +} from './tui-onboarding/tui-first-run-welcome-copy.ts' const NAME = 'dsh' @@ -134,11 +134,6 @@ export async function runTui( process.exit(1) } installFailLoud(NAME) - const dshHome = resolveDshHome() - const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement( - dshHome, - TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, - ) // 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 @@ -149,6 +144,11 @@ export async function runTui( // 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 diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index 844de8cb0b..07361cf654 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -42,8 +42,6 @@ while time.monotonic() < deadline: if output.count(marker) < actions[action_index].get("occurrence", 1): break action = actions[action_index] - if action.get("delayMs", 0) > 0: - time.sleep(action["delayMs"] / 1000) if "signal" in action: os.kill(pid, getattr(signal, action["signal"])) elif "writeFile" in action: @@ -55,9 +53,6 @@ while time.monotonic() < deadline: os.write(fd, action["send"].encode()) else: os.write(fd, action["send"].encode()) - if "signalAfterMs" in action: - time.sleep(action["signalAfterMs"] / 1000) - os.kill(pid, getattr(signal, action.get("signalAfter", "SIGTERM"))) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -83,19 +78,13 @@ type TuiPtyAction = readonly waitFor: string readonly occurrence?: number readonly send: string - readonly delayMs?: number - /** Terminate the process this many milliseconds after sending input. */ - readonly signalAfterMs?: number - /** Signal used by {@link signalAfterMs}; defaults to `SIGTERM`. */ - readonly signalAfter?: 'SIGTERM' | 'SIGKILL' } - | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } + | { 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 - readonly delayMs?: number } /** Inputs for a keyless real-Loader TUI process smoke. */ @@ -212,19 +201,9 @@ async function runWindowsPtySmoke( mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, action.writeFile.content) const input = action.send - if (input !== undefined) { - if (action.delayMs === undefined) terminal.write(input) - else setTimeout(() => { terminal.write(input) }, action.delayMs) - } + if (input !== undefined) terminal.write(input) } else { - const send = (): void => { - terminal.write(action.send) - if (action.signalAfterMs !== undefined) { - setTimeout(() => { terminal.kill(action.signalAfter ?? 'SIGTERM') }, action.signalAfterMs) - } - } - if (action.delayMs === undefined) send() - else setTimeout(send, action.delayMs) + terminal.write(action.send) } actionIndex += 1 } 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 index 2bc2914a52..9a5051ceb3 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -47,15 +47,15 @@ overlay 120x30 rows=20 style 0-0 dim style 8-38 fg=blue style 119-119 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ ,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈, │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ 磨得更好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 都会帮助我们把它打磨得更好。 │" style 0-0 dim style 8-39 fg=blue style 119-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 index 91b6610600..a374d14da8 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -39,15 +39,15 @@ overlay 160x30 rows=20 style 0-0 dim style 7-39 fg=blue style 159-159 dim -10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以【关闭方式待补 │" +10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -11| "│ ▀███▄ ▀███████████▀ 充】。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +11| "│ ▀███▄ ▀███████████▀ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我 │" style 0-0 dim style 8-38 fg=blue style 159-159 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 们把它打磨得更好。 │" style 0-0 dim style 9-38 fg=blue style 159-159 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 index daa9bbfc67..ae53f4bfda 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -1,4 +1,4 @@ -overlay 60x30 rows=20 +overlay 60x30 rows=21 0| "╭──────────────────────────────────────────────────────────╮" style 0-59 dim 1| "│ DeepSeek Harness │" @@ -39,23 +39,26 @@ overlay 60x30 rows=20 12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │" style 0-0 dim style 59-59 dim -13| "│ 会上传所有 Session Log;如需关闭,可以【关闭方式待补充】 │" +13| "│ 会上传所有 Session Log;如需关闭,请设置环境变量 │" style 0-0 dim style 59-59 dim -14| "│ 。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉 │" +14| "│ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议, │" style 0-0 dim style 59-59 dim -15| "│ 我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +15| "│ 请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把 │" style 0-0 dim style 59-59 dim -16| "├──────────────────────────────────────────────────────────┤" +16| "│ 它打磨得更好。 │" + style 0-0 dim + style 59-59 dim +17| "├──────────────────────────────────────────────────────────┤" style 0-59 dim -17| "│ Enter 继续 │" +18| "│ Enter 继续 │" style 0-0 dim style 24-34 fg=bright-magenta bold style 59-59 dim -18| "│ │" +19| "│ │" style 0-0 dim style 59-59 dim -19| "╰──────────────────────────────────────────────────────────╯" +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 index 09fb756633..c3a8b5d496 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -1,4 +1,4 @@ -overlay 80x30 rows=26 +overlay 80x30 rows=27 0| "╭──────────────────────────────────────────────────────────────────────────────╮" style 0-79 dim 1| "│ ▄▄▄▄▄▄ ▄▄ │" @@ -67,20 +67,23 @@ overlay 80x30 rows=26 19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │" style 0-0 dim style 79-79 dim -20| "│ Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈与建议,请在企 │" +20| "│ Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反 │" style 0-0 dim style 79-79 dim -21| "│ 业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +21| "│ 馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │" style 0-0 dim style 79-79 dim -22| "├──────────────────────────────────────────────────────────────────────────────┤" +22| "│ 好。 │" + style 0-0 dim + style 79-79 dim +23| "├──────────────────────────────────────────────────────────────────────────────┤" style 0-79 dim -23| "│ Enter 继续 │" +24| "│ Enter 继续 │" style 0-0 dim style 34-44 fg=bright-magenta bold style 79-79 dim -24| "│ │" +25| "│ │" style 0-0 dim style 79-79 dim -25| "╰──────────────────────────────────────────────────────────────────────────────╯" +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 index 0f040de8d1..984ab15a03 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -4,8 +4,8 @@ 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 { - tuiVisibleWidth, type TuiOverlayHost, type TuiOverlayRequest, type TuiTheme, @@ -18,13 +18,19 @@ import { TuiFirstRunWelcomeComponent, tuiFirstRunWelcomeAcknowledgementPath, tuiFirstRunWelcomeArtTier, -} from '../src/tui-first-run-welcome.ts' +} 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-first-run-welcome-copy.ts' -import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-first-run-welcome-art.ts' +} 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, @@ -63,6 +69,10 @@ const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCA 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, '') } @@ -74,6 +84,7 @@ async function temporaryHome(prefix: string): Promise { } afterEach(async () => { + mockDisposeRootAndExit.mockClear() await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true }))) }) @@ -125,7 +136,7 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('54389347f93109c7cb17baa4312ae55eaefe77cbbf2ffe3e7579a4538e9f5738') + .toBe('99f9a828b4f083b28de21bf5e03f939c00238531e765db78911957c44c6e98da') expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy) }) @@ -137,31 +148,45 @@ describe('TUI first-run welcome composition', () => { { 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 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 => tuiVisibleWidth(line) <= renderWidth)).toBe(true) + 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(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(lines.join('\n')).toContain(artAnchor(tier)) } const rendered = lines.join('\n') - const placeholder = copy.paragraphs.at(-1)!.match(/【[^】]+】/u)![0] + 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(placeholder.slice(0, 3)) - expect(rendered).toContain(placeholder.slice(-3)) + 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 component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}) const initial = component.render(54).join('\n') expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined() expect(initial).toContain(openingSentence) @@ -181,32 +206,44 @@ describe('TUI first-run welcome composition', () => { 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 component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}, () => {}) const lines = component.render(2) - expect(lines.every(line => tuiVisibleWidth(line) <= 6)).toBe(true) + 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 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 => tuiVisibleWidth(line) <= 100)).toBe(true) + 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 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) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {}) component.render(72) component.handleInput('\x1b') @@ -219,11 +256,23 @@ describe('TUI first-run welcome composition', () => { 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) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {}) component.render(72) component.handleInput('\r') @@ -242,7 +291,7 @@ describe('TUI first-run welcome composition', () => { const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => { attempts += 1 if (attempts === 1) throw new Error('disk unavailable') - }) + }, () => {}) component.render(72) component.handleInput('\r') @@ -260,7 +309,12 @@ describe('TUI first-run welcome composition', () => { 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 @@ -279,10 +333,11 @@ describe('TUI first-run welcome composition', () => { 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 vi.waitFor(async () => { - expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) - }) + await disposePending?.() + expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) apply(ctx, { dshHome: home, asciiArt: true }) expect(request?.create(fixture.host).render(72).join('\n')) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 7901a79555..7c7ad10141 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -13,12 +13,12 @@ import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-termin import { acknowledgeTuiFirstRunWelcome, hasTuiFirstRunWelcomeAcknowledgement, -} from '../src/tui-first-run-welcome.ts' +} 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-first-run-welcome-copy.ts' -import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-first-run-welcome-art.ts' +} 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 @@ -174,6 +174,10 @@ function smoke(overrides: Partial & { 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[][] = [] @@ -231,15 +235,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -9, + expectedExitCode: 0, columns, rows: 30, actions: [ { waitFor: `Enter ${firstRunCopy.continueLabel}`, - send: '\r', - signalAfterMs: 2_000, - signalAfter: 'SIGKILL', + send: '\r\x03', }, ], inspect: async (cwd) => { @@ -257,7 +259,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { if (tier === undefined) { expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) } else { - expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(output).toContain(firstRunArtAnchor(tier)) } expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) }, PTY_SMOKE_TEST_TIMEOUT_MS) @@ -268,7 +270,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: 'dsh-tui-welcome-low-', configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: 0, columns: 60, rows: 12, actions: [ @@ -276,8 +278,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { { waitFor: `Enter ${firstRunCopy.continueLabel}`, occurrence: 2, - send: '\r', - signalAfterMs: 2_000, + send: '\r\x03', }, ], }) @@ -299,9 +300,9 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: 0, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, ], }) expect(first).toContain(firstRunCopy.title) @@ -344,9 +345,9 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: -15, + expectedExitCode: 0, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, ], }) expect(next).toContain(firstRunOpeningSentence) @@ -534,7 +535,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { binScript: dshBinScript, configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath], showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: 0, prepare: async (cwd) => { await seedResumeSession(cwd) const before = await readFile(logPath( @@ -546,7 +547,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { originalLineCount = before.split('\n').filter(Boolean).length }, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, ], inspect: async (cwd) => { const after = await readFile(logPath( diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5221ecdef0..fa1e4e768c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2336,7 +2336,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:248`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index f327292d41..d1a1bbb3c5 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/tui/README.md -README.md: c1ab57410469d649fd589c5038903324c60343cb -README.zh.md: 7c92136398f7a84213712d06f843a29afd8f1277 +README.md: 86e54ab76d07e32fad93965fcbb585d2b5fdfe06 +README.zh.md: a8d072a82c9cff8db7cfe436957a9c1391fb8d8f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index c1ab574104..86e54ab76d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p 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. The package exports semantic-key, ANSI-wrap, truncation, and visible-width primitives for extension components, keeping the underlying renderer dependency inside `dsh-tui`. 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. +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 in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. 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. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 7c92136398..a8d072a82c 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 本包(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 句柄。本包还为扩展组件导出语义按键、ANSI 换行、截断和可见宽度原语,使底层 renderer 依赖始终留在 `dsh-tui` 内。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.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` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 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 消息——不渲染任何内容。 diff --git a/packages/ui/tui/src/extension/primitives.ts b/packages/ui/tui/src/extension/primitives.ts deleted file mode 100644 index bb4fba8012..0000000000 --- a/packages/ui/tui/src/extension/primitives.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Terminal-safe primitives for components mounted through the TUI extension service. - * - * Extensions use these wrappers instead of importing the underlying renderer, so - * `ctx.tui` remains the owner of key decoding, ANSI wrapping, and column width. - * @module @deepseek-ai/dsh-tui/extension-primitives - */ - -import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, - type KeyId, -} from '@earendil-works/pi-tui' - -/** Key identifiers accepted by TUI extension components. */ -export const TuiKey = Key - -/** - * Test whether terminal input matches one semantic key. - * @param data - Raw terminal input delivered to the component. - * @param key - Semantic key identifier to match. - * @returns Whether the input encodes the requested key. - */ -export function matchesTuiKey(data: string, key: KeyId): boolean { - return matchesKey(data, key) -} - -/** - * Measure terminal columns after ignoring ANSI control sequences. - * @param value - Styled or plain terminal text. - * @returns Visible terminal-column width. - */ -export function tuiVisibleWidth(value: string): number { - return visibleWidth(value) -} - -/** - * Wrap styled terminal text without splitting ANSI sequences. - * @param value - Styled or plain terminal text. - * @param width - Maximum visible columns per line. - * @returns Wrapped lines preserving active ANSI styling. - */ -export function wrapTuiText(value: string, width: number): string[] { - return wrapTextWithAnsi(value, width) -} - -/** - * Truncate styled terminal text to a visible-column limit. - * @param value - Styled or plain terminal text. - * @param width - Maximum visible columns. - * @param ellipsis - Suffix used when truncation occurs. - * @returns Text whose visible width does not exceed the limit. - */ -export function truncateTuiText(value: string, width: number, ellipsis = ''): string { - return truncateToWidth(value, width, ellipsis) -} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 16f7bd9938..8448d79f8e 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -141,13 +141,6 @@ import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' export { TuiPromptService } from './prompt.ts' export { renderSkillInvocation } from './chat/skill-invocation.ts' -export { - matchesTuiKey, - truncateTuiText, - TuiKey, - tuiVisibleWidth, - wrapTuiText, -} from './extension/primitives.ts' export type { TuiResumeHost, TuiRuntime } from './runtime.ts' export { resolveTuiConfig, diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index eb3103f5da..15bfee33f6 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -17,13 +17,6 @@ import { TuiOverlayManager, type TuiOverlayDriver, } from '../src/extension/overlay-manager.ts' -import { - matchesTuiKey, - truncateTuiText, - TuiKey, - tuiVisibleWidth, - wrapTuiText, -} from '../src/index.ts' const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, @@ -37,17 +30,6 @@ const theme: TuiTheme = Object.freeze({ bold: (value: string) => `bold:${value}`, }) -describe('TUI extension terminal primitives', () => { - it('owns semantic keys, ANSI-safe wrapping, truncation, and visible width', () => { - expect(matchesTuiKey('\r', TuiKey.enter)).toBe(true) - expect(tuiVisibleWidth('\x1b[34m鲸鱼\x1b[39m')).toBe(4) - const truncated = truncateTuiText('鲸鱼欢迎', 6) - expect(truncated).toContain('鲸鱼欢') - expect(tuiVisibleWidth(truncated)).toBe(6) - expect(wrapTuiText('\x1b[34m鲸鱼欢迎\x1b[39m', 4)).toHaveLength(2) - }) -}) - interface ShownOverlay { component: Component options: TuiOverlayOptions | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fd27b86da..03bfb99195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,6 +435,9 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context + '@earendil-works/pi-tui': + specifier: 0.80.7 + version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) commander: specifier: ^15.0.0 version: 15.0.0 From f9dc4aa7028f4d7ff772ba8a4816286bb37bfc7c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 19:12:16 +0800 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20remove=20"=E6=8F=92=E8=AF=9D"=20b?= =?UTF-8?q?adge=20displayed=20in=20user=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 2 +- ...web-input-machine-and-slash-pipeline.zh.md | 2 +- ...eer-entry-or-interjection-chrome.i18n.yaml | 6 +++ ...i-no-steer-entry-or-interjection-chrome.md | 38 +++++++++++++ ...o-steer-entry-or-interjection-chrome.zh.md | 38 +++++++++++++ .../snapshots/steering/settled.expected.md | 2 +- apps/web/tests/steering.e2e.ts | 53 +++++++++---------- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.module.css | 10 ---- .../src/client/chat/MessageItem.tsx | 9 ++-- .../src/client/input/contract.ts | 8 +-- .../src/client/input/facade.ts | 17 +++--- .../ui-conversation/src/client/input/hub.ts | 6 +-- .../src/client/input/machine.ts | 17 +++--- .../ui-conversation/src/client/locales.ts | 2 - .../ui-conversation/src/client/service.ts | 10 ++-- .../src/client/skeleton/InputBar.tsx | 4 +- .../tests/apply-inject.spec.tsx | 10 ++-- .../tests/chat-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 8 +-- .../tests/input-machine.spec.ts | 40 +++++++------- .../tests/input-matrix.spec.tsx | 4 +- .../tests/input-scenarios.spec.tsx | 4 +- .../tests/service-orchestration.spec.ts | 12 ++--- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- 28 files changed, 191 insertions(+), 129 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md create mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 98423a8c7d..37b3413f41 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c +2026-07-25-web-input-machine-and-slash-pipeline.md: fbff722279b7f625a650602adce6ab6d21183076 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: adc3e22334f4115373aa3e48015518a033b26d91 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 92bb91c3e8..fbff722279 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -72,7 +72,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. -- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt {mode:'queue'|'steer'}`; backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. +- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 5bee6df52f..adc3e22334 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -72,7 +72,7 @@ occurrence 表与 chip 三投影: - 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 -- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt {mode:'queue'|'steer'}`;失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 +- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml new file mode 100644 index 0000000000..2059841653 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 6f397668106a6c74f327fc799327752c54824d8c +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: f127bd57006747465a1ece87406f5f97086b6a34 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md new file mode 100644 index 0000000000..6f39766810 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -0,0 +1,38 @@ +# Agent Note: Web UI drops steer entry and interjection chrome + +Status: implemented + +English | [中文](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md) + +## Problem + +Mid-turn steering is a host/agent-loop capability (`mode:'steer'`, durable `steering/message`). The Web product already locked the composer while a turn runs and never shipped a queue/steer menu, yet the client still threaded `'queue' | 'steer'` through the input machine, `conversation.send`, and locale keys, and rendered consumed steering as a badged 「插话」/「Interjection」 bubble. That left a half-built UI surface: an unused submit mode, a product label for a gesture users cannot perform, and e2e goldens that pinned chrome the product does not own. + +## Decision + +Keep host and runtime steering intact. Remove only the Web UI entry and chrome: + +- `InputMachine` / `SessionInput` / `InputActions.submit` / hub `defaultSink` are queue-only; they always call `session.prompt(..., 'queue')`. +- `ConversationService.send(text)` drops its mode argument and always queues. +- `MessageItem`'s `steering` arm still folds durable `steering/message` content into a plain right-aligned bubble (no badge, no user IconActions) so external/host steers stay visible on replay. +- Delete `message.steering` locale strings and the unused badge CSS. +- The web steering e2e still POSTs `mode:'steer'` over `/api/session.prompt` and asserts durable + model-visible obedience; it no longer expects interjection chrome. Update [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) fact lines to match. + +## Alternatives considered + +**Delete host steering entirely.** Out of scope; the user asked only for Web UI display and entry. Agent-loop drain, session events, and the wire mode remain load-bearing for ACP/TUI/automation. + +**Hide `steering/message` from the transcript.** Would lie on replay when an external client steers; rejected in favor of a plain bubble. + +**Keep the mode parameter but only ever pass `'queue'`.** Leaves dead API surface and tests that invent `'steer'` paths the composer cannot reach. + +## Consequences + +- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. +- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. +- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. + +## Testing + +- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, MessageItem steering arm (no 「插话」), InputBar submit. +- `apps/web/tests/steering.e2e.ts` keyless replay plus updated `settled.expected.md` (steer text without badge). diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md new file mode 100644 index 0000000000..f127bd5700 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -0,0 +1,38 @@ +# Agent Note: Web UI 去掉 steer 入口与插话 chrome + +Status: implemented + +[English](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) | 中文 + +## 问题 + +中途 steering 是 host/agent-loop 能力(`mode:'steer'`、持久 `steering/message`)。Web 产品已在 turn 运行中锁定 composer,且从未交付排队/steer 菜单,但客户端仍把 `'queue' | 'steer'` 穿进 input machine、`conversation.send` 与 locale 键,并把已消费的 steering 渲染成带「插话」/「Interjection」徽章的气泡。这留下半成品 UI:用不到的提交 mode、用户做不到的手势却有产品文案,以及把产品并不拥有的 chrome 钉死在 e2e golden 上。 + +## 决策 + +保留 host 与 runtime 的 steering。只去掉 Web UI 入口与 chrome: + +- `InputMachine`/`SessionInput`/`InputActions.submit`/hub `defaultSink` 仅 queue;始终调用 `session.prompt(..., 'queue')`。 +- `ConversationService.send(text)` 去掉 mode 参数,始终排队。 +- `MessageItem` 的 `steering` 分支仍把持久 `steering/message` 内容折成右对齐普通气泡(无徽章、无用户 IconActions),以便外部/host steer 在回放时仍可见。 +- 删除 `message.steering` locale 字符串与未使用的徽章 CSS。 +- web steering e2e 仍通过 `/api/session.prompt` POST `mode:'steer'`,并断言持久化与模型可见服从;不再期望插话 chrome。同步更新 [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) 中的事实行。 + +## 曾考虑的替代方案 + +**整段删除 host steering。** 超出范围;用户只要求清 Web UI 展示与入口。agent-loop 排空、session 事件与线缆 mode 对 ACP/TUI/自动化仍是承重能力。 + +**在 transcript 中隐藏 `steering/message`。** 外部客户端 steer 时回放会撒谎;改为普通气泡。 + +**保留 mode 参数但永远只传 `'queue'`。** 留下死 API 面与只会虚构 composer 到不了的 `'steer'` 路径的测试。 + +## 后果 + +- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 +- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 +- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 + +## 测试 + +- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem steering 分支(无「插话」)、InputBar submit。 +- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章)。 diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5e61b89b0b..aa16fd5142 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -19,7 +19,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 3cbae0b9ee..19ca4bf8c5 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -1,16 +1,15 @@ -// Web e2e scenario: mid-turn steering, end to end. The product composer -// deliberately exposes Queue only, so the steer is POSTed from the page -// itself over the same same-origin /api transport the client uses. -// TODO(web-steer-ui): Drive this through a dedicated steering interaction -// once one exists. Everything downstream is product: the gateway -// routes mode:'steer' to Agent.steer, the loop drains it at the step -// boundary into a durable steering/message event, the SSE mux pushes it, and -// the transcript renders the badged interjection bubble. The question -// composer supplies the deterministic mid-turn window: while ask_user_question -// blocks, the turn is provably running, so record and replay perform the -// identical steer-then-answer sequence with zero timing dependence — and the -// recorded final reply proves the steer reached the MODEL (it obeys an -// instruction that only the steering message carries). +// Web e2e scenario: mid-turn steering over the host wire. The Web UI has no +// steer entry, so the steer is POSTed from the page over the same +// same-origin /api transport the client uses. Everything downstream is +// product: the gateway routes mode:'steer' to Agent.steer, the loop drains +// it at the step boundary into a durable steering/message event, the SSE mux +// pushes it, and the transcript shows the text as a plain bubble (no +// interjection chrome). The question composer supplies the deterministic +// mid-turn window: while ask_user_question blocks, the turn is provably +// running, so record and replay perform the identical steer-then-answer +// sequence with zero timing dependence — and the recorded final reply proves +// the steer reached the MODEL (it obeys an instruction that only the +// steering message carries). import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -29,11 +28,11 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.u const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // Two goldens for the two distinct states this interaction produces: the // mid-turn moment (steer ACCEPTED but deliberately invisible — the loop -// drains steering at the step boundary, so no interjection bubble exists -// while the question still blocks the step) and the settled transcript -// (badged bubble in place, final reply obeying it). The pair pins the -// timing semantics visually: if the client ever starts rendering pending -// steers eagerly, the mid-steer golden flips first. +// drains steering at the step boundary, so no steering text exists while +// the question still blocks the step) and the settled transcript (plain +// bubble in place, final reply obeying it). The pair pins the timing +// semantics visually: if the client ever starts rendering pending steers +// eagerly, the mid-steer golden flips first. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() @@ -80,7 +79,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await scaffold?.close() }) - it('steers during the blocked step; the interjection is logged, rendered, and obeyed', async () => { + it('steers during the blocked step; the message is logged, rendered, and obeyed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-steering')) if (MODE !== 'record') { // The steer must NOT be a user/message — it lands as steering/message. @@ -118,10 +117,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { if (MODE !== 'record') { // Mid-turn golden: the ACCEPTED steer is durable in the inbox but the // loop drains steering only at the step boundary, so no steering/message - // exists yet and no interjection bubble renders — the composer still - // blocks, alone. The DOM is stable here (no further SSE frames can - // arrive until the question is answered), making this state capturable. - expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0) + // exists yet and no steer text renders — the composer still blocks, + // alone. The DOM is stable here (no further SSE frames can arrive until + // the question is answered), making this state capturable. expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) @@ -155,14 +153,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { expect(turnEnds).toHaveLength(1) expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') - // Visible: the badged interjection bubble plus the reply that obeys it + // Visible: the plain steering bubble plus the reply that obeys it // (steer text + final reply each contain the marker word). - await expect.poll(() => page.getByText('Interjection', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.locator('[data-question-key]').count()).toBe(0) - // Settled golden: badge + interjection between the question round trip - // and the obeying reply, composer takeover gone. + // Settled golden: steer text between the question round trip and the + // obeying reply, composer takeover gone. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index cc9f5a575b..b3423544ab 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe -README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391 +README.md: c2db1fba330a5ba968ba8fbbb5825ee62e699b4c +README.zh.md: bc2c3297ee75bd30c9090d9d4dadf673cc3e0484 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b4b1e56537..c2db1fba33 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -45,4 +45,4 @@ None; this package neither assembles nor sends a provider request. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. - **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control. -- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay. +- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 74e0f3dc0e..bc2c3297ee 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -45,4 +45,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。 -- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。 +- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 2667024bcd..5e2c623f50 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -20,16 +20,6 @@ color: var(--dsw-alias-label-primary); } -.badge { - display: inline-block; - margin-bottom: 4px; - padding: 1px 6px; - border-radius: 6px; - background: var(--dsw-alias-state-warn-primary); - color: var(--dsw-alias-label-primary-foreground); - font-size: 11px; -} - .contextRow { padding: 2px 0; } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index bb6429470f..20e613dc9e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ // MessageItem: the four simple node kinds — user bubble (right-aligned, with -// clock + copy / branch / edit IconActions), steering (badged bubble), context -// injection and unknown-surface JSON rows. Props are frozen node slices off -// the snapshot cache; memo holds across streaming because unchanged nodes -// keep their references. +// clock + copy / branch / edit IconActions), steering (same bubble, no +// actions), context injection and unknown-surface JSON rows. Props are frozen +// node slices off the snapshot cache; memo holds across streaming because +// unchanged nodes keep their references. import { memo } from 'react' import type { ReactNode } from 'react' @@ -94,7 +94,6 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag return (
- {t('message.steering')} {projectUserText(text)} {rest.map((block, i) => )}
diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 317adc0ec5..f503381bbb 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -29,7 +29,7 @@ export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ - submit(mode?: 'queue' | 'steer'): void + submit(): void /** * Surface a notice outside the machine's own effect stream: detached * command results and business notifications render through here. @@ -60,7 +60,7 @@ export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void /** Enter submission (adjudication / claim transaction / default sink inside). */ - submit(mode?: 'queue' | 'steer'): void + submit(): void } /** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */ @@ -239,7 +239,7 @@ export type InputEvent = | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ | { readonly type: 'invalidate-paste' } - | { readonly type: 'enter'; readonly mode: 'queue' | 'steer' } + | { readonly type: 'enter' } | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } @@ -258,5 +258,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' } + | { readonly type: 'default-sink'; readonly draft: string } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index d6b2fde80a..79a223a43e 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -39,7 +39,7 @@ export interface SessionInputDeps { /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, mode: 'queue' | 'steer'): void + defaultSink(text: string): void } /** Guard tier from the machine phase. */ @@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session — decision 20). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - submit: (mode) => { this.submit(mode) }, + submit: () => { this.submit() }, } // Real wall clock: the typing-run merge window must actually expire in @@ -151,10 +151,9 @@ export class SessionInputShell implements SessionInput { * from the machine; this method only feeds the event. Lock entry * (adjudicating/submitting) force-closes the transient layers: the popup * dismisses and the menu tracks frozen. - * @param mode - default-sink mode (queue appends; steer interrupts). */ - submit(mode: 'queue' | 'steer' = 'queue'): void { - this.run(this.core.dispatch({ type: 'enter', mode })) + submit(): void { + this.run(this.core.dispatch({ type: 'enter' })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() @@ -339,7 +338,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft, fx.mode) + this.sinkSerialized(fx.draft) return } default: @@ -354,10 +353,10 @@ export class SessionInputShell implements SessionInput { * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void { + private sinkSerialized(draft: string): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), mode) + this.deps.defaultSink(draft.trim()) return } const slash = this.deps.slash?.() @@ -377,7 +376,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), mode) + this.deps.defaultSink(out.trim()) }, (error: unknown) => { controller.abort() diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2641e0dcc4..599cb85a08 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -56,7 +56,7 @@ export class InputHub implements InputService { slash: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, mode) => { this.sink(session, text, mode) }, + defaultSink: (text) => { this.sink(session, text) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -112,12 +112,12 @@ export class InputHub implements InputService { * exactly one path; a failed first prompt is an ordinary prompt failure * (error strip via promptError, draft restored only while untouched). */ - private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void { + private sink(session: SessionFace, text: string): void { if (text === '') return const shell = this.shells.get(session.sessionId) // Commit, not an editable clear: undo must not resurrect sent content. shell?.commitSend() - void session.prompt([{ type: 'text', text }], mode).then( + void session.prompt([{ type: 'text', text }], 'queue').then( (result) => { if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) }, diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 48f6ddd872..f32d9e11d7 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -112,7 +112,6 @@ export class InputMachine { private inflight: { readonly attempt: SubmitAttempt readonly controller: AbortController - readonly mode: 'queue' | 'steer' } | undefined private log: Transaction[] = [] private redoStack: Transaction[] = [] @@ -163,7 +162,7 @@ export class InputMachine { this.paste = undefined return [] } - case 'enter': return this.onEnter(ev.mode) + case 'enter': return this.onEnter() case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) @@ -462,18 +461,18 @@ export class InputMachine { // ---- submit plane ---- /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt { + private beginAttempt(): SubmitAttempt { const controller = new AbortController() this.seq += 1 const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft } - this.inflight = { attempt, controller, mode } + this.inflight = { attempt, controller } return attempt } - private onEnter(mode: 'queue' | 'steer'): InputEffect[] { + private onEnter(): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt() this.phase = 'submitting' this.paste = undefined return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }] @@ -482,11 +481,11 @@ export class InputMachine { if (trimmed === '') return [] this.paste = undefined if (trimmed.startsWith('/')) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt() this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft, mode }] + return [{ type: 'default-sink', draft: this.draft }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -507,7 +506,7 @@ export class InputMachine { this.inflight = undefined this.phase = 'plain' return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }] + ? [{ type: 'default-sink', draft: attempt.draftSnapshot }] : [] } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 737d6dc9f0..976a397b03 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -42,7 +42,6 @@ export const zh = { 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', 'message.extraBlock': '附加内容块', - 'message.steering': '插话', 'message.contextInjection': '上下文注入', 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', @@ -124,7 +123,6 @@ export const en = { 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', 'message.extraBlock': 'Extra content block', - 'message.steering': 'Interjection', 'message.contextInjection': 'Context injection', 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 939073d22a..bbbb13f3ca 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -25,12 +25,11 @@ export interface IConversation { /** The per-session input machine registry (InputService face). */ readonly input: InputService /** - * Send a prompt into the caller scope's session. + * Send a prompt into the caller scope's session (queued turn). * @param text - prompt text, sent verbatim as one text block. - * @param mode - queue after the current turn, or steer into it. * @returns completion; business failures reject (and land in promptError). */ - send(text: string, mode: 'queue' | 'steer'): Promise + send(text: string): Promise /** * Apply one operation to a pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. @@ -71,11 +70,10 @@ export class ConversationService extends Service implements IConversation { * session snapshot's promptError (object-layer surface); the rejection here * exists for caller choreography (the composer restores the draft on it). * @param text - prompt text, sent verbatim as one text block. - * @param mode - queue after the current turn, or steer into it. */ - async send(text: string, mode: 'queue' | 'steer'): Promise { + async send(text: string): Promise { const session = this.scopedSession('send') - const result = await session.prompt([{ type: 'text', text }], mode) + const result = await session.prompt([{ type: 'text', text }], 'queue') if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 887c7872b6..bdf28add34 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -172,7 +172,7 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return - inputActions.submit('queue') + inputActions.submit() } const onChange = (e: ChangeEvent): void => { @@ -266,7 +266,7 @@ export function InputBar({ return } /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */ - if (!empty && !disabled && !machineBusy) inputActions.submit('queue') + if (!empty && !disabled && !machineBusy) inputActions.submit() } // The Access seat: the projection-fed permission chip (renders nothing diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 367b3519e9..53cd0df455 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -101,7 +101,7 @@ async function bench() { } const actions = info.props['inputActions'] as { setDraft: (text: string) => void - submit: (mode?: 'queue' | 'steer') => void + submit: () => void } return { state, actions } } @@ -140,25 +140,25 @@ describe('conversation slot inject surface', () => { const { state, actions } = b.inputSurface(ROOT) // Whitespace-only: the machine treats it as empty — no prompt, draft kept. actions.setDraft(' ') - actions.submit('queue') + actions.submit() expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') // Success: cleared and stays cleared. actions.setDraft('hello') - actions.submit('queue') + actions.submit() expect(state.getSnapshot().draft).toBe('') await Promise.resolve() expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') // Failure: restored (draft still empty when the rejection lands). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') - actions.submit('queue') + actions.submit() await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('retry me') }) // Failure landing after new typing: no clobber (restore fills empty only). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) - actions.submit('queue') + actions.submit() actions.setDraft('typed during flight') await new Promise(r => setTimeout(r, 0)) expect(state.getSnapshot().draft).toBe('typed during flight') diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a88d1175ee..a9a28e373d 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -99,7 +99,7 @@ describe('MessageItem arms', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) - it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => { + it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => { const view = render( { } as never} />, ) - expect(view.getByText('插话')).toBeTruthy() + expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() expect(view.queryByRole('button', { name: '复制' })).toBeNull() diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index a501ac1a4c..b173c09499 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -127,7 +127,7 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', 'queue') + expect(sink).toHaveBeenCalledWith('hello') fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) @@ -195,7 +195,7 @@ describe('running and lock semantics (queue cut 1)', () => { expect(textarea.disabled).toBe(false) // running no longer locks fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2') expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -211,7 +211,7 @@ describe('running and lock semantics (queue cut 1)', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', 'queue') + expect(sink).toHaveBeenCalledWith('go') const empty = bench() expect(empty.button.disabled).toBe(true) }) @@ -320,7 +320,7 @@ describe('machine pending lock', () => { }, { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, ) - shell.submit('queue') + shell.submit() }) expect(shell.snapshot.phase).toBe('submitting') const textarea = view.container.querySelector('textarea')! diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 9ce23c4a10..510cb28076 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -40,9 +40,9 @@ function effectAt( } /** Drive plain → adjudicating and hand back the minted attempt. */ -function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt { +function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt { m.dispatch({ type: 'draft-changed', draft }) - const fx = m.dispatch({ type: 'enter', mode }) + const fx = m.dispatch({ type: 'enter' }) return effectAt(fx, 0, 'adjudicate').attempt } @@ -52,7 +52,7 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` }) m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) }) m.dispatch({ type: 'draft-changed', draft: claim.token + args }) - const fx = m.dispatch({ type: 'enter', mode: 'queue' }) + const fx = m.dispatch({ type: 'enter' }) return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim } } @@ -63,24 +63,24 @@ function staleAttempt(): SubmitAttempt { describe('input-machine: plain × enter', () => { it('empty and whitespace-only drafts produce nothing', () => { const m = new InputMachine() - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) m.dispatch({ type: 'draft-changed', draft: ' \n ' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) expect(m.state.phase).toBe('plain') }) - it('non-command text falls to the default sink with the given mode', () => { + it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - expect(m.dispatch({ type: 'enter', mode: 'steer' })) - .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }]) + expect(m.dispatch({ type: 'enter' })) + .toEqual([{ type: 'default-sink', draft: 'hello world' }]) expect(m.state.phase).toBe('plain') }) it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '/goal x' }) - const fx = m.dispatch({ type: 'enter', mode: 'queue' }) + const fx = m.dispatch({ type: 'enter' }) const eff = effectAt(fx, 0, 'adjudicate') expect(eff.draft).toBe('/goal x') expect(eff.attempt.draftSnapshot).toBe('/goal x') @@ -91,14 +91,14 @@ describe('input-machine: plain × enter', () => { it('leading is judged after trim including newlines', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate') + expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate') }) it('a non-whitespace prefix before "/" is not leading — default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })) - .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) + expect(m.dispatch({ type: 'enter' })) + .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }]) }) }) @@ -124,11 +124,11 @@ describe('input-machine: adjudication outcomes', () => { expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x') }) - it('undefined outcome falls back to the default sink preserving the enter mode', () => { + it('undefined outcome falls back to the default sink', () => { const m = new InputMachine() - const attempt = enterAdjudicating(m, '/unknown thing', 'steer') + const attempt = enterAdjudicating(m, '/unknown thing') expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) - .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }]) + .toEqual([{ type: 'default-sink', draft: '/unknown thing' }]) expect(m.state.phase).toBe('plain') }) @@ -152,7 +152,7 @@ describe('input-machine: adjudication outcomes', () => { it('enter is a no-op while adjudicating (pending lock)', () => { const m = new InputMachine() enterAdjudicating(m, '/goal x') - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) expect(m.state.phase).toBe('adjudicating') }) @@ -587,7 +587,7 @@ describe('input-machine: paste plane', () => { const b = new InputMachine() b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } }) - b.dispatch({ type: 'enter', mode: 'queue' }) + b.dispatch({ type: 'enter' }) expect(b.state.paste).toBeUndefined() }) @@ -755,7 +755,7 @@ describe('input-machine: submitting transaction', () => { it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => { const m = new InputMachine() enterSubmitting(m, 'goal', 'x') - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([]) expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' }) }) @@ -768,7 +768,7 @@ describe('input-machine: submitting transaction', () => { m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } }) m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) m.dispatch({ type: 'draft-changed', draft: '/goal go' }) - const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt + const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } }) expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }]) expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] }) @@ -809,7 +809,7 @@ describe('input-machine: submitting transaction', () => { const m = new InputMachine() const { attempt: first } = enterSubmitting(m, 'goal', 'x') m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' }) - const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt + const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt expect(second.seq).not.toBe(first.seq) expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([]) expect(m.state.phase).toBe('submitting') diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 005aedc693..b4dee5ce8c 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -85,7 +85,7 @@ describe('matrix row: plain', () => { fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', 'queue') + expect(sink).toHaveBeenCalledWith('普通消息') expect(shell.snapshot.phase).toBe('plain') }) }) @@ -184,7 +184,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', 'queue') + expect(sink).toHaveBeenCalledWith('排队') }) }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 2c735071e3..841e5d267f 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -224,7 +224,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') }) expect(b2.executed).toHaveLength(0) }) }) @@ -278,7 +278,7 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') }) + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 209cdee212..41a21b86e3 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -33,11 +33,11 @@ async function bench() { describe('ConversationService', () => { it('routes operations through the public Session binding', async () => { const b = await bench() - await b.scoped.send('hello', 'steer') + await b.scoped.send('hello') await b.scoped.updateQueue('item-1' as never, { kind: 'remove' }) await b.scoped.cancel() await b.scoped.loadOlder() - expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer') + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' }) expect(b.cancel).toHaveBeenCalledOnce() expect(b.loadOlder).toHaveBeenCalledOnce() @@ -47,7 +47,7 @@ describe('ConversationService', () => { it('folds Session business failures into callback rejections', async () => { const b = await bench() b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never) - await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy') + await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy') b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') await b.runtime.dispose() @@ -55,9 +55,9 @@ describe('ConversationService', () => { it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() - await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) + await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) await b.runtime.sessions.remove('s1') - await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/) + await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/) await b.runtime.dispose() // No SessionsService at all: a bare context (the runtime always provides one). const bare = new Context() @@ -65,6 +65,6 @@ describe('ConversationService', () => { input: new InputHub(bare), }).await() const orphan = bare.get('conversation') as ConversationService - await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/) + await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a257688bd3..5698df8c5b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -206,7 +206,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised') fireEvent.click(b.view.getByRole('button', { name: 'Root' })) expect(b.open).toHaveBeenCalledWith(sid('root')) }) From 53e7e5b2478089fc43303790b81f85a25025df97 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:14:24 +0800 Subject: [PATCH 14/16] docs(cli): track welcome storage migration --- apps/cli/src/tui-onboarding/tui-first-run-welcome.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts index d18e6d2a0c..41b4c5aca2 100644 --- a/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts @@ -35,6 +35,9 @@ import { 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' From 57d6d9ee1cce882ae0937577ca87dc0d4f787148 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 19:20:12 +0800 Subject: [PATCH 15/16] fix: ci --- .../src/client/chat/MessageItem.tsx | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index eab509286f..e4730924fd 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -142,41 +142,52 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +/** Right-aligned bubble shared by user and steering rows (steering has no actions). */ +function UserStyleBubble({ + content, actions, t, +}: { + content: readonly unknown[] + /** Optional IconActions (or similar) below the bubble; receives the joined text. */ + actions?: (text: string) => ReactNode + t: ChatViewSlotProps['t'] +}): ReactNode { + const { text, rest } = contentText(content) + const truncated = (total: number): string => t('json.truncated', { total }) + return ( +
+
+ {projectUserText(text)} + {rest.map((block, i) => )} +
+ {actions?.(text)} +
+ ) +} + export const MessageItem = memo(function MessageItem({ node, retryActive = false, onFork, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { - case 'user': { - const { text, rest } = contentText(node.content) + case 'user': return ( -
-
- {projectUserText(text)} - {rest.map((block, i) => )} -
- { onFork(node.seq) }} - className={css.actions} - t={t} - /> -
+ ( + { onFork(node.seq) }} + className={css.actions} + t={t} + /> + )} + /> ) - } - case 'steering': { - const { text, rest } = contentText(node.content) - return ( -
-
- {projectUserText(text)} - {rest.map((block, i) => )} -
-
- ) - } + case 'steering': + return case 'context': return ( From c83159f1c0a54af6b908eb2540bebada693e943e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:48:04 +0800 Subject: [PATCH 16/16] test(cli): acknowledge welcome in composition smoke --- apps/cli/tests/shipped-composition.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index 565aacd269..ac1e9d5456 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -7,6 +7,7 @@ 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)) @@ -105,6 +106,7 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => { 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: [