From 13afc8d0fba8c6577b3d07fe6ce3f69248acdb24 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sun, 18 Jan 2026 14:28:07 +0100 Subject: [PATCH] feat(gadgets): improve Tmux capture and EditFile display (#81) Tmux capture action now reports session status: - Added getSessionStatus() helper to TmuxControlClient - handleCapture() now returns status=running or status=exited exit_code=N - Prevents agents from infinite polling loops when commands complete - Updated examples to show new status field EditFile output uses intuitive diff markers: - BEFORE sections now use '<' marker (content being removed) - AFTER sections use '>' marker (content being added) - Added editMarker parameter to formatContext() - Updated all examples to reflect new markers Interactive mode EditFile display improvements: - Shows comment field (rationale for the edit) - Displays mode in yellow ([search_replace], [insert_at_line], [remove_lines]) - Proper formatting for all three modes Co-authored-by: Claude Opus 4.5 --- src/gadgets/EditFile.ts | 41 ++++++++++++------------ src/gadgets/tmux.ts | 69 ++++++++++++++++++++++++++++++++++++---- src/utils/interactive.ts | 61 +++++++++++++++++++++++++++++++++-- 3 files changed, 141 insertions(+), 30 deletions(-) diff --git a/src/gadgets/EditFile.ts b/src/gadgets/EditFile.ts index b1cc3edf..3de3aab5 100644 --- a/src/gadgets/EditFile.ts +++ b/src/gadgets/EditFile.ts @@ -121,7 +121,7 @@ Replaced 1 occurrence. 2 | 3 | const CONFIG = { 4 | debug: false, -> 5 | timeout: 1000, +< 5 | timeout: 1000, 6 | retries: 3, 7 | }; @@ -158,7 +158,7 @@ Replaced 2 occurrences. 1 | // API constants 2 | export const API_URL = "https://api.example.com"; 3 | -> 4 | export const MAX_RETRIES = 3; +< 4 | export const MAX_RETRIES = 3; 5 | export const TIMEOUT = 5000; --- AFTER --- @@ -173,7 +173,7 @@ Replaced 2 occurrences. 9 | // Client constants 10 | export const CLIENT_URL = "https://client.example.com"; 11 | -> 12 | export const MAX_RETRIES = 3; +< 12 | export const MAX_RETRIES = 3; 13 | export const CLIENT_TIMEOUT = 3000; --- AFTER --- @@ -206,7 +206,7 @@ Replaced 1 occurrence. --- BEFORE --- 1 | { 2 | "name": "example", -> 3 | "enabled": false, +< 3 | "enabled": false, 4 | "count": 0 5 | } @@ -232,7 +232,7 @@ Replaced 1 occurrence. Inserted 1 line at line 1. --- BEFORE (around line 1) --- -> 1 | import { foo } from 'bar'; +< 1 | import { foo } from 'bar'; 2 | import { baz } from 'qux'; 3 | @@ -265,7 +265,7 @@ Inserted 3 lines at line 10. 7 | } 8 | 9 | // Utils below -> 10 | function process(data: string) { +< 10 | function process(data: string) { 11 | return data.trim(); 12 | } @@ -333,7 +333,7 @@ Removed 1 line (line 3). --- BEFORE --- 1 | import { foo } from 'foo'; 2 | import { bar } from 'bar'; -> 3 | import { unused } from 'unused'; +< 3 | import { unused } from 'unused'; 4 | import { baz } from 'baz'; 5 | @@ -366,12 +366,12 @@ Removed 6 lines (lines 5-10). 2 | 3 | export function keepThis() {} 4 | -> 5 | /** @deprecated */ -> 6 | function oldFunc(x: number) { -> 7 | console.log('deprecated'); -> 8 | return x * 2; -> 9 | } -> 10 | +< 5 | /** @deprecated */ +< 6 | function oldFunc(x: number) { +< 7 | console.log('deprecated'); +< 8 | return x * 2; +< 9 | } +< 10 | 11 | export function keepThisToo() {} --- AFTER --- @@ -401,9 +401,9 @@ Removed 3 lines (lines 2-4). --- BEFORE --- 1 | { -> 2 | "// NOTE": "Remove this later", -> 3 | "// TODO": "Clean up config", -> 4 | "// FIXME": "Legacy value", +< 2 | "// NOTE": "Remove this later", +< 3 | "// TODO": "Clean up config", +< 4 | "// FIXME": "Legacy value", 5 | "enabled": true, 6 | "timeout": 5000 7 | } @@ -481,7 +481,7 @@ Removed 3 lines (lines 2-4). beforeContexts.push({ startLine: match.startLine, endLine: match.endLine, - context: this.formatContext(originalLines, match.startLine, match.endLine, 5), + context: this.formatContext(originalLines, match.startLine, match.endLine, 5, '<'), }); } @@ -533,7 +533,7 @@ Removed 3 lines (lines 2-4). const effectiveLine = Math.min(line, lines.length + 1); // Store before context - const beforeContext = this.formatContext(lines, effectiveLine, effectiveLine, 3); + const beforeContext = this.formatContext(lines, effectiveLine, effectiveLine, 3, '<'); // Insert lines const newLines = [ @@ -611,7 +611,7 @@ Removed 3 lines (lines 2-4). const removedCount = effectiveEndLine - startLine + 1; // Store before context with highlight on lines to remove - const beforeContext = this.formatContext(lines, startLine, effectiveEndLine, 3); + const beforeContext = this.formatContext(lines, startLine, effectiveEndLine, 3, '<'); // Remove lines const newLines = [...lines.slice(0, startLine - 1), ...lines.slice(effectiveEndLine)]; @@ -749,6 +749,7 @@ Removed 3 lines (lines 2-4). startLine: number, endLine: number, contextLines = 5, + editMarker = '>', ): string { const rangeStart = Math.max(0, startLine - 1 - contextLines); const rangeEnd = Math.min(lines.length, endLine + contextLines); @@ -757,7 +758,7 @@ Removed 3 lines (lines 2-4). for (let i = rangeStart; i < rangeEnd; i++) { const lineNum = i + 1; const isEdited = lineNum >= startLine && lineNum <= endLine; - const marker = isEdited ? '>' : ' '; + const marker = isEdited ? editMarker : ' '; const paddedNum = String(lineNum).padStart(4); result.push(`${marker}${paddedNum} | ${lines[i]}`); } diff --git a/src/gadgets/tmux.ts b/src/gadgets/tmux.ts index 366cde8f..11b7d9ff 100644 --- a/src/gadgets/tmux.ts +++ b/src/gadgets/tmux.ts @@ -476,6 +476,36 @@ class TmuxControlClient { }; } + /** + * Get session status - checks if command has exited and returns exit code. + * Combines checkExitMarker and isPaneDead for comprehensive detection. + */ + async getSessionStatus( + windowName: string, + ): Promise<{ status: 'running' | 'exited' | 'not_found'; exitCode?: number }> { + // Check if window exists first + if (!(await this.windowExists(windowName))) { + return { status: 'not_found' }; + } + + // Method 1: Check for exit marker in streamed output (most reliable) + const markerResult = this.checkExitMarker(windowName); + if (markerResult.exited) { + return { status: 'exited', exitCode: markerResult.exitCode }; + } + + // Method 2: Check if pane is dead via tmux + const paneId = this.windowToPaneId.get(windowName); + if (paneId) { + const { dead, exitCode } = await this.isPaneDead(paneId); + if (dead) { + return { status: 'exited', exitCode }; + } + } + + return { status: 'running' }; + } + /** * Get buffered output for a window */ @@ -732,8 +762,19 @@ Commands are interpreted by bash, so pipes, &&, ||, redirects, and globs all wor session: 'npm-install', lines: 25, }, - output: 'session=npm-install lines=25\n\nadded 874 packages in 45s', - comment: 'Check output from running session', + output: 'session=npm-install status=running lines=25\n\nadded 874 packages in 45s', + comment: 'Check output from running session - status=running means command still executing', + }, + { + params: { + action: 'capture', + comment: 'Checking if tests completed', + session: 'test-run', + lines: 50, + }, + output: + 'session=test-run status=exited exit_code=0 lines=50\n\n✓ 15 tests passed\n✓ All tests completed', + comment: 'Capture shows command finished - status=exited with exit code', }, { params: { @@ -914,21 +955,35 @@ Commands are interpreted by bash, so pipes, &&, ||, redirects, and globs all wor const client = await getControlClient(); const lines = params.lines ?? 25; - if (!(await client.windowExists(params.session))) { + // Check session status (existence + exit detection) + const sessionStatus = await client.getSessionStatus(params.session); + + if (sessionStatus.status === 'not_found') { return `session=${params.session} status=error\n\nSession '${params.session}' does not exist`; } - // Try streamed output first, then capture-pane + // Get output (try streamed output first, then capture-pane) let output = client.getOutput(params.session); if (!output.trim()) { output = await client.capturePaneOutput(params.session, lines); } - // Take last N lines + // Take last N lines and clean up const outputLines = output.split('\n'); - const captured = outputLines.slice(-lines).join('\n').trim(); + let captured = outputLines.slice(-lines).join('\n').trim(); + + // Clean exit marker from output if present + captured = captured + .replace(new RegExp(`${EXIT_MARKER_PREFIX}\\d+${EXIT_MARKER_SUFFIX}\\s*`), '') + .replace(/\nPane is dead \([^)]+\)\s*$/, '') + .trim(); + + // Report status with exit code if exited + if (sessionStatus.status === 'exited') { + return `session=${params.session} status=exited exit_code=${sessionStatus.exitCode} lines=${lines}\n\n${captured || '(no output)'}`; + } - return `session=${params.session} lines=${lines}\n\n${captured || '(no output yet)'}`; + return `session=${params.session} status=running lines=${lines}\n\n${captured || '(no output yet)'}`; } private async handleList(): Promise { diff --git a/src/utils/interactive.ts b/src/utils/interactive.ts index 970e2397..f6d4898c 100644 --- a/src/utils/interactive.ts +++ b/src/utils/interactive.ts @@ -9,15 +9,42 @@ function horizontalLine(): string { } /** - * Display EditFile params with search/replace as separate colored blocks. + * Display EditFile params with mode-specific formatting. */ function displayEditFileParams(params: Record): void { - // File path + // Comment (rationale for the edit) + if (params.comment) { + console.log(chalk.dim('Comment: ') + chalk.white(String(params.comment))); + } + + // File path and mode if (params.filePath) { - console.log(chalk.dim('File: ') + chalk.cyan(String(params.filePath))); + const modeStr = params.mode ? chalk.yellow(`[${params.mode}]`) : ''; + console.log(`${chalk.dim('File: ')}${chalk.cyan(String(params.filePath))} ${modeStr}`); console.log(horizontalLine()); } + // Mode-specific display + switch (params.mode) { + case 'search_replace': + displaySearchReplaceMode(params); + break; + case 'insert_at_line': + displayInsertAtLineMode(params); + break; + case 'remove_lines': + displayRemoveLinesMode(params); + break; + default: + // Fallback for unknown mode - show as search_replace for backwards compat + displaySearchReplaceMode(params); + } +} + +/** + * Display search_replace mode params. + */ +function displaySearchReplaceMode(params: Record): void { // Search block (what's being replaced) - red if (params.search !== undefined) { console.log(chalk.red('━ Search (to replace):')); @@ -36,6 +63,34 @@ function displayEditFileParams(params: Record): void { } } +/** + * Display insert_at_line mode params. + */ +function displayInsertAtLineMode(params: Record): void { + const lineNum = params.line !== undefined ? String(params.line) : '?'; + console.log(chalk.green(`+ Insert BEFORE line ${lineNum}:`)); + + if (params.content !== undefined) { + for (const line of String(params.content).split('\n')) { + console.log(chalk.green(line)); + } + } +} + +/** + * Display remove_lines mode params. + */ +function displayRemoveLinesMode(params: Record): void { + const startLine = params.startLine !== undefined ? String(params.startLine) : '?'; + const endLine = params.endLine !== undefined ? String(params.endLine) : '?'; + + if (startLine === endLine) { + console.log(chalk.red(`━ Remove line ${startLine}`)); + } else { + console.log(chalk.red(`━ Remove lines ${startLine}-${endLine}`)); + } +} + /** * Display default params as JSON. */