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

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