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 changed files with 68 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' 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 { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest' import { afterAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
@@ -106,6 +106,13 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT) const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
const observedScenarios = new Set<string>() 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 { function scenarioDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.name) return join(SNAPSHOTS_DIR, scenario.name)
} }
@@ -136,9 +143,10 @@ function rawSessionLog(session: Session): string {
].join('\n') ].join('\n')
} }
function normalizeTerminalSnapshot(snapshot: string, cwd: string): string { function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
return snapshot return snapshot
.split(`/private${cwd}`).join('/workspace/project') .split(`/private${cwd}`).join('/workspace/project')
.split(displayCwd).join('/workspace/project')
.split(cwd).join('/workspace/project') .split(cwd).join('/workspace/project')
.replace(UUID_RE, '{{uuid}}') .replace(UUID_RE, '{{uuid}}')
} }
@@ -157,9 +165,20 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
async function mountScenarioContext( async function mountScenarioContext(
scenario: Scenario, scenario: Scenario,
cwd: string, cwd: string,
displayCwd: string,
fixtureFile: string, fixtureFile: string,
childFiles: string[], childFiles: string[],
): Promise<Context> { ): 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() const ctx = new Context()
await ctx.plugin(AgentCore, { await ctx.plugin(AgentCore, {
agents: [], agents: [],
@@ -169,7 +188,7 @@ async function mountScenarioContext(
skills: { local: { agentsHome: join(cwd, '.agents') } }, skills: { local: { agentsHome: join(cwd, '.agents') } },
}) })
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) 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(FsPolicy)
await ctx.plugin(ToolFs) await ctx.plugin(ToolFs)
await ctx.plugin(UserInteractionService) 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) 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 cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
const displayCwd = `/tmp/${basename(cwd)}`
let ctx: Context | undefined let ctx: Context | undefined
let controller: ReturnType<typeof createTuiChat> | undefined let controller: ReturnType<typeof createTuiChat> | undefined
const terminal = new HeadlessTerminal(100, 36) const terminal = new HeadlessTerminal(100, 36)
@@ -215,7 +235,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const source = join(scenarioDir(scenario), 'workspace') const source = join(scenarioDir(scenario), 'workspace')
await cp(source, cwd, { recursive: true }) await cp(source, cwd, { recursive: true })
} }
ctx = await mountScenarioContext(scenario, cwd, fixtureFile, childFiles) ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
const disposedSessions: Session[] = [] const disposedSessions: Session[] = []
ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
const workflowEvents: string[] = [] const workflowEvents: string[] = []
@@ -235,7 +255,11 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
title: 'DSH TUI snapshot', title: 'DSH TUI snapshot',
welcome: `Recorded replay: ${scenario.name}`, welcome: `Recorded replay: ${scenario.name}`,
maxToolOutputLines: 8, maxToolOutputLines: 8,
}, { terminal, exit: () => {} }) }, {
terminal,
exit: () => {},
formatCwd: () => displayCwd,
})
await settleTerminal(terminal) await settleTerminal(terminal)
for (const prompt of prompts) { for (const prompt of prompts) {
@@ -266,6 +290,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const snapshot = normalizeTerminalSnapshot( const snapshot = normalizeTerminalSnapshot(
await terminal.snapshot({ includeScrollback: true }), await terminal.snapshot({ includeScrollback: true }),
cwd, cwd,
displayCwd,
) )
await handle.dispose() await handle.dispose()
const children = disposedSessions const children = disposedSessions

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. 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. 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. 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.

View File

@@ -6,7 +6,7 @@
*/ */
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { relative, resolve, sep } from 'node:path' import { isAbsolute, relative, resolve, sep } from 'node:path'
import { import {
CombinedAutocompleteProvider, CombinedAutocompleteProvider,
Container, Container,
@@ -135,6 +135,12 @@ export interface TuiRuntime {
terminal: Terminal terminal: Terminal
/** Exit hook used by terminal shutdown or a target-agent startup failure. */ /** Exit hook used by terminal shutdown or a target-agent startup failure. */
exit(code: number): void 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 home = homedir()
const rel = relative(resolve(home), resolve(cwd)) const rel = relative(resolve(home), resolve(cwd))
if (rel === '') return '~' if (rel === '') return '~'
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`) /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
return displayText(cwd) if (isAbsolute(rel)) return cwd
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
return cwd
} }
function sessionTokens(session: Session): { input: number; output: number } { function sessionTokens(session: Session): { input: number; output: number } {
@@ -630,13 +638,15 @@ class FooterComponent implements Component {
private readonly toolsExpanded: () => boolean, private readonly toolsExpanded: () => boolean,
private readonly showReasoning: () => boolean, private readonly showReasoning: () => boolean,
private readonly tokens: () => { input: number; output: number }, private readonly tokens: () => { input: number; output: number },
private readonly cwdFormatter: TuiRuntime['formatCwd'],
) {} ) {}
invalidate(): void {} invalidate(): void {}
render(width: number): string[] { render(width: number): string[] {
const { input, output } = this.tokens() 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 right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}`
const leftStyled = this.palette.dim(left) const leftStyled = this.palette.dim(left)
const available = Math.max(0, width - visibleWidth(left) - 2) const available = Math.max(0, width - visibleWidth(left) - 2)
@@ -843,7 +853,14 @@ export function createTuiChat(
const welcome = config.welcome ?? 'ready.' const welcome = config.welcome ?? 'ready.'
const header = new HeaderComponent(agent, welcome, palette) 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(header)
ui.addChild(chat) ui.addChild(chat)
ui.addChild(statusContainer) ui.addChild(statusContainer)

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 SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction' 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 { interface FakeAgent extends Agent {
status: AgentStatus status: AgentStatus
@@ -21,6 +21,7 @@ export interface TuiHarnessOptions {
configureContext?: (ctx: Context) => Promise<void> configureContext?: (ctx: Context) => Promise<void>
beforeMount?: (session: Session) => void beforeMount?: (session: Session) => void
cwd?: string | null cwd?: string | null
formatCwd?: TuiRuntime['formatCwd']
} }
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> { 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.', welcome: 'Coding agent ready.',
sessionId, sessionId,
color: false, color: false,
}, options.config), { terminal, exit }) }, options.config), {
terminal,
exit,
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
})
return { ctx, session, agent, terminal, exit, controller } return { ctx, session, agent, terminal, exit, controller }
} }

View File

@@ -364,6 +364,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
const outsideResult = await setup({ cwd: '/opt' }) const outsideResult = await setup({ cwd: '/opt' })
expect(outsideResult.terminal.output).toContain('/opt') expect(outsideResult.terminal.output).toContain('/opt')
await dispose(outsideResult) 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 () => { it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {