@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.
- package/.claude/skills/specify/SKILL.md +2 -2
- package/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/src/agents/amp.ts +3 -6
- package/src/agents/claude.ts +4 -7
- package/src/agents/codex.ts +3 -6
- package/src/agents/droid.ts +3 -6
- package/src/agents/exec.ts +56 -10
- package/src/agents/gemini.ts +3 -6
- package/src/agents/opencode.ts +3 -6
- package/src/agents/types.ts +2 -0
- package/src/config/schema.ts +2 -2
- package/src/tui/App.tsx +132 -16
- package/src/tui/TUIRunner.tsx +68 -4
- package/src/tui/components/CostBadge.tsx +59 -0
- package/src/tui/components/MessageItem.tsx +113 -0
- package/src/tui/components/MessageQueuePanel.tsx +126 -0
- package/src/tui/components/OutputPanel.tsx +270 -0
- package/src/tui/components/QueueInput.tsx +28 -11
- package/src/tui/components/RateLimitIndicator.tsx +97 -0
- package/src/tui/components/StatusBar.tsx +188 -0
- package/src/tui/components/TaskItem.tsx +131 -0
- package/src/tui/components/TaskPanel.tsx +189 -0
- package/src/tui/components/TokenCounter.tsx +48 -0
- package/src/tui/hooks/useAnimation.ts +220 -0
- package/src/tui/hooks/useCostTracking.ts +199 -0
- package/src/tui/hooks/useResponsiveLayout.ts +94 -0
- package/src/tui/hooks/useTUI.ts +57 -1
- package/src/tui/index.tsx +24 -0
- package/src/tui/layouts/LayoutSwitcher.tsx +95 -0
- package/src/tui/layouts/ThreeColumnLayout.tsx +97 -0
- package/src/tui/layouts/VerticalLayout.tsx +69 -0
- package/src/tui/layouts/index.ts +9 -0
- package/src/tui/theme.ts +152 -21
- package/src/tui/types.ts +95 -0
package/src/tui/TUIRunner.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|