Skip to content

Commit 06ca9dd

Browse files
authored
feat(tui-harness): tui_action tool, bug fixes, SVG rendering (aws#575)
* feat(tui-harness): add tui_action tool, fix sendKeys bug, add annotations - Add tui_action composite tool combining send keys + wait for pattern + read screen in a single MCP round-trip - Fix sendKeys silent discard bug where both keys and specialKey were sent sequentially with the first result overwritten - Surface settling monitor return value via new SendResult type so agents know if the screen fully settled or hit the hard ceiling - Add title and annotations (openWorldHint, readOnlyHint) to all tools Constraint: sendKeys/sendSpecialKey return type changed from ScreenState to SendResult Rejected: Add settled to ScreenState | mixes screen data with process metadata Confidence: high Scope-risk: moderate Directive: tui_action intentionally reads screen twice (once from sendKeys, once for formatting options) — the buffer read is cheap Not-tested: empty string keys parameter passes validation but writes nothing to PTY * fix(tui-harness): fix SVG screenshot character positioning SVG screenshots had misaligned characters ("janky letters all over the place") due to two issues: 1. No xml:space="preserve" on <text> elements — SVG defaults to collapsing whitespace, so spaces in terminal text were eaten 2. No textLength on <tspan> elements — character positioning depended entirely on the viewer's font metrics matching our charWidth=0.6em assumption, which varies across renderers Add xml:space="preserve" to prevent whitespace collapse and textLength with lengthAdjust="spacing" to force each span to occupy the exact calculated grid width regardless of font rendering differences. Constraint: Must work across browser, GitHub markdown, and macOS Preview SVG renderers Rejected: Render each character individually | extremely verbose SVG output Rejected: Use ch CSS units | not supported in SVG Confidence: high Scope-risk: narrow
1 parent 9d964d5 commit 06ca9dd

7 files changed

Lines changed: 232 additions & 22 deletions

File tree

src/tui-harness/__tests__/svg-renderer.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,21 @@ describe('SVG renderer', () => {
108108

109109
expect(svg).toContain('My Terminal');
110110
});
111+
112+
it('preserves whitespace and uses textLength for precise character positioning', async () => {
113+
terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true });
114+
terminal.write('test');
115+
await new Promise(resolve => setTimeout(resolve, 50));
116+
117+
const svg = renderTerminalToSvg(terminal);
118+
119+
// Both CSS white-space:pre and xml:space="preserve" prevent whitespace collapse
120+
expect(svg).toContain('white-space: pre');
121+
expect(svg).toContain('xml:space="preserve"');
122+
// Must use textLength + lengthAdjust for font-independent character grid alignment
123+
expect(svg).toContain('textLength=');
124+
expect(svg).toContain('lengthAdjust="spacing"');
125+
// Tspans must be inline within <text> — no newlines that white-space:pre would render
126+
expect(svg).not.toMatch(/<text[^>]*>\n/);
127+
});
111128
});

src/tui-harness/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
export { TuiSession } from './lib/tui-session.js';
1414

1515
// --- Types and error classes ---
16-
export type { LaunchOptions, ScreenState, ReadOptions, CloseResult, SessionInfo } from './lib/types.js';
16+
export type { LaunchOptions, ScreenState, ReadOptions, CloseResult, SendResult, SessionInfo } from './lib/types.js';
1717
export type { SpecialKey } from './lib/types.js';
1818
export { SPECIAL_KEY_VALUES, WaitForTimeoutError, LaunchError } from './lib/types.js';
1919

src/tui-harness/lib/svg-renderer.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,12 @@ export function renderTerminalToSvg(terminal: Terminal, options?: SvgRenderOptio
423423
if (!hasVisibleText) continue;
424424

425425
const textY = y + textBaseline;
426-
svgParts.push(`<text y="${textY}" class="fg">`);
426+
const tspanParts: string[] = [];
427427

428428
for (const span of spans) {
429429
const x = span.startCol * charWidth;
430-
const attrs: string[] = [`x="${x}"`];
430+
const spanWidth = span.colWidth * charWidth;
431+
const attrs: string[] = [`x="${x}"`, `textLength="${spanWidth}"`, 'lengthAdjust="spacing"'];
431432

432433
if (span.style.fg !== null) {
433434
attrs.push(`fill="${span.style.fg}"`);
@@ -443,10 +444,12 @@ export function renderTerminalToSvg(terminal: Terminal, options?: SvgRenderOptio
443444
attrs.push(`class="${classes.join(' ')}"`);
444445
}
445446

446-
svgParts.push(`<tspan ${attrs.join(' ')}>${escapeXml(span.text)}</tspan>`);
447+
tspanParts.push(`<tspan ${attrs.join(' ')}>${escapeXml(span.text)}</tspan>`);
447448
}
448449

449-
svgParts.push('</text>');
450+
// Concatenate tspans inline — newlines between tspans would render as
451+
// visible line breaks under white-space:pre / xml:space="preserve".
452+
svgParts.push(`<text y="${textY}" xml:space="preserve" class="fg">${tspanParts.join('')}</text>`);
450453
}
451454

452455
svgParts.push('</g>');

src/tui-harness/lib/tui-session.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ import { register, unregister } from './session-manager.js';
1717
import { SettlingMonitor } from './settling.js';
1818
import { renderTerminalToSvg } from './svg-renderer.js';
1919
import type { SvgRenderOptions } from './svg-renderer.js';
20-
import type { CloseResult, LaunchOptions, ReadOptions, ScreenState, SessionInfo, SpecialKey } from './types.js';
20+
import type {
21+
CloseResult,
22+
LaunchOptions,
23+
ReadOptions,
24+
ScreenState,
25+
SendResult,
26+
SessionInfo,
27+
SpecialKey,
28+
} from './types.js';
2129
import { LaunchError, WaitForTimeoutError } from './types.js';
2230
import xtermHeadless from '@xterm/headless';
2331
import { randomUUID } from 'crypto';
@@ -314,28 +322,28 @@ export class TuiSession {
314322
* @param keys - The raw characters or escape sequences to write.
315323
* @param waitMs - Optional settling time in milliseconds. Defaults to the
316324
* settling monitor's default (300ms).
317-
* @returns The screen state after output settles.
325+
* @returns The screen state and settling status after output settles.
318326
*/
319-
async sendKeys(keys: string, waitMs?: number): Promise<ScreenState> {
327+
async sendKeys(keys: string, waitMs?: number): Promise<SendResult> {
320328
this.assertAlive();
321329
this.ptyProcess.write(keys);
322-
await this.settlingMonitor.waitForSettle(waitMs);
323-
return this.readScreen();
330+
const settled = await this.settlingMonitor.waitForSettle(waitMs);
331+
return { screen: this.readScreen(), settled };
324332
}
325333

326334
/**
327335
* Send a named special key to the PTY process.
328336
*
329337
* @param key - The special key name (e.g., 'enter', 'ctrl+c', 'f5').
330338
* @param waitMs - Optional settling time in milliseconds.
331-
* @returns The screen state after output settles.
339+
* @returns The screen state and settling status after output settles.
332340
*/
333-
async sendSpecialKey(key: SpecialKey, waitMs?: number): Promise<ScreenState> {
341+
async sendSpecialKey(key: SpecialKey, waitMs?: number): Promise<SendResult> {
334342
this.assertAlive();
335343
const sequence = resolveKey(key);
336344
this.ptyProcess.write(sequence);
337-
await this.settlingMonitor.waitForSettle(waitMs);
338-
return this.readScreen();
345+
const settled = await this.settlingMonitor.waitForSettle(waitMs);
346+
return { screen: this.readScreen(), settled };
339347
}
340348

341349
// ---------------------------------------------------------------------------

src/tui-harness/lib/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ export interface ReadOptions {
5959
numbered?: boolean;
6060
}
6161

62+
/**
63+
* Result from sending keys to a TUI session.
64+
*
65+
* @property screen - The screen state after output settles.
66+
* @property settled - Whether the terminal output fully settled within the
67+
* wait period. When `false`, the hard ceiling timeout was reached and the
68+
* screen content may still be updating.
69+
*/
70+
export interface SendResult {
71+
screen: ScreenState;
72+
settled: boolean;
73+
}
74+
6275
/**
6376
* Result returned when a TUI session is closed.
6477
*

0 commit comments

Comments
 (0)