Merge latest master into PR 555
# Conflicts: # packages/ui/tui/README.i18n.yaml
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: 43524950ae7d3379406a0c859b6449f0697d204b
|
||||
README.zh.md: 01954f1229a6f4d076155d42566ed29d568631ca
|
||||
README.md: e2567bebea80b154c01d1cad8094008818e5fd13
|
||||
README.zh.md: a3b7f433040ce0ff02848e3741f407dffe8f8cb8
|
||||
|
||||
@@ -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)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
|
||||
- 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, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
|
||||
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
|
||||
|
||||
`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 the environment is settled, so 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)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。
|
||||
- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。
|
||||
- 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。
|
||||
|
||||
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 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"
|
||||
],
|
||||
@@ -133,6 +134,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-onboarding/tui-first-run-welcome-art.ts
Normal file
111
apps/cli/src/tui-onboarding/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-onboarding/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>>
|
||||
49
apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts
Normal file
49
apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Centrally owned version and all-locale Chinese 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-onboarding/tui-first-run-welcome-copy
|
||||
*/
|
||||
|
||||
/** Copy version persisted after the user explicitly continues. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 4
|
||||
|
||||
/** 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 Chinese notice used for every locale. */
|
||||
const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze<TuiFirstRunWelcomeNoticeCopy>({
|
||||
title: 'DeepSeek Harness',
|
||||
paragraphs: Object.freeze([
|
||||
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
|
||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
||||
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
||||
]),
|
||||
continueLabel: '继续',
|
||||
scrollHint: '↑/↓ 滚动',
|
||||
saving: '正在保存确认…',
|
||||
saveError: '无法保存确认,请按 Enter 重试。',
|
||||
})
|
||||
|
||||
/** Locale map whose entries deliberately share the single Chinese owner copy. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({
|
||||
'zh-CN': TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
|
||||
en: TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
|
||||
})
|
||||
|
||||
/** Locale presented by the shipped first-run notice. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE = 'zh-CN' as const
|
||||
385
apps/cli/src/tui-onboarding/tui-first-run-welcome.ts
Normal file
385
apps/cli/src/tui-onboarding/tui-first-run-welcome.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
loadPersonalPatches,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
|
||||
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
@@ -40,6 +41,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-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
} from './tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -111,6 +122,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
|
||||
@@ -229,5 +245,15 @@ export async function runTui(
|
||||
)
|
||||
app.current = ctx
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
if (showFirstRunWelcome) {
|
||||
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,9 +37,14 @@ 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 "writeFile" in action:
|
||||
if "signal" in action:
|
||||
os.kill(pid, getattr(signal, action["signal"]))
|
||||
elif "writeFile" in action:
|
||||
target = os.path.join(cwd, action["writeFile"]["path"])
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
@@ -68,9 +74,15 @@ 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 waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM' }
|
||||
| {
|
||||
readonly waitFor: string
|
||||
readonly occurrence?: number
|
||||
readonly writeFile: { readonly path: string; readonly content: string }
|
||||
readonly send?: string
|
||||
}
|
||||
@@ -89,6 +101,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 +137,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 +170,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 +179,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,9 +189,14 @@ 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) {
|
||||
if ('signal' in action) {
|
||||
terminal.kill(action.signal)
|
||||
} else if ('writeFile' in action) {
|
||||
const target = join(cwd, action.writeFile.path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, action.writeFile.content)
|
||||
@@ -205,7 +230,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 +257,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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { COMPOSITION_REPLY_TEXT } from './fixtures/composition-echo-llm.ts'
|
||||
import { COMPOSITION_SETTLED_MARKER } from './fixtures/composition-settled.ts'
|
||||
import { runTuiPtySmoke } from './pty-harness.ts'
|
||||
import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-run-welcome.ts'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
@@ -105,6 +106,7 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => {
|
||||
tsconfigPath,
|
||||
configPath: keylessTail,
|
||||
env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' },
|
||||
prepare: cwd => acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh')),
|
||||
// Artifact CI builds and smokes concurrently on a contended runner.
|
||||
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
|
||||
actions: [
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
overlay 120x30 rows=20
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-119 dim
|
||||
1| "│ ▄ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 10-37 fg=blue
|
||||
style 75-90 fg=blue bold
|
||||
style 119-119 dim
|
||||
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 119-119 dim
|
||||
4| "│ ▄███████████████████▄ ████████████▀ 仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 119-119 dim
|
||||
5| "│ ▄██████████████████████▄ ▀█████████▀ │"
|
||||
style 0-0 dim
|
||||
style 4-42 fg=blue
|
||||
style 119-119 dim
|
||||
6| "│ ▄███▀█████████████████████▄ ████▀▀ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 6-41 fg=blue
|
||||
style 49-70 bold
|
||||
style 119-119 dim
|
||||
7| "│ ███ ▀▀█████████▀▀▀█████████▀ │"
|
||||
style 0-0 dim
|
||||
style 7-40 fg=blue
|
||||
style 119-119 dim
|
||||
8| "│ ███ ▀███████▀█ ▀███████ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 119-119 dim
|
||||
9| "│ ███▄ ▀███████▄ ▀█████▀ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 119-119 dim
|
||||
10| "│ ▀███ ▀██████████████ │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 119-119 dim
|
||||
11| "│ ▀███▄ ▀███████████▀ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 │"
|
||||
style 0-0 dim
|
||||
style 8-38 fg=blue
|
||||
style 119-119 dim
|
||||
12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外 │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
13| "│ █████▄ ███▄▄ ▀█████▄▄ ,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈, │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
14| "│ ▀█████████████▄▄▄▄█▀█████▀ 都会帮助我们把它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 119-119 dim
|
||||
15| "│ ▀▀███████████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 12-34 fg=blue
|
||||
style 119-119 dim
|
||||
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-119 dim
|
||||
17| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 54-64 fg=bright-magenta bold
|
||||
style 119-119 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 119-119 dim
|
||||
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-119 dim
|
||||
@@ -0,0 +1,76 @@
|
||||
overlay 160x30 rows=20
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-159 dim
|
||||
1| "│ ▄ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 10-37 fg=blue
|
||||
style 95-110 fg=blue bold
|
||||
style 159-159 dim
|
||||
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 159-159 dim
|
||||
4| "│ ▄███████████████████▄ ████████████▀ │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 159-159 dim
|
||||
5| "│ ▄██████████████████████▄ ▀█████████▀ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 4-42 fg=blue
|
||||
style 49-70 bold
|
||||
style 159-159 dim
|
||||
6| "│ ▄███▀█████████████████████▄ ████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 6-41 fg=blue
|
||||
style 159-159 dim
|
||||
7| "│ ███ ▀▀█████████▀▀▀█████████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │"
|
||||
style 0-0 dim
|
||||
style 7-40 fg=blue
|
||||
style 159-159 dim
|
||||
8| "│ ███ ▀███████▀█ ▀███████ 有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 159-159 dim
|
||||
9| "│ ███▄ ▀███████▄ ▀█████▀ │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 159-159 dim
|
||||
10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 159-159 dim
|
||||
11| "│ ▀███▄ ▀███████████▀ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我 │"
|
||||
style 0-0 dim
|
||||
style 8-38 fg=blue
|
||||
style 159-159 dim
|
||||
12| "│ ▀███▄ ▄▄▄ ▀████████▀ 们把它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
13| "│ █████▄ ███▄▄ ▀█████▄▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
14| "│ ▀█████████████▄▄▄▄█▀█████▀ │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 159-159 dim
|
||||
15| "│ ▀▀███████████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 12-34 fg=blue
|
||||
style 159-159 dim
|
||||
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-159 dim
|
||||
17| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 74-84 fg=bright-magenta bold
|
||||
style 159-159 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 159-159 dim
|
||||
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-159 dim
|
||||
@@ -0,0 +1,31 @@
|
||||
overlay 60x12 rows=10
|
||||
0| "╭──────────────────────────────────────────────────────────╮"
|
||||
style 0-59 dim
|
||||
1| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 22-37 fg=blue bold
|
||||
style 59-59 dim
|
||||
2| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
5| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
6| "├──────────────────────────────────────────────────────────┤"
|
||||
style 0-59 dim
|
||||
7| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 24-34 fg=bright-magenta bold
|
||||
style 59-59 dim
|
||||
8| "│ ↑/↓ 滚动 ↓ │"
|
||||
style 0-0 dim
|
||||
style 24-35 dim
|
||||
style 59-59 dim
|
||||
9| "╰──────────────────────────────────────────────────────────╯"
|
||||
style 0-59 dim
|
||||
@@ -0,0 +1,64 @@
|
||||
overlay 60x30 rows=21
|
||||
0| "╭──────────────────────────────────────────────────────────╮"
|
||||
style 0-59 dim
|
||||
1| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 22-37 fg=blue bold
|
||||
style 59-59 dim
|
||||
2| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
5| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
6| "│ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 2-23 bold
|
||||
style 59-59 dim
|
||||
7| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
8| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
9| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
10| "│ 设计。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
11| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
13| "│ 会上传所有 Session Log;如需关闭,请设置环境变量 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
14| "│ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议, │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
15| "│ 请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
16| "│ 它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
17| "├──────────────────────────────────────────────────────────┤"
|
||||
style 0-59 dim
|
||||
18| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 24-34 fg=bright-magenta bold
|
||||
style 59-59 dim
|
||||
19| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
20| "╰──────────────────────────────────────────────────────────╯"
|
||||
style 0-59 dim
|
||||
@@ -0,0 +1,89 @@
|
||||
overlay 80x30 rows=27
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-79 dim
|
||||
1| "│ ▄▄▄▄▄▄ ▄▄ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
2| "│ ▄████████▄ ▀████▀ │"
|
||||
style 0-0 dim
|
||||
style 31-48 fg=blue
|
||||
style 79-79 dim
|
||||
3| "│ █▀▀▀▀███████▄██▀ │"
|
||||
style 0-0 dim
|
||||
style 32-47 fg=blue
|
||||
style 79-79 dim
|
||||
4| "│ █▄ ▀███ ▀███ │"
|
||||
style 0-0 dim
|
||||
style 32-46 fg=blue
|
||||
style 79-79 dim
|
||||
5| "│ ▀█▄ ▀█████ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
6| "│ ▀█▄▄ █▄▄▀███▄ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
7| "│ ▀▀▀▀▀▀ │"
|
||||
style 0-0 dim
|
||||
style 35-44 fg=blue
|
||||
style 79-79 dim
|
||||
8| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
9| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 32-47 fg=blue bold
|
||||
style 79-79 dim
|
||||
10| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
12| "│ 善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
13| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
14| "│ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 2-23 bold
|
||||
style 79-79 dim
|
||||
15| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
16| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
17| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
20| "│ Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
21| "│ 馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
22| "│ 好。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
23| "├──────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-79 dim
|
||||
24| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 34-44 fg=bright-magenta bold
|
||||
style 79-79 dim
|
||||
25| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
26| "╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-79 dim
|
||||
346
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
346
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
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 type { Context } from 'cordis'
|
||||
import { visibleWidth } from '@earendil-works/pi-tui'
|
||||
import {
|
||||
type TuiOverlayHost,
|
||||
type TuiOverlayRequest,
|
||||
type TuiTheme,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
acknowledgeTuiFirstRunWelcome,
|
||||
apply,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
needsTuiFirstRunWelcomeAsciiArt,
|
||||
TuiFirstRunWelcomeComponent,
|
||||
tuiFirstRunWelcomeAcknowledgementPath,
|
||||
tuiFirstRunWelcomeArtTier,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
|
||||
|
||||
const mockDisposeRootAndExit = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@deepseek-ai/dsh-tui', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@deepseek-ai/dsh-tui')>(),
|
||||
disposeRootAndExit: mockDisposeRootAndExit,
|
||||
}))
|
||||
|
||||
const identityTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => value,
|
||||
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 openingSentence = `${copy.paragraphs[0]!.split('。', 1)[0]}。`
|
||||
const temporaryHomes: string[] = []
|
||||
|
||||
function artAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
|
||||
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
|
||||
}
|
||||
|
||||
function withoutWhitespace(value: string): string {
|
||||
return value.replace(/\s/gu, '')
|
||||
}
|
||||
|
||||
async function temporaryHome(prefix: string): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), prefix))
|
||||
temporaryHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mockDisposeRootAndExit.mockClear()
|
||||
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')
|
||||
await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('detects only explicit ASCII-only terminal environments', () => {
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ TERM: 'dumb' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_ALL: 'C' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_CTYPE: 'POSIX' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'C' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'en_US.UTF-8' })).toBe(false)
|
||||
expect(typeof needsTuiFirstRunWelcomeAsciiArt()).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
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('99f9a828b4f083b28de21bf5e03f939c00238531e765db78911957c44c6e98da')
|
||||
expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ columns: 60, inner: 50, rows: 30, tier: undefined },
|
||||
{ columns: 80, inner: 68, rows: 30, tier: 'minimal' },
|
||||
{ columns: 100, inner: 84, rows: 34, tier: 'compact' },
|
||||
{ columns: 120, inner: 104, rows: 30, tier: 'full' },
|
||||
{ columns: 160, inner: 140, rows: 30, tier: 'full' },
|
||||
] as const)('renders the responsive composition at $columns columns without overdraw', ({ inner, rows, tier }) => {
|
||||
const fixture = hostFixture(rows)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const renderWidth = inner + 4
|
||||
const lines = component.render(renderWidth)
|
||||
|
||||
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
|
||||
expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true)
|
||||
if (tier === undefined) {
|
||||
expect(lines.join('\n')).not.toMatch(/[▀▄█]/u)
|
||||
} else {
|
||||
expect(lines.join('\n')).toContain(artAnchor(tier))
|
||||
}
|
||||
const rendered = lines.join('\n')
|
||||
const optOut = copy.paragraphs.at(-1)!.match(/[A-Z_]+=1/u)![0]
|
||||
expect(rendered).not.toContain(copy.scrollHint)
|
||||
expect(rendered).toContain(copy.paragraphs.at(-1)!.match(/[A-Za-z]+ [A-Za-z]+/u)![0])
|
||||
expect(rendered).toContain(optOut)
|
||||
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
|
||||
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
|
||||
expect(lines.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ inner: 68, rows: 14, tier: undefined },
|
||||
{ inner: 68, rows: 17, tier: undefined },
|
||||
{ inner: 68, rows: 18, tier: 'minimal' },
|
||||
{ inner: 84, rows: 21, tier: 'minimal' },
|
||||
{ inner: 84, rows: 22, tier: 'compact' },
|
||||
] as const)('degrades art to preserve the action at $rows rows', ({ inner, rows, tier }) => {
|
||||
const fixture = hostFixture(rows)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const lines = component.render(inner + 4)
|
||||
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
|
||||
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
|
||||
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
|
||||
})
|
||||
|
||||
it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => {
|
||||
const fixture = hostFixture(10)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const initial = component.render(54).join('\n')
|
||||
expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined()
|
||||
expect(initial).toContain(openingSentence)
|
||||
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(-7)))
|
||||
expect(end).toContain(`Enter ${copy.continueLabel}`)
|
||||
|
||||
for (const key of ['\x1b[A', '\x1b[B', '\x1b[5~', '\x1b[6~', '\x1b[H', 'x']) {
|
||||
component.handleInput(key)
|
||||
}
|
||||
component.invalidate()
|
||||
})
|
||||
|
||||
it('renders a tiny viewport and a quotation-only paragraph without overdraw', () => {
|
||||
const fixture = hostFixture(5)
|
||||
const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] }
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}, () => {})
|
||||
const lines = component.render(2)
|
||||
expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the side-by-side composition aligned when prose outgrows the full raster', () => {
|
||||
const fixture = hostFixture(40)
|
||||
const longCopy = { ...copy, paragraphs: [copy.paragraphs.join(' ').repeat(4)] }
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}, () => {})
|
||||
const lines = component.render(100)
|
||||
expect(lines.length).toBeGreaterThan(TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length)
|
||||
expect(lines.every(line => visibleWidth(line) <= 100)).toBe(true)
|
||||
component.handleInput('\x1b[F')
|
||||
expect(component.render(100).join('\n')).toContain(copy.title)
|
||||
})
|
||||
|
||||
it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => {
|
||||
const fixture = hostFixture(30)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}, true)
|
||||
const rendered = component.render(72).join('\n')
|
||||
expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
|
||||
expect(rendered).not.toMatch(/[▀▄█]/u)
|
||||
})
|
||||
|
||||
it.each(['full', 'compact', 'minimal'] as const)('keeps the $tier ASCII raster bit-equivalent', (tier) => {
|
||||
const mapped = TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode.map(line => Array.from(line).map((cell) => {
|
||||
if (cell === '▀') return "'"
|
||||
if (cell === '▄') return '_'
|
||||
if (cell === '█') return '#'
|
||||
return cell
|
||||
}).join(''))
|
||||
expect(mapped).toEqual(TUI_FIRST_RUN_WELCOME_WHALE[tier].ascii)
|
||||
})
|
||||
|
||||
it('ignores Escape and acknowledges only Enter before closing', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const acknowledge = vi.fn(async () => {})
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
|
||||
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 notice eligible when Ctrl+C or Ctrl+D requests a normal exit', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const acknowledge = vi.fn(async () => {})
|
||||
const exit = vi.fn()
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, exit)
|
||||
component.handleInput('\x03')
|
||||
component.handleInput('\x04')
|
||||
expect(exit).toHaveBeenCalledTimes(2)
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
expect(fixture.closed()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start a second acknowledgement while the first Enter is pending', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const acknowledge = vi.fn(async () => pending.promise)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\r')
|
||||
component.handleInput('\r')
|
||||
component.handleInput('\x1b[B')
|
||||
expect(component.render(72).join('\n')).toContain(copy.saving)
|
||||
expect(acknowledge).toHaveBeenCalledOnce()
|
||||
|
||||
pending.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('opens through the TUI extension and uses the launcher-owned acknowledgement closure', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-apply-')
|
||||
let request: TuiOverlayRequest | undefined
|
||||
let disposePending: (() => Promise<void>) | undefined
|
||||
const ctx = {
|
||||
effect(register: () => () => Promise<void>) {
|
||||
disposePending = register()
|
||||
return () => {}
|
||||
},
|
||||
tui: {
|
||||
openOverlay(value: TuiOverlayRequest) {
|
||||
request = value
|
||||
return {} as never
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
apply(ctx, { dshHome: home })
|
||||
expect(request?.options).toEqual({
|
||||
width: '100%',
|
||||
maxHeight: '90%',
|
||||
anchor: 'center',
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
const fixture = hostFixture(30)
|
||||
const component = request?.create(fixture.host)
|
||||
expect(component).toBeInstanceOf(TuiFirstRunWelcomeComponent)
|
||||
component?.handleInput?.('\x03')
|
||||
expect(mockDisposeRootAndExit).toHaveBeenCalledWith(ctx, 0)
|
||||
component?.handleInput?.('\r')
|
||||
await disposePending?.()
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
|
||||
|
||||
apply(ctx, { dshHome: home, asciiArt: true })
|
||||
expect(request?.create(fixture.host).render(72).join('\n'))
|
||||
.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
|
||||
})
|
||||
})
|
||||
@@ -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,30 @@ 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-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
// `--config` layers an overlay over the shared base, so the default surface
|
||||
// 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('./tui-first-run-snapshots/', import.meta.url))
|
||||
const synchronizedFrameEnd = '\x1b[?2026l'
|
||||
// Artifact mode gives the inner PTY driver 60 seconds and its execa owner a
|
||||
// five-second backstop. Keep Vitest outside both deadlines so the harness can
|
||||
// report its own marker, exit, and cleanup failure instead of being cut off.
|
||||
const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
|
||||
? 75_000
|
||||
: LOADER_SMOKE_TEST_TIMEOUT_MS
|
||||
|
||||
/**
|
||||
* Seed the isolated process workspace: ordinary files land in `cwd`, personal
|
||||
@@ -125,20 +144,77 @@ 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,
|
||||
// Telemetry now mounts in the shared base: keep fixture sessions from
|
||||
// POSTing to the production endpoint when run outside CI's workflow env.
|
||||
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
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]
|
||||
const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}。`
|
||||
|
||||
function firstRunArtAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
|
||||
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
|
||||
}
|
||||
|
||||
/** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */
|
||||
function overlaySnapshot(snapshot: string, columns: number, rows: number): string {
|
||||
const blocks: string[][] = []
|
||||
for (const line of snapshot.split('\n')) {
|
||||
if (/^\d+(?:-\d+)?~?\| /u.test(line)) blocks.push([line])
|
||||
else if (line.startsWith(' style ') && blocks.length > 0) blocks.at(-1)?.push(line)
|
||||
}
|
||||
const first = blocks.findIndex(block => block[0]?.includes('╭') === true)
|
||||
const last = blocks.findIndex((block, index) => index >= first && block[0]?.includes('╰') === true)
|
||||
if (first < 0 || last < first) throw new Error('first-run PTY snapshot has no complete overlay frame')
|
||||
const overlay = blocks.slice(first, last + 1).flatMap((block, index) => [
|
||||
block[0]!.replace(/^\d+(?:-\d+)?(~)?\|/u, `${String(index)}$1|`),
|
||||
...block.slice(1),
|
||||
])
|
||||
return [`overlay ${String(columns)}x${String(rows)} rows=${String(last - first + 1)}`, ...overlay, ''].join('\n')
|
||||
}
|
||||
|
||||
/** Project the first synchronized PTY frame containing `marker` into an overlay-only snapshot. */
|
||||
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 overlaySnapshot(await terminal.snapshot(), columns, rows)
|
||||
} 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).
|
||||
@@ -148,6 +224,138 @@ const SELECT_PRO_MODEL = [
|
||||
] as const
|
||||
|
||||
describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
it.each([
|
||||
{ columns: 60, tier: undefined },
|
||||
{ columns: 80, tier: 'minimal' },
|
||||
{ columns: 120, tier: 'full' },
|
||||
{ columns: 160, tier: 'full' },
|
||||
] as const)('renders and acknowledges the responsive 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,
|
||||
expectedExitCode: 0,
|
||||
columns,
|
||||
rows: 30,
|
||||
actions: [
|
||||
{
|
||||
waitFor: `Enter ${firstRunCopy.continueLabel}`,
|
||||
send: '\r\x03',
|
||||
},
|
||||
],
|
||||
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, firstRunOpeningSentence, columns, 30))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`))
|
||||
if (tier === undefined) {
|
||||
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
|
||||
} else {
|
||||
expect(output).toContain(firstRunArtAnchor(tier))
|
||||
}
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
}, PTY_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,
|
||||
expectedExitCode: 0,
|
||||
columns: 60,
|
||||
rows: 12,
|
||||
actions: [
|
||||
{ waitFor: firstRunOpeningSentence, send: '\x1b[F' },
|
||||
{
|
||||
waitFor: `Enter ${firstRunCopy.continueLabel}`,
|
||||
occurrence: 2,
|
||||
send: '\r\x03',
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, 60, 12))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt'))
|
||||
expect(output).toContain(firstRunCopy.title)
|
||||
expect(output).toContain(firstRunOpeningSentence)
|
||||
expect(output).toContain('企业微信群')
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
|
||||
}, PTY_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,
|
||||
expectedExitCode: 0,
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
})
|
||||
expect(first).toContain(firstRunCopy.title)
|
||||
|
||||
const second = await smoke({
|
||||
label: 'dsh second welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: process.platform === 'win32' ? 0 : -15,
|
||||
actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }],
|
||||
})
|
||||
expect(second).not.toContain(firstRunOpeningSentence)
|
||||
expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('keeps the notice eligible when the process exits before Enter', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-abort-'))
|
||||
try {
|
||||
await smoke({
|
||||
label: 'dsh aborted welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: -15,
|
||||
actions: [{ waitFor: firstRunOpeningSentence, signal: 'SIGTERM' }],
|
||||
inspect: async (workspace) => {
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false)
|
||||
},
|
||||
})
|
||||
|
||||
const next = await smoke({
|
||||
label: 'dsh welcome after aborted launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
})
|
||||
expect(next).toContain(firstRunOpeningSentence)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, PTY_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
|
||||
@@ -170,7 +378,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).not.toContain('╭')
|
||||
expect(output).not.toContain('╮')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
|
||||
const output = await smoke({
|
||||
@@ -223,7 +431,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('Registered tools')
|
||||
expect(output).toContain('ask_user_question')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
|
||||
// The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
|
||||
@@ -259,7 +467,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).not.toContain('[instructions]')
|
||||
expect(output).toContain('Scripted skill body received.')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('adds a watched local skill to live /skill: autocomplete without restarting', async () => {
|
||||
const skill = [
|
||||
@@ -289,7 +497,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
})
|
||||
expect(output).toContain('HOT_ADDED_COMPLETION_MARKER')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => {
|
||||
const output = await smoke({
|
||||
@@ -314,11 +522,51 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('File · terminal-special-case.t')
|
||||
expect(output).toContain('@src/terminal-special-case.ts')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
})
|
||||
|
||||
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,
|
||||
expectedExitCode: 0,
|
||||
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: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
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).not.toContainEqual(expect.objectContaining({ type: 'user/message' }))
|
||||
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' }))
|
||||
},
|
||||
})
|
||||
expect(output).toContain(firstRunOpeningSentence)
|
||||
expect(output).toContain('Resume selector design — DeepSeek Harness')
|
||||
}, PTY_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',
|
||||
@@ -338,7 +586,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(released).toBeGreaterThanOrEqual(0)
|
||||
expect(restored).toBeGreaterThan(released)
|
||||
expect(output).toContain('Preserve restored state')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('boots the shipped default config with no arguments and no personal overlay', async () => {
|
||||
const output = await smoke({
|
||||
@@ -353,7 +601,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).not.toContain('╭')
|
||||
expect(output).not.toContain('╮')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
|
||||
// The whole personal-config chain in one boot, plus the environment layer
|
||||
@@ -390,7 +638,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).toContain('PROJECT OVERLAY READY.')
|
||||
expect(output).not.toContain('HOME ENV LEAKED.')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
|
||||
const output = await smoke({
|
||||
@@ -402,7 +650,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(output).toContain('must be a top-level YAML array of loader patch entries')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => {
|
||||
// The flag path end to end: apps/cli parses `--resume missing-session`,
|
||||
@@ -417,7 +665,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('prints the launcher-owned resume command on exit, naming the booted config', async () => {
|
||||
// The exit line is built by apps/cli from this invocation, so it must carry
|
||||
@@ -430,7 +678,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }],
|
||||
})
|
||||
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => {
|
||||
// Loader patches replace a targeted `config` key wholesale, so a personal
|
||||
@@ -465,7 +713,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }],
|
||||
})
|
||||
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('reports a failing bash command exactly once, as the terminal card exit pill', async () => {
|
||||
// The model-facing result ends in `[exit code: 3]`, which the terminal card
|
||||
@@ -488,7 +736,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).toContain('SCRIPTED_BASH_FAILED')
|
||||
expect(output).toContain('[exit 3]')
|
||||
expect(output).not.toContain('[exit code: 3]')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('tells the model its source path and offers the bundled maintenance skills', async () => {
|
||||
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
|
||||
@@ -514,5 +762,5 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.")
|
||||
expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.')
|
||||
expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user