From 0f8d0082e4b916f2afed3fec850853a9d26dfb77 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:20:59 +0800 Subject: [PATCH] fix(tui): stabilize Windows terminal snapshots Treat an absolute path.relative() result as a cross-volume path instead of incorrectly abbreviating it beneath the user's home directory. Allow embeddings to project a logical footer cwd without changing the operational session cwd. The recorded-session harness now uses a POSIX-shaped display alias for both the footer and filesystem result paths, preserving the existing pre-normalization layout width on every host. Keep runtime-provided labels behind terminal-control escaping, cover that boundary, and document the embedding contract. --- examples/tui-agent/tests/tui.snapshot.ts | 35 ++++++++++++++++++++---- packages/ui/tui/README.md | 2 ++ packages/ui/tui/src/index.ts | 27 ++++++++++++++---- packages/ui/tui/tests/harness.ts | 9 ++++-- packages/ui/tui/tests/tui.spec.ts | 7 +++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index c0537021e8..5c443ee474 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -1,6 +1,6 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' @@ -106,6 +106,13 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode { const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT) const observedScenarios = new Set() +function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string { + const rel = relative(cwd, displayPath) + if (rel === '') return displayCwd + if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return displayPath + return `${displayCwd}/${rel.split(sep).join('/')}` +} + function scenarioDir(scenario: Scenario): string { return join(SNAPSHOTS_DIR, scenario.name) } @@ -136,9 +143,10 @@ function rawSessionLog(session: Session): string { ].join('\n') } -function normalizeTerminalSnapshot(snapshot: string, cwd: string): string { +function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string { return snapshot .split(`/private${cwd}`).join('/workspace/project') + .split(displayCwd).join('/workspace/project') .split(cwd).join('/workspace/project') .replace(UUID_RE, '{{uuid}}') } @@ -157,9 +165,20 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise { async function mountScenarioContext( scenario: Scenario, cwd: string, + displayCwd: string, fixtureFile: string, childFiles: string[], ): Promise { + class SnapshotLocalFileSystem extends LocalFileSystem { + override async resolve( + path: string, + opts?: { cwd?: string; signal?: AbortSignal }, + ): Promise>> { + const target = await super.resolve(path, opts) + return { ...target, displayPath: snapshotDisplayPath(target.displayPath, cwd, displayCwd) } + } + } + const ctx = new Context() await ctx.plugin(AgentCore, { agents: [], @@ -169,7 +188,7 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) - await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) await ctx.plugin(UserInteractionService) @@ -207,6 +226,7 @@ async function runScenario(scenario: Scenario): Promise { expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) + const displayCwd = `/tmp/${basename(cwd)}` let ctx: Context | undefined let controller: ReturnType | undefined const terminal = new HeadlessTerminal(100, 36) @@ -215,7 +235,7 @@ async function runScenario(scenario: Scenario): Promise { const source = join(scenarioDir(scenario), 'workspace') await cp(source, cwd, { recursive: true }) } - ctx = await mountScenarioContext(scenario, cwd, fixtureFile, childFiles) + ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles) const disposedSessions: Session[] = [] ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) const workflowEvents: string[] = [] @@ -235,7 +255,11 @@ async function runScenario(scenario: Scenario): Promise { title: 'DSH TUI snapshot', welcome: `Recorded replay: ${scenario.name}`, maxToolOutputLines: 8, - }, { terminal, exit: () => {} }) + }, { + terminal, + exit: () => {}, + formatCwd: () => displayCwd, + }) await settleTerminal(terminal) for (const prompt of prompts) { @@ -266,6 +290,7 @@ async function runScenario(scenario: Scenario): Promise { const snapshot = normalizeTerminalSnapshot( await terminal.snapshot({ includeScrollback: true }), cwd, + displayCwd, ) await handle.dispose() const children = disposedSessions diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index e426640ae5..34987c5d55 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -8,6 +8,8 @@ This package owns interactive terminal presentation and input only. It injects ` The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. +An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. + Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 0906b7bc9f..210ff10071 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -6,7 +6,7 @@ */ import { homedir } from 'node:os' -import { relative, resolve, sep } from 'node:path' +import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, @@ -135,6 +135,12 @@ export interface TuiRuntime { terminal: Terminal /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void + /** + * Override the footer's logical working-directory label without changing the session directory used by tools. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped label; the TUI makes terminal controls visible. + */ + formatCwd?: (cwd: string | undefined) => string } /** @@ -608,8 +614,10 @@ function formatCwd(cwd: string | undefined): string { const home = homedir() const rel = relative(resolve(home), resolve(cwd)) if (rel === '') return '~' - if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`) - return displayText(cwd) + /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */ + if (isAbsolute(rel)) return cwd + if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` + return cwd } function sessionTokens(session: Session): { input: number; output: number } { @@ -630,13 +638,15 @@ class FooterComponent implements Component { private readonly toolsExpanded: () => boolean, private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, + private readonly cwdFormatter: TuiRuntime['formatCwd'], ) {} invalidate(): void {} render(width: number): string[] { const { input, output } = this.tokens() - const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` + const cwd = this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd) + const left = `${displayText(cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}` const leftStyled = this.palette.dim(left) const available = Math.max(0, width - visibleWidth(left) - 2) @@ -843,7 +853,14 @@ export function createTuiChat( const welcome = config.welcome ?? 'ready.' const header = new HeaderComponent(agent, welcome, palette) - const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens) + const footer = new FooterComponent( + agent, + palette, + () => toolsExpanded, + () => showReasoning, + () => tokens, + runtime.formatCwd, + ) ui.addChild(header) ui.addChild(chat) ui.addChild(statusContainer) diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..7cef8ed185 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { createTuiChat, type Config } from '../src/index.ts' +import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -21,6 +21,7 @@ export interface TuiHarnessOptions { configureContext?: (ctx: Context) => Promise beforeMount?: (session: Session) => void cwd?: string | null + formatCwd?: TuiRuntime['formatCwd'] } export interface TuiHarness void> { @@ -95,7 +96,11 @@ export async function createTuiTestHarness { const outsideResult = await setup({ cwd: '/opt' }) expect(outsideResult.terminal.output).toContain('/opt') await dispose(outsideResult) + + const logicalResult = await setup({ + cwd: '/host/worktree', + formatCwd: cwd => `logical:${cwd}\x1b`, + }) + expect(logicalResult.terminal.output).toContain('logical:/host/worktree\\x1b') + await dispose(logicalResult) }) it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {