1
0
Эх сурвалжийг харах

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.
Tianyi Cui 2 сар өмнө
parent
commit
0f8d0082e4

+ 30 - 5
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<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 - 0
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.

+ 22 - 5
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)

+ 7 - 2
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<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 - 0
packages/ui/tui/tests/tui.spec.ts

@@ -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 () => {