386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
/**
|
|
* Effect-owned first-run overlay for the shipped `dsh` TUI.
|
|
*
|
|
* 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-onboarding/tui-first-run-welcome
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
import { lstat, mkdir, open, rename, rm } from 'node:fs/promises'
|
|
import { basename, dirname, join } from 'node:path'
|
|
import type { Context } from 'cordis'
|
|
import {
|
|
Key,
|
|
matchesKey,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
wrapTextWithAnsi,
|
|
} from '@earendil-works/pi-tui'
|
|
import {
|
|
disposeRootAndExit,
|
|
type TuiComponent,
|
|
type TuiFocusable,
|
|
type TuiOverlayHost,
|
|
} from '@deepseek-ai/dsh-tui'
|
|
import {
|
|
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
|
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
|
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
|
type TuiFirstRunWelcomeNoticeCopy,
|
|
} from './tui-first-run-welcome-copy.ts'
|
|
import {
|
|
TUI_FIRST_RUN_WELCOME_WHALE,
|
|
type TuiFirstRunWelcomeArtTier,
|
|
} from './tui-first-run-welcome-art.ts'
|
|
|
|
// TODO: Move acknowledgement persistence behind @deepseek-ai/dsh-storage once
|
|
// its backend contract supports concurrent host processes. This same-value
|
|
// marker must not inherit JSON lost updates or SQLite busy failures.
|
|
const ACKNOWLEDGEMENT_DIRECTORY = 'notices'
|
|
const ACKNOWLEDGEMENT_BASENAME = 'tui-first-run-welcome'
|
|
|
|
/** Cordis plugin name. */
|
|
export const name = 'tui-first-run-welcome'
|
|
/** The notice can open only after the terminal-local overlay service mounts. */
|
|
export const inject = ['tui']
|
|
|
|
/** Launcher-resolved configuration for the terminal-local notice. */
|
|
interface Config {
|
|
/** Absolute DeepSeek Harness home owning this acknowledgement. */
|
|
readonly dshHome: string
|
|
/** Render the bit-equivalent printable ASCII icon fallback. */
|
|
readonly asciiArt?: boolean
|
|
}
|
|
|
|
/**
|
|
* Detect an explicitly non-Unicode terminal locale for the static ASCII art fallback.
|
|
* @param env - Process environment carrying locale and terminal declarations.
|
|
* @returns `true` only when the environment explicitly declares an ASCII-only locale or dumb terminal.
|
|
*/
|
|
export function needsTuiFirstRunWelcomeAsciiArt(
|
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
): boolean {
|
|
const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG
|
|
return env.TERM === 'dumb' || locale === 'C' || locale === 'POSIX'
|
|
}
|
|
|
|
/**
|
|
* Resolve the immutable marker for one notice version.
|
|
* @param dshHome - Resolved Harness home.
|
|
* @param version - Copy version whose acknowledgement is queried.
|
|
* @returns Absolute marker path beneath the Harness home.
|
|
*/
|
|
export function tuiFirstRunWelcomeAcknowledgementPath(dshHome: string, version: number): string {
|
|
return join(
|
|
dshHome,
|
|
ACKNOWLEDGEMENT_DIRECTORY,
|
|
`${ACKNOWLEDGEMENT_BASENAME}-v${String(version)}.ack`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Test whether one notice version has been acknowledged.
|
|
* @param dshHome - Resolved Harness home.
|
|
* @param version - Copy version to inspect.
|
|
* @returns `true` only for a regular marker file; a malformed marker fails loud.
|
|
*/
|
|
export async function hasTuiFirstRunWelcomeAcknowledgement(
|
|
dshHome: string,
|
|
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
|
): Promise<boolean> {
|
|
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
|
|
try {
|
|
const info = await lstat(path)
|
|
if (!info.isFile()) throw new Error(`TUI welcome acknowledgement is not a file: ${path}`)
|
|
return true
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return false
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist one version acknowledgement by syncing a random same-directory file
|
|
* before atomically replacing the immutable marker. Concurrent launches publish
|
|
* the same fact, so same-value last-writer-wins replacement loses no state.
|
|
* @param dshHome - Resolved Harness home.
|
|
* @param version - Copy version being acknowledged.
|
|
*/
|
|
export async function acknowledgeTuiFirstRunWelcome(
|
|
dshHome: string,
|
|
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
|
): Promise<void> {
|
|
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
|
|
const directory = dirname(path)
|
|
const temp = join(directory, `.${basename(path)}.${randomUUID()}.tmp`)
|
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
await syncDirectory(dirname(directory))
|
|
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
try {
|
|
handle = await open(temp, 'wx', 0o600)
|
|
await handle.sync()
|
|
const created = handle
|
|
handle = undefined
|
|
await created.close()
|
|
await rename(temp, path)
|
|
} catch (error) {
|
|
/* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */
|
|
try {
|
|
await handle?.close()
|
|
} finally {
|
|
await rm(temp, { force: true })
|
|
}
|
|
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. */
|
|
/* v8 ignore start -- Windows rejects directory opens; POSIX unit coverage owns this path. */
|
|
async function syncDirectory(path: string): Promise<void> {
|
|
if (process.platform === 'win32') return
|
|
const handle = await open(path, 'r')
|
|
try {
|
|
await handle.sync()
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
/* v8 ignore stop */
|
|
|
|
/** Render one visible-width-padded line inside the notice frame. */
|
|
function framed(content: string, innerWidth: number, host: TuiOverlayHost): string {
|
|
const clipped = truncateToWidth(content, innerWidth, '')
|
|
return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}`
|
|
}
|
|
|
|
/** Center one line by terminal column width. */
|
|
function centered(content: string, width: number): string {
|
|
const clipped = truncateToWidth(content, width, '')
|
|
const remaining = Math.max(0, width - visibleWidth(clipped))
|
|
return `${' '.repeat(Math.floor(remaining / 2))}${clipped}`
|
|
}
|
|
|
|
/**
|
|
* Select the art tier for the actual overlay width and viewport height.
|
|
* @param innerWidth - Columns inside the frame.
|
|
* @param viewportRows - Current terminal rows.
|
|
* @returns full, compact, minimal, or no art when prose must take priority.
|
|
*/
|
|
export function tuiFirstRunWelcomeArtTier(
|
|
innerWidth: number,
|
|
viewportRows: number,
|
|
): TuiFirstRunWelcomeArtTier | undefined {
|
|
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
|
|
}
|
|
|
|
/** Wrap the centrally owned prose while promoting its opening quotation. */
|
|
function proseLines(
|
|
copy: TuiFirstRunWelcomeNoticeCopy,
|
|
width: number,
|
|
host: TuiOverlayHost,
|
|
): string[] {
|
|
const lines: string[] = []
|
|
for (const [index, paragraph] of copy.paragraphs.entries()) {
|
|
if (index > 0) lines.push('')
|
|
const quoteEnd = paragraph.startsWith('“') ? paragraph.indexOf('”') : -1
|
|
if (quoteEnd > 0) {
|
|
const quote = paragraph.slice(0, quoteEnd + 1)
|
|
const remainder = paragraph.slice(quoteEnd + 1).trimStart()
|
|
lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width))
|
|
lines.push('')
|
|
if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width))
|
|
} else {
|
|
lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width))
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
/** Render centered static brand art without putting ANSI into its owner file. */
|
|
function artLines(
|
|
tier: TuiFirstRunWelcomeArtTier,
|
|
width: number,
|
|
host: TuiOverlayHost,
|
|
asciiArt: boolean,
|
|
): string[] {
|
|
const art = TUI_FIRST_RUN_WELCOME_WHALE[tier][asciiArt ? 'ascii' : 'unicode']
|
|
return art.map(line => centered(host.theme.brand(line), width))
|
|
}
|
|
|
|
/** Responsive, scrollable notice whose only completion input is Enter. */
|
|
export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable {
|
|
focused = false
|
|
private scrollOffset = 0
|
|
private bodyCapacity = 1
|
|
private maxScrollOffset = 0
|
|
private saving = false
|
|
private saveFailed = false
|
|
|
|
constructor(
|
|
private readonly host: TuiOverlayHost,
|
|
private readonly copy: TuiFirstRunWelcomeNoticeCopy,
|
|
private readonly acknowledge: () => Promise<void>,
|
|
private readonly exit: () => void,
|
|
private readonly asciiArt = false,
|
|
) {}
|
|
|
|
invalidate(): void {}
|
|
|
|
render(width: number): string[] {
|
|
const frameWidth = Math.max(6, width)
|
|
const innerWidth = Math.max(1, frameWidth - 4)
|
|
const viewportRows = this.host.viewport.rows
|
|
const tier = tuiFirstRunWelcomeArtTier(innerWidth, viewportRows)
|
|
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
|
|
|
|
if (tier === 'full') {
|
|
fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt)
|
|
const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3)
|
|
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), '']
|
|
body = proseLines(this.copy, innerWidth, this.host)
|
|
}
|
|
|
|
const compositionCapacity = Math.max(1, availableRows - 5)
|
|
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
|
|
this.scrollOffset = Math.min(this.scrollOffset, maxOffset)
|
|
const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyCapacity)
|
|
|
|
const top = this.host.theme.dim(`╭${'─'.repeat(Math.max(0, frameWidth - 2))}╮`)
|
|
const separator = this.host.theme.dim(`├${'─'.repeat(Math.max(0, frameWidth - 2))}┤`)
|
|
const bottom = this.host.theme.dim(`╰${'─'.repeat(Math.max(0, frameWidth - 2))}╯`)
|
|
const action = this.host.theme.bold(this.host.theme.accent(`Enter ${this.copy.continueLabel}`))
|
|
const hasAbove = this.scrollOffset > 0
|
|
const hasBelow = this.scrollOffset < maxOffset
|
|
const scroll = hasAbove || hasBelow
|
|
? `${hasAbove ? '↑' : ' '} ${this.copy.scrollHint} ${hasBelow ? '↓' : ' '}`
|
|
: ''
|
|
const status = this.saveFailed
|
|
? this.host.theme.error(this.copy.saveError)
|
|
: this.saving
|
|
? 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, fullContent.length) }, (_, index) => {
|
|
const art = fullArt[index] ?? ''
|
|
const line = fullContent[index] ?? ''
|
|
const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}`
|
|
return `${left} ${line}`
|
|
})
|
|
|
|
return [
|
|
top,
|
|
...composition.map(line => framed(line, innerWidth, this.host)),
|
|
separator,
|
|
framed(centered(action, innerWidth), innerWidth, this.host),
|
|
framed(centered(status, innerWidth), innerWidth, this.host),
|
|
bottom,
|
|
]
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
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 || 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 {
|
|
this.scrollTo(this.scrollOffset + delta)
|
|
}
|
|
|
|
private scrollTo(offset: number): void {
|
|
this.scrollOffset = Math.min(this.maxScrollOffset, Math.max(0, offset))
|
|
this.host.invalidate()
|
|
}
|
|
|
|
private async commit(): Promise<void> {
|
|
this.saving = true
|
|
this.saveFailed = false
|
|
this.host.invalidate()
|
|
try {
|
|
await this.acknowledge()
|
|
this.host.close()
|
|
} catch {
|
|
this.saving = false
|
|
this.saveFailed = true
|
|
this.host.invalidate()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the first-run notice through the mounted TUI's FIFO overlay owner.
|
|
* @param ctx - Plugin context carrying the terminal-local TUI service.
|
|
* @param config - Launcher-resolved Harness home.
|
|
*/
|
|
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,
|
|
acknowledge,
|
|
() => { disposeRootAndExit(ctx, 0) },
|
|
config.asciiArt ?? false,
|
|
),
|
|
options: {
|
|
width: '100%',
|
|
maxHeight: '90%',
|
|
anchor: 'center',
|
|
margin: 0,
|
|
},
|
|
})
|
|
}
|