Skip to content

Commit 01cdb12

Browse files
feat(tui): add TUI improvements and crash recovery
- Max iterations: display current/max in header, adjust with +/- keys, dynamic default based on remaining stories - Log viewer: compact tool display with icons and arguments, use full width for tool results - Branch warning: warn when starting on main/master, offer to create feature branch - Crash recovery: auto-retry on failures with configurable limits (3 retries with delays 0s, 5s, 15s), --no-retry flag to disable - Remove duplicate conversion message from new/edit commands
1 parent 21d1891 commit 01cdb12

17 files changed

Lines changed: 1053 additions & 81 deletions

File tree

cmd/chief/main.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type TUIOptions struct {
2626
Verbose bool
2727
Merge bool
2828
Force bool
29+
NoRetry bool
2930
}
3031

3132
func main() {
@@ -113,11 +114,12 @@ func listAvailablePRDs() []string {
113114
func parseTUIFlags() *TUIOptions {
114115
opts := &TUIOptions{
115116
PRDPath: "", // Will be resolved later
116-
MaxIterations: 10,
117+
MaxIterations: 0, // 0 signals dynamic calculation (remaining stories + 5)
117118
NoSound: false,
118119
Verbose: false,
119120
Merge: false,
120121
Force: false,
122+
NoRetry: false,
121123
}
122124

123125
for i := 1; i < len(os.Args); i++ {
@@ -138,6 +140,8 @@ func parseTUIFlags() *TUIOptions {
138140
opts.Merge = true
139141
case arg == "--force":
140142
opts.Force = true
143+
case arg == "--no-retry":
144+
opts.NoRetry = true
141145
case arg == "--max-iterations" || arg == "-n":
142146
// Next argument should be the number
143147
if i+1 < len(os.Args) {
@@ -345,6 +349,11 @@ func runTUIWithOptions(opts *TUIOptions) {
345349
app.SetVerbose(true)
346350
}
347351

352+
// Disable retry if requested
353+
if opts.NoRetry {
354+
app.DisableRetry()
355+
}
356+
348357
// Initialize sound notifier (unless disabled)
349358
if !opts.NoSound {
350359
notifier, err := notify.GetNotifier()
@@ -415,8 +424,9 @@ Commands:
415424
help Show this help message
416425
417426
Global Options:
418-
--max-iterations N, -n N Set maximum iterations (default: 10)
427+
--max-iterations N, -n N Set maximum iterations (default: dynamic)
419428
--no-sound Disable completion sound notifications
429+
--no-retry Disable auto-retry on Claude crashes
420430
--verbose Show raw Claude output in log
421431
--merge Auto-merge progress on conversion conflicts
422432
--force Auto-overwrite on conversion conflicts

internal/cmd/edit.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ func RunEdit(opts EditOptions) error {
5959
fmt.Println("\nPRD editing complete!")
6060

6161
// Run conversion from prd.md to prd.json with progress protection
62-
fmt.Println("Converting prd.md to prd.json...")
6362
convertOpts := ConvertOptions{
6463
PRDDir: prdDir,
6564
Merge: opts.Merge,

internal/cmd/new.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ func RunNew(opts NewOptions) error {
7272
fmt.Println("\nPRD created successfully!")
7373

7474
// Run conversion from prd.md to prd.json
75-
fmt.Println("Converting prd.md to prd.json...")
7675
if err := RunConvert(prdDir); err != nil {
7776
return fmt.Errorf("conversion failed: %w", err)
7877
}

internal/git/git.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Package git provides Git utility functions for Chief.
2+
package git
3+
4+
import (
5+
"os/exec"
6+
"strings"
7+
)
8+
9+
// GetCurrentBranch returns the current git branch name for a directory.
10+
func GetCurrentBranch(dir string) (string, error) {
11+
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
12+
cmd.Dir = dir
13+
output, err := cmd.Output()
14+
if err != nil {
15+
return "", err
16+
}
17+
return strings.TrimSpace(string(output)), nil
18+
}
19+
20+
// IsProtectedBranch returns true if the branch name is main or master.
21+
func IsProtectedBranch(branch string) bool {
22+
return branch == "main" || branch == "master"
23+
}
24+
25+
// CreateBranch creates a new branch and switches to it.
26+
func CreateBranch(dir, branchName string) error {
27+
cmd := exec.Command("git", "checkout", "-b", branchName)
28+
cmd.Dir = dir
29+
return cmd.Run()
30+
}
31+
32+
// BranchExists returns true if a branch with the given name exists.
33+
func BranchExists(dir, branchName string) (bool, error) {
34+
cmd := exec.Command("git", "rev-parse", "--verify", branchName)
35+
cmd.Dir = dir
36+
err := cmd.Run()
37+
if err != nil {
38+
// Branch doesn't exist
39+
return false, nil
40+
}
41+
return true, nil
42+
}
43+
44+
// IsGitRepo returns true if the directory is inside a git repository.
45+
func IsGitRepo(dir string) bool {
46+
cmd := exec.Command("git", "rev-parse", "--git-dir")
47+
cmd.Dir = dir
48+
return cmd.Run() == nil
49+
}

internal/git/git_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package git
2+
3+
import "testing"
4+
5+
func TestIsProtectedBranch(t *testing.T) {
6+
tests := []struct {
7+
branch string
8+
expected bool
9+
}{
10+
{"main", true},
11+
{"master", true},
12+
{"develop", false},
13+
{"feature/foo", false},
14+
{"chief/my-prd", false},
15+
}
16+
17+
for _, tt := range tests {
18+
t.Run(tt.branch, func(t *testing.T) {
19+
result := IsProtectedBranch(tt.branch)
20+
if result != tt.expected {
21+
t.Errorf("IsProtectedBranch(%q) = %v, want %v", tt.branch, result, tt.expected)
22+
}
23+
})
24+
}
25+
}

internal/loop/loop.go

Lines changed: 139 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,51 @@ import (
1313
"os/exec"
1414
"path/filepath"
1515
"sync"
16+
"time"
1617

1718
"github.com/minicodemonkey/chief/embed"
1819
"github.com/minicodemonkey/chief/internal/prd"
1920
)
2021

22+
// RetryConfig configures automatic retry behavior on Claude crashes.
23+
type RetryConfig struct {
24+
MaxRetries int // Maximum number of retry attempts (default: 3)
25+
RetryDelays []time.Duration // Delays between retries (default: 0s, 5s, 15s)
26+
Enabled bool // Whether retry is enabled (default: true)
27+
}
28+
29+
// DefaultRetryConfig returns the default retry configuration.
30+
func DefaultRetryConfig() RetryConfig {
31+
return RetryConfig{
32+
MaxRetries: 3,
33+
RetryDelays: []time.Duration{0, 5 * time.Second, 15 * time.Second},
34+
Enabled: true,
35+
}
36+
}
37+
2138
// Loop manages the core agent loop that invokes Claude repeatedly until all stories are complete.
2239
type Loop struct {
23-
prdPath string
24-
prompt string
25-
maxIter int
26-
iteration int
27-
events chan Event
28-
claudeCmd *exec.Cmd
29-
logFile *os.File
30-
mu sync.Mutex
31-
stopped bool
32-
paused bool
40+
prdPath string
41+
prompt string
42+
maxIter int
43+
iteration int
44+
events chan Event
45+
claudeCmd *exec.Cmd
46+
logFile *os.File
47+
mu sync.Mutex
48+
stopped bool
49+
paused bool
50+
retryConfig RetryConfig
3351
}
3452

3553
// NewLoop creates a new Loop instance.
3654
func NewLoop(prdPath, prompt string, maxIter int) *Loop {
3755
return &Loop{
38-
prdPath: prdPath,
39-
prompt: prompt,
40-
maxIter: maxIter,
41-
events: make(chan Event, 100),
56+
prdPath: prdPath,
57+
prompt: prompt,
58+
maxIter: maxIter,
59+
events: make(chan Event, 100),
60+
retryConfig: DefaultRetryConfig(),
4261
}
4362
}
4463

@@ -102,8 +121,8 @@ func (l *Loop) Run(ctx context.Context) error {
102121
Iteration: currentIter,
103122
}
104123

105-
// Run a single iteration
106-
if err := l.runIteration(ctx); err != nil {
124+
// Run a single iteration with retry logic
125+
if err := l.runIterationWithRetry(ctx); err != nil {
107126
l.events <- Event{
108127
Type: EventError,
109128
Err: err,
@@ -146,6 +165,82 @@ func (l *Loop) Run(ctx context.Context) error {
146165
}
147166
}
148167

168+
// runIterationWithRetry wraps runIteration with retry logic for crash recovery.
169+
func (l *Loop) runIterationWithRetry(ctx context.Context) error {
170+
l.mu.Lock()
171+
config := l.retryConfig
172+
l.mu.Unlock()
173+
174+
var lastErr error
175+
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
176+
// Check if retry is enabled (except for first attempt)
177+
if attempt > 0 {
178+
if !config.Enabled {
179+
return lastErr
180+
}
181+
182+
// Get delay for this retry
183+
delayIdx := attempt - 1
184+
if delayIdx >= len(config.RetryDelays) {
185+
delayIdx = len(config.RetryDelays) - 1
186+
}
187+
delay := config.RetryDelays[delayIdx]
188+
189+
// Emit retry event
190+
l.mu.Lock()
191+
iter := l.iteration
192+
l.mu.Unlock()
193+
l.events <- Event{
194+
Type: EventRetrying,
195+
Iteration: iter,
196+
RetryCount: attempt,
197+
RetryMax: config.MaxRetries,
198+
Text: fmt.Sprintf("Claude crashed, retrying (%d/%d)...", attempt, config.MaxRetries),
199+
}
200+
201+
// Wait before retry
202+
if delay > 0 {
203+
select {
204+
case <-time.After(delay):
205+
case <-ctx.Done():
206+
return ctx.Err()
207+
}
208+
}
209+
}
210+
211+
// Check if stopped during delay
212+
l.mu.Lock()
213+
if l.stopped {
214+
l.mu.Unlock()
215+
return nil
216+
}
217+
l.mu.Unlock()
218+
219+
// Run the iteration
220+
err := l.runIteration(ctx)
221+
if err == nil {
222+
return nil // Success
223+
}
224+
225+
// Check if this is a context cancellation (don't retry)
226+
if ctx.Err() != nil {
227+
return ctx.Err()
228+
}
229+
230+
// Check if stopped intentionally
231+
l.mu.Lock()
232+
stopped := l.stopped
233+
l.mu.Unlock()
234+
if stopped {
235+
return nil
236+
}
237+
238+
lastErr = err
239+
}
240+
241+
return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, lastErr)
242+
}
243+
149244
// runIteration spawns Claude and processes its output.
150245
func (l *Loop) runIteration(ctx context.Context) error {
151246
// Build Claude command with required flags
@@ -302,3 +397,31 @@ func (l *Loop) IsRunning() bool {
302397
defer l.mu.Unlock()
303398
return l.claudeCmd != nil && l.claudeCmd.Process != nil
304399
}
400+
401+
// SetMaxIterations updates the maximum iterations limit.
402+
func (l *Loop) SetMaxIterations(maxIter int) {
403+
l.mu.Lock()
404+
defer l.mu.Unlock()
405+
l.maxIter = maxIter
406+
}
407+
408+
// MaxIterations returns the current max iterations limit.
409+
func (l *Loop) MaxIterations() int {
410+
l.mu.Lock()
411+
defer l.mu.Unlock()
412+
return l.maxIter
413+
}
414+
415+
// SetRetryConfig updates the retry configuration.
416+
func (l *Loop) SetRetryConfig(config RetryConfig) {
417+
l.mu.Lock()
418+
defer l.mu.Unlock()
419+
l.retryConfig = config
420+
}
421+
422+
// DisableRetry disables automatic retry on crash.
423+
func (l *Loop) DisableRetry() {
424+
l.mu.Lock()
425+
defer l.mu.Unlock()
426+
l.retryConfig.Enabled = false
427+
}

internal/loop/loop_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,61 @@ func TestLoop_ChiefCompleteEvent(t *testing.T) {
316316
t.Error("Expected Complete event for <chief-complete/>")
317317
}
318318
}
319+
320+
// TestLoop_SetMaxIterations tests setting max iterations at runtime.
321+
func TestLoop_SetMaxIterations(t *testing.T) {
322+
l := NewLoop("/test/prd.json", "test", 5)
323+
324+
if l.MaxIterations() != 5 {
325+
t.Errorf("Expected initial maxIter 5, got %d", l.MaxIterations())
326+
}
327+
328+
l.SetMaxIterations(10)
329+
330+
if l.MaxIterations() != 10 {
331+
t.Errorf("Expected maxIter 10 after set, got %d", l.MaxIterations())
332+
}
333+
}
334+
335+
// TestDefaultRetryConfig tests the default retry configuration.
336+
func TestDefaultRetryConfig(t *testing.T) {
337+
config := DefaultRetryConfig()
338+
339+
if config.MaxRetries != 3 {
340+
t.Errorf("Expected MaxRetries 3, got %d", config.MaxRetries)
341+
}
342+
if !config.Enabled {
343+
t.Error("Expected Enabled to be true")
344+
}
345+
if len(config.RetryDelays) != 3 {
346+
t.Errorf("Expected 3 retry delays, got %d", len(config.RetryDelays))
347+
}
348+
}
349+
350+
// TestLoop_SetRetryConfig tests setting retry config.
351+
func TestLoop_SetRetryConfig(t *testing.T) {
352+
l := NewLoop("/test/prd.json", "test", 5)
353+
354+
// Check default
355+
if !l.retryConfig.Enabled {
356+
t.Error("Expected default retry to be enabled")
357+
}
358+
359+
// Disable retry
360+
l.DisableRetry()
361+
if l.retryConfig.Enabled {
362+
t.Error("Expected retry to be disabled after DisableRetry()")
363+
}
364+
365+
// Set custom config
366+
customConfig := RetryConfig{
367+
MaxRetries: 5,
368+
RetryDelays: []time.Duration{time.Second},
369+
Enabled: true,
370+
}
371+
l.SetRetryConfig(customConfig)
372+
373+
if l.retryConfig.MaxRetries != 5 {
374+
t.Errorf("Expected MaxRetries 5, got %d", l.retryConfig.MaxRetries)
375+
}
376+
}

0 commit comments

Comments
 (0)