fix(tui): address onboarding review feedback

This commit is contained in:
NI0317
2026-07-31 17:47:07 +08:00
parent 63404c7a31
commit 346d1e4c74
23 changed files with 212 additions and 221 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。

View File

@@ -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"

View File

@@ -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. */

View File

@@ -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<TuiFirstRunWelcomeNotic
paragraphs: Object.freeze([
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭可以【关闭方式待补充】。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
]),
continueLabel: '继续',
scrollHint: '↑/↓ 滚动',

View File

@@ -4,7 +4,7 @@
* 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
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome
*/
import { randomUUID } from 'node:crypto'
@@ -12,11 +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 {
matchesTuiKey,
truncateTuiText,
TuiKey,
tuiVisibleWidth,
wrapTuiText,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from '@earendil-works/pi-tui'
import {
disposeRootAndExit,
type TuiComponent,
type TuiFocusable,
type TuiOverlayHost,
@@ -119,7 +122,6 @@ export async function acknowledgeTuiFirstRunWelcome(
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 {
@@ -130,6 +132,13 @@ export async function acknowledgeTuiFirstRunWelcome(
throw error
/* v8 ignore stop */
}
try {
await syncDirectory(directory)
/* v8 ignore next -- rename is the commit point; directory-fsync fault injection is platform-specific */
} catch {
// Swallow post-rename directory fsync failure: the marker is already committed,
// and crash loss can only make the notice reappear on the safe side.
}
}
/** Sync one POSIX directory after publishing a child entry. */
@@ -147,14 +156,14 @@ async function syncDirectory(path: string): Promise<void> {
/** 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<void>,
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<Promise<void>>()
const acknowledge = (): Promise<void> => {
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: {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<typeof import('@deepseek-ai/dsh-tui')>(),
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<string> {
}
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<undefined>()
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<void>) | undefined
const ctx = {
effect(register: () => () => Promise<void>) {
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'))

View File

@@ -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<TuiPtySmokeOptions> & {
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(

View File

@@ -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`

View File

@@ -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

View File

@@ -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 `<session title> — <configured title>`. 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.

View File

@@ -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`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript被它遮蔽的对话仍可阅读而已落地的压缩compaction检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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

3
pnpm-lock.yaml generated
View File

@@ -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