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:
5 files changed
+68
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user