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

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