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.
This commit is contained in:
Tianyi Cui
2026-07-19 13:20:59 +08:00
parent bdba206640
commit 0f8d0082e4
5 files changed
+68 -12

No files matched your search

+30 -5
View File
@@ -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<string>()
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<void> {
async function mountScenarioContext(
scenario: Scenario,
cwd: string,
displayCwd: string,
fixtureFile: string,
childFiles: string[],
): Promise<Context> {
class SnapshotLocalFileSystem extends LocalFileSystem {
override async resolve(
path: string,
opts?: { cwd?: string; signal?: AbortSignal },
): Promise<Awaited<ReturnType<LocalFileSystem['resolve']>>> {
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<ScenarioResult> {
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<typeof createTuiChat> | undefined
const terminal = new HeadlessTerminal(100, 36)
@@ -215,7 +235,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
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<ScenarioResult> {
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<ScenarioResult> {
const snapshot = normalizeTerminalSnapshot(
await terminal.snapshot({ includeScrollback: true }),
cwd,
displayCwd,
)
await handle.dispose()
const children = disposedSessions
+2
View File
@@ -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.
+22 -5
View File
@@ -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)
+7 -2
View File
@@ -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<void>
beforeMount?: (session: Session) => void
cwd?: string | null
formatCwd?: TuiRuntime['formatCwd']
}
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -95,7 +96,11 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
welcome: 'Coding agent ready.',
sessionId,
color: false,
}, options.config), { terminal, exit })
}, options.config), {
terminal,
exit,
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
})
return { ctx, session, agent, terminal, exit, controller }
}
+7
View File
@@ -364,6 +364,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
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 () => {