@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.
Files changed (35) hide show
  1. package/.claude/skills/specify/SKILL.md +2 -2
  2. package/CHANGELOG.md +59 -0
  3. package/package.json +1 -1
  4. package/src/agents/amp.ts +3 -6
  5. package/src/agents/claude.ts +25 -8
  6. package/src/agents/codex.ts +3 -6
  7. package/src/agents/droid.ts +3 -6
  8. package/src/agents/exec.ts +56 -10
  9. package/src/agents/gemini.ts +3 -6
  10. package/src/agents/opencode.ts +3 -6
  11. package/src/agents/types.ts +2 -0
  12. package/src/config/schema.ts +2 -2
  13. package/src/tui/App.tsx +132 -16
  14. package/src/tui/TUIRunner.tsx +68 -4
  15. package/src/tui/components/CostBadge.tsx +59 -0
  16. package/src/tui/components/MessageItem.tsx +113 -0
  17. package/src/tui/components/MessageQueuePanel.tsx +126 -0
  18. package/src/tui/components/OutputPanel.tsx +270 -0
  19. package/src/tui/components/QueueInput.tsx +28 -11
  20. package/src/tui/components/RateLimitIndicator.tsx +97 -0
  21. package/src/tui/components/StatusBar.tsx +188 -0
  22. package/src/tui/components/TaskItem.tsx +131 -0
  23. package/src/tui/components/TaskPanel.tsx +189 -0
  24. package/src/tui/components/TokenCounter.tsx +48 -0
  25. package/src/tui/hooks/useAnimation.ts +220 -0
  26. package/src/tui/hooks/useCostTracking.ts +199 -0
  27. package/src/tui/hooks/useResponsiveLayout.ts +94 -0
  28. package/src/tui/hooks/useTUI.ts +57 -1
  29. package/src/tui/index.tsx +24 -0
  30. package/src/tui/layouts/LayoutSwitcher.tsx +95 -0
  31. package/src/tui/layouts/ThreeColumnLayout.tsx +97 -0
  32. package/src/tui/layouts/VerticalLayout.tsx +69 -0
  33. package/src/tui/layouts/index.ts +9 -0
  34. package/src/tui/theme.ts +152 -21
  35. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arvorco/relentless",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Universal AI agent orchestrator - works with Claude Code, Amp, OpenCode, Codex, Droid, and Gemini",
5
5
  "type": "module",
6
6
  "publishConfig": {
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
- if (output.includes("[Relentless] Idle timeout")) {
106
- return {
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 = [
@@ -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
- if (output.includes("[Relentless] Idle timeout")) {
184
- return {
185
- limited: true,
186
- message: "Claude idle timeout",
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
- // Avoid matching if Claude just mentions "model" and "not_found_error" in conversation
199
- if (/error.*model.*not[_\s]?found|model.*not[_\s]?found.*error|"type":\s*"not_found_error"/i.test(output)) {
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,
@@ -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
- if (output.includes("[Relentless] Idle timeout")) {
113
- return {
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) ||
@@ -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
- if (output.includes("[Relentless] Idle timeout")) {
113
- return {
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 {
@@ -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
- try {
80
- proc.kill();
81
- } catch {
82
- // Best-effort kill on timeout.
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
- }, 500);
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
  }
@@ -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
- if (output.includes("[Relentless] Idle timeout")) {
109
- return {
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 = [
@@ -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
- if (output.includes("[Relentless] Idle timeout")) {
241
- return {
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 = [
@@ -18,6 +18,8 @@ export interface InvokeOptions {
18
18
  dangerouslyAllowAll?: boolean;
19
19
  /** Claude Code TaskList ID for cross-session coordination */
20
20
  taskListId?: string;
21
+ /** AbortSignal for cancelling the agent execution */
22
+ signal?: AbortSignal;
21
23
  }
22
24
 
23
25
  /**
@@ -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(600000), // 10 minutes
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: 600000,
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 that renders the full interface
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.rows ?? 24;
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
- 🎉 All stories complete!
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
- {/* Keyboard hint when not in input mode */}
119
- {!state.queueInputActive && !state.deleteMode && !state.confirmClearActive && !state.statusMessage && (
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.dim}>Press 'q' to add, 'd' to delete, 'D' to clear queue</Text>
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
  }