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