zerg-ztc 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +63 -2
  3. package/dist/App.js.map +1 -1
  4. package/dist/agent/commands/dictation.d.ts +3 -0
  5. package/dist/agent/commands/dictation.d.ts.map +1 -0
  6. package/dist/agent/commands/dictation.js +10 -0
  7. package/dist/agent/commands/dictation.js.map +1 -0
  8. package/dist/agent/commands/index.d.ts.map +1 -1
  9. package/dist/agent/commands/index.js +2 -1
  10. package/dist/agent/commands/index.js.map +1 -1
  11. package/dist/agent/commands/types.d.ts +7 -0
  12. package/dist/agent/commands/types.d.ts.map +1 -1
  13. package/dist/components/InputArea.d.ts +1 -0
  14. package/dist/components/InputArea.d.ts.map +1 -1
  15. package/dist/components/InputArea.js +591 -43
  16. package/dist/components/InputArea.js.map +1 -1
  17. package/dist/components/SingleMessage.d.ts.map +1 -1
  18. package/dist/components/SingleMessage.js +157 -7
  19. package/dist/components/SingleMessage.js.map +1 -1
  20. package/dist/config/types.d.ts +6 -0
  21. package/dist/config/types.d.ts.map +1 -1
  22. package/dist/ui/views/status_bar.js +2 -2
  23. package/dist/ui/views/status_bar.js.map +1 -1
  24. package/dist/utils/dictation.d.ts +46 -0
  25. package/dist/utils/dictation.d.ts.map +1 -0
  26. package/dist/utils/dictation.js +409 -0
  27. package/dist/utils/dictation.js.map +1 -0
  28. package/dist/utils/dictation_native.d.ts +51 -0
  29. package/dist/utils/dictation_native.d.ts.map +1 -0
  30. package/dist/utils/dictation_native.js +216 -0
  31. package/dist/utils/dictation_native.js.map +1 -0
  32. package/dist/utils/path_format.d.ts +20 -0
  33. package/dist/utils/path_format.d.ts.map +1 -0
  34. package/dist/utils/path_format.js +90 -0
  35. package/dist/utils/path_format.js.map +1 -0
  36. package/dist/utils/table.d.ts +38 -0
  37. package/dist/utils/table.d.ts.map +1 -0
  38. package/dist/utils/table.js +133 -0
  39. package/dist/utils/table.js.map +1 -0
  40. package/dist/utils/tool_trace.d.ts +7 -2
  41. package/dist/utils/tool_trace.d.ts.map +1 -1
  42. package/dist/utils/tool_trace.js +156 -51
  43. package/dist/utils/tool_trace.js.map +1 -1
  44. package/package.json +4 -1
  45. package/packages/ztc-dictation/Cargo.toml +43 -0
  46. package/packages/ztc-dictation/README.md +65 -0
  47. package/packages/ztc-dictation/bin/.gitkeep +0 -0
  48. package/packages/ztc-dictation/index.d.ts +16 -0
  49. package/packages/ztc-dictation/index.js +74 -0
  50. package/packages/ztc-dictation/package.json +41 -0
  51. package/packages/ztc-dictation/src/main.rs +430 -0
  52. package/src/App.tsx +98 -1
  53. package/src/agent/commands/dictation.ts +11 -0
  54. package/src/agent/commands/index.ts +2 -0
  55. package/src/agent/commands/types.ts +8 -0
  56. package/src/components/InputArea.tsx +606 -42
  57. package/src/components/SingleMessage.tsx +248 -9
  58. package/src/config/types.ts +7 -0
  59. package/src/ui/views/status_bar.ts +2 -2
  60. package/src/utils/dictation.ts +467 -0
  61. package/src/utils/dictation_native.ts +258 -0
  62. package/src/utils/path_format.ts +99 -0
  63. package/src/utils/table.ts +171 -0
  64. package/src/utils/tool_trace.ts +184 -54
@@ -0,0 +1,99 @@
1
+ import { homedir } from 'os';
2
+ import { basename, dirname } from 'path';
3
+
4
+ /**
5
+ * Shorten a path for display:
6
+ * - Replace home directory with ~
7
+ * - Truncate middle of long paths
8
+ * - Show basename prominently
9
+ */
10
+ export function shortenPath(path: string, maxLength = 60): string {
11
+ if (!path) return '';
12
+
13
+ // Replace home directory with ~
14
+ const home = homedir();
15
+ let shortened = path;
16
+ if (path.startsWith(home)) {
17
+ shortened = '~' + path.slice(home.length);
18
+ }
19
+
20
+ // If short enough, return as-is
21
+ if (shortened.length <= maxLength) {
22
+ return shortened;
23
+ }
24
+
25
+ // Truncate middle, keeping start and end
26
+ const base = basename(shortened);
27
+ const dir = dirname(shortened);
28
+
29
+ // Always show at least the basename
30
+ if (base.length >= maxLength - 4) {
31
+ return '…' + base.slice(-(maxLength - 1));
32
+ }
33
+
34
+ // Show as much of the directory as we can
35
+ const availableForDir = maxLength - base.length - 2; // 2 for /…
36
+ if (availableForDir < 10) {
37
+ return '…/' + base;
38
+ }
39
+
40
+ // Split dir and keep start + end
41
+ const startLen = Math.floor(availableForDir * 0.4);
42
+ const endLen = availableForDir - startLen - 1; // 1 for …
43
+
44
+ const dirStart = dir.slice(0, startLen);
45
+ const dirEnd = dir.slice(-endLen);
46
+
47
+ return `${dirStart}…${dirEnd}/${base}`;
48
+ }
49
+
50
+ /**
51
+ * Format a path for trace display - very compact
52
+ */
53
+ export function formatTracePath(path: string): string {
54
+ if (!path) return '""';
55
+
56
+ const home = homedir();
57
+ let formatted = path;
58
+
59
+ // Replace home with ~
60
+ if (path.startsWith(home)) {
61
+ formatted = '~' + path.slice(home.length);
62
+ }
63
+
64
+ // For very long paths, just show basename with hint
65
+ if (formatted.length > 50) {
66
+ const base = basename(formatted);
67
+ const dir = dirname(formatted);
68
+ // Show abbreviated dir + full basename
69
+ if (dir.length > 30) {
70
+ const dirParts = dir.split('/').filter(Boolean);
71
+ if (dirParts.length > 3) {
72
+ const abbreviated = dirParts.slice(0, 2).join('/') + '/…/' + dirParts.slice(-1).join('/');
73
+ return abbreviated + '/' + base;
74
+ }
75
+ }
76
+ }
77
+
78
+ return formatted;
79
+ }
80
+
81
+ /**
82
+ * Format bytes in human readable form
83
+ */
84
+ export function formatBytes(bytes: number): string {
85
+ if (bytes < 1024) return `${bytes} bytes`;
86
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
87
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
88
+ }
89
+
90
+ /**
91
+ * Format duration in human readable form
92
+ */
93
+ export function formatDuration(ms: number): string {
94
+ if (ms < 1000) return `${ms}ms`;
95
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
96
+ const mins = Math.floor(ms / 60000);
97
+ const secs = Math.round((ms % 60000) / 1000);
98
+ return `${mins}m${secs}s`;
99
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Box-drawing table utilities
3
+ * Uses Unicode box-drawing characters for clean terminal tables
4
+ */
5
+
6
+ // Box-drawing characters
7
+ const BOX = {
8
+ topLeft: '┌',
9
+ topRight: '┐',
10
+ bottomLeft: '└',
11
+ bottomRight: '┘',
12
+ horizontal: '─',
13
+ vertical: '│',
14
+ leftT: '├',
15
+ rightT: '┤',
16
+ topT: '┬',
17
+ bottomT: '┴',
18
+ cross: '┼'
19
+ };
20
+
21
+ // Light box (for subtler tables)
22
+ const BOX_LIGHT = {
23
+ topLeft: '╭',
24
+ topRight: '╮',
25
+ bottomLeft: '╰',
26
+ bottomRight: '╯',
27
+ horizontal: '─',
28
+ vertical: '│',
29
+ leftT: '├',
30
+ rightT: '┤',
31
+ topT: '┬',
32
+ bottomT: '┴',
33
+ cross: '┼'
34
+ };
35
+
36
+ export interface TableColumn {
37
+ header: string;
38
+ key: string;
39
+ width?: number;
40
+ align?: 'left' | 'right' | 'center';
41
+ }
42
+
43
+ export interface TableOptions {
44
+ rounded?: boolean;
45
+ headerSeparator?: boolean;
46
+ compact?: boolean;
47
+ }
48
+
49
+ function padString(str: string, width: number, align: 'left' | 'right' | 'center' = 'left'): string {
50
+ const visibleLength = str.replace(/\x1b\[[0-9;]*m/g, '').length;
51
+ const padding = Math.max(0, width - visibleLength);
52
+
53
+ if (align === 'right') {
54
+ return ' '.repeat(padding) + str;
55
+ }
56
+ if (align === 'center') {
57
+ const left = Math.floor(padding / 2);
58
+ const right = padding - left;
59
+ return ' '.repeat(left) + str + ' '.repeat(right);
60
+ }
61
+ return str + ' '.repeat(padding);
62
+ }
63
+
64
+ function getColumnWidths(columns: TableColumn[], rows: Record<string, unknown>[]): number[] {
65
+ return columns.map(col => {
66
+ const headerWidth = col.header.length;
67
+ const dataWidth = rows.reduce((max, row) => {
68
+ const value = String(row[col.key] || '');
69
+ const visibleLength = value.replace(/\x1b\[[0-9;]*m/g, '').length;
70
+ return Math.max(max, visibleLength);
71
+ }, 0);
72
+ return col.width || Math.max(headerWidth, dataWidth);
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Format data as a box-drawing table
78
+ */
79
+ export function formatTable(
80
+ columns: TableColumn[],
81
+ rows: Record<string, unknown>[],
82
+ options: TableOptions = {}
83
+ ): string {
84
+ const { rounded = false, headerSeparator = true, compact = false } = options;
85
+ const box = rounded ? BOX_LIGHT : BOX;
86
+ const widths = getColumnWidths(columns, rows);
87
+ const lines: string[] = [];
88
+
89
+ // Top border
90
+ const topBorder = box.topLeft +
91
+ widths.map(w => box.horizontal.repeat(w + 2)).join(box.topT) +
92
+ box.topRight;
93
+ lines.push(topBorder);
94
+
95
+ // Header row
96
+ const headerRow = box.vertical +
97
+ columns.map((col, i) => ' ' + padString(col.header, widths[i], 'center') + ' ').join(box.vertical) +
98
+ box.vertical;
99
+ lines.push(headerRow);
100
+
101
+ // Header separator
102
+ if (headerSeparator) {
103
+ const separator = box.leftT +
104
+ widths.map(w => box.horizontal.repeat(w + 2)).join(box.cross) +
105
+ box.rightT;
106
+ lines.push(separator);
107
+ }
108
+
109
+ // Data rows
110
+ for (const row of rows) {
111
+ const dataRow = box.vertical +
112
+ columns.map((col, i) => {
113
+ const value = String(row[col.key] || '');
114
+ return ' ' + padString(value, widths[i], col.align) + ' ';
115
+ }).join(box.vertical) +
116
+ box.vertical;
117
+ lines.push(dataRow);
118
+
119
+ // Row separator (if not compact)
120
+ if (!compact && rows.indexOf(row) < rows.length - 1) {
121
+ const rowSep = box.leftT +
122
+ widths.map(w => box.horizontal.repeat(w + 2)).join(box.cross) +
123
+ box.rightT;
124
+ lines.push(rowSep);
125
+ }
126
+ }
127
+
128
+ // Bottom border
129
+ const bottomBorder = box.bottomLeft +
130
+ widths.map(w => box.horizontal.repeat(w + 2)).join(box.bottomT) +
131
+ box.bottomRight;
132
+ lines.push(bottomBorder);
133
+
134
+ return lines.join('\n');
135
+ }
136
+
137
+ /**
138
+ * Simple two-column table (key-value style)
139
+ */
140
+ export function formatKeyValueTable(
141
+ data: Array<{ label: string; value: string }>,
142
+ options: TableOptions = {}
143
+ ): string {
144
+ const columns: TableColumn[] = [
145
+ { header: 'Key', key: 'label', align: 'left' },
146
+ { header: 'Value', key: 'value', align: 'left' }
147
+ ];
148
+ const rows = data.map(d => ({ label: d.label, value: d.value }));
149
+ return formatTable(columns, rows, { ...options, headerSeparator: false });
150
+ }
151
+
152
+ /**
153
+ * Format a schedule/calendar table
154
+ */
155
+ export function formatScheduleTable(
156
+ events: Array<{ time: string; event: string }>,
157
+ options: TableOptions = {}
158
+ ): string {
159
+ const columns: TableColumn[] = [
160
+ { header: 'Time', key: 'time', align: 'left', width: 7 },
161
+ { header: 'Event', key: 'event', align: 'left' }
162
+ ];
163
+ return formatTable(columns, events, { rounded: true, compact: true, ...options });
164
+ }
165
+
166
+ /**
167
+ * Simple horizontal line/divider
168
+ */
169
+ export function formatDivider(width = 80, char = '─'): string {
170
+ return char.repeat(width);
171
+ }
@@ -1,45 +1,65 @@
1
- import { getTraceStyle, TraceStyle } from '../emulation/trace_style.js';
1
+ import { getTraceStyle } from '../emulation/trace_style.js';
2
+ import { formatTracePath, formatBytes, formatDuration } from './path_format.js';
2
3
 
3
4
  const toolLabels: Record<string, string> = {
4
5
  run_command: 'Bash',
5
6
  read_file: 'Read',
6
7
  write_file: 'Update',
7
8
  list_directory: 'List',
8
- search: 'Search'
9
+ search: 'Search',
10
+ screenshot: 'Screenshot',
11
+ list_windows: 'ListWindows',
12
+ run_and_capture: 'RunCapture'
9
13
  };
10
14
 
11
- function formatValue(value: unknown): string {
12
- if (typeof value === 'string') {
13
- const escaped = value.replace(/\n/g, '\\n');
14
- return `"${escaped}"`;
15
- }
16
- if (typeof value === 'number' || typeof value === 'boolean') {
17
- return String(value);
18
- }
19
- return JSON.stringify(value);
20
- }
21
-
22
15
  function formatArgs(tool: string, args: Record<string, unknown>): string {
16
+ // Compact arg formatting - just show the key values
23
17
  if (tool === 'run_command') {
24
18
  const command = args.command ? String(args.command) : '';
25
- const cwd = args.cwd ? `, cwd: ${formatValue(args.cwd)}` : '';
26
- const timeout = args.timeout ? `, timeout: ${formatValue(args.timeout)}` : '';
27
- return `(${command}${cwd}${timeout})`;
19
+ // Truncate long commands
20
+ const truncated = command.length > 60 ? command.slice(0, 57) + '...' : command;
21
+ return `(${truncated})`;
28
22
  }
23
+
29
24
  if (tool === 'search') {
30
- const pattern = args.pattern ? `pattern: ${formatValue(args.pattern)}` : '';
31
- const path = args.path ? `path: ${formatValue(args.path)}` : '';
32
- const output = args.output_mode ? `output_mode: ${formatValue(args.output_mode)}` : '';
33
- const parts = [pattern, path, output].filter(Boolean);
34
- return parts.length ? `(${parts.join(', ')})` : '';
25
+ const pattern = args.pattern ? String(args.pattern) : '';
26
+ const path = args.path ? formatTracePath(String(args.path)) : '';
27
+ if (path) {
28
+ return `(${pattern}, ${path})`;
29
+ }
30
+ return `(${pattern})`;
35
31
  }
32
+
36
33
  if (tool === 'read_file' || tool === 'write_file' || tool === 'list_directory') {
37
- const path = args.path ? formatValue(args.path) : '""';
34
+ const path = args.path ? formatTracePath(String(args.path)) : '';
38
35
  return `(${path})`;
39
36
  }
40
37
 
41
- const entries = Object.entries(args).map(([key, value]) => `${key}: ${formatValue(value)}`);
42
- return entries.length ? `(${entries.join(', ')})` : '';
38
+ if (tool === 'screenshot') {
39
+ if (args.app) return `(app: "${args.app}")`;
40
+ if (args.pid) return `(pid: ${args.pid})`;
41
+ if (args.windowId) return `(windowId: ${args.windowId})`;
42
+ return '';
43
+ }
44
+
45
+ if (tool === 'list_windows') {
46
+ if (args.filter) return `(filter: "${args.filter}")`;
47
+ return '';
48
+ }
49
+
50
+ // Generic: show first few args compactly
51
+ const entries = Object.entries(args).slice(0, 3);
52
+ if (entries.length === 0) return '';
53
+
54
+ const parts = entries.map(([key, value]) => {
55
+ if (typeof value === 'string') {
56
+ const v = value.length > 30 ? value.slice(0, 27) + '...' : value;
57
+ return `${key}: "${v}"`;
58
+ }
59
+ return `${key}: ${value}`;
60
+ });
61
+
62
+ return `(${parts.join(', ')})`;
43
63
  }
44
64
 
45
65
  function parseResult(raw: string): Record<string, unknown> | null {
@@ -52,37 +72,96 @@ function parseResult(raw: string): Record<string, unknown> | null {
52
72
  return null;
53
73
  }
54
74
 
75
+ interface ParsedDiff {
76
+ addedLines: number;
77
+ removedLines: number;
78
+ hunks: Array<{
79
+ header: string;
80
+ lines: Array<{ type: 'add' | 'remove' | 'context'; lineNum?: number; text: string }>;
81
+ }>;
82
+ }
83
+
84
+ function parseDiff(diff: string): ParsedDiff | null {
85
+ if (!diff || !diff.includes('@@')) return null;
86
+
87
+ const lines = diff.split('\n');
88
+ let addedLines = 0;
89
+ let removedLines = 0;
90
+ const hunks: ParsedDiff['hunks'] = [];
91
+ let currentHunk: ParsedDiff['hunks'][0] | null = null;
92
+
93
+ for (const line of lines) {
94
+ if (line.startsWith('@@')) {
95
+ if (currentHunk) hunks.push(currentHunk);
96
+ currentHunk = { header: line, lines: [] };
97
+ continue;
98
+ }
99
+
100
+ if (!currentHunk) continue;
101
+
102
+ if (line.startsWith('+') && !line.startsWith('+++')) {
103
+ addedLines++;
104
+ currentHunk.lines.push({ type: 'add', text: line.slice(1) });
105
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
106
+ removedLines++;
107
+ currentHunk.lines.push({ type: 'remove', text: line.slice(1) });
108
+ } else if (line.startsWith(' ')) {
109
+ currentHunk.lines.push({ type: 'context', text: line.slice(1) });
110
+ }
111
+ }
112
+
113
+ if (currentHunk) hunks.push(currentHunk);
114
+
115
+ return { addedLines, removedLines, hunks };
116
+ }
117
+
55
118
  function formatOutcome(tool: string, result: string, durationMs?: number): string {
56
119
  const parsed = parseResult(result);
57
- const duration = typeof durationMs === 'number' ? ` in ${(durationMs / 1000).toFixed(1)}s` : '';
120
+ const duration = typeof durationMs === 'number' ? ` in ${formatDuration(durationMs)}` : '';
58
121
 
59
122
  if (parsed && tool === 'search') {
60
123
  const count = typeof parsed.count === 'number' ? parsed.count : 0;
61
124
  const label = count === 1 ? 'match' : 'matches';
62
125
  return `Found ${count} ${label}${duration}`;
63
126
  }
127
+
64
128
  if (parsed && tool === 'run_command') {
65
129
  const stdout = String(parsed.stdout || '');
66
130
  const stderr = String(parsed.stderr || '');
67
131
  const lines = (stdout || stderr).trim().split('\n').filter(Boolean).length;
68
132
  const label = lines === 1 ? 'line' : 'lines';
69
- return lines > 0 ? `Output ${lines} ${label}${duration}` : `Command finished${duration}`;
133
+ return lines > 0 ? `Output ${lines} ${label}${duration}` : `Done${duration}`;
70
134
  }
135
+
71
136
  if (parsed && tool === 'read_file') {
72
- const path = String(parsed.path || '');
73
- const size = parsed.size ? `${parsed.size} bytes` : 'unknown size';
137
+ const path = formatTracePath(String(parsed.path || ''));
138
+ const size = typeof parsed.size === 'number' ? formatBytes(parsed.size) : '';
74
139
  const truncated = parsed.truncated ? ' (truncated)' : '';
75
- return `Read ${path} (${size})${duration}${truncated}`;
140
+ return `${path} (${size})${duration}${truncated}`;
76
141
  }
142
+
77
143
  if (parsed && tool === 'write_file') {
78
- const path = String(parsed.path || '');
79
- const action = String(parsed.action || 'written');
80
- return `Updated ${path} (${action})${duration}`;
144
+ const path = formatTracePath(String(parsed.path || ''));
145
+ const diff = parseDiff(String(parsed.diff || ''));
146
+ if (diff) {
147
+ const parts: string[] = [];
148
+ if (diff.addedLines > 0) parts.push(`+${diff.addedLines}`);
149
+ if (diff.removedLines > 0) parts.push(`-${diff.removedLines}`);
150
+ const summary = parts.length > 0 ? ` (${parts.join(', ')} lines)` : '';
151
+ return `${path}${summary}${duration}`;
152
+ }
153
+ return `${path}${duration}`;
81
154
  }
155
+
82
156
  if (parsed && tool === 'list_directory') {
83
- const path = String(parsed.path || '');
157
+ const path = formatTracePath(String(parsed.path || ''));
84
158
  const entries = Array.isArray(parsed.entries) ? parsed.entries.length : 0;
85
- return `Listed ${path} (${entries} items)${duration}`;
159
+ return `${path} (${entries} items)${duration}`;
160
+ }
161
+
162
+ if (parsed && tool === 'screenshot') {
163
+ const desc = String(parsed.description || 'Screenshot captured');
164
+ return `${desc}${duration}`;
86
165
  }
87
166
 
88
167
  return `Done${duration}`;
@@ -107,7 +186,7 @@ function buildOutputLines(tool: string, result: string): { lines: string[]; trun
107
186
  const lines = matches.map((entry: unknown) => {
108
187
  if (!entry || typeof entry !== 'object') return '';
109
188
  const row = entry as Record<string, unknown>;
110
- const file = String(row.path || '');
189
+ const file = formatTracePath(String(row.path || ''));
111
190
  const line = row.line ? `:${row.line}` : '';
112
191
  const text = String(row.text || '');
113
192
  return `${file}${line} ${text}`.trim();
@@ -122,9 +201,24 @@ function buildOutputLines(tool: string, result: string): { lines: string[]; trun
122
201
  }
123
202
 
124
203
  if (tool === 'write_file') {
125
- const diff = typeof parsed.diff === 'string' ? parsed.diff : '';
126
- const lines = diff ? diff.split('\n') : [];
127
- return { lines, truncated: Boolean(parsed.truncated) };
204
+ // Return formatted diff lines
205
+ const diff = parseDiff(String(parsed.diff || ''));
206
+ if (diff && diff.hunks.length > 0) {
207
+ const lines: string[] = [];
208
+ for (const hunk of diff.hunks) {
209
+ for (const line of hunk.lines) {
210
+ if (line.type === 'add') {
211
+ lines.push(`+ ${line.text}`);
212
+ } else if (line.type === 'remove') {
213
+ lines.push(`- ${line.text}`);
214
+ } else {
215
+ lines.push(` ${line.text}`);
216
+ }
217
+ }
218
+ }
219
+ return { lines, truncated: Boolean(parsed.truncated) };
220
+ }
221
+ return { lines: [], truncated: false };
128
222
  }
129
223
 
130
224
  return { lines: [], truncated: false };
@@ -134,6 +228,7 @@ export function formatToolStart(tool: string, args: Record<string, unknown>, emu
134
228
  const style = getTraceStyle(emulationId);
135
229
  const label = toolLabels[tool] || tool;
136
230
  const argsText = formatArgs(tool, args);
231
+
137
232
  if (style === 'claude_code') {
138
233
  return `⏺ ${label}${argsText}`;
139
234
  }
@@ -150,8 +245,9 @@ export function formatToolStart(tool: string, args: Record<string, unknown>, emu
150
245
  export function formatToolEnd(tool: string, result: string, durationMs?: number, emulationId?: string): string {
151
246
  const style = getTraceStyle(emulationId);
152
247
  const summary = formatOutcome(tool, result, durationMs);
248
+
153
249
  if (style === 'claude_code') {
154
- return `⎿ ${summary}`;
250
+ return `⎿ ${summary}`;
155
251
  }
156
252
  if (style === 'codex') {
157
253
  if (tool === 'run_command') {
@@ -167,13 +263,28 @@ export function formatToolEnd(tool: string, result: string, durationMs?: number,
167
263
 
168
264
  export function formatToolError(tool: string, error: string, emulationId?: string): string {
169
265
  const style = getTraceStyle(emulationId);
266
+ const label = toolLabels[tool] || tool;
267
+
268
+ // Provide helpful error messages
269
+ let displayError = error;
270
+ if (error.includes('Screen Recording permission')) {
271
+ displayError = 'Screen Recording permission required (System Settings > Privacy & Security)';
272
+ }
273
+
170
274
  if (style === 'claude_code') {
171
- return `⎿ ${tool} failed: ${error}`;
275
+ return `⎿ ${label} failed: ${displayError}`;
172
276
  }
173
277
  if (style === 'codex') {
174
- return ` ${tool} failed: ${error}`;
278
+ return ` ${label} failed: ${displayError}`;
175
279
  }
176
- return `< ${tool} failed: ${error}`;
280
+ return `< ${label} failed: ${displayError}`;
281
+ }
282
+
283
+ export interface ToolOutputMessage {
284
+ preview: string;
285
+ full: string;
286
+ truncated: boolean;
287
+ diffLines?: Array<{ type: 'add' | 'remove' | 'context'; text: string }>;
177
288
  }
178
289
 
179
290
  export function buildToolOutputMessage(
@@ -181,9 +292,25 @@ export function buildToolOutputMessage(
181
292
  result: string,
182
293
  durationMs?: number,
183
294
  emulationId?: string
184
- ): { preview: string; full: string; truncated: boolean } {
295
+ ): ToolOutputMessage {
185
296
  const { lines, truncated } = buildOutputLines(tool, result);
186
- if (lines.length === 0) {
297
+ const parsed = parseResult(result);
298
+
299
+ // For write_file, extract diff info
300
+ let diffLines: ToolOutputMessage['diffLines'];
301
+ if (tool === 'write_file' && parsed) {
302
+ const diff = parseDiff(String(parsed.diff || ''));
303
+ if (diff && diff.hunks.length > 0) {
304
+ diffLines = [];
305
+ for (const hunk of diff.hunks) {
306
+ for (const line of hunk.lines) {
307
+ diffLines.push({ type: line.type, text: line.text });
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ if (lines.length === 0 && !diffLines) {
187
314
  const summary = formatToolEnd(tool, result, durationMs, emulationId);
188
315
  return { preview: summary, full: summary, truncated: false };
189
316
  }
@@ -194,23 +321,26 @@ export function buildToolOutputMessage(
194
321
  const expandHint = truncated || remaining > 0 ? ' (ctrl+o to expand)' : '';
195
322
 
196
323
  const summary = formatToolEnd(tool, result, durationMs, emulationId);
197
- const previewBlock = [
198
- summary,
199
- ...previewLines.map(line => ` ${line}`)
200
- ];
324
+ const indent = ' ';
325
+
326
+ const previewBlock = [summary];
327
+ for (const line of previewLines) {
328
+ previewBlock.push(`${indent}${line}`);
329
+ }
201
330
  if (expandHint) {
202
331
  const extraLabel = remaining > 0 ? `… +${remaining} lines` : '…';
203
- previewBlock.push(` ${extraLabel}${expandHint}`);
332
+ previewBlock.push(`${indent}${extraLabel}${expandHint}`);
204
333
  }
205
334
 
206
- const fullBlock = [
207
- summary,
208
- ...lines.map(line => ` ${line}`)
209
- ];
335
+ const fullBlock = [summary];
336
+ for (const line of lines) {
337
+ fullBlock.push(`${indent}${line}`);
338
+ }
210
339
 
211
340
  return {
212
341
  preview: previewBlock.join('\n'),
213
342
  full: fullBlock.join('\n'),
214
- truncated: Boolean(expandHint)
343
+ truncated: Boolean(expandHint),
344
+ diffLines
215
345
  };
216
346
  }