pi-subagents 0.3.0

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.
@@ -0,0 +1,612 @@
1
+ /**
2
+ * Chain Clarification TUI Component
3
+ *
4
+ * Shows templates and resolved behaviors for each step in a chain.
5
+ * Supports editing templates, output paths, reads lists, and progress toggle.
6
+ */
7
+
8
+ import type { Theme } from "@mariozechner/pi-coding-agent";
9
+ import type { Component, TUI } from "@mariozechner/pi-tui";
10
+ import { matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
11
+ import type { AgentConfig } from "./agents.js";
12
+ import type { ResolvedStepBehavior } from "./settings.js";
13
+
14
+ /** Modified behavior overrides from TUI editing */
15
+ export interface BehaviorOverride {
16
+ output?: string | false;
17
+ reads?: string[] | false;
18
+ progress?: boolean;
19
+ }
20
+
21
+ export interface ChainClarifyResult {
22
+ confirmed: boolean;
23
+ templates: string[];
24
+ /** User-modified behavior overrides per step (undefined = no changes) */
25
+ behaviorOverrides: (BehaviorOverride | undefined)[];
26
+ }
27
+
28
+ type EditMode = "template" | "output" | "reads";
29
+
30
+ /**
31
+ * TUI component for chain clarification.
32
+ * Factory signature matches ctx.ui.custom: (tui, theme, kb, done) => Component
33
+ */
34
+ export class ChainClarifyComponent implements Component {
35
+ readonly width = 84;
36
+
37
+ private selectedStep = 0;
38
+ private editingStep: number | null = null;
39
+ private editMode: EditMode = "template";
40
+ private editBuffer: string = "";
41
+ private editCursor: number = 0;
42
+ private editViewportOffset: number = 0;
43
+
44
+ /** Lines visible in full edit mode */
45
+ private readonly EDIT_VIEWPORT_HEIGHT = 12;
46
+
47
+ /** Track user modifications to behaviors (sparse - only stores changes) */
48
+ private behaviorOverrides: Map<number, BehaviorOverride> = new Map();
49
+
50
+ constructor(
51
+ private tui: TUI,
52
+ private theme: Theme,
53
+ private agentConfigs: AgentConfig[],
54
+ private templates: string[],
55
+ private originalTask: string,
56
+ private chainDir: string,
57
+ private resolvedBehaviors: ResolvedStepBehavior[],
58
+ private done: (result: ChainClarifyResult) => void,
59
+ ) {}
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ // Helper methods for rendering
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ /** Pad string to specified visible width */
66
+ private pad(s: string, len: number): string {
67
+ const vis = visibleWidth(s);
68
+ return s + " ".repeat(Math.max(0, len - vis));
69
+ }
70
+
71
+ /** Create a row with border characters */
72
+ private row(content: string): string {
73
+ const innerW = this.width - 2;
74
+ return this.theme.fg("border", "│") + this.pad(content, innerW) + this.theme.fg("border", "│");
75
+ }
76
+
77
+ /** Render centered header line with border */
78
+ private renderHeader(text: string): string {
79
+ const innerW = this.width - 2;
80
+ const padLen = Math.max(0, innerW - visibleWidth(text));
81
+ const padLeft = Math.floor(padLen / 2);
82
+ const padRight = padLen - padLeft;
83
+ return (
84
+ this.theme.fg("border", "╭" + "─".repeat(padLeft)) +
85
+ this.theme.fg("accent", text) +
86
+ this.theme.fg("border", "─".repeat(padRight) + "╮")
87
+ );
88
+ }
89
+
90
+ /** Render centered footer line with border */
91
+ private renderFooter(text: string): string {
92
+ const innerW = this.width - 2;
93
+ const padLen = Math.max(0, innerW - visibleWidth(text));
94
+ const padLeft = Math.floor(padLen / 2);
95
+ const padRight = padLen - padLeft;
96
+ return (
97
+ this.theme.fg("border", "╰" + "─".repeat(padLeft)) +
98
+ this.theme.fg("dim", text) +
99
+ this.theme.fg("border", "─".repeat(padRight) + "╯")
100
+ );
101
+ }
102
+
103
+ /** Exit edit mode and reset state */
104
+ private exitEditMode(): void {
105
+ this.editingStep = null;
106
+ this.editViewportOffset = 0;
107
+ this.tui.requestRender();
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // Full edit mode methods
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+
114
+ /** Word-wrap text to specified width, tracking buffer positions */
115
+ private wrapText(text: string, width: number): { lines: string[]; starts: number[] } {
116
+ const lines: string[] = [];
117
+ const starts: number[] = [];
118
+
119
+ // Guard against invalid width
120
+ if (width <= 0) {
121
+ return { lines: [text], starts: [0] };
122
+ }
123
+
124
+ // Handle empty text
125
+ if (text.length === 0) {
126
+ return { lines: [""], starts: [0] };
127
+ }
128
+
129
+ let pos = 0;
130
+ while (pos < text.length) {
131
+ starts.push(pos);
132
+
133
+ // Take up to `width` characters
134
+ const remaining = text.length - pos;
135
+ const lineLen = Math.min(width, remaining);
136
+ lines.push(text.slice(pos, pos + lineLen));
137
+ pos += lineLen;
138
+ }
139
+
140
+ // Handle cursor at very end when text fills last line exactly
141
+ // Cursor at position text.length needs a place to render
142
+ if (text.length > 0 && text.length % width === 0) {
143
+ starts.push(text.length);
144
+ lines.push(""); // Empty line for cursor to sit on
145
+ }
146
+
147
+ return { lines, starts };
148
+ }
149
+
150
+ /** Convert buffer position to display line/column */
151
+ private getCursorDisplayPos(cursor: number, starts: number[]): { line: number; col: number } {
152
+ for (let i = starts.length - 1; i >= 0; i--) {
153
+ if (cursor >= starts[i]) {
154
+ return { line: i, col: cursor - starts[i] };
155
+ }
156
+ }
157
+ return { line: 0, col: 0 };
158
+ }
159
+
160
+ /** Calculate new viewport offset to keep cursor visible */
161
+ private ensureCursorVisible(cursorLine: number, viewportHeight: number, currentOffset: number): number {
162
+ let offset = currentOffset;
163
+
164
+ // Cursor above viewport - scroll up
165
+ if (cursorLine < offset) {
166
+ offset = cursorLine;
167
+ }
168
+ // Cursor below viewport - scroll down
169
+ else if (cursorLine >= offset + viewportHeight) {
170
+ offset = cursorLine - viewportHeight + 1;
171
+ }
172
+
173
+ return Math.max(0, offset);
174
+ }
175
+
176
+ /** Render the full-edit takeover view */
177
+ private renderFullEditMode(): string[] {
178
+ const innerW = this.width - 2;
179
+ const textWidth = innerW - 2; // 1 char padding on each side
180
+ const lines: string[] = [];
181
+
182
+ // Word wrap the edit buffer
183
+ const { lines: wrapped, starts } = this.wrapText(this.editBuffer, textWidth);
184
+
185
+ // Find cursor display position
186
+ const cursorPos = this.getCursorDisplayPos(this.editCursor, starts);
187
+
188
+ // Auto-scroll to keep cursor visible
189
+ this.editViewportOffset = this.ensureCursorVisible(
190
+ cursorPos.line,
191
+ this.EDIT_VIEWPORT_HEIGHT,
192
+ this.editViewportOffset,
193
+ );
194
+
195
+ // Header (truncate agent name to prevent overflow)
196
+ const fieldName = this.editMode === "template" ? "task" : this.editMode;
197
+ const rawAgentName = this.agentConfigs[this.editingStep!]?.name ?? "unknown";
198
+ const maxAgentLen = innerW - 30; // Reserve space for " Editing X (Step N: ) "
199
+ const agentName = rawAgentName.length > maxAgentLen
200
+ ? rawAgentName.slice(0, maxAgentLen - 1) + "…"
201
+ : rawAgentName;
202
+ const headerText = ` Editing ${fieldName} (Step ${this.editingStep! + 1}: ${agentName}) `;
203
+ lines.push(this.renderHeader(headerText));
204
+ lines.push(this.row(""));
205
+
206
+ // Render visible lines from viewport
207
+ for (let i = 0; i < this.EDIT_VIEWPORT_HEIGHT; i++) {
208
+ const lineIdx = this.editViewportOffset + i;
209
+ if (lineIdx < wrapped.length) {
210
+ let content = wrapped[lineIdx];
211
+
212
+ // Insert cursor if on this line
213
+ if (lineIdx === cursorPos.line) {
214
+ content = this.renderWithCursor(content, cursorPos.col);
215
+ }
216
+
217
+ lines.push(this.row(` ${content}`));
218
+ } else {
219
+ lines.push(this.row(""));
220
+ }
221
+ }
222
+
223
+ // Scroll indicators
224
+ const linesBelow = wrapped.length - this.editViewportOffset - this.EDIT_VIEWPORT_HEIGHT;
225
+ const hasMore = linesBelow > 0;
226
+ const hasLess = this.editViewportOffset > 0;
227
+ let scrollInfo = "";
228
+ if (hasLess) scrollInfo += "↑";
229
+ if (hasMore) scrollInfo += `↓ ${linesBelow}+`;
230
+
231
+ lines.push(this.row(""));
232
+
233
+ // Footer with scroll indicators if applicable
234
+ const footerText = scrollInfo
235
+ ? ` [Esc] Done • [Ctrl+C] Discard • ${scrollInfo} `
236
+ : " [Esc] Done • [Ctrl+C] Discard ";
237
+ lines.push(this.renderFooter(footerText));
238
+
239
+ return lines;
240
+ }
241
+
242
+ // ─────────────────────────────────────────────────────────────────────────────
243
+ // Behavior helpers
244
+ // ─────────────────────────────────────────────────────────────────────────────
245
+
246
+ /** Get effective behavior for a step (with user overrides applied) */
247
+ private getEffectiveBehavior(stepIndex: number): ResolvedStepBehavior {
248
+ const base = this.resolvedBehaviors[stepIndex]!;
249
+ const override = this.behaviorOverrides.get(stepIndex);
250
+ if (!override) return base;
251
+
252
+ return {
253
+ output: override.output !== undefined ? override.output : base.output,
254
+ reads: override.reads !== undefined ? override.reads : base.reads,
255
+ progress: override.progress !== undefined ? override.progress : base.progress,
256
+ };
257
+ }
258
+
259
+ /** Update a behavior override for a step */
260
+ private updateBehavior(stepIndex: number, field: keyof BehaviorOverride, value: string | boolean | string[] | false): void {
261
+ const existing = this.behaviorOverrides.get(stepIndex) ?? {};
262
+ this.behaviorOverrides.set(stepIndex, { ...existing, [field]: value });
263
+ }
264
+
265
+ handleInput(data: string): void {
266
+ if (this.editingStep !== null) {
267
+ this.handleEditInput(data);
268
+ return;
269
+ }
270
+
271
+ // Navigation mode
272
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
273
+ this.done({ confirmed: false, templates: [], behaviorOverrides: [] });
274
+ return;
275
+ }
276
+
277
+ if (matchesKey(data, "return")) {
278
+ // Build behavior overrides array
279
+ const overrides: (BehaviorOverride | undefined)[] = [];
280
+ for (let i = 0; i < this.agentConfigs.length; i++) {
281
+ overrides.push(this.behaviorOverrides.get(i));
282
+ }
283
+ this.done({ confirmed: true, templates: this.templates, behaviorOverrides: overrides });
284
+ return;
285
+ }
286
+
287
+ if (matchesKey(data, "up")) {
288
+ this.selectedStep = Math.max(0, this.selectedStep - 1);
289
+ this.tui.requestRender();
290
+ return;
291
+ }
292
+
293
+ if (matchesKey(data, "down")) {
294
+ const maxStep = Math.max(0, this.agentConfigs.length - 1);
295
+ this.selectedStep = Math.min(maxStep, this.selectedStep + 1);
296
+ this.tui.requestRender();
297
+ return;
298
+ }
299
+
300
+ // 'e' to edit template
301
+ if (data === "e") {
302
+ this.enterEditMode("template");
303
+ return;
304
+ }
305
+
306
+ // 'o' to edit output
307
+ if (data === "o") {
308
+ this.enterEditMode("output");
309
+ return;
310
+ }
311
+
312
+ // 'r' to edit reads
313
+ if (data === "r") {
314
+ this.enterEditMode("reads");
315
+ return;
316
+ }
317
+
318
+ // 'p' to toggle progress
319
+ if (data === "p") {
320
+ const current = this.getEffectiveBehavior(this.selectedStep);
321
+ this.updateBehavior(this.selectedStep, "progress", !current.progress);
322
+ this.tui.requestRender();
323
+ return;
324
+ }
325
+ }
326
+
327
+ private enterEditMode(mode: EditMode): void {
328
+ this.editingStep = this.selectedStep;
329
+ this.editMode = mode;
330
+ this.editViewportOffset = 0; // Reset scroll position
331
+
332
+ if (mode === "template") {
333
+ const template = this.templates[this.selectedStep] ?? "";
334
+ // For template, use first line only (single-line editor)
335
+ this.editBuffer = template.split("\n")[0] ?? "";
336
+ } else if (mode === "output") {
337
+ const behavior = this.getEffectiveBehavior(this.selectedStep);
338
+ this.editBuffer = behavior.output === false ? "" : (behavior.output || "");
339
+ } else if (mode === "reads") {
340
+ const behavior = this.getEffectiveBehavior(this.selectedStep);
341
+ this.editBuffer = behavior.reads === false ? "" : (behavior.reads?.join(", ") || "");
342
+ }
343
+
344
+ this.editCursor = 0; // Start at beginning so cursor is visible
345
+ this.tui.requestRender();
346
+ }
347
+
348
+ private handleEditInput(data: string): void {
349
+ const textWidth = this.width - 4; // Must match render: innerW - 2 = (width - 2) - 2
350
+ const { lines: wrapped, starts } = this.wrapText(this.editBuffer, textWidth);
351
+ const cursorPos = this.getCursorDisplayPos(this.editCursor, starts);
352
+
353
+ // Escape - save and exit
354
+ if (matchesKey(data, "escape")) {
355
+ this.saveEdit();
356
+ this.exitEditMode();
357
+ return;
358
+ }
359
+
360
+ // Ctrl+C - discard and exit
361
+ if (matchesKey(data, "ctrl+c")) {
362
+ this.exitEditMode();
363
+ return;
364
+ }
365
+
366
+ // Enter - ignored (single-line editing, no newlines)
367
+ if (matchesKey(data, "return")) {
368
+ return;
369
+ }
370
+
371
+ // Left arrow - move cursor left
372
+ if (matchesKey(data, "left")) {
373
+ if (this.editCursor > 0) this.editCursor--;
374
+ this.tui.requestRender();
375
+ return;
376
+ }
377
+
378
+ // Right arrow - move cursor right
379
+ if (matchesKey(data, "right")) {
380
+ if (this.editCursor < this.editBuffer.length) this.editCursor++;
381
+ this.tui.requestRender();
382
+ return;
383
+ }
384
+
385
+ // Up arrow - move up one display line
386
+ if (matchesKey(data, "up")) {
387
+ if (cursorPos.line > 0) {
388
+ const targetLine = cursorPos.line - 1;
389
+ const targetCol = Math.min(cursorPos.col, wrapped[targetLine].length);
390
+ this.editCursor = starts[targetLine] + targetCol;
391
+ }
392
+ this.tui.requestRender();
393
+ return;
394
+ }
395
+
396
+ // Down arrow - move down one display line
397
+ if (matchesKey(data, "down")) {
398
+ if (cursorPos.line < wrapped.length - 1) {
399
+ const targetLine = cursorPos.line + 1;
400
+ const targetCol = Math.min(cursorPos.col, wrapped[targetLine].length);
401
+ this.editCursor = starts[targetLine] + targetCol;
402
+ }
403
+ this.tui.requestRender();
404
+ return;
405
+ }
406
+
407
+ // Page up (Shift+Up or PageUp)
408
+ if (matchesKey(data, "shift+up") || matchesKey(data, "pageup")) {
409
+ const targetLine = Math.max(0, cursorPos.line - this.EDIT_VIEWPORT_HEIGHT);
410
+ const targetCol = Math.min(cursorPos.col, wrapped[targetLine]?.length ?? 0);
411
+ this.editCursor = starts[targetLine] + targetCol;
412
+ this.tui.requestRender();
413
+ return;
414
+ }
415
+
416
+ // Page down (Shift+Down or PageDown)
417
+ if (matchesKey(data, "shift+down") || matchesKey(data, "pagedown")) {
418
+ const targetLine = Math.min(wrapped.length - 1, cursorPos.line + this.EDIT_VIEWPORT_HEIGHT);
419
+ const targetCol = Math.min(cursorPos.col, wrapped[targetLine]?.length ?? 0);
420
+ this.editCursor = starts[targetLine] + targetCol;
421
+ this.tui.requestRender();
422
+ return;
423
+ }
424
+
425
+ // Home - start of current display line
426
+ if (matchesKey(data, "home")) {
427
+ this.editCursor = starts[cursorPos.line];
428
+ this.tui.requestRender();
429
+ return;
430
+ }
431
+
432
+ // End - end of current display line
433
+ if (matchesKey(data, "end")) {
434
+ this.editCursor = starts[cursorPos.line] + wrapped[cursorPos.line].length;
435
+ this.tui.requestRender();
436
+ return;
437
+ }
438
+
439
+ // Ctrl+Home - start of text
440
+ if (matchesKey(data, "ctrl+home")) {
441
+ this.editCursor = 0;
442
+ this.tui.requestRender();
443
+ return;
444
+ }
445
+
446
+ // Ctrl+End - end of text
447
+ if (matchesKey(data, "ctrl+end")) {
448
+ this.editCursor = this.editBuffer.length;
449
+ this.tui.requestRender();
450
+ return;
451
+ }
452
+
453
+ // Backspace - delete character before cursor
454
+ if (matchesKey(data, "backspace")) {
455
+ if (this.editCursor > 0) {
456
+ this.editBuffer =
457
+ this.editBuffer.slice(0, this.editCursor - 1) +
458
+ this.editBuffer.slice(this.editCursor);
459
+ this.editCursor--;
460
+ }
461
+ this.tui.requestRender();
462
+ return;
463
+ }
464
+
465
+ // Delete - delete character at cursor
466
+ if (matchesKey(data, "delete")) {
467
+ if (this.editCursor < this.editBuffer.length) {
468
+ this.editBuffer =
469
+ this.editBuffer.slice(0, this.editCursor) +
470
+ this.editBuffer.slice(this.editCursor + 1);
471
+ }
472
+ this.tui.requestRender();
473
+ return;
474
+ }
475
+
476
+ // Printable character - insert at cursor
477
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
478
+ this.editBuffer =
479
+ this.editBuffer.slice(0, this.editCursor) +
480
+ data +
481
+ this.editBuffer.slice(this.editCursor);
482
+ this.editCursor++;
483
+ this.tui.requestRender();
484
+ return;
485
+ }
486
+ }
487
+
488
+ private saveEdit(): void {
489
+ const stepIndex = this.editingStep!;
490
+
491
+ if (this.editMode === "template") {
492
+ // For template, preserve other lines if they existed
493
+ const original = this.templates[stepIndex] ?? "";
494
+ const originalLines = original.split("\n");
495
+ originalLines[0] = this.editBuffer;
496
+ this.templates[stepIndex] = originalLines.join("\n");
497
+ } else if (this.editMode === "output") {
498
+ // Empty string or whitespace means disable output
499
+ const trimmed = this.editBuffer.trim();
500
+ this.updateBehavior(stepIndex, "output", trimmed === "" ? false : trimmed);
501
+ } else if (this.editMode === "reads") {
502
+ // Parse comma-separated list, empty means disable reads
503
+ const trimmed = this.editBuffer.trim();
504
+ if (trimmed === "") {
505
+ this.updateBehavior(stepIndex, "reads", false);
506
+ } else {
507
+ const files = trimmed.split(",").map(f => f.trim()).filter(f => f !== "");
508
+ this.updateBehavior(stepIndex, "reads", files.length > 0 ? files : false);
509
+ }
510
+ }
511
+ }
512
+
513
+ render(_width: number): string[] {
514
+ if (this.editingStep !== null) {
515
+ return this.renderFullEditMode();
516
+ }
517
+ return this.renderNavigationMode();
518
+ }
519
+
520
+ /** Render navigation mode (step selection, preview) */
521
+ private renderNavigationMode(): string[] {
522
+ const innerW = this.width - 2;
523
+ const th = this.theme;
524
+ const lines: string[] = [];
525
+
526
+ // Header with chain name (truncate if too long)
527
+ const chainLabel = this.agentConfigs.map((c) => c.name).join(" → ");
528
+ const maxHeaderLen = innerW - 4;
529
+ const headerText = ` Chain: ${truncateToWidth(chainLabel, maxHeaderLen - 9)} `;
530
+ lines.push(this.renderHeader(headerText));
531
+
532
+ lines.push(this.row(""));
533
+
534
+ // Original task (truncated) and chain dir
535
+ const taskPreview = truncateToWidth(this.originalTask, innerW - 16);
536
+ lines.push(this.row(` Original Task: ${taskPreview}`));
537
+ const chainDirPreview = truncateToWidth(this.chainDir, innerW - 12);
538
+ lines.push(this.row(` Chain Dir: ${th.fg("dim", chainDirPreview)}`));
539
+ lines.push(this.row(""));
540
+
541
+ // Each step
542
+ for (let i = 0; i < this.agentConfigs.length; i++) {
543
+ const config = this.agentConfigs[i]!;
544
+ const isSelected = i === this.selectedStep;
545
+ const behavior = this.getEffectiveBehavior(i);
546
+
547
+ // Step header (truncate agent name to prevent overflow)
548
+ const color = isSelected ? "accent" : "dim";
549
+ const prefix = isSelected ? "▶ " : " ";
550
+ const stepPrefix = `Step ${i + 1}: `;
551
+ const maxNameLen = innerW - 4 - prefix.length - stepPrefix.length; // 4 for " " prefix and padding
552
+ const agentName = config.name.length > maxNameLen
553
+ ? config.name.slice(0, maxNameLen - 1) + "…"
554
+ : config.name;
555
+ const stepLabel = `${stepPrefix}${agentName}`;
556
+ lines.push(
557
+ this.row(` ${th.fg(color, prefix + stepLabel)}`),
558
+ );
559
+
560
+ // Template line (with syntax highlighting for variables)
561
+ const template = (this.templates[i] ?? "").split("\n")[0] ?? "";
562
+ const highlighted = template
563
+ .replace(/\{task\}/g, th.fg("success", "{task}"))
564
+ .replace(/\{previous\}/g, th.fg("warning", "{previous}"))
565
+ .replace(/\{chain_dir\}/g, th.fg("accent", "{chain_dir}"));
566
+
567
+ const templateLabel = th.fg("dim", "task: ");
568
+ lines.push(this.row(` ${templateLabel}${truncateToWidth(highlighted, innerW - 12)}`));
569
+
570
+ // Output line
571
+ const outputValue = behavior.output === false
572
+ ? th.fg("dim", "(disabled)")
573
+ : (behavior.output || th.fg("dim", "(none)"));
574
+ const outputLabel = th.fg("dim", "output: ");
575
+ lines.push(this.row(` ${outputLabel}${truncateToWidth(outputValue, innerW - 14)}`));
576
+
577
+ // Reads line
578
+ const readsValue = behavior.reads === false
579
+ ? th.fg("dim", "(disabled)")
580
+ : (behavior.reads && behavior.reads.length > 0
581
+ ? behavior.reads.join(", ")
582
+ : th.fg("dim", "(none)"));
583
+ const readsLabel = th.fg("dim", "reads: ");
584
+ lines.push(this.row(` ${readsLabel}${truncateToWidth(readsValue, innerW - 13)}`));
585
+
586
+ // Progress line
587
+ const progressValue = behavior.progress ? th.fg("success", "✓ enabled") : th.fg("dim", "✗ disabled");
588
+ const progressLabel = th.fg("dim", "progress: ");
589
+ lines.push(this.row(` ${progressLabel}${progressValue}`));
590
+
591
+ lines.push(this.row(""));
592
+ }
593
+
594
+ // Footer with keybindings
595
+ const footerText = " [Enter] Run • [Esc] Cancel • [e]dit [o]utput [r]eads [p]rogress ";
596
+ lines.push(this.renderFooter(footerText));
597
+
598
+ return lines;
599
+ }
600
+
601
+ /** Render text with cursor at position (reverse video for visibility) */
602
+ private renderWithCursor(text: string, cursorPos: number): string {
603
+ const before = text.slice(0, cursorPos);
604
+ const cursorChar = text[cursorPos] ?? " ";
605
+ const after = text.slice(cursorPos + 1);
606
+ // Use reverse video (\x1b[7m) for cursor, then disable reverse (\x1b[27m)
607
+ return `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;
608
+ }
609
+
610
+ invalidate(): void {}
611
+ dispose(): void {}
612
+ }