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