@arvorco/relentless 0.6.1 → 0.7.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.
@@ -86,6 +86,12 @@ function TUIRunnerComponent({
86
86
  deleteMode: false,
87
87
  confirmClearActive: false,
88
88
  statusMessage: undefined,
89
+ // New fields for enhanced TUI
90
+ messages: [],
91
+ layoutMode: "vertical",
92
+ outputMode: "normal",
93
+ totalElapsedSeconds: 0,
94
+ startTime: new Date(),
89
95
  });
90
96
 
91
97
  // Queue file watcher ref
@@ -93,6 +99,12 @@ function TUIRunnerComponent({
93
99
  const featurePathRef = React.useRef<string>("");
94
100
  const lastOutputAtRef = React.useRef<number>(Date.now());
95
101
 
102
+ // Skip iteration - AbortController to kill the running process
103
+ const abortControllerRef = React.useRef<AbortController | null>(null);
104
+
105
+ // Idle warning threshold (5 minutes)
106
+ const IDLE_WARNING_THRESHOLD_SECONDS = 300;
107
+
96
108
  // Load queue items function
97
109
  const loadQueueItems = useCallback(async () => {
98
110
  if (!featurePathRef.current) return;
@@ -134,9 +146,29 @@ function TUIRunnerComponent({
134
146
  };
135
147
  }, [prdPath, loadQueueItems]);
136
148
 
137
- // Handle keyboard input for queue
149
+ // Handle keyboard input for queue and skip
138
150
  useInput((input, key) => {
139
- const keyString = key.escape ? "escape" : key.return ? "return" : key.backspace ? "backspace" : key.tab ? "tab" : input;
151
+ // Detect backspace: key.backspace flag OR delete/backspace character codes
152
+ const isBackspace = key.backspace || key.delete || input === "\x7f" || input === "\b";
153
+ const keyString = key.escape ? "escape" : key.return ? "return" : isBackspace ? "backspace" : key.tab ? "tab" : input;
154
+
155
+ // Handle 's' key to skip current iteration when idle
156
+ if (input === "s" && !state.queueInputActive && !state.deleteMode && !state.confirmClearActive) {
157
+ if (state.isRunning && state.idleSeconds >= IDLE_WARNING_THRESHOLD_SECONDS) {
158
+ // Abort the current agent process
159
+ if (abortControllerRef.current) {
160
+ abortControllerRef.current.abort();
161
+ }
162
+ setState((prev) => ({
163
+ ...prev,
164
+ statusMessage: "Killing agent and skipping to next iteration...",
165
+ }));
166
+ setTimeout(() => {
167
+ setState((prev) => ({ ...prev, statusMessage: undefined }));
168
+ }, 2000);
169
+ return;
170
+ }
171
+ }
140
172
 
141
173
  // First check deletion handling (d/D keys, numbers in delete mode, y/n in confirm mode)
142
174
  const deletionState = {
@@ -270,6 +302,9 @@ function TUIRunnerComponent({
270
302
  ...prev,
271
303
  elapsedSeconds: prev.elapsedSeconds + 1,
272
304
  idleSeconds: Math.floor((now - lastOutputAtRef.current) / 1000),
305
+ totalElapsedSeconds: prev.startTime
306
+ ? Math.floor((now - prev.startTime.getTime()) / 1000)
307
+ : prev.totalElapsedSeconds + 1,
273
308
  }));
274
309
  }, 1000);
275
310
 
@@ -360,6 +395,8 @@ function TUIRunnerComponent({
360
395
 
361
396
  // Main loop
362
397
  for (let i = 1; i <= maxIterations && !cancelled; i++) {
398
+ // Create new AbortController for this iteration
399
+ abortControllerRef.current = new AbortController();
363
400
  setState((prev) => ({ ...prev, iteration: i }));
364
401
 
365
402
  // Reload PRD
@@ -514,11 +551,19 @@ function TUIRunnerComponent({
514
551
  dangerouslyAllowAll: config.agents[agent.name]?.dangerouslyAllowAll ?? true,
515
552
  model: autoModeEnabled ? autoRoutingModel : config.agents[agent.name]?.model,
516
553
  timeout: config.execution.timeout,
554
+ signal: abortControllerRef.current?.signal,
517
555
  });
518
556
 
519
557
  let result;
558
+ let aborted = false;
520
559
  for await (const chunk of stream) {
521
560
  if (cancelled) break;
561
+ // Check if aborted (user pressed 's' to skip)
562
+ if (abortControllerRef.current?.signal.aborted) {
563
+ addOutput("⏭️ Agent killed - skipping to next iteration...");
564
+ aborted = true;
565
+ break;
566
+ }
522
567
  // Split chunk into lines and add each
523
568
  const lines = chunk.split("\n");
524
569
  for (const line of lines) {
@@ -529,6 +574,12 @@ function TUIRunnerComponent({
529
574
  result = chunk; // Will be overwritten by return value
530
575
  }
531
576
 
577
+ // If aborted, continue to next iteration
578
+ if (aborted) {
579
+ await sleep(500); // Brief delay before next iteration
580
+ continue;
581
+ }
582
+
532
583
  // Get the final result
533
584
  const finalResult = await stream.next();
534
585
  if (finalResult.done && finalResult.value) {
@@ -571,8 +622,16 @@ function TUIRunnerComponent({
571
622
  dangerouslyAllowAll: config.agents[agent.name]?.dangerouslyAllowAll ?? true,
572
623
  model: autoModeEnabled ? autoRoutingModel : config.agents[agent.name]?.model,
573
624
  timeout: config.execution.timeout,
625
+ signal: abortControllerRef.current?.signal,
574
626
  });
575
627
 
628
+ // Check if aborted (user pressed 's' to skip)
629
+ if (abortControllerRef.current?.signal.aborted) {
630
+ addOutput("⏭️ Agent killed - skipping to next iteration...");
631
+ await sleep(500);
632
+ continue;
633
+ }
634
+
576
635
  // Add output preview
577
636
  const lines = result.output.split("\n").slice(0, 10);
578
637
  for (const line of lines) {
@@ -632,8 +691,13 @@ function TUIRunnerComponent({
632
691
  const counts = countStories(updatedPRD);
633
692
  addOutput(`Progress: ${counts.completed}/${counts.total} complete`);
634
693
 
635
- // Delay between iterations
636
- await sleep(config.execution.iterationDelay);
694
+ // Delay between iterations (interruptible by abort)
695
+ const delayMs = config.execution.iterationDelay;
696
+ const delaySteps = Math.ceil(delayMs / 100);
697
+ for (let step = 0; step < delaySteps; step++) {
698
+ if (abortControllerRef.current?.signal.aborted || cancelled) break;
699
+ await sleep(Math.min(100, delayMs - step * 100));
700
+ }
637
701
  }
638
702
 
639
703
  // Final state
@@ -0,0 +1,59 @@
1
+ /**
2
+ * CostBadge Component
3
+ *
4
+ * Real-time cost display with actual vs estimated
5
+ */
6
+
7
+ import React from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors } from "../theme.js";
10
+ import { formatCost } from "../hooks/useCostTracking.js";
11
+
12
+ interface CostBadgeProps {
13
+ /** Actual cost incurred */
14
+ actual: number;
15
+ /** Estimated total cost */
16
+ estimated?: number;
17
+ /** Whether to show compact view */
18
+ compact?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Get cost color based on amount
23
+ */
24
+ function getCostColor(cost: number): string {
25
+ if (cost === 0) return colors.cost.free;
26
+ if (cost < 0.10) return colors.cost.cheap;
27
+ if (cost < 1.00) return colors.cost.medium;
28
+ return colors.cost.expensive;
29
+ }
30
+
31
+ export function CostBadge({
32
+ actual,
33
+ estimated,
34
+ compact = false,
35
+ }: CostBadgeProps): React.ReactElement {
36
+ const actualFormatted = formatCost(actual);
37
+ const costColor = getCostColor(actual);
38
+
39
+ if (compact) {
40
+ return (
41
+ <Text color={costColor} bold>
42
+ {actualFormatted}
43
+ </Text>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <Box>
49
+ <Text color={costColor} bold>
50
+ {actualFormatted}
51
+ </Text>
52
+ {estimated !== undefined && estimated > 0 && (
53
+ <Text color={colors.dim}>
54
+ {" "}(~{formatCost(estimated)} est)
55
+ </Text>
56
+ )}
57
+ </Box>
58
+ );
59
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * MessageItem Component
3
+ *
4
+ * Individual message with timestamp in mIRC style
5
+ */
6
+
7
+ import React from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors } from "../theme.js";
10
+ import type { MessageItem as MessageItemType } from "../types.js";
11
+
12
+ interface MessageItemProps {
13
+ /** Message data */
14
+ message: MessageItemType;
15
+ /** Whether to show full timestamp or just time */
16
+ showFullTimestamp?: boolean;
17
+ /** Maximum content width for truncation */
18
+ maxWidth?: number;
19
+ }
20
+
21
+ /**
22
+ * Format timestamp for display
23
+ */
24
+ function formatTimestamp(date: Date, full: boolean = false): string {
25
+ const hours = date.getHours().toString().padStart(2, "0");
26
+ const minutes = date.getMinutes().toString().padStart(2, "0");
27
+
28
+ if (full) {
29
+ const seconds = date.getSeconds().toString().padStart(2, "0");
30
+ return `${hours}:${minutes}:${seconds}`;
31
+ }
32
+
33
+ return `${hours}:${minutes}`;
34
+ }
35
+
36
+ /**
37
+ * Get color for message type
38
+ */
39
+ function getMessageColor(type: MessageItemType["type"]): string {
40
+ switch (type) {
41
+ case "command":
42
+ return colors.message.command;
43
+ case "prompt":
44
+ return colors.message.prompt;
45
+ case "system":
46
+ return colors.message.system;
47
+ case "info":
48
+ return colors.message.info;
49
+ case "success":
50
+ return colors.message.success;
51
+ case "error":
52
+ return colors.message.error;
53
+ default:
54
+ return colors.dim;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Get prefix symbol for message type
60
+ */
61
+ function getMessagePrefix(type: MessageItemType["type"]): string {
62
+ switch (type) {
63
+ case "command":
64
+ return ">";
65
+ case "prompt":
66
+ return "\u00BB";
67
+ case "system":
68
+ return "\u2022";
69
+ case "info":
70
+ return "i";
71
+ case "success":
72
+ return "\u2713";
73
+ case "error":
74
+ return "\u00D7";
75
+ default:
76
+ return " ";
77
+ }
78
+ }
79
+
80
+ export function MessageItemComponent({
81
+ message,
82
+ showFullTimestamp = false,
83
+ maxWidth = 40,
84
+ }: MessageItemProps): React.ReactElement {
85
+ const timestamp = formatTimestamp(message.timestamp, showFullTimestamp);
86
+ const color = getMessageColor(message.type);
87
+ const prefix = getMessagePrefix(message.type);
88
+
89
+ // Truncate content if needed
90
+ const displayContent =
91
+ message.content.length > maxWidth
92
+ ? message.content.substring(0, maxWidth - 1) + "\u2026"
93
+ : message.content;
94
+
95
+ return (
96
+ <Box flexDirection="row">
97
+ {/* Timestamp */}
98
+ <Text color={colors.dim}>{timestamp} </Text>
99
+
100
+ {/* Type prefix */}
101
+ <Text color={color} bold>
102
+ {prefix}{" "}
103
+ </Text>
104
+
105
+ {/* Content */}
106
+ <Text color={color} wrap="truncate">
107
+ {displayContent}
108
+ </Text>
109
+ </Box>
110
+ );
111
+ }
112
+
113
+ export { MessageItemComponent as MessageItem };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * MessageQueuePanel Component
3
+ *
4
+ * mIRC-style message queue panel for the right column
5
+ */
6
+
7
+ import React from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors, borders, symbols } from "../theme.js";
10
+ import { MessageItem } from "./MessageItem.js";
11
+ import type { MessageItem as MessageItemType } from "../types.js";
12
+ import type { QueueItem } from "../../queue/types.js";
13
+
14
+ interface MessageQueuePanelProps {
15
+ /** Messages to display (mIRC-style) */
16
+ messages: MessageItemType[];
17
+ /** Queue items (pending commands) */
18
+ queueItems: QueueItem[];
19
+ /** Maximum messages to show */
20
+ maxMessages?: number;
21
+ /** Maximum queue items to show */
22
+ maxQueueItems?: number;
23
+ /** Panel title */
24
+ title?: string;
25
+ }
26
+
27
+ /**
28
+ * Format queue item for display
29
+ */
30
+ function formatQueueItem(item: QueueItem, index: number): string {
31
+ return `${index + 1}. ${item.content}`;
32
+ }
33
+
34
+ export function MessageQueuePanel({
35
+ messages,
36
+ queueItems,
37
+ maxMessages = 10,
38
+ maxQueueItems = 3,
39
+ title = "Messages",
40
+ }: MessageQueuePanelProps): React.ReactElement {
41
+ // Get visible messages (most recent)
42
+ const visibleMessages = messages.slice(-maxMessages);
43
+ const hasMoreMessages = messages.length > maxMessages;
44
+
45
+ // Get visible queue items
46
+ const visibleQueueItems = queueItems.slice(0, maxQueueItems);
47
+ const hasMoreQueueItems = queueItems.length > maxQueueItems;
48
+
49
+ return (
50
+ <Box flexDirection="column" paddingX={1} width="100%">
51
+ {/* Messages section */}
52
+ <Box flexDirection="column" marginBottom={1}>
53
+ <Box>
54
+ <Text color={colors.accent} bold>
55
+ {title}
56
+ </Text>
57
+ {messages.length > 0 && (
58
+ <Text color={colors.dim}> ({messages.length})</Text>
59
+ )}
60
+ </Box>
61
+
62
+ {/* Scroll indicator */}
63
+ {hasMoreMessages && (
64
+ <Text color={colors.dim}>
65
+ {symbols.priority.high} {messages.length - maxMessages} more
66
+ </Text>
67
+ )}
68
+
69
+ {/* Messages list */}
70
+ <Box flexDirection="column">
71
+ {visibleMessages.length > 0 ? (
72
+ visibleMessages.map((msg) => (
73
+ <MessageItem key={msg.id} message={msg} maxWidth={25} />
74
+ ))
75
+ ) : (
76
+ <Text color={colors.dim} dimColor>
77
+ No messages yet
78
+ </Text>
79
+ )}
80
+ </Box>
81
+ </Box>
82
+
83
+ {/* Queue section */}
84
+ <Box flexDirection="column">
85
+ <Box>
86
+ <Text color={colors.dim} bold>
87
+ {borders.horizontal}
88
+ {borders.horizontal} Queue {borders.horizontal}
89
+ </Text>
90
+ {queueItems.length > 0 && (
91
+ <Text color={colors.warning}> ({queueItems.length})</Text>
92
+ )}
93
+ </Box>
94
+
95
+ {/* Queue items */}
96
+ <Box flexDirection="column">
97
+ {visibleQueueItems.length > 0 ? (
98
+ visibleQueueItems.map((item, i) => (
99
+ <Text key={item.addedAt} color={colors.dim} wrap="truncate">
100
+ {formatQueueItem(item, i)}
101
+ </Text>
102
+ ))
103
+ ) : (
104
+ <Text color={colors.dim} dimColor>
105
+ Empty
106
+ </Text>
107
+ )}
108
+
109
+ {/* More items indicator */}
110
+ {hasMoreQueueItems && (
111
+ <Text color={colors.dim}>
112
+ +{queueItems.length - maxQueueItems} more
113
+ </Text>
114
+ )}
115
+ </Box>
116
+
117
+ {/* Help text */}
118
+ <Box marginTop={1}>
119
+ <Text color={colors.dim} dimColor>
120
+ Items sent to agent each iteration
121
+ </Text>
122
+ </Box>
123
+ </Box>
124
+ </Box>
125
+ );
126
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * OutputPanel Component
3
+ *
4
+ * Enhanced central output panel with context header,
5
+ * code block detection, and fullscreen support
6
+ */
7
+
8
+ import React, { useMemo } from "react";
9
+ import { Box, Text } from "ink";
10
+ import { colors, borders } from "../theme.js";
11
+ import type { Story, OutputMode } from "../types.js";
12
+ import type { AgentName } from "../../agents/types.js";
13
+
14
+ interface OutputPanelProps {
15
+ /** Output lines to display */
16
+ lines: string[];
17
+ /** Maximum lines to show */
18
+ maxLines?: number;
19
+ /** Current story being worked on */
20
+ currentStory?: Story | null;
21
+ /** Current agent name */
22
+ currentAgent?: string;
23
+ /** Current model being used */
24
+ currentModel?: string;
25
+ /** Routing information */
26
+ routing?: {
27
+ mode: "free" | "cheap" | "good" | "genius";
28
+ complexity: "simple" | "medium" | "complex" | "expert";
29
+ harness: AgentName;
30
+ model: string;
31
+ };
32
+ /** Display mode (normal or fullscreen) */
33
+ displayMode?: OutputMode;
34
+ /** Panel title */
35
+ title?: string;
36
+ }
37
+
38
+ interface ParsedLine {
39
+ text: string;
40
+ type: "normal" | "code" | "code-start" | "code-end" | "header" | "success" | "error" | "warning";
41
+ language?: string;
42
+ }
43
+
44
+ /**
45
+ * Parse output lines to detect code blocks and special formatting
46
+ */
47
+ function parseLines(lines: string[]): ParsedLine[] {
48
+ const parsed: ParsedLine[] = [];
49
+ let inCodeBlock = false;
50
+
51
+ for (const line of lines) {
52
+ // Check for code block markers
53
+ if (line.startsWith("```")) {
54
+ if (inCodeBlock) {
55
+ parsed.push({ text: line, type: "code-end" });
56
+ inCodeBlock = false;
57
+ } else {
58
+ const language = line.substring(3).trim();
59
+ parsed.push({ text: line, type: "code-start", language });
60
+ inCodeBlock = true;
61
+ }
62
+ continue;
63
+ }
64
+
65
+ // Inside code block
66
+ if (inCodeBlock) {
67
+ parsed.push({ text: line, type: "code" });
68
+ continue;
69
+ }
70
+
71
+ // Check for special line types
72
+ if (line.startsWith("---") || line.startsWith("===")) {
73
+ parsed.push({ text: line, type: "header" });
74
+ } else if (
75
+ line.includes("success") ||
76
+ line.includes("complete") ||
77
+ line.includes("\u2713") ||
78
+ line.includes("\uD83C\uDF89")
79
+ ) {
80
+ parsed.push({ text: line, type: "success" });
81
+ } else if (
82
+ line.includes("error") ||
83
+ line.includes("Error") ||
84
+ line.includes("failed") ||
85
+ line.includes("\u274C")
86
+ ) {
87
+ parsed.push({ text: line, type: "error" });
88
+ } else if (
89
+ line.includes("warning") ||
90
+ line.includes("Warning") ||
91
+ line.includes("\u26A0")
92
+ ) {
93
+ parsed.push({ text: line, type: "warning" });
94
+ } else {
95
+ parsed.push({ text: line, type: "normal" });
96
+ }
97
+ }
98
+
99
+ return parsed;
100
+ }
101
+
102
+ /**
103
+ * Get color for a parsed line type
104
+ */
105
+ function getLineColor(type: ParsedLine["type"]): string {
106
+ switch (type) {
107
+ case "code":
108
+ case "code-start":
109
+ case "code-end":
110
+ return colors.accent;
111
+ case "header":
112
+ return colors.primary;
113
+ case "success":
114
+ return colors.success;
115
+ case "error":
116
+ return colors.error;
117
+ case "warning":
118
+ return colors.warning;
119
+ default:
120
+ return colors.dim;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Format mode badge text
126
+ */
127
+ function getModeBadge(mode: string): { text: string; color: string } {
128
+ switch (mode) {
129
+ case "free":
130
+ return { text: "FREE", color: colors.success };
131
+ case "cheap":
132
+ return { text: "CHEAP", color: colors.primary };
133
+ case "good":
134
+ return { text: "GOOD", color: colors.warning };
135
+ case "genius":
136
+ return { text: "GENIUS", color: colors.accent };
137
+ default:
138
+ return { text: mode.toUpperCase(), color: colors.dim };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Format complexity badge
144
+ */
145
+ function getComplexityBadge(complexity: string): { text: string; color: string } {
146
+ switch (complexity) {
147
+ case "simple":
148
+ return { text: "S", color: colors.success };
149
+ case "medium":
150
+ return { text: "M", color: colors.warning };
151
+ case "complex":
152
+ return { text: "C", color: colors.error };
153
+ case "expert":
154
+ return { text: "E", color: colors.accent };
155
+ default:
156
+ return { text: complexity[0]?.toUpperCase() ?? "?", color: colors.dim };
157
+ }
158
+ }
159
+
160
+ export function OutputPanel({
161
+ lines,
162
+ maxLines = 12,
163
+ currentStory,
164
+ currentAgent,
165
+ currentModel,
166
+ routing,
167
+ displayMode = "normal",
168
+ title = "Output",
169
+ }: OutputPanelProps): React.ReactElement {
170
+ // Parse lines for syntax highlighting
171
+ const parsedLines = useMemo(() => parseLines(lines), [lines]);
172
+
173
+ // Get visible lines based on max
174
+ const clampedMaxLines = Math.max(0, maxLines);
175
+ const displayLines =
176
+ clampedMaxLines > 0 ? parsedLines.slice(-clampedMaxLines) : [];
177
+
178
+ // Build context header
179
+ const hasContext = currentStory || currentAgent || routing;
180
+
181
+ return (
182
+ <Box flexDirection="column" paddingX={1} width="100%">
183
+ {/* Context header */}
184
+ {hasContext && (
185
+ <Box flexDirection="row" marginBottom={1}>
186
+ {/* Story ID */}
187
+ {currentStory && (
188
+ <>
189
+ <Text color={colors.warning} bold>
190
+ {currentStory.id}
191
+ </Text>
192
+ <Text color={colors.dim}> {borders.vertical} </Text>
193
+ </>
194
+ )}
195
+
196
+ {/* Agent/Model */}
197
+ {(currentAgent || currentModel) && (
198
+ <>
199
+ <Text color={colors.primary}>
200
+ {currentAgent ?? ""}
201
+ {currentModel ? `/${currentModel}` : ""}
202
+ </Text>
203
+ </>
204
+ )}
205
+
206
+ {/* Routing info */}
207
+ {routing && (
208
+ <>
209
+ <Text color={colors.dim}> {borders.vertical} </Text>
210
+ <Text color={getModeBadge(routing.mode).color}>
211
+ {getModeBadge(routing.mode).text}
212
+ </Text>
213
+ <Text color={colors.dim}>/</Text>
214
+ <Text color={getComplexityBadge(routing.complexity).color}>
215
+ {getComplexityBadge(routing.complexity).text}
216
+ </Text>
217
+ </>
218
+ )}
219
+
220
+ {/* Fullscreen indicator */}
221
+ {displayMode === "fullscreen" && (
222
+ <>
223
+ <Text color={colors.dim}> {borders.vertical} </Text>
224
+ <Text color={colors.accent}>[F] Fullscreen</Text>
225
+ </>
226
+ )}
227
+ </Box>
228
+ )}
229
+
230
+ {/* Title bar */}
231
+ <Box>
232
+ <Text color={colors.dim} bold>
233
+ {borders.horizontal}
234
+ {borders.horizontal} {title} {borders.horizontal}
235
+ {borders.horizontal}
236
+ </Text>
237
+ </Box>
238
+
239
+ {/* Output content */}
240
+ <Box flexDirection="column">
241
+ {displayLines.length > 0 ? (
242
+ displayLines.map((line, i) => (
243
+ <Text
244
+ key={i}
245
+ color={getLineColor(line.type)}
246
+ dimColor={line.type === "normal"}
247
+ wrap="truncate"
248
+ >
249
+ {line.text}
250
+ </Text>
251
+ ))
252
+ ) : (
253
+ <Text color={colors.dim} dimColor>
254
+ Waiting for agent output...
255
+ </Text>
256
+ )}
257
+ </Box>
258
+
259
+ {/* Auto-scroll indicator */}
260
+ {lines.length > clampedMaxLines && (
261
+ <Box marginTop={1}>
262
+ <Text color={colors.dim}>
263
+ {borders.vertical} {lines.length - clampedMaxLines} lines above{" "}
264
+ {borders.vertical}
265
+ </Text>
266
+ </Box>
267
+ )}
268
+ </Box>
269
+ );
270
+ }