From 890343751a356273128416eba6be6b09ece7d463 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 22:29:35 +0800 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] =?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/14] 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/14] 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/14] 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 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 13/14] 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 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 14/14] 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: [