diff --git a/cli/internal/adapters/tui/model.go b/cli/internal/adapters/tui/model.go index 70734e5..fcc0c7a 100644 --- a/cli/internal/adapters/tui/model.go +++ b/cli/internal/adapters/tui/model.go @@ -77,6 +77,7 @@ type Model struct { ViewMode ViewMode TickInterval time.Duration DisableTick bool // Disable tick loop for testing + BuildFailed bool // Tracks if any task has failed } // Init initializes the model. @@ -311,6 +312,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { node.Cached = msg.Cached if msg.Err != nil { node.Status = StatusError + m.BuildFailed = true + // Move cursor to the failed task + for i, t := range m.FlatList { + if t.Name == node.Name { + m.SelectedIdx = i + m.FollowMode = false + m.ensureVisible() + m.updateActiveView() + break + } + } } else { node.Status = StatusDone } diff --git a/cli/internal/adapters/tui/styles.go b/cli/internal/adapters/tui/styles.go index 65c5fed..949a8e5 100644 --- a/cli/internal/adapters/tui/styles.go +++ b/cli/internal/adapters/tui/styles.go @@ -43,4 +43,10 @@ var ( Padding(0, 1). Background(colorIris). Foreground(colorWhite) + + failureTitleStyle = lipgloss.NewStyle(). + Bold(true). + Padding(0, 1). + Background(colorRed). + Foreground(colorWhite) ) diff --git a/cli/internal/adapters/tui/view.go b/cli/internal/adapters/tui/view.go index d86acfd..3c3c5e4 100644 --- a/cli/internal/adapters/tui/view.go +++ b/cli/internal/adapters/tui/view.go @@ -30,7 +30,14 @@ func (m *Model) View() string { func (m *Model) treeView() string { var s strings.Builder - s.WriteString(titleStyle.Render("BUILD PLAN") + "\n\n") + // Header: change title and style based on build state + header := "BUILD PLAN" + headerStyle := titleStyle + if m.BuildFailed { + header = "BUILD FAILED - Press 'q' to exit" + headerStyle = failureTitleStyle + } + s.WriteString(headerStyle.Render(header) + "\n\n") // Handle empty plan if len(m.FlatList) == 0 { diff --git a/cli/internal/adapters/tui/view_test.go b/cli/internal/adapters/tui/view_test.go index 79308c8..57b0e0b 100644 --- a/cli/internal/adapters/tui/view_test.go +++ b/cli/internal/adapters/tui/view_test.go @@ -293,3 +293,133 @@ func TestView_FullScreenLogView_WithDuration(t *testing.T) { assert.Contains(t, output, "Completed") assert.Contains(t, output, "[2.0s]") } + +func TestView_BuildFailed_Header(t *testing.T) { + task := &tui.TaskNode{Name: "task1", Status: tui.StatusError, Term: tui.NewVterm()} + + m := tui.Model{ + FlatList: []*tui.TaskNode{task}, + TreeRoots: []*tui.TaskNode{task}, + ListHeight: 10, + ViewMode: tui.ViewModeTree, + BuildFailed: true, + TaskMap: map[string]*tui.TaskNode{"task1": task}, + } + + output := m.View() + + assert.Contains(t, output, "BUILD FAILED") + assert.Contains(t, output, "Press 'q' to exit") +} + +func TestView_BuildSuccess_Header(t *testing.T) { + task := &tui.TaskNode{Name: "task1", Status: tui.StatusDone, Term: tui.NewVterm()} + + m := tui.Model{ + FlatList: []*tui.TaskNode{task}, + TreeRoots: []*tui.TaskNode{task}, + ListHeight: 10, + ViewMode: tui.ViewModeTree, + BuildFailed: false, + TaskMap: map[string]*tui.TaskNode{"task1": task}, + } + + output := m.View() + + assert.Contains(t, output, "BUILD PLAN") + assert.NotContains(t, output, "BUILD FAILED") +} + +func TestView_TreeConnectors(t *testing.T) { + child1 := &tui.TaskNode{Name: "child1", Status: tui.StatusDone, Term: tui.NewVterm(), Depth: 1} + child2 := &tui.TaskNode{Name: "child2", Status: tui.StatusDone, Term: tui.NewVterm(), Depth: 1} + child3 := &tui.TaskNode{Name: "child3", Status: tui.StatusDone, Term: tui.NewVterm(), Depth: 1} + parent := &tui.TaskNode{ + Name: "parent", + Status: tui.StatusDone, + Term: tui.NewVterm(), + Depth: 0, + Children: []*tui.TaskNode{child1, child2, child3}, + IsExpanded: true, + } + child1.Parent = parent + child2.Parent = parent + child3.Parent = parent + + m := tui.Model{ + FlatList: []*tui.TaskNode{parent, child1, child2, child3}, + TreeRoots: []*tui.TaskNode{parent}, + ListHeight: 10, + SelectedIdx: 0, + TaskMap: map[string]*tui.TaskNode{ + "parent": parent, + "child1": child1, + "child2": child2, + "child3": child3, + }, + ViewMode: tui.ViewModeTree, + } + + output := m.View() + + assert.Contains(t, output, "├──") + assert.Contains(t, output, "└──") +} + +func TestView_RootNodeNoConnector(t *testing.T) { + root := &tui.TaskNode{ + Name: "root", + Status: tui.StatusDone, + Term: tui.NewVterm(), + Depth: 0, + Parent: nil, + Children: []*tui.TaskNode{}, + } + + m := tui.Model{ + FlatList: []*tui.TaskNode{root}, + TreeRoots: []*tui.TaskNode{root}, + ListHeight: 10, + SelectedIdx: 0, + TaskMap: map[string]*tui.TaskNode{"root": root}, + ViewMode: tui.ViewModeTree, + } + + output := m.View() + + assert.Contains(t, output, "root") + assert.NotContains(t, output, "├──") + assert.NotContains(t, output, "└──") +} + +func TestView_EmptyParentChildren(t *testing.T) { + parent := &tui.TaskNode{ + Name: "parent", + Status: tui.StatusDone, + Term: tui.NewVterm(), + Depth: 0, + Children: []*tui.TaskNode{}, + } + orphan := &tui.TaskNode{ + Name: "orphan", + Status: tui.StatusDone, + Term: tui.NewVterm(), + Depth: 1, + Parent: parent, + } + + m := tui.Model{ + FlatList: []*tui.TaskNode{parent, orphan}, + TreeRoots: []*tui.TaskNode{parent}, + ListHeight: 10, + SelectedIdx: 0, + TaskMap: map[string]*tui.TaskNode{ + "parent": parent, + "orphan": orphan, + }, + ViewMode: tui.ViewModeTree, + } + + output := m.View() + assert.Contains(t, output, "orphan") +} diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index a50d93a..b81c406 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -149,19 +149,23 @@ func (a *App) Run(ctx context.Context, targetNames []string, opts RunOptions) er // Scheduler Routine g.Go(func() error { + var schedErr error defer func() { // Handle panic recovery for the scheduler goroutine if r := recover(); r != nil { // Print panic info before renderer shutdown fmt.Fprintf(os.Stderr, "Scheduler panic: %v\n", r) } - // Ensure renderer stops when scheduler finishes, UNLESS inspection mode is on. - if !opts.Inspect { + // Stop renderer ONLY if: + // 1. Inspect mode is not enabled AND + // 2. No build failure occurred + if !opts.Inspect && schedErr == nil { _ = renderer.Stop() } }() if err := sched.Run(ctx, graph, targetNames, runtime.NumCPU(), opts.NoCache); err != nil { + schedErr = err return errors.Join(domain.ErrBuildExecutionFailed, err) } return nil