@arvorco/relentless 0.6.0 → 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 +25 -8
- 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
|
@@ -198,14 +198,14 @@ If validation fails, revise and re-check (max 3 iterations).
|
|
|
198
198
|
## Step 6: Save & Report
|
|
199
199
|
|
|
200
200
|
1. Write complete specification to `SPEC_FILE` from JSON output
|
|
201
|
-
2. Create progress.txt if it doesn't exist:
|
|
201
|
+
2. Create progress.txt if it doesn't exist (note that markdown frontmatter must be properly formatted YAML):
|
|
202
202
|
```markdown
|
|
203
203
|
---
|
|
204
204
|
feature: FEATURE_NAME
|
|
205
205
|
started: DATE
|
|
206
206
|
last_updated: DATE
|
|
207
207
|
stories_completed: 0
|
|
208
|
-
routing_preference: [auto: mode | allow free: yes/no]
|
|
208
|
+
routing_preference: "[auto: mode | allow free: yes/no]"
|
|
209
209
|
---
|
|
210
210
|
|
|
211
211
|
# Progress Log: FEATURE_NAME
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0](https://github.com/ArvorCo/Relentless/releases/tag/v0.7.0) - 2026-01-24
|
|
11
|
+
|
|
12
|
+
### Major Features
|
|
13
|
+
|
|
14
|
+
#### Enhanced TUI with mIRC-Inspired 3-Column Layout
|
|
15
|
+
- **Responsive layout system**: Automatic switching between layouts based on terminal width
|
|
16
|
+
- Full 3-column (≥120 cols): Tasks | Output | Queue
|
|
17
|
+
- Compressed 3-column (≥100 cols): Narrower panels
|
|
18
|
+
- Vertical layout (<100 cols): Stacked panels for narrow terminals
|
|
19
|
+
- **New layout components**: `ThreeColumnLayout`, `VerticalLayout`, `LayoutSwitcher`
|
|
20
|
+
- **Task Panel (Left)**: Scrollable task list with status indicators and pulse animation for active tasks
|
|
21
|
+
- **Output Panel (Center)**: Enhanced output display with context header and code block detection
|
|
22
|
+
- **Message Queue Panel (Right)**: mIRC-style message display with timestamps
|
|
23
|
+
|
|
24
|
+
#### Real-Time Metrics Display
|
|
25
|
+
- **Cost Badge**: Real-time cost tracking display
|
|
26
|
+
- **Token Counter**: Input/output token usage visualization
|
|
27
|
+
- **Rate Limit Indicator**: Countdown timer when rate limited
|
|
28
|
+
- **Status Bar**: Comprehensive bottom bar with all metrics
|
|
29
|
+
|
|
30
|
+
#### Skip Iteration with Process Kill
|
|
31
|
+
- **Idle warning**: Shows warning after 5 minutes of agent inactivity
|
|
32
|
+
- **Press 's' to skip**: Kills the running agent process and moves to next iteration
|
|
33
|
+
- **AbortController integration**: Clean process termination via SIGTERM (with SIGKILL fallback)
|
|
34
|
+
- **AbortSignal support**: Added to all 6 agent adapters (Claude, Amp, OpenCode, Codex, Droid, Gemini)
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `src/tui/layouts/` - New layout system with responsive switching
|
|
38
|
+
- `src/tui/components/TaskItem.tsx` - Individual task display with animations
|
|
39
|
+
- `src/tui/components/TaskPanel.tsx` - Left panel with scrollable task list
|
|
40
|
+
- `src/tui/components/OutputPanel.tsx` - Enhanced center output panel
|
|
41
|
+
- `src/tui/components/MessageItem.tsx` - mIRC-style message component
|
|
42
|
+
- `src/tui/components/MessageQueuePanel.tsx` - Right panel queue display
|
|
43
|
+
- `src/tui/components/CostBadge.tsx` - Real-time cost display
|
|
44
|
+
- `src/tui/components/TokenCounter.tsx` - Token usage display
|
|
45
|
+
- `src/tui/components/RateLimitIndicator.tsx` - Rate limit countdown
|
|
46
|
+
- `src/tui/components/StatusBar.tsx` - Bottom metrics bar
|
|
47
|
+
- `src/tui/hooks/useResponsiveLayout.ts` - Terminal size detection with breakpoints
|
|
48
|
+
- `src/tui/hooks/useAnimation.ts` - Pulse, typing, blinking effects
|
|
49
|
+
- `src/tui/hooks/useCostTracking.ts` - Cost and token aggregation
|
|
50
|
+
- `signal` option in `InvokeOptions` for agent cancellation
|
|
51
|
+
- `aborted` flag in `RunCommandResult` to detect cancelled commands
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- **Default timeout**: Increased from 10 minutes to 30 minutes (1800000ms)
|
|
55
|
+
- **Idle timeout behavior**: No longer kills process automatically, just shows warning
|
|
56
|
+
- **Theme extended**: Added panel colors, animation constants, badges to `theme.ts`
|
|
57
|
+
- **Types extended**: Added `TokenUsage`, `CostData`, `MessageItem`, `LayoutMode`, `OutputMode` interfaces
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- **Idle timeout no longer detected as rate limit**: Removed idle timeout from rate limit detection in all 6 agents
|
|
61
|
+
- **Process cleanup**: Skip functionality now properly kills the agent process instead of leaving it running
|
|
62
|
+
- **YAML parsing error for routing_preference** (#9): Fixed template quoting to prevent YAML parser errors when routing_preference contains colons (thanks @namick!)
|
|
63
|
+
|
|
64
|
+
## [0.6.1](https://github.com/ArvorCo/Relentless/releases/tag/v0.6.1) - 2026-01-23
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
- **Rate Limit Detection**: Added debug logging to help diagnose false positive rate limit detection
|
|
68
|
+
|
|
10
69
|
## [0.6.0](https://github.com/ArvorCo/Relentless/releases/tag/v0.6.0) - 2026-01-23
|
|
11
70
|
|
|
12
71
|
### Major Features
|
package/package.json
CHANGED
package/src/agents/amp.ts
CHANGED
|
@@ -80,6 +80,7 @@ export const ampAdapter: AgentAdapter = {
|
|
|
80
80
|
cwd: options?.workingDirectory,
|
|
81
81
|
stdin: new Blob([prompt]),
|
|
82
82
|
timeoutMs: options?.timeout,
|
|
83
|
+
signal: options?.signal,
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
const timeoutNote =
|
|
@@ -102,12 +103,8 @@ export const ampAdapter: AgentAdapter = {
|
|
|
102
103
|
},
|
|
103
104
|
|
|
104
105
|
detectRateLimit(output: string): RateLimitInfo {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
limited: true,
|
|
108
|
-
message: "Amp idle timeout",
|
|
109
|
-
};
|
|
110
|
-
}
|
|
106
|
+
// NOTE: Idle timeout is NOT a rate limit - it just means the agent stopped
|
|
107
|
+
// producing output for a while, which is normal behavior for complex tasks.
|
|
111
108
|
|
|
112
109
|
// Amp rate limit patterns
|
|
113
110
|
const patterns = [
|
package/src/agents/claude.ts
CHANGED
|
@@ -90,6 +90,7 @@ export const claudeAdapter: AgentAdapter = {
|
|
|
90
90
|
stdin: new Blob([prompt]),
|
|
91
91
|
timeoutMs: options?.timeout,
|
|
92
92
|
env: Object.keys(env).length > 0 ? env : undefined,
|
|
93
|
+
signal: options?.signal,
|
|
93
94
|
});
|
|
94
95
|
|
|
95
96
|
const timeoutNote =
|
|
@@ -180,14 +181,27 @@ export const claudeAdapter: AgentAdapter = {
|
|
|
180
181
|
},
|
|
181
182
|
|
|
182
183
|
detectRateLimit(output: string): RateLimitInfo {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
184
|
+
// Debug: Log to file when rate limit is detected
|
|
185
|
+
const debugRateLimit = (pattern: string, message: string) => {
|
|
186
|
+
if (process.env.RELENTLESS_DEBUG) {
|
|
187
|
+
const debugInfo = {
|
|
188
|
+
timestamp: new Date().toISOString(),
|
|
189
|
+
pattern,
|
|
190
|
+
message,
|
|
191
|
+
outputLength: output.length,
|
|
192
|
+
outputSample: output.slice(0, 500),
|
|
193
|
+
outputEnd: output.slice(-500),
|
|
194
|
+
};
|
|
195
|
+
console.error(`[RELENTLESS_DEBUG] Rate limit detected: ${JSON.stringify(debugInfo, null, 2)}`);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// NOTE: Idle timeout is NOT a rate limit - it just means the agent stopped
|
|
200
|
+
// producing output for a while, which is normal behavior for complex tasks.
|
|
201
|
+
// We should NOT switch agents on idle timeout.
|
|
189
202
|
|
|
190
203
|
if (/(?:operation not permitted|permission denied|\beperm\b).*(?:\/\.claude|\.claude)/i.test(output)) {
|
|
204
|
+
debugRateLimit("permission_error", "Claude unavailable due to permission error");
|
|
191
205
|
return {
|
|
192
206
|
limited: true,
|
|
193
207
|
message: "Claude unavailable due to permission error",
|
|
@@ -195,8 +209,10 @@ export const claudeAdapter: AgentAdapter = {
|
|
|
195
209
|
}
|
|
196
210
|
|
|
197
211
|
// More specific pattern for actual API model not found errors
|
|
198
|
-
//
|
|
199
|
-
|
|
212
|
+
// Only match JSON API error responses, not conversational mentions
|
|
213
|
+
const modelNotFoundPattern = /"type":\s*"not_found_error".*"model"/i;
|
|
214
|
+
if (modelNotFoundPattern.test(output)) {
|
|
215
|
+
debugRateLimit("model_not_found", "Claude model not found");
|
|
200
216
|
return {
|
|
201
217
|
limited: true,
|
|
202
218
|
message: "Claude model not found",
|
|
@@ -225,6 +241,7 @@ export const claudeAdapter: AgentAdapter = {
|
|
|
225
241
|
}
|
|
226
242
|
}
|
|
227
243
|
|
|
244
|
+
debugRateLimit("hit_your_limit", "Claude Code rate limit exceeded");
|
|
228
245
|
return {
|
|
229
246
|
limited: true,
|
|
230
247
|
resetTime,
|
package/src/agents/codex.ts
CHANGED
|
@@ -87,6 +87,7 @@ export const codexAdapter: AgentAdapter = {
|
|
|
87
87
|
cwd: options?.workingDirectory,
|
|
88
88
|
stdin: new Blob([prompt]),
|
|
89
89
|
timeoutMs: options?.timeout,
|
|
90
|
+
signal: options?.signal,
|
|
90
91
|
});
|
|
91
92
|
|
|
92
93
|
const timeoutNote =
|
|
@@ -109,12 +110,8 @@ export const codexAdapter: AgentAdapter = {
|
|
|
109
110
|
},
|
|
110
111
|
|
|
111
112
|
detectRateLimit(output: string): RateLimitInfo {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
limited: true,
|
|
115
|
-
message: "Codex idle timeout",
|
|
116
|
-
};
|
|
117
|
-
}
|
|
113
|
+
// NOTE: Idle timeout is NOT a rate limit - it just means the agent stopped
|
|
114
|
+
// producing output for a while, which is normal behavior for complex tasks.
|
|
118
115
|
|
|
119
116
|
if (
|
|
120
117
|
/cannot access session files/i.test(output) ||
|
package/src/agents/droid.ts
CHANGED
|
@@ -87,6 +87,7 @@ export const droidAdapter: AgentAdapter = {
|
|
|
87
87
|
cwd: options?.workingDirectory,
|
|
88
88
|
stdin: new Blob([prompt]),
|
|
89
89
|
timeoutMs: options?.timeout,
|
|
90
|
+
signal: options?.signal,
|
|
90
91
|
});
|
|
91
92
|
|
|
92
93
|
const timeoutNote =
|
|
@@ -109,12 +110,8 @@ export const droidAdapter: AgentAdapter = {
|
|
|
109
110
|
},
|
|
110
111
|
|
|
111
112
|
detectRateLimit(output: string): RateLimitInfo {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
limited: true,
|
|
115
|
-
message: "Droid idle timeout",
|
|
116
|
-
};
|
|
117
|
-
}
|
|
113
|
+
// NOTE: Idle timeout is NOT a rate limit - it just means the agent stopped
|
|
114
|
+
// producing output for a while, which is normal behavior for complex tasks.
|
|
118
115
|
|
|
119
116
|
if (/mcp start failed/i.test(output) || /error reloading mcp servers/i.test(output)) {
|
|
120
117
|
return {
|
package/src/agents/exec.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface RunCommandOptions {
|
|
|
8
8
|
timeoutMs?: number;
|
|
9
9
|
/** Environment variables to pass to the command */
|
|
10
10
|
env?: Record<string, string>;
|
|
11
|
+
/** AbortSignal for cancelling the command */
|
|
12
|
+
signal?: AbortSignal;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface RunCommandResult {
|
|
@@ -16,6 +18,8 @@ export interface RunCommandResult {
|
|
|
16
18
|
exitCode: number;
|
|
17
19
|
duration: number;
|
|
18
20
|
timedOut: boolean;
|
|
21
|
+
/** Whether the command was aborted via signal */
|
|
22
|
+
aborted: boolean;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
async function readStream(
|
|
@@ -66,34 +70,75 @@ export async function runCommand(
|
|
|
66
70
|
|
|
67
71
|
let lastOutput = Date.now();
|
|
68
72
|
let timedOut = false;
|
|
73
|
+
let aborted = false;
|
|
69
74
|
let idleTimer: ReturnType<typeof setInterval> | undefined;
|
|
70
75
|
|
|
71
76
|
const onChunk = () => {
|
|
72
77
|
lastOutput = Date.now();
|
|
73
78
|
};
|
|
74
79
|
|
|
80
|
+
// Handle abort signal - kill the process when skip is requested
|
|
81
|
+
const abortHandler = () => {
|
|
82
|
+
aborted = true;
|
|
83
|
+
proc.kill("SIGTERM");
|
|
84
|
+
// Give it a moment, then force kill if still running
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
try {
|
|
87
|
+
proc.kill("SIGKILL");
|
|
88
|
+
} catch {
|
|
89
|
+
// Process already exited, ignore
|
|
90
|
+
}
|
|
91
|
+
}, 1000);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (options.signal) {
|
|
95
|
+
if (options.signal.aborted) {
|
|
96
|
+
// Already aborted before we started
|
|
97
|
+
proc.kill("SIGTERM");
|
|
98
|
+
aborted = true;
|
|
99
|
+
} else {
|
|
100
|
+
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// NOTE: We no longer kill the process on idle timeout.
|
|
105
|
+
// Idle timeout is just informational - the TUI will show a warning
|
|
106
|
+
// and let the user decide to skip if needed.
|
|
107
|
+
// The process continues running until it completes naturally.
|
|
75
108
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
76
109
|
idleTimer = setInterval(() => {
|
|
77
110
|
if (Date.now() - lastOutput > options.timeoutMs!) {
|
|
78
111
|
timedOut = true;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
112
|
+
// We intentionally do NOT kill the process here anymore.
|
|
113
|
+
// Just mark that idle timeout was reached for informational purposes.
|
|
114
|
+
clearInterval(idleTimer!);
|
|
115
|
+
idleTimer = undefined;
|
|
84
116
|
}
|
|
85
|
-
},
|
|
117
|
+
}, 1000);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Read streams - they may error if process is killed, so we handle that
|
|
121
|
+
let stdout = "";
|
|
122
|
+
let stderr = "";
|
|
123
|
+
try {
|
|
124
|
+
[stdout, stderr] = await Promise.all([
|
|
125
|
+
readStream(proc.stdout, onChunk),
|
|
126
|
+
readStream(proc.stderr, onChunk),
|
|
127
|
+
]);
|
|
128
|
+
} catch {
|
|
129
|
+
// Stream read failed, likely due to process being killed
|
|
130
|
+
// Continue with whatever output we collected
|
|
86
131
|
}
|
|
87
132
|
|
|
88
|
-
const [stdout, stderr] = await Promise.all([
|
|
89
|
-
readStream(proc.stdout, onChunk),
|
|
90
|
-
readStream(proc.stderr, onChunk),
|
|
91
|
-
]);
|
|
92
133
|
const exitCode = await proc.exited;
|
|
93
134
|
|
|
135
|
+
// Cleanup
|
|
94
136
|
if (idleTimer) {
|
|
95
137
|
clearInterval(idleTimer);
|
|
96
138
|
}
|
|
139
|
+
if (options.signal) {
|
|
140
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
141
|
+
}
|
|
97
142
|
|
|
98
143
|
return {
|
|
99
144
|
stdout,
|
|
@@ -101,5 +146,6 @@ export async function runCommand(
|
|
|
101
146
|
exitCode,
|
|
102
147
|
duration: Date.now() - startTime,
|
|
103
148
|
timedOut,
|
|
149
|
+
aborted,
|
|
104
150
|
};
|
|
105
151
|
}
|
package/src/agents/gemini.ts
CHANGED
|
@@ -83,6 +83,7 @@ export const geminiAdapter: AgentAdapter = {
|
|
|
83
83
|
const result = await runCommand(["gemini", ...args], {
|
|
84
84
|
cwd: options?.workingDirectory,
|
|
85
85
|
timeoutMs: options?.timeout,
|
|
86
|
+
signal: options?.signal,
|
|
86
87
|
});
|
|
87
88
|
|
|
88
89
|
const timeoutNote =
|
|
@@ -105,12 +106,8 @@ export const geminiAdapter: AgentAdapter = {
|
|
|
105
106
|
},
|
|
106
107
|
|
|
107
108
|
detectRateLimit(output: string): RateLimitInfo {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
limited: true,
|
|
111
|
-
message: "Gemini idle timeout",
|
|
112
|
-
};
|
|
113
|
-
}
|
|
109
|
+
// NOTE: Idle timeout is NOT a rate limit - it just means the agent stopped
|
|
110
|
+
// producing output for a while, which is normal behavior for complex tasks.
|
|
114
111
|
|
|
115
112
|
// Gemini rate limit patterns
|
|
116
113
|
const patterns = [
|
package/src/agents/opencode.ts
CHANGED
|
@@ -81,6 +81,7 @@ export const opencodeAdapter: AgentAdapter = {
|
|
|
81
81
|
const result = await runCommand(args, {
|
|
82
82
|
cwd: options?.workingDirectory,
|
|
83
83
|
timeoutMs: options?.timeout,
|
|
84
|
+
signal: options?.signal,
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
const timeoutNote =
|
|
@@ -237,12 +238,8 @@ export const opencodeAdapter: AgentAdapter = {
|
|
|
237
238
|
},
|
|
238
239
|
|
|
239
240
|
detectRateLimit(output: string): RateLimitInfo {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
limited: true,
|
|
243
|
-
message: "OpenCode idle timeout",
|
|
244
|
-
};
|
|
245
|
-
}
|
|
241
|
+
// NOTE: Idle timeout is NOT a rate limit - it just means the agent stopped
|
|
242
|
+
// producing output for a while, which is normal behavior for complex tasks.
|
|
246
243
|
|
|
247
244
|
// OpenCode rate limit patterns
|
|
248
245
|
const patterns = [
|
package/src/agents/types.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -171,7 +171,7 @@ export type FallbackConfig = z.infer<typeof FallbackConfigSchema>;
|
|
|
171
171
|
export const ExecutionConfigSchema = z.object({
|
|
172
172
|
maxIterations: z.number().int().positive().default(20),
|
|
173
173
|
iterationDelay: z.number().int().nonnegative().default(2000),
|
|
174
|
-
timeout: z.number().int().positive().default(
|
|
174
|
+
timeout: z.number().int().positive().default(1800000), // 30 minutes
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
export type ExecutionConfig = z.infer<typeof ExecutionConfigSchema>;
|
|
@@ -232,7 +232,7 @@ export const DEFAULT_CONFIG: RelentlessConfig = {
|
|
|
232
232
|
execution: {
|
|
233
233
|
maxIterations: 20,
|
|
234
234
|
iterationDelay: 2000,
|
|
235
|
-
timeout:
|
|
235
|
+
timeout: 1800000, // 30 minutes
|
|
236
236
|
},
|
|
237
237
|
prompt: {
|
|
238
238
|
path: "prompt.md",
|
package/src/tui/App.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Relentless TUI App
|
|
3
3
|
*
|
|
4
|
-
* Main application component
|
|
4
|
+
* Main application component with responsive 3-column layout
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import React from "react";
|
|
@@ -15,6 +15,12 @@ import { AgentStatus } from "./components/AgentStatus.js";
|
|
|
15
15
|
import { QueuePanel } from "./components/QueuePanel.js";
|
|
16
16
|
import { QueueInput } from "./components/QueueInput.js";
|
|
17
17
|
import { QueueRemovalPrompt } from "./components/QueueRemoval.js";
|
|
18
|
+
import { TaskPanel } from "./components/TaskPanel.js";
|
|
19
|
+
import { OutputPanel } from "./components/OutputPanel.js";
|
|
20
|
+
import { MessageQueuePanel } from "./components/MessageQueuePanel.js";
|
|
21
|
+
import { StatusBar } from "./components/StatusBar.js";
|
|
22
|
+
import { LayoutSwitcher } from "./layouts/LayoutSwitcher.js";
|
|
23
|
+
import { useResponsiveLayout } from "./hooks/useResponsiveLayout.js";
|
|
18
24
|
import { colors } from "./theme.js";
|
|
19
25
|
import type { TUIState } from "./types.js";
|
|
20
26
|
|
|
@@ -24,23 +30,112 @@ interface AppProps {
|
|
|
24
30
|
|
|
25
31
|
export function App({ state }: AppProps): React.ReactElement {
|
|
26
32
|
const { stdout } = useStdout();
|
|
27
|
-
const terminalRows = stdout
|
|
28
|
-
|
|
33
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
34
|
+
const layout = useResponsiveLayout();
|
|
35
|
+
|
|
29
36
|
const completedCount = state.stories.filter((s) => s.passes).length;
|
|
30
37
|
const totalCount = state.stories.length;
|
|
31
|
-
|
|
38
|
+
|
|
32
39
|
// Calculate available rows for stories based on terminal height
|
|
33
|
-
// Chrome: Header(2) + Feature/Progress(2) + CurrentStory(2) + AgentOutputHeader(1) + AgentStatusFooter(2) + Padding(2) = ~11 lines
|
|
34
|
-
// AgentOutput: 6 lines
|
|
35
|
-
// Remaining space for stories
|
|
36
40
|
const chromeHeight = 11;
|
|
37
41
|
const agentOutputLines = 6;
|
|
38
42
|
const availableForStories = Math.max(8, terminalRows - chromeHeight - agentOutputLines);
|
|
39
|
-
|
|
40
|
-
// Calculate story rows needed for 2-column layout
|
|
41
43
|
const storyRows = Math.ceil(totalCount / 2);
|
|
42
44
|
const maxStoryRows = Math.min(storyRows, availableForStories);
|
|
43
45
|
|
|
46
|
+
// Use 3-column layout for wider terminals
|
|
47
|
+
if (layout.mode !== "vertical") {
|
|
48
|
+
return (
|
|
49
|
+
<Box flexDirection="column" width="100%">
|
|
50
|
+
<LayoutSwitcher
|
|
51
|
+
taskPanel={
|
|
52
|
+
<TaskPanel
|
|
53
|
+
stories={state.stories}
|
|
54
|
+
currentStoryId={state.currentStory?.id}
|
|
55
|
+
maxRows={terminalRows - 4}
|
|
56
|
+
showPhases={true}
|
|
57
|
+
/>
|
|
58
|
+
}
|
|
59
|
+
outputPanel={
|
|
60
|
+
<OutputPanel
|
|
61
|
+
lines={state.outputLines}
|
|
62
|
+
maxLines={terminalRows - 6}
|
|
63
|
+
currentStory={state.currentStory}
|
|
64
|
+
currentAgent={state.currentAgent?.displayName}
|
|
65
|
+
currentModel={state.currentRouting?.model}
|
|
66
|
+
routing={state.currentRouting}
|
|
67
|
+
displayMode={state.outputMode}
|
|
68
|
+
/>
|
|
69
|
+
}
|
|
70
|
+
queuePanel={
|
|
71
|
+
<MessageQueuePanel
|
|
72
|
+
messages={state.messages}
|
|
73
|
+
queueItems={state.queueItems}
|
|
74
|
+
maxMessages={terminalRows - 10}
|
|
75
|
+
maxQueueItems={5}
|
|
76
|
+
/>
|
|
77
|
+
}
|
|
78
|
+
statusBar={
|
|
79
|
+
<StatusBar
|
|
80
|
+
costData={state.costData}
|
|
81
|
+
tokens={state.costData?.tokens}
|
|
82
|
+
iteration={state.iteration}
|
|
83
|
+
maxIterations={state.maxIterations}
|
|
84
|
+
agents={state.agents}
|
|
85
|
+
elapsedSeconds={state.totalElapsedSeconds || state.elapsedSeconds}
|
|
86
|
+
mode={state.currentRouting?.mode}
|
|
87
|
+
complexity={state.currentRouting?.complexity}
|
|
88
|
+
harness={state.currentRouting?.harness}
|
|
89
|
+
model={state.currentRouting?.model}
|
|
90
|
+
savingsPercent={state.savingsPercent}
|
|
91
|
+
completedStories={completedCount}
|
|
92
|
+
totalStories={totalCount}
|
|
93
|
+
/>
|
|
94
|
+
}
|
|
95
|
+
forceMode={layout.mode}
|
|
96
|
+
/>
|
|
97
|
+
|
|
98
|
+
{/* Error display */}
|
|
99
|
+
{state.error && (
|
|
100
|
+
<Box paddingX={1}>
|
|
101
|
+
<Text color={colors.error}>Error: {state.error}</Text>
|
|
102
|
+
</Box>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{/* Completion message */}
|
|
106
|
+
{state.isComplete && (
|
|
107
|
+
<Box paddingX={1}>
|
|
108
|
+
<Text color={colors.success} bold>
|
|
109
|
+
All stories complete!
|
|
110
|
+
</Text>
|
|
111
|
+
</Box>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Idle warning when agent has been idle for too long */}
|
|
115
|
+
{state.isRunning && state.idleSeconds >= 300 && (
|
|
116
|
+
<Box paddingX={1}>
|
|
117
|
+
<Text color={colors.warning}>
|
|
118
|
+
Agent idle for {Math.floor(state.idleSeconds / 60)}m {state.idleSeconds % 60}s - Press 's' to skip
|
|
119
|
+
</Text>
|
|
120
|
+
</Box>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{/* Queue removal prompt */}
|
|
124
|
+
<QueueRemovalPrompt
|
|
125
|
+
deleteMode={state.deleteMode}
|
|
126
|
+
confirmClearActive={state.confirmClearActive}
|
|
127
|
+
statusMessage={state.statusMessage}
|
|
128
|
+
/>
|
|
129
|
+
|
|
130
|
+
{/* Full-width queue input at bottom (Claude Code style) */}
|
|
131
|
+
{!state.deleteMode && !state.confirmClearActive && !state.statusMessage && (
|
|
132
|
+
<QueueInput active={state.queueInputActive} value={state.queueInputValue} />
|
|
133
|
+
)}
|
|
134
|
+
</Box>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Vertical layout fallback for narrow terminals
|
|
44
139
|
return (
|
|
45
140
|
<Box flexDirection="column" width="100%">
|
|
46
141
|
{/* Header */}
|
|
@@ -89,6 +184,23 @@ export function App({ state }: AppProps): React.ReactElement {
|
|
|
89
184
|
maxIterations={state.maxIterations}
|
|
90
185
|
/>
|
|
91
186
|
|
|
187
|
+
{/* Status bar */}
|
|
188
|
+
<StatusBar
|
|
189
|
+
costData={state.costData}
|
|
190
|
+
tokens={state.costData?.tokens}
|
|
191
|
+
iteration={state.iteration}
|
|
192
|
+
maxIterations={state.maxIterations}
|
|
193
|
+
agents={state.agents}
|
|
194
|
+
elapsedSeconds={state.totalElapsedSeconds || state.elapsedSeconds}
|
|
195
|
+
mode={state.currentRouting?.mode}
|
|
196
|
+
complexity={state.currentRouting?.complexity}
|
|
197
|
+
harness={state.currentRouting?.harness}
|
|
198
|
+
model={state.currentRouting?.model}
|
|
199
|
+
savingsPercent={state.savingsPercent}
|
|
200
|
+
completedStories={completedCount}
|
|
201
|
+
totalStories={totalCount}
|
|
202
|
+
/>
|
|
203
|
+
|
|
92
204
|
{/* Error display */}
|
|
93
205
|
{state.error && (
|
|
94
206
|
<Box paddingX={1}>
|
|
@@ -100,14 +212,11 @@ export function App({ state }: AppProps): React.ReactElement {
|
|
|
100
212
|
{state.isComplete && (
|
|
101
213
|
<Box paddingX={1} paddingY={1}>
|
|
102
214
|
<Text color={colors.success} bold>
|
|
103
|
-
|
|
215
|
+
All stories complete!
|
|
104
216
|
</Text>
|
|
105
217
|
</Box>
|
|
106
218
|
)}
|
|
107
219
|
|
|
108
|
-
{/* Queue input */}
|
|
109
|
-
<QueueInput active={state.queueInputActive} value={state.queueInputValue} />
|
|
110
|
-
|
|
111
220
|
{/* Queue removal prompt */}
|
|
112
221
|
<QueueRemovalPrompt
|
|
113
222
|
deleteMode={state.deleteMode}
|
|
@@ -115,12 +224,19 @@ export function App({ state }: AppProps): React.ReactElement {
|
|
|
115
224
|
statusMessage={state.statusMessage}
|
|
116
225
|
/>
|
|
117
226
|
|
|
118
|
-
{/*
|
|
119
|
-
{
|
|
227
|
+
{/* Idle warning when agent has been idle for too long */}
|
|
228
|
+
{state.isRunning && state.idleSeconds >= 300 && (
|
|
120
229
|
<Box paddingX={1}>
|
|
121
|
-
<Text color={colors.
|
|
230
|
+
<Text color={colors.warning}>
|
|
231
|
+
Agent idle for {Math.floor(state.idleSeconds / 60)}m {state.idleSeconds % 60}s - Press 's' to skip to next iteration
|
|
232
|
+
</Text>
|
|
122
233
|
</Box>
|
|
123
234
|
)}
|
|
235
|
+
|
|
236
|
+
{/* Full-width queue input at bottom (Claude Code style) */}
|
|
237
|
+
{!state.deleteMode && !state.confirmClearActive && !state.statusMessage && (
|
|
238
|
+
<QueueInput active={state.queueInputActive} value={state.queueInputValue} />
|
|
239
|
+
)}
|
|
124
240
|
</Box>
|
|
125
241
|
);
|
|
126
242
|
}
|