feat(tui): add versioned first-run welcome
This commit is contained in:
@@ -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 apps/cli/README.md
|
||||
README.md: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215
|
||||
README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c
|
||||
README.md: 3f421fa7a47a8a56ccbe969497e7bfc4b69dcf10
|
||||
README.zh.md: fc7fe373070a0dc62b547a76e661669dadf12f69
|
||||
|
||||
@@ -12,6 +12,7 @@ The TUI surface:
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, bilingual copy, and notice version are static local owners; the overlay never writes a session event or model context.
|
||||
|
||||
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ TUI 界面:
|
||||
- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
|
||||
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
|
||||
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
|
||||
- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、双语文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。
|
||||
|
||||
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`。
|
||||
|
||||
|
||||
1
apps/cli/assets/deepseek-color.svg
Normal file
1
apps/cli/assets/deepseek-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/bin.js",
|
||||
"assets",
|
||||
"config",
|
||||
"src"
|
||||
],
|
||||
@@ -114,6 +115,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"
|
||||
|
||||
111
apps/cli/src/tui-first-run-welcome-art.ts
Normal file
111
apps/cli/src/tui-first-run-welcome-art.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Static terminal rasters derived from the official 24x24 DeepSeek icon.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
/** Responsive official-icon raster tier. */
|
||||
export type TuiFirstRunWelcomeArtTier = 'full' | 'compact' | 'minimal'
|
||||
|
||||
/** One raster with a block-cell primary and bit-equivalent ASCII fallback. */
|
||||
export interface TuiFirstRunWelcomeArt {
|
||||
/** Two vertical source pixels per terminal cell. */
|
||||
readonly unicode: readonly string[]
|
||||
/** Same two-bit cells encoded as top `'`, bottom `_`, and both `#`. */
|
||||
readonly ascii: readonly string[]
|
||||
}
|
||||
|
||||
const fullUnicode = Object.freeze([
|
||||
' ▄',
|
||||
' ▄▄▄▄▄▄▄▄▄▄███▀ ██▄',
|
||||
' ▄███████████████▄ ████▄ ▄▄▄▄██',
|
||||
' ▄███████████████████▄ ████████████▀',
|
||||
' ▄██████████████████████▄ ▀█████████▀',
|
||||
'▄███▀█████████████████████▄ ████▀▀',
|
||||
'███ ▀▀█████████▀▀▀█████████▀',
|
||||
'███ ▀███████▀█ ▀███████',
|
||||
'███▄ ▀███████▄ ▀█████▀',
|
||||
'▀███ ▀██████████████',
|
||||
' ▀███▄ ▀███████████▀',
|
||||
' ▀███▄ ▄▄▄ ▀████████▀',
|
||||
' █████▄ ███▄▄ ▀█████▄▄',
|
||||
' ▀█████████████▄▄▄▄█▀█████▀',
|
||||
' ▀▀███████████▀▀',
|
||||
])
|
||||
|
||||
const fullAscii = Object.freeze([
|
||||
' _',
|
||||
" __________###' ##_",
|
||||
' _###############_ ####_ ____##',
|
||||
" _###################_ ############'",
|
||||
" _######################_ '#########'",
|
||||
"_###'#####################_ ####''",
|
||||
"### ''#########'''#########'",
|
||||
"### '#######'# '#######",
|
||||
"###_ '#######_ '#####'",
|
||||
"'### '##############",
|
||||
" '###_ '###########'",
|
||||
" '###_ ___ '########'",
|
||||
" #####_ ###__ '#####__",
|
||||
" '#############____#'#####'",
|
||||
" ''###########''",
|
||||
])
|
||||
|
||||
const compactUnicode = Object.freeze([
|
||||
' ▄▄▄▄▄▄▄██▀ █▄ ▄',
|
||||
' ▄███████████▄▄ ███▄▄████',
|
||||
' ████████████████▄ ▀██████▀',
|
||||
'██▀▀▀▀▀████████████▄▄██▀',
|
||||
'██ ▀█████▄ ▀█████',
|
||||
'██▄ ▀████▄ ▄████',
|
||||
' ██▄ ████████▀',
|
||||
' ██▄ ▄▄ ▀█████▀',
|
||||
' ▀███▄▄▄███▄ ████▄▄',
|
||||
' ▀▀▀███████▀▀',
|
||||
])
|
||||
|
||||
const compactAscii = Object.freeze([
|
||||
" _______##' #_ _",
|
||||
' _###########__ ###__####',
|
||||
" ################_ '######'",
|
||||
"##'''''############__##'",
|
||||
"## '#####_ '#####",
|
||||
"##_ '####_ _####",
|
||||
" ##_ ########'",
|
||||
" ##_ __ '#####'",
|
||||
" '###___###_ ####__",
|
||||
" '''#######''",
|
||||
])
|
||||
|
||||
const minimalUnicode = Object.freeze([
|
||||
' ▄▄▄▄▄▄ ▄▄',
|
||||
' ▄████████▄ ▀████▀',
|
||||
'█▀▀▀▀███████▄██▀',
|
||||
'█▄ ▀███ ▀███',
|
||||
'▀█▄ ▀█████',
|
||||
' ▀█▄▄ █▄▄▀███▄',
|
||||
' ▀▀▀▀▀▀',
|
||||
])
|
||||
|
||||
const minimalAscii = Object.freeze([
|
||||
' ______ __',
|
||||
" _########_ '####'",
|
||||
"#''''#######_##'",
|
||||
"#_ '### '###",
|
||||
"'#_ '#####",
|
||||
" '#__ #__'###_",
|
||||
" ''''''",
|
||||
])
|
||||
|
||||
/** Exact-path terminal rasters by responsive tier. */
|
||||
export const TUI_FIRST_RUN_WELCOME_WHALE = Object.freeze({
|
||||
full: Object.freeze({ unicode: fullUnicode, ascii: fullAscii }),
|
||||
compact: Object.freeze({ unicode: compactUnicode, ascii: compactAscii }),
|
||||
minimal: Object.freeze({ unicode: minimalUnicode, ascii: minimalAscii }),
|
||||
}) satisfies Readonly<Record<TuiFirstRunWelcomeArtTier, TuiFirstRunWelcomeArt>>
|
||||
62
apps/cli/src/tui-first-run-welcome-copy.ts
Normal file
62
apps/cli/src/tui-first-run-welcome-copy.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Centrally owned version and bilingual copy for the shipped TUI first-run notice.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
/** Copy version persisted after the user explicitly continues. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 1
|
||||
|
||||
/** Locale-shaped text rendered by the first-run welcome overlay. */
|
||||
export interface TuiFirstRunWelcomeNoticeCopy {
|
||||
/** Overlay heading. */
|
||||
readonly title: string
|
||||
/** Ordered prose paragraphs. */
|
||||
readonly paragraphs: readonly string[]
|
||||
/** Enter action label. */
|
||||
readonly continueLabel: string
|
||||
/** Hint shown when the prose is scrollable. */
|
||||
readonly scrollHint: string
|
||||
/** Status shown while the acknowledgement reaches disk. */
|
||||
readonly saving: string
|
||||
/** Retry message shown when the acknowledgement cannot be persisted. */
|
||||
readonly saveError: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete notice copy. The shipped TUI currently presents the supplied
|
||||
* Simplified Chinese locale; English remains its reviewed locale counterpart.
|
||||
*/
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({
|
||||
'zh-CN': Object.freeze<TuiFirstRunWelcomeNoticeCopy>({
|
||||
title: 'DeepSeek Harness',
|
||||
paragraphs: Object.freeze([
|
||||
'感谢您愿意拨冗试用 DeepSeek Harness。',
|
||||
'目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。',
|
||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
||||
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
||||
]),
|
||||
continueLabel: '继续',
|
||||
scrollHint: '↑/↓ 滚动',
|
||||
saving: '正在保存确认…',
|
||||
saveError: '无法保存确认,请按 Enter 重试。',
|
||||
}),
|
||||
en: Object.freeze<TuiFirstRunWelcomeNoticeCopy>({
|
||||
title: 'DeepSeek Harness',
|
||||
paragraphs: Object.freeze([
|
||||
'Thank you for taking the time to try DeepSeek Harness.',
|
||||
'This release is still in internal testing. Some features remain unfinished, and parts of the experience will inevitably feel rough.',
|
||||
'“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems uncovered in real use may lead us to re-examine, or even overturn, existing designs.',
|
||||
'We especially want to hear about failures, confusion, and friction. If DeepSeek Harness did not help—or made your work harder—please tell us in the company WeChat group. Every piece of feedback helps us refine it.',
|
||||
]),
|
||||
continueLabel: 'Continue',
|
||||
scrollHint: '↑/↓ Scroll',
|
||||
saving: 'Saving acknowledgement…',
|
||||
saveError: 'Could not save the acknowledgement. Press Enter to retry.',
|
||||
}),
|
||||
})
|
||||
|
||||
/** Locale presented by the shipped first-run notice. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE = 'zh-CN' as const
|
||||
352
apps/cli/src/tui-first-run-welcome.ts
Normal file
352
apps/cli/src/tui-first-run-welcome.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 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-first-run-welcome
|
||||
*/
|
||||
|
||||
import { lstat, mkdir, open, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
Key,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type {
|
||||
TuiComponent,
|
||||
TuiFocusable,
|
||||
TuiOverlayHost,
|
||||
} 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'
|
||||
|
||||
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 as an immutable exclusive marker.
|
||||
* Concurrent launches race only on file creation: the winner syncs the marker,
|
||||
* and every loser accepts the same already-published regular file.
|
||||
* @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)
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
await syncDirectory(dirname(directory))
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
try {
|
||||
handle = await open(path, 'wx', 0o600)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') {
|
||||
if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await handle.sync()
|
||||
const created = handle
|
||||
handle = undefined
|
||||
await created.close()
|
||||
await syncDirectory(directory)
|
||||
} catch (error) {
|
||||
await rm(path, { force: true })
|
||||
throw error
|
||||
} finally {
|
||||
await handle?.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (innerWidth >= 96 && viewportRows >= 22) return 'full'
|
||||
if (innerWidth >= 64 && viewportRows >= 18) return 'compact'
|
||||
if (innerWidth >= 48 && viewportRows >= 14) 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 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 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)
|
||||
body = [centered(title, contentWidth), '', ...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)
|
||||
}
|
||||
|
||||
this.bodyCapacity = Math.max(1, availableRows - 5 - fixedHeader.length)
|
||||
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)
|
||||
while (visibleBody.length < this.bodyCapacity) visibleBody.push('')
|
||||
|
||||
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 composition = fullArt === undefined
|
||||
? [...fixedHeader, ...visibleBody]
|
||||
: visibleBody.map((line, index) => {
|
||||
const art = fullArt[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.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]
|
||||
ctx.tui.openOverlay({
|
||||
create: host => new TuiFirstRunWelcomeComponent(
|
||||
host,
|
||||
copy,
|
||||
() => acknowledgeTuiFirstRunWelcome(config.dshHome),
|
||||
config.asciiArt ?? false,
|
||||
),
|
||||
options: {
|
||||
width: '100%',
|
||||
maxHeight: '90%',
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,16 @@ import {
|
||||
type MainSessionIdentity,
|
||||
type TuiResumeHost,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
apply as applyTuiFirstRunWelcome,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
inject as tuiFirstRunWelcomeInject,
|
||||
name as tuiFirstRunWelcomeName,
|
||||
needsTuiFirstRunWelcomeAsciiArt,
|
||||
} from './tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
} from './tui-first-run-welcome-copy.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -126,7 +136,12 @@ export async function runTui(
|
||||
installFailLoud(NAME)
|
||||
// The bin already loaded the invoking directory's .env; the personal .env
|
||||
// only fills what is still unset (process.loadEnvFile never overrides).
|
||||
loadEnv(NAME, resolveDshHome())
|
||||
const dshHome = resolveDshHome()
|
||||
loadEnv(NAME, dshHome)
|
||||
const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement(
|
||||
dshHome,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
)
|
||||
// Both .env layers are loaded, so switching the workspace here cannot alter
|
||||
// environment precedence. The cwd IS the workspace seam: the shipped config
|
||||
// resolves the session cwd and the HMR watch root from it, so one chdir moves
|
||||
@@ -200,7 +215,7 @@ export async function runTui(
|
||||
const patches = replaceTree ? [] : [
|
||||
...loadOverlayPatches(NAME, TUI_OVERLAY),
|
||||
...resolvedConfig === undefined
|
||||
? loadPersonalPatches(NAME) ?? []
|
||||
? loadPersonalPatches(NAME, dshHome) ?? []
|
||||
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
|
||||
]
|
||||
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
|
||||
@@ -241,5 +256,15 @@ export async function runTui(
|
||||
)
|
||||
app.current = ctx
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
if (showFirstRunWelcome && ctx.get('tui') !== undefined) {
|
||||
await ctx.plugin({
|
||||
name: tuiFirstRunWelcomeName,
|
||||
inject: tuiFirstRunWelcomeInject,
|
||||
apply: applyTuiFirstRunWelcome,
|
||||
}, {
|
||||
dshHome,
|
||||
asciiArt: needsTuiFirstRunWelcomeAsciiArt(),
|
||||
})
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -6,11 +6,11 @@ import { execa } from 'execa'
|
||||
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const POSIX_PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
|
||||
import errno, fcntl, json, os, pty, select, signal, struct, sys, termios, time
|
||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds, columns, rows = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(launch_env_json))
|
||||
env.update({"COLUMNS": "100", "LINES": "30"})
|
||||
env.update({"COLUMNS": columns, "LINES": rows})
|
||||
# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
|
||||
# banner to the per-letter gradient (one SGR per letter), breaking literal
|
||||
# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
|
||||
@@ -20,6 +20,7 @@ pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(columns), 0, 0))
|
||||
|
||||
output = bytearray()
|
||||
action_index = 0
|
||||
@@ -36,8 +37,13 @@ while time.monotonic() < deadline:
|
||||
chunk = b""
|
||||
if chunk:
|
||||
output.extend(chunk)
|
||||
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
|
||||
while action_index < len(actions):
|
||||
marker = actions[action_index]["waitFor"].encode()
|
||||
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 "writeFile" in action:
|
||||
target = os.path.join(cwd, action["writeFile"]["path"])
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
@@ -68,11 +74,13 @@ if actual_exit != int(expected_exit):
|
||||
|
||||
/** One terminal input or workspace mutation performed after its marker renders. */
|
||||
type TuiPtyAction =
|
||||
| { readonly waitFor: string; readonly send: string }
|
||||
| { readonly waitFor: string; readonly occurrence?: number; readonly send: string; readonly delayMs?: number }
|
||||
| {
|
||||
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. */
|
||||
@@ -89,6 +97,12 @@ export interface TuiPtySmokeOptions {
|
||||
readonly env?: Readonly<NodeJS.ProcessEnv>
|
||||
readonly expectedExitCode?: number
|
||||
readonly timeoutMs?: number
|
||||
/** Existing isolated workspace to reuse; when omitted the harness creates and removes one. */
|
||||
readonly cwd?: string
|
||||
/** Pseudo-terminal columns; defaults to 100. */
|
||||
readonly columns?: number
|
||||
/** Pseudo-terminal rows; defaults to 30. */
|
||||
readonly rows?: number
|
||||
/** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
|
||||
readonly prepare?: (cwd: string) => Promise<void>
|
||||
/** Inspect the workspace after a passing run, before the temp dir is removed. */
|
||||
@@ -119,6 +133,8 @@ async function runPosixPtySmoke(
|
||||
JSON.stringify(options.actions ?? []),
|
||||
String(options.expectedExitCode ?? 0),
|
||||
String(timeoutMs / 1_000),
|
||||
String(options.columns ?? 100),
|
||||
String(options.rows ?? 30),
|
||||
], {
|
||||
stdin: 'ignore',
|
||||
timeout: timeoutMs + 5_000,
|
||||
@@ -150,8 +166,8 @@ async function runWindowsPtySmoke(
|
||||
let timedOut = false
|
||||
const terminal = pty.spawn(launch.command, launch.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cols: options.columns ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
cwd,
|
||||
env: definedEnv({
|
||||
...process.env,
|
||||
@@ -159,8 +175,8 @@ async function runWindowsPtySmoke(
|
||||
// Match the POSIX driver: no COLORTERM, so the banner never takes the
|
||||
// truecolor gradient path under a developer's shell.
|
||||
COLORTERM: undefined,
|
||||
COLUMNS: '100',
|
||||
LINES: '30',
|
||||
COLUMNS: String(options.columns ?? 100),
|
||||
LINES: String(options.rows ?? 30),
|
||||
}),
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
@@ -169,16 +185,23 @@ async function runWindowsPtySmoke(
|
||||
}, timeoutMs)
|
||||
terminal.onData((chunk) => {
|
||||
output += chunk
|
||||
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
|
||||
while (
|
||||
actionIndex < actions.length
|
||||
&& output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1)
|
||||
) {
|
||||
const action = actions[actionIndex]!
|
||||
if ('writeFile' in action) {
|
||||
const target = join(cwd, action.writeFile.path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, action.writeFile.content)
|
||||
const input = action.send
|
||||
if (input !== undefined) terminal.write(input)
|
||||
if (input !== undefined) {
|
||||
if (action.delayMs === undefined) terminal.write(input)
|
||||
else setTimeout(() => { terminal.write(input) }, action.delayMs)
|
||||
}
|
||||
} else {
|
||||
terminal.write(action.send)
|
||||
if (action.delayMs === undefined) terminal.write(action.send)
|
||||
else setTimeout(() => { terminal.write(action.send) }, action.delayMs)
|
||||
}
|
||||
actionIndex += 1
|
||||
}
|
||||
@@ -205,7 +228,8 @@ async function runWindowsPtySmoke(
|
||||
* @returns complete pseudo-terminal output.
|
||||
*/
|
||||
export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const ownedCwd = options.cwd === undefined
|
||||
const cwd = options.cwd ?? await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const timeoutMs = options.timeoutMs ?? 25_000
|
||||
try {
|
||||
await options.prepare?.(cwd)
|
||||
@@ -231,6 +255,6 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
|
||||
await options.inspect?.(cwd)
|
||||
return output
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
if (ownedCwd) await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
terminal 120x30 buffer=normal length=32 base=2 viewport=2
|
||||
lifecycle started=0 stopped=0 progress=inactive
|
||||
title ""
|
||||
cursor visible column=0 viewportRow=29 bufferRow=31
|
||||
viewport
|
||||
2| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 1-118 dim
|
||||
4| " │ ▄ DeepSeek Harness │ "
|
||||
style 1-1 dim
|
||||
style 11-38 fg=blue
|
||||
style 75-90 fg=blue bold
|
||||
style 118-118 dim
|
||||
5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ "
|
||||
style 1-1 dim
|
||||
style 10-39 fg=blue
|
||||
style 118-118 dim
|
||||
6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │e"
|
||||
style 0-0 fg=bright-magenta bold
|
||||
style 1-1 dim
|
||||
style 5-44 fg=blue
|
||||
style 118-119 dim
|
||||
7| " │ ▄███████████████████▄ ████████████▀ │ "
|
||||
style 1-1 dim
|
||||
style 5-44 fg=blue
|
||||
style 118-118 dim
|
||||
8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ "
|
||||
style 1-1 dim
|
||||
style 5-43 fg=blue
|
||||
style 118-118 dim
|
||||
9| " │ ▄███▀█████████████████████▄ ████▀▀ │ "
|
||||
style 1-1 dim
|
||||
style 7-42 fg=blue
|
||||
style 118-118 dim
|
||||
10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ "
|
||||
style 1-1 dim
|
||||
style 8-41 fg=blue
|
||||
style 50-71 bold
|
||||
style 118-118 dim
|
||||
11| " │ ███ ▀███████▀█ ▀███████ │ "
|
||||
style 1-1 dim
|
||||
style 8-40 fg=blue
|
||||
style 118-118 dim
|
||||
12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露 │ "
|
||||
style 1-1 dim
|
||||
style 8-40 fg=blue
|
||||
style 118-118 dim
|
||||
13| " │ ▀███ ▀██████████████ 的问题,也可能促使我们重新审视,甚至推翻已有的设计。 │ "
|
||||
style 1-1 dim
|
||||
style 9-40 fg=blue
|
||||
style 118-118 dim
|
||||
14| " │ ▀███▄ ▀███████████▀ │ "
|
||||
style 1-1 dim
|
||||
style 9-39 fg=blue
|
||||
style 118-118 dim
|
||||
15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚 │ "
|
||||
style 1-1 dim
|
||||
style 10-39 fg=blue
|
||||
style 118-118 dim
|
||||
16| " │ █████▄ ███▄▄ ▀█████▄▄ 至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。 │ "
|
||||
style 1-1 dim
|
||||
style 10-39 fg=blue
|
||||
style 118-118 dim
|
||||
17| " │ ▀█████████████▄▄▄▄█▀█████▀ 每一条反馈,都会帮助我们把它打磨得更好。 │ "
|
||||
style 1-1 dim
|
||||
style 9-40 fg=blue
|
||||
style 118-118 dim
|
||||
18| " │ ▀▀███████████▀▀ │ "
|
||||
style 1-1 dim
|
||||
style 13-35 fg=blue
|
||||
style 118-118 dim
|
||||
19| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
20| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
21| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
22| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
23| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
24| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
25| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ "
|
||||
style 1-118 dim
|
||||
27| " │ Enter 继续 │ "
|
||||
style 1-1 dim
|
||||
style 54-64 fg=bright-magenta bold
|
||||
style 118-118 dim
|
||||
28| " │ │ "
|
||||
style 1-1 dim
|
||||
style 118-118 dim
|
||||
29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 1-118 dim
|
||||
30-31| <blank>
|
||||
@@ -0,0 +1,106 @@
|
||||
terminal 160x30 buffer=normal length=32 base=2 viewport=2
|
||||
lifecycle started=0 stopped=0 progress=inactive
|
||||
title ""
|
||||
cursor visible column=0 viewportRow=29 bufferRow=31
|
||||
viewport
|
||||
2| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 1-158 dim
|
||||
4| " │ ▄ DeepSeek Harness │ "
|
||||
style 1-1 dim
|
||||
style 11-38 fg=blue
|
||||
style 95-110 fg=blue bold
|
||||
style 158-158 dim
|
||||
5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ "
|
||||
style 1-1 dim
|
||||
style 10-39 fg=blue
|
||||
style 158-158 dim
|
||||
6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │ "
|
||||
style 0-0 fg=bright-magenta bold
|
||||
style 1-1 dim
|
||||
style 5-44 fg=blue
|
||||
style 158-158 dim
|
||||
7| " │ ▄███████████████████▄ ████████████▀ │ "
|
||||
style 1-1 dim
|
||||
style 5-44 fg=blue
|
||||
style 158-158 dim
|
||||
8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ "
|
||||
style 1-1 dim
|
||||
style 5-43 fg=blue
|
||||
style 158-158 dim
|
||||
9| " │ ▄███▀█████████████████████▄ ████▀▀ │ "
|
||||
style 1-1 dim
|
||||
style 7-42 fg=blue
|
||||
style 158-158 dim
|
||||
10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ "
|
||||
style 1-1 dim
|
||||
style 8-41 fg=blue
|
||||
style 50-71 bold
|
||||
style 158-158 dim
|
||||
11| " │ ███ ▀███████▀█ ▀███████ │ "
|
||||
style 1-1 dim
|
||||
style 8-40 fg=blue
|
||||
style 158-158 dim
|
||||
12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻 │ "
|
||||
style 1-1 dim
|
||||
style 8-40 fg=blue
|
||||
style 158-158 dim
|
||||
13| " │ ▀███ ▀██████████████ 已有的设计。 │ "
|
||||
style 1-1 dim
|
||||
style 9-40 fg=blue
|
||||
style 158-158 dim
|
||||
14| " │ ▀███▄ ▀███████████▀ │ "
|
||||
style 1-1 dim
|
||||
style 9-39 fg=blue
|
||||
style 158-158 dim
|
||||
15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中 │ "
|
||||
style 1-1 dim
|
||||
style 10-39 fg=blue
|
||||
style 158-158 dim
|
||||
16| " │ █████▄ ███▄▄ ▀█████▄▄ 留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │ "
|
||||
style 1-1 dim
|
||||
style 10-39 fg=blue
|
||||
style 158-158 dim
|
||||
17| " │ ▀█████████████▄▄▄▄█▀█████▀ │ "
|
||||
style 1-1 dim
|
||||
style 9-40 fg=blue
|
||||
style 158-158 dim
|
||||
18| " │ ▀▀███████████▀▀ │ "
|
||||
style 1-1 dim
|
||||
style 13-35 fg=blue
|
||||
style 158-158 dim
|
||||
19| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
20| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
21| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
22| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
23| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
24| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
25| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ "
|
||||
style 1-158 dim
|
||||
27| " │ Enter 继续 │ "
|
||||
style 1-1 dim
|
||||
style 74-84 fg=bright-magenta bold
|
||||
style 158-158 dim
|
||||
28| " │ │ "
|
||||
style 1-1 dim
|
||||
style 158-158 dim
|
||||
29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 1-158 dim
|
||||
30-31| <blank>
|
||||
@@ -0,0 +1,100 @@
|
||||
terminal 60x30 buffer=normal length=34 base=4 viewport=4
|
||||
lifecycle started=0 stopped=0 progress=inactive
|
||||
title ""
|
||||
cursor visible column=0 viewportRow=29 bufferRow=33
|
||||
viewport
|
||||
4| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
5| " ╭────────────────────────────────────────────────────────╮ "
|
||||
style 1-58 dim
|
||||
6| " │ ▄▄▄▄▄▄ ▄▄ │ "
|
||||
style 1-1 dim
|
||||
style 23-36 fg=blue
|
||||
style 58-58 dim
|
||||
7| " │ ▄████████▄ ▀████▀ │ "
|
||||
style 1-1 dim
|
||||
style 21-38 fg=blue
|
||||
style 58-58 dim
|
||||
8| "/│ █▀▀▀▀███████▄██▀ │h"
|
||||
style 0-0 fg=bright-magenta bold
|
||||
style 1-1 dim
|
||||
style 22-37 fg=blue
|
||||
style 58-58 dim
|
||||
style 59-59 fg=bright-magenta bold
|
||||
9| " │ █▄ ▀███ ▀███ │ "
|
||||
style 1-1 dim
|
||||
style 22-36 fg=blue
|
||||
style 58-58 dim
|
||||
10| " │ ▀█▄ ▀█████ │ "
|
||||
style 1-1 dim
|
||||
style 23-36 fg=blue
|
||||
style 58-58 dim
|
||||
11| " │ ▀█▄▄ █▄▄▀███▄ │ "
|
||||
style 1-1 dim
|
||||
style 23-36 fg=blue
|
||||
style 58-58 dim
|
||||
12| " │ ▀▀▀▀▀▀ │ "
|
||||
style 1-1 dim
|
||||
style 25-34 fg=blue
|
||||
style 58-58 dim
|
||||
13| " │ │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
14| " │ DeepSeek Harness │ "
|
||||
style 1-1 dim
|
||||
style 22-37 fg=blue bold
|
||||
style 58-58 dim
|
||||
15| " │ │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
16| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
17| " │ │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
18| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体 │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
19| " │ 验难免粗粝。 │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
20| " │ │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
21| " │ “如切如磋,如琢如磨。” │ "
|
||||
style 1-1 dim
|
||||
style 3-24 bold
|
||||
style 58-58 dim
|
||||
22| " │ │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
23| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真 │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
24| " │ 实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
25| " │ 有的设计。 │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
26| " │ │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
27| " │ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未 │ "
|
||||
style 1-1 dim
|
||||
style 58-58 dim
|
||||
28| " ├────────────────────────────────────────────────────────┤ "
|
||||
style 1-58 dim
|
||||
29| " │ Enter 继续 │ "
|
||||
style 1-1 dim
|
||||
style 24-34 fg=bright-magenta bold
|
||||
style 58-58 dim
|
||||
30| " │ ↑/↓ 滚动 ↓ │ "
|
||||
style 1-1 dim
|
||||
style 24-35 dim
|
||||
style 58-58 dim
|
||||
31| " ╰────────────────────────────────────────────────────────╯ "
|
||||
style 1-58 dim
|
||||
32-33| <blank>
|
||||
@@ -0,0 +1,103 @@
|
||||
terminal 80x30 buffer=normal length=33 base=3 viewport=3
|
||||
lifecycle started=0 stopped=0 progress=inactive
|
||||
title ""
|
||||
cursor visible column=0 viewportRow=29 bufferRow=32
|
||||
viewport
|
||||
3| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
4| " ╭────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 1-78 dim
|
||||
5| " │ ▄▄▄▄▄▄▄██▀ █▄ ▄ │ "
|
||||
style 1-1 dim
|
||||
style 26-53 fg=blue
|
||||
style 78-78 dim
|
||||
6| " │ ▄███████████▄▄ ███▄▄████ │ "
|
||||
style 1-1 dim
|
||||
style 26-53 fg=blue
|
||||
style 78-78 dim
|
||||
7| "/│ ████████████████▄ ▀██████▀ │F"
|
||||
style 0-0 fg=bright-magenta bold
|
||||
style 1-1 dim
|
||||
style 26-52 fg=blue
|
||||
style 78-78 dim
|
||||
style 79-79 fg=bright-magenta bold
|
||||
8| " │ ██▀▀▀▀▀████████████▄▄██▀ │ "
|
||||
style 1-1 dim
|
||||
style 28-51 fg=blue
|
||||
style 78-78 dim
|
||||
9| " │ ██ ▀█████▄ ▀█████ │ "
|
||||
style 1-1 dim
|
||||
style 28-50 fg=blue
|
||||
style 78-78 dim
|
||||
10| " │ ██▄ ▀████▄ ▄████ │ "
|
||||
style 1-1 dim
|
||||
style 28-50 fg=blue
|
||||
style 78-78 dim
|
||||
11| " │ ██▄ ████████▀ │ "
|
||||
style 1-1 dim
|
||||
style 29-50 fg=blue
|
||||
style 78-78 dim
|
||||
12| " │ ██▄ ▄▄ ▀█████▀ │ "
|
||||
style 1-1 dim
|
||||
style 29-49 fg=blue
|
||||
style 78-78 dim
|
||||
13| " │ ▀███▄▄▄███▄ ████▄▄ │ "
|
||||
style 1-1 dim
|
||||
style 29-50 fg=blue
|
||||
style 78-78 dim
|
||||
14| " │ ▀▀▀███████▀▀ │ "
|
||||
style 1-1 dim
|
||||
style 31-47 fg=blue
|
||||
style 78-78 dim
|
||||
15| " │ │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
16| " │ DeepSeek Harness │ "
|
||||
style 1-1 dim
|
||||
style 32-47 fg=blue bold
|
||||
style 78-78 dim
|
||||
17| " │ │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
18| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
19| " │ │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
20| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
21| " │ │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
22| " │ “如切如磋,如琢如磨。” │ "
|
||||
style 1-1 dim
|
||||
style 3-24 bold
|
||||
style 78-78 dim
|
||||
23| " │ │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
24| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题, │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
25| " │ 也可能促使我们重新审视,甚至推翻已有的设计。 │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
26| " │ │ "
|
||||
style 1-1 dim
|
||||
style 78-78 dim
|
||||
27| " ├────────────────────────────────────────────────────────────────────────────┤ "
|
||||
style 1-78 dim
|
||||
28| " │ Enter 继续 │ "
|
||||
style 1-1 dim
|
||||
style 34-44 fg=bright-magenta bold
|
||||
style 78-78 dim
|
||||
29| " │ ↑/↓ 滚动 ↓ │ "
|
||||
style 1-1 dim
|
||||
style 34-45 dim
|
||||
style 78-78 dim
|
||||
30| " ╰────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 1-78 dim
|
||||
31-32| <blank>
|
||||
189
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
189
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { visibleWidth } from '@earendil-works/pi-tui'
|
||||
import type { TuiOverlayHost, TuiTheme } from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
acknowledgeTuiFirstRunWelcome,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
TuiFirstRunWelcomeComponent,
|
||||
tuiFirstRunWelcomeAcknowledgementPath,
|
||||
tuiFirstRunWelcomeArtTier,
|
||||
} from '../src/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'
|
||||
|
||||
const identityTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => value,
|
||||
brand: (value: string) => value,
|
||||
dim: (value: string) => value,
|
||||
accent: (value: string) => value,
|
||||
success: (value: string) => value,
|
||||
warning: (value: string) => value,
|
||||
error: (value: string) => value,
|
||||
bold: (value: string) => value,
|
||||
})
|
||||
|
||||
function hostFixture(rows: number): {
|
||||
host: TuiOverlayHost
|
||||
closed: () => boolean
|
||||
invalidations: () => number
|
||||
} {
|
||||
let closed = false
|
||||
let invalidations = 0
|
||||
const controller = new AbortController()
|
||||
return {
|
||||
host: Object.freeze({
|
||||
signal: controller.signal,
|
||||
viewport: Object.freeze({ columns: 160, rows }),
|
||||
theme: identityTheme,
|
||||
display: (value: string) => value,
|
||||
invalidate: () => { invalidations += 1 },
|
||||
close: () => { closed = true },
|
||||
}),
|
||||
closed: () => closed,
|
||||
invalidations: () => invalidations,
|
||||
}
|
||||
}
|
||||
|
||||
const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
|
||||
const temporaryHomes: string[] = []
|
||||
|
||||
function withoutWhitespace(value: string): string {
|
||||
return value.replace(/\s/gu, '')
|
||||
}
|
||||
|
||||
async function temporaryHome(prefix: string): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), prefix))
|
||||
temporaryHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('TUI first-run welcome acknowledgement', () => {
|
||||
it('publishes one immutable per-version marker safely across concurrent acknowledgements', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-ack-')
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(false)
|
||||
|
||||
await Promise.all(Array.from({ length: 8 }, () => acknowledgeTuiFirstRunWelcome(home)))
|
||||
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
|
||||
const info = await stat(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION))
|
||||
expect(info.isFile()).toBe(true)
|
||||
if (process.platform !== 'win32') expect(info.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('treats a notice-version bump as a new one-time acknowledgement', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-version-')
|
||||
await acknowledgeTuiFirstRunWelcome(home)
|
||||
const nextVersion = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION + 1
|
||||
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(false)
|
||||
await acknowledgeTuiFirstRunWelcome(home, nextVersion)
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a malformed marker instead of silently acknowledging it', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-malformed-')
|
||||
await mkdir(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION), {
|
||||
recursive: true,
|
||||
})
|
||||
await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI first-run welcome composition', () => {
|
||||
it('pins the supplied official icon and exact Chinese copy at their owner boundaries', async () => {
|
||||
const icon = (await readFile(new URL('../assets/deepseek-color.svg', import.meta.url), 'utf8')).trimEnd()
|
||||
expect(createHash('sha256').update(icon).digest('hex'))
|
||||
.toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439')
|
||||
expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex'))
|
||||
.toBe('c75e395999f572ee231688ef70d5b7f553de3809b57ce8160b4406bd7650f2ec')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ columns: 60, inner: 50, rows: 30, tier: 'minimal' },
|
||||
{ columns: 80, inner: 68, rows: 30, tier: 'compact' },
|
||||
{ columns: 120, inner: 104, rows: 30, tier: 'full' },
|
||||
{ columns: 160, inner: 140, rows: 30, tier: 'full' },
|
||||
] as const)('renders the $tier composition at $columns columns without overdraw', ({ inner, rows, tier }) => {
|
||||
const fixture = hostFixture(rows)
|
||||
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 => visibleWidth(line) <= renderWidth)).toBe(true)
|
||||
expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim())
|
||||
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
|
||||
expect(lines).toHaveLength(Math.floor(rows * 0.9))
|
||||
})
|
||||
|
||||
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 initial = component.render(54).join('\n')
|
||||
expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined()
|
||||
expect(initial).toContain(copy.paragraphs[0])
|
||||
expect(initial).toContain(`Enter ${copy.continueLabel}`)
|
||||
|
||||
component.handleInput('\x1b[F')
|
||||
const end = component.render(54).join('\n')
|
||||
expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-10)))
|
||||
expect(end).toContain(`Enter ${copy.continueLabel}`)
|
||||
})
|
||||
|
||||
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 rendered = component.render(72).join('\n')
|
||||
expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.compact.ascii[0]!.trim())
|
||||
expect(rendered).not.toMatch(/[▀▄█]/u)
|
||||
})
|
||||
|
||||
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)
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\x1b')
|
||||
await Promise.resolve()
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
expect(fixture.closed()).toBe(false)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
expect(acknowledge).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the overlay open after a persistence failure and lets Enter retry', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
let attempts = 0
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('disk unavailable')
|
||||
})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(component.render(72).join('\n')).toContain(copy.saveError)
|
||||
})
|
||||
expect(fixture.closed()).toBe(false)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
expect(attempts).toBe(2)
|
||||
expect(fixture.invalidations()).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -8,12 +9,24 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
|
||||
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
|
||||
import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts'
|
||||
import {
|
||||
acknowledgeTuiFirstRunWelcome,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
} from '../src/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'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
// `--config` layers an overlay over the shared base, so the default surface
|
||||
// needs no config argument at all; these are the overlays under test.
|
||||
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const firstRunSnapshots = fileURLToPath(new URL('./snapshots/tui-first-run-welcome/', import.meta.url))
|
||||
const synchronizedFrameEnd = '\x1b[?2026l'
|
||||
|
||||
/**
|
||||
* Seed the isolated process workspace: ordinary files land in `cwd`, personal
|
||||
@@ -125,18 +138,54 @@ async function readLoggedRequestContext(cwd: string): Promise<LoggedRequestConte
|
||||
* `tui.cordis.yml`, with no flags) or `configPath` (an overlay layered over that
|
||||
* same base through `--config`).
|
||||
*/
|
||||
function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
|
||||
function smoke(overrides: Partial<TuiPtySmokeOptions> & {
|
||||
label: string
|
||||
showFirstRunWelcome?: boolean
|
||||
}): Promise<string> {
|
||||
const { showFirstRunWelcome = false, prepare, ...options } = overrides
|
||||
return runTuiPtySmoke({
|
||||
tempDirPrefix: 'dsh-tui-smoke-',
|
||||
binScript: dshBinScript,
|
||||
tsconfigPath,
|
||||
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
LC_CTYPE: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
// Artifact CI builds and smokes concurrently on a contended runner.
|
||||
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
|
||||
...overrides,
|
||||
...options,
|
||||
prepare: async (cwd) => {
|
||||
if (!showFirstRunWelcome) await acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh'))
|
||||
await prepare?.(cwd)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
|
||||
|
||||
/** Project the first synchronized PTY frame containing `marker` into the stable terminal snapshot format. */
|
||||
async function firstRunFrameSnapshot(
|
||||
output: string,
|
||||
marker: string,
|
||||
columns: number,
|
||||
rows: number,
|
||||
): Promise<string> {
|
||||
const markerIndex = output.indexOf(marker)
|
||||
if (markerIndex < 0) throw new Error(`first-run PTY output has no marker ${JSON.stringify(marker)}`)
|
||||
const frameEnd = output.indexOf(synchronizedFrameEnd, markerIndex)
|
||||
if (frameEnd < 0) throw new Error(`first-run PTY output has no complete frame after ${JSON.stringify(marker)}`)
|
||||
const terminal = new HeadlessTerminal(columns, rows)
|
||||
try {
|
||||
terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length))
|
||||
return await terminal.snapshot()
|
||||
} finally {
|
||||
await terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// The scripted conversation switches to the pro model first: the scripted
|
||||
// adapter proves routing + prompt variables by rejecting tool-ful calls on any
|
||||
// other route (see fixtures/tui-scripted-llm.ts).
|
||||
@@ -146,6 +195,94 @@ const SELECT_PRO_MODEL = [
|
||||
] as const
|
||||
|
||||
describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
it.each([
|
||||
{ columns: 60, tier: 'minimal' },
|
||||
{ columns: 80, tier: 'compact' },
|
||||
{ columns: 120, tier: 'full' },
|
||||
{ columns: 160, tier: 'full' },
|
||||
] as const)('renders and acknowledges the $tier first-run composition at $columns columns', async ({ columns, tier }) => {
|
||||
const output = await smoke({
|
||||
label: `dsh first-run welcome ${String(columns)} columns`,
|
||||
tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
columns,
|
||||
rows: 30,
|
||||
actions: [
|
||||
{ waitFor: firstRunCopy.paragraphs[0]!, send: '\r' },
|
||||
{ waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' },
|
||||
],
|
||||
inspect: async (cwd) => {
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true)
|
||||
const entries = await readdir(join(cwd, '.sessions'), { recursive: true })
|
||||
const logs = entries.filter(name => name.endsWith('.jsonl'))
|
||||
for (const log of logs) {
|
||||
const stored = await readFile(join(cwd, '.sessions', log), 'utf8')
|
||||
expect(stored).not.toContain(firstRunCopy.paragraphs[0])
|
||||
}
|
||||
},
|
||||
})
|
||||
await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, columns, 30))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`))
|
||||
expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim())
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh low-height first-run welcome',
|
||||
tempDirPrefix: 'dsh-tui-welcome-low-',
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
columns: 60,
|
||||
rows: 12,
|
||||
actions: [
|
||||
{ waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' },
|
||||
{ waitFor: '企业微信群', send: '\r' },
|
||||
{ waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' },
|
||||
],
|
||||
})
|
||||
await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt'))
|
||||
expect(output).toContain(firstRunCopy.title)
|
||||
expect(output).toContain(firstRunCopy.paragraphs[0])
|
||||
expect(output).toContain('企业微信群')
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('shows once and skips the second launch under the same DSH_HOME', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-twice-'))
|
||||
try {
|
||||
const first = await smoke({
|
||||
label: 'dsh first welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
actions: [
|
||||
{ waitFor: firstRunCopy.paragraphs[0]!, send: '\r' },
|
||||
{ waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' },
|
||||
],
|
||||
})
|
||||
expect(first).toContain(firstRunCopy.title)
|
||||
|
||||
const second = await smoke({
|
||||
label: 'dsh second welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
actions: [{ waitFor: 'main-session-', send: '/exit\r', delayMs: 1_500 }],
|
||||
})
|
||||
expect(second).not.toContain(firstRunCopy.paragraphs[0])
|
||||
expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => {
|
||||
// With no configured welcome the borderless banner sweeps in left-to-right;
|
||||
// the detail line's session id (`main-session-<uuid>`) renders only once
|
||||
@@ -317,6 +454,51 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
})
|
||||
|
||||
describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
it('shows the terminal-local notice over a resumed session without changing its log', async () => {
|
||||
let originalLineCount = 0
|
||||
const output = await smoke({
|
||||
label: 'dsh first-run notice on resume',
|
||||
tempDirPrefix: 'dsh-tui-welcome-resume-',
|
||||
binScript: dshBinScript,
|
||||
configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath],
|
||||
showFirstRunWelcome: true,
|
||||
prepare: async (cwd) => {
|
||||
await seedResumeSession(cwd)
|
||||
const before = await readFile(logPath(
|
||||
join(cwd, '.sessions'),
|
||||
realpathSync.native(cwd),
|
||||
SessionId('resume-target'),
|
||||
'none',
|
||||
), 'utf8')
|
||||
originalLineCount = before.split('\n').filter(Boolean).length
|
||||
},
|
||||
actions: [
|
||||
{ waitFor: firstRunCopy.paragraphs[0]!, send: '\r' },
|
||||
{ waitFor: 'resume-target', occurrence: 2, send: '/exit\r' },
|
||||
],
|
||||
inspect: async (cwd) => {
|
||||
const after = await readFile(logPath(
|
||||
join(cwd, '.sessions'),
|
||||
realpathSync.native(cwd),
|
||||
SessionId('resume-target'),
|
||||
'none',
|
||||
), 'utf8')
|
||||
expect(after).not.toContain(firstRunCopy.paragraphs[0])
|
||||
const appended = after.split('\n').filter(Boolean).slice(originalLineCount)
|
||||
.map(line => JSON.parse(line) as SessionEvent)
|
||||
expect(appended.map(event => event.type)).toEqual([
|
||||
'session/end-seed',
|
||||
'command/run',
|
||||
'command/done',
|
||||
])
|
||||
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' }))
|
||||
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' }))
|
||||
},
|
||||
})
|
||||
expect(output).toContain(firstRunCopy.paragraphs[0])
|
||||
expect(output).toContain('Resume selector design — DeepSeek Harness')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('exec-replaces the TUI for /resume and restores the same session state', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh in-place resume',
|
||||
|
||||
Reference in New Issue
Block a user