From 27653558b52d9088d6773b535708b5226f9006d3 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Mon, 31 Aug 2026 20:00:49 +0200 Subject: [PATCH 01/46] feat: add TUI output mode --- call.go | 3 + executor.go | 1 + internal/flags/flags.go | 2 +- internal/output/output.go | 67 ++ internal/output/tui.go | 782 ++++++++++++++++++++ internal/output/tui_test.go | 216 ++++++ task.go | 54 +- tui_output_test.go | 111 +++ website/src/latest/docs/guide.md | 21 +- website/src/latest/docs/reference/schema.md | 2 +- website/src/next/docs/guide.md | 21 +- website/src/next/docs/reference/schema.md | 2 +- website/src/public/next-schema.json | 2 +- website/src/public/schema.json | 2 +- 14 files changed, 1271 insertions(+), 15 deletions(-) create mode 100644 internal/output/tui.go create mode 100644 internal/output/tui_test.go create mode 100644 tui_output_test.go diff --git a/call.go b/call.go index a0b357185c..63a2c5a94d 100644 --- a/call.go +++ b/call.go @@ -8,4 +8,7 @@ type Call struct { Vars *ast.Vars Silent bool Indirect bool // True if the task was called by another task + + invocationID uint64 + parentInvocationID uint64 } diff --git a/executor.go b/executor.go index 2ed4463beb..ad5f94c470 100644 --- a/executor.go +++ b/executor.go @@ -81,6 +81,7 @@ type ( mkdirMutexMap map[string]*sync.Mutex executionHashes map[string]*executionState executionHashesMutex sync.Mutex + taskInvocationID uint64 watchedDirs *xsync.Map[string, bool] } TempDir struct { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 9e43d4a943..5d29bb0fe3 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -145,7 +145,7 @@ func init() { pflag.StringVarP(&Dir, "dir", "d", "", "Sets the directory in which Task will execute and look for a Taskfile.") pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`) pflag.StringVar(&TempDir, "temp-dir", getConfig(config, "TEMP_DIR", func() *string { return config.TempDir }, ""), "Sets the directory used to store Task temporary files, such as checksums. Relative paths are relative to the root Taskfile.") - pflag.StringVarP(&Output.Name, "output", "o", getConfig(config, "OUTPUT", func() *string { return nil }, ""), "Sets output style: [interleaved|group|prefixed].") + pflag.StringVarP(&Output.Name, "output", "o", getConfig(config, "OUTPUT", func() *string { return nil }, ""), "Sets output style: [interleaved|group|prefixed|tui].") pflag.StringVar(&Output.Group.Begin, "output-group-begin", getConfig(config, "OUTPUT_GROUP_BEGIN", func() *string { return nil }, ""), "Message template to print before a task's grouped output.") pflag.StringVar(&Output.Group.End, "output-group-end", getConfig(config, "OUTPUT_GROUP_END", func() *string { return nil }, ""), "Message template to print after a task's grouped output.") pflag.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", getConfig(config, "OUTPUT_GROUP_ERROR_ONLY", func() *bool { return nil }, false), "Swallow output from successful tasks.") diff --git a/internal/output/output.go b/internal/output/output.go index 9940f29fa8..a5d9c6f533 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -1,6 +1,7 @@ package output import ( + "context" "fmt" "io" @@ -15,6 +16,67 @@ type Output interface { type CloseFunc func(err error) error +// TaskInvocation identifies one runtime invocation of a task. IDs are unique +// within an Executor; ParentID is zero for tasks invoked from the CLI. +type TaskInvocation struct { + ID uint64 + ParentID uint64 + Name string +} + +// Runner is implemented by output modes that need to own the terminal while +// tasks execute. +type Runner interface { + Run(context.Context, func(context.Context) error) error +} + +// TaskLifecycle is implemented by output modes that display task state in +// addition to command output. +type TaskLifecycle interface { + TaskStarted(TaskInvocation) + TaskFinished(id uint64, err error) +} + +// TaskOutput is implemented by output modes that keep output for individual +// task invocations. Other output modes continue to use Output.WrapWriter. +type TaskOutput interface { + WrapWriterForTask(stdOut, stdErr io.Writer, task TaskInvocation, cache *templater.Cache) (io.Writer, io.Writer, CloseFunc) +} + +// Run executes fn through the output mode when it needs to manage the whole +// execution lifecycle. Traditional stream-based output modes simply call fn. +func Run(o Output, ctx context.Context, fn func(context.Context) error) error { + if runner, ok := o.(Runner); ok { + return runner.Run(ctx, fn) + } + return fn(ctx) +} + +func TaskStarted(o Output, task TaskInvocation) { + if lifecycle, ok := o.(TaskLifecycle); ok { + lifecycle.TaskStarted(task) + } +} + +func TaskFinished(o Output, id uint64, err error) { + if lifecycle, ok := o.(TaskLifecycle); ok { + lifecycle.TaskFinished(id, err) + } +} + +// WrapWriter returns task-aware writers when the output mode supports them. +func WrapWriter(o Output, stdOut, stdErr io.Writer, task TaskInvocation, cache *templater.Cache) (io.Writer, io.Writer, CloseFunc) { + if taskOutput, ok := o.(TaskOutput); ok { + return taskOutput.WrapWriterForTask(stdOut, stdErr, task, cache) + } + return o.WrapWriter(stdOut, stdErr, task.Name, cache) +} + +func IsTUI(o Output) bool { + _, ok := o.(*TUI) + return ok +} + // Build the Output for the requested ast.Output. func BuildFor(o *ast.Output, logger *logger.Logger) (Output, error) { switch o.Name { @@ -34,6 +96,11 @@ func BuildFor(o *ast.Output, logger *logger.Logger) (Output, error) { return nil, err } return NewPrefixed(logger), nil + case "tui": + if err := checkOutputGroupUnset(o); err != nil { + return nil, err + } + return NewTUI(logger) default: return nil, fmt.Errorf(`task: output style %q not recognized`, o.Name) } diff --git a/internal/output/tui.go b/internal/output/tui.go new file mode 100644 index 0000000000..3ca83f0b56 --- /dev/null +++ b/internal/output/tui.go @@ -0,0 +1,782 @@ +package output + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" + + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/internal/term" +) + +const ( + systemTaskName = "Task messages" + maxTaskOutputLen = 10 << 20 +) + +type TUI struct { + logger *logger.Logger + input io.Reader + output io.Writer + + mutex sync.RWMutex + program *tea.Program + + outputMutex sync.Mutex + pending map[uint64]pendingOutput + outputQueued bool +} + +type pendingOutput struct { + name string + data string +} + +func NewTUI(log *logger.Logger) (*TUI, error) { + if !log.AssumeTerm && !term.IsTerminal() { + return nil, fmt.Errorf(`task: output style "tui" requires an interactive terminal`) + } + return &TUI{ + logger: log, + input: log.Stdin, + output: log.Stdout, + pending: make(map[uint64]pendingOutput), + }, nil +} + +// WrapWriter satisfies Output. Executor calls WrapWriterForTask so output can +// be associated with a specific invocation; other callers use the system log. +func (t *TUI) WrapWriter(_ io.Writer, _ io.Writer, _ string, _ *templater.Cache) (io.Writer, io.Writer, CloseFunc) { + w := &tuiWriter{tui: t, name: systemTaskName} + return w, w, func(error) error { return nil } +} + +func (t *TUI) WrapWriterForTask(_ io.Writer, _ io.Writer, task TaskInvocation, _ *templater.Cache) (io.Writer, io.Writer, CloseFunc) { + w := &tuiWriter{tui: t, id: task.ID, name: task.Name} + return w, w, func(error) error { return nil } +} + +func (t *TUI) TaskStarted(task TaskInvocation) { + t.send(taskStartedMsg{task: task}) +} + +func (t *TUI) TaskFinished(id uint64, err error) { + t.send(taskFinishedMsg{id: id, err: err}) +} + +func (t *TUI) Run(ctx context.Context, run func(context.Context) error) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + model := newTUIModel(cancel) + program := tea.NewProgram( + model, + tea.WithInput(t.input), + tea.WithOutput(t.output), + ) + + t.mutex.Lock() + t.program = program + t.mutex.Unlock() + + oldStdout, oldStderr := t.logger.Stdout, t.logger.Stderr + systemWriter := &tuiWriter{tui: t, name: systemTaskName} + t.logger.Stdout, t.logger.Stderr = systemWriter, systemWriter + defer func() { + t.logger.Stdout, t.logger.Stderr = oldStdout, oldStderr + t.mutex.Lock() + t.program = nil + t.mutex.Unlock() + }() + + done := make(chan error, 1) + go func() { + <-ctx.Done() + program.Send(tea.Interrupt()) + }() + go func() { + err := run(ctx) + done <- err + t.send(executionDoneMsg{err: err}) + }() + + _, uiErr := program.Run() + cancel() + runErr := <-done + if uiErr != nil { + return fmt.Errorf("task: TUI failed: %w", uiErr) + } + return runErr +} + +func (t *TUI) send(msg tea.Msg) { + t.mutex.RLock() + program := t.program + t.mutex.RUnlock() + if program != nil { + program.Send(msg) + } +} + +func (t *TUI) enqueueOutput(id uint64, name, data string) { + t.outputMutex.Lock() + pending := t.pending[id] + pending.name = name + pending.data += data + t.pending[id] = pending + if t.outputQueued { + t.outputMutex.Unlock() + return + } + t.outputQueued = true + t.outputMutex.Unlock() + + // Sending asynchronously lets bursts of command output collapse into one + // model update instead of rebuilding the viewport for every pipe write. + go t.send(outputReadyMsg{tui: t}) +} + +func (t *TUI) drainOutput() map[uint64]pendingOutput { + t.outputMutex.Lock() + defer t.outputMutex.Unlock() + output := t.pending + t.pending = make(map[uint64]pendingOutput) + t.outputQueued = false + return output +} + +type tuiWriter struct { + tui *TUI + id uint64 + name string +} + +func (w *tuiWriter) Write(p []byte) (int, error) { + data := string(append([]byte(nil), p...)) + w.tui.enqueueOutput(w.id, w.name, data) + return len(p), nil +} + +type taskState uint8 + +const ( + taskLog taskState = iota + taskRunning + taskSucceeded + taskFailed +) + +type paneFocus uint8 + +const ( + taskPane paneFocus = iota + outputPane +) + +type tuiTask struct { + id uint64 + parentID uint64 + name string + output string + state taskState + truncated bool + + scrollOffset int + followOutput bool +} + +type taskStartedMsg struct{ task TaskInvocation } +type taskFinishedMsg struct { + id uint64 + err error +} +type taskOutputMsg struct { + id uint64 + name, data string +} +type outputReadyMsg struct{ tui *TUI } +type executionDoneMsg struct{ err error } + +type tuiModel struct { + tasks []*tuiTask + byID map[uint64]*tuiTask + selectedID uint64 + hasSelect bool + listTop int + focus paneFocus + width int + height int + viewport viewport.Model + done bool + err error + cancel context.CancelFunc +} + +func newTUIModel(cancel context.CancelFunc) tuiModel { + view := viewport.New() + view.SoftWrap = true + view.MouseWheelDelta = 3 + return tuiModel{ + byID: make(map[uint64]*tuiTask), + width: 100, + height: 30, + viewport: view, + cancel: cancel, + } +} + +func (m tuiModel) Init() tea.Cmd { return nil } + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.saveViewport() + m.width, m.height = msg.Width, msg.Height + m.resizeViewport() + m.loadViewport() + m.keepSelectionVisible() + return m, nil + case taskStartedMsg: + task := m.ensureTask(msg.task.ID, msg.task.Name, msg.task.ParentID) + task.state = taskRunning + m.keepSelectionVisible() + return m, nil + case taskFinishedMsg: + task := m.ensureTask(msg.id, "", 0) + if msg.err != nil { + task.state = taskFailed + } else { + task.state = taskSucceeded + } + return m, nil + case taskOutputMsg: + m.appendOutput(msg.id, msg.name, msg.data) + return m, nil + case outputReadyMsg: + for id, pending := range msg.tui.drainOutput() { + m.appendOutput(id, pending.name, pending.data) + } + return m, nil + case executionDoneMsg: + m.done, m.err = true, msg.err + return m, nil + case tea.InterruptMsg: + m.cancel() + return m, tea.Quit + case tea.MouseClickMsg: + m.handleMouseClick(tea.Mouse(msg)) + return m, nil + case tea.MouseWheelMsg: + return m, m.handleMouseWheel(msg) + case tea.KeyPressMsg: + return m.handleKey(msg) + } + + return m, nil +} + +func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc", "ctrl+c": + if !m.done { + m.cancel() + } + return *m, tea.Quit + case "tab", "shift+tab": + m.toggleFocus() + return *m, nil + case "left", "h": + m.focus = taskPane + return *m, nil + case "right", "l": + m.focus = outputPane + return *m, nil + case "pgup", "pgdown": + m.focus = outputPane + return *m, m.updateViewport(msg) + case "up", "k": + if m.focus == taskPane { + m.moveSelection(-1) + return *m, nil + } + return *m, m.updateViewport(msg) + case "down", "j": + if m.focus == taskPane { + m.moveSelection(1) + return *m, nil + } + return *m, m.updateViewport(msg) + case "home", "g": + if m.focus == taskPane { + m.selectTask(0) + } else { + m.viewport.GotoTop() + m.saveViewport() + } + return *m, nil + case "end", "G": + if m.focus == taskPane { + m.selectTask(len(m.taskRows()) - 1) + } else { + m.viewport.GotoBottom() + m.saveViewport() + } + return *m, nil + case "enter": + if m.done { + return *m, tea.Quit + } + } + return *m, nil +} + +func (m tuiModel) View() tea.View { + layout := newTUILayout(m.width, m.height) + left, right := m.renderPanes(layout) + body := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", layout.gap), right) + + help := " tab/←/→ pane • ↑/↓ select • click a task • q quit" + if m.focus == outputPane { + help = " tab/←/→ pane • ↑/↓ or pgup/pgdn scroll • mouse wheel • q quit" + } + helpStyle := tuiHelpStyle + if m.done { + if m.err != nil { + help = " execution failed • enter/q quit" + helpStyle = tuiFailureStyle + } else { + help = " execution complete • enter/q quit" + helpStyle = tuiSuccessStyle + } + } + help = truncateRunes(help, max(layout.width, 1)) + + view := tea.NewView(body + "\n" + helpStyle.Render(help)) + view.AltScreen = true + view.MouseMode = tea.MouseModeCellMotion + view.WindowTitle = "Task" + return view +} + +func (m tuiModel) renderPanes(layout tuiLayout) (string, string) { + leftStyle, rightStyle := tuiPanelStyle, tuiPanelStyle + if m.focus == taskPane { + leftStyle = leftStyle.BorderForeground(tuiAccentColor) + } else { + rightStyle = rightStyle.BorderForeground(tuiAccentColor) + } + left := leftStyle.Width(layout.leftOuterWidth).Height(layout.bodyHeight). + Render(m.taskList(layout.leftInnerWidth, layout.innerHeight)) + right := rightStyle.Width(layout.rightOuterWidth).Height(layout.bodyHeight). + Render(m.outputPanel(layout.rightInnerWidth)) + return left, right +} + +type tuiLayout struct { + width int + bodyHeight int + gap int + leftOuterWidth int + rightOuterWidth int + leftInnerWidth int + rightInnerWidth int + innerHeight int +} + +func newTUILayout(width, height int) tuiLayout { + width, height = max(width, 1), max(height, 1) + bodyHeight := max(height-1, 3) + gap := 1 + leftOuterWidth := min(max(width*30/100, 20), 36) + if right := width - gap - leftOuterWidth; right < 16 { + leftOuterWidth = max(width-gap-16, 8) + } + rightOuterWidth := max(width-gap-leftOuterWidth, 8) + return tuiLayout{ + width: width, + bodyHeight: bodyHeight, + gap: gap, + leftOuterWidth: leftOuterWidth, + rightOuterWidth: rightOuterWidth, + leftInnerWidth: max(leftOuterWidth-4, 1), + rightInnerWidth: max(rightOuterWidth-4, 1), + innerHeight: max(bodyHeight-2, 1), + } +} + +func (m *tuiModel) ensureTask(id uint64, name string, parentID uint64) *tuiTask { + if task, ok := m.byID[id]; ok { + if name != "" { + task.name = name + } + if parentID != 0 && parentID != id { + task.parentID = parentID + } + return task + } + if name == "" { + name = fmt.Sprintf("task %d", id) + } + task := &tuiTask{ + id: id, + parentID: parentID, + name: name, + state: taskLog, + followOutput: true, + } + m.byID[id] = task + m.tasks = append(m.tasks, task) + if !m.hasSelect { + m.selectedID = id + m.hasSelect = true + m.loadViewport() + } + return task +} + +func (m *tuiModel) appendOutput(id uint64, name, data string) { + task := m.ensureTask(id, name, 0) + if task.state == taskLog && id != 0 { + task.state = taskRunning + } + task.output += normalizeOutput(data) + if len(task.output) > maxTaskOutputLen { + task.output = task.output[len(task.output)-maxTaskOutputLen:] + task.truncated = true + } + if m.hasSelect && id == m.selectedID { + m.loadViewport() + } +} + +type tuiTaskRow struct { + task *tuiTask + depth int + treePrefix string +} + +func (m tuiModel) taskRows() []tuiTaskRow { + children := make(map[uint64][]*tuiTask) + var roots []*tuiTask + for _, task := range m.tasks { + _, parentExists := m.byID[task.parentID] + if task.parentID == 0 || task.parentID == task.id || !parentExists { + roots = append(roots, task) + continue + } + children[task.parentID] = append(children[task.parentID], task) + } + + rows := make([]tuiTaskRow, 0, len(m.tasks)) + visited := make(map[uint64]bool, len(m.tasks)) + var walk func(*tuiTask, int, []bool, string, bool) + walk = func(task *tuiTask, depth int, ancestorContinues []bool, connector string, hasNextSibling bool) { + if visited[task.id] { + return + } + visited[task.id] = true + var prefix strings.Builder + for _, continues := range ancestorContinues { + if continues { + prefix.WriteString("│ ") + } else { + prefix.WriteString(" ") + } + } + prefix.WriteString(connector) + rows = append(rows, tuiTaskRow{task: task, depth: depth, treePrefix: prefix.String()}) + childAncestors := ancestorContinues + if depth > 0 { + childAncestors = append(append([]bool(nil), ancestorContinues...), hasNextSibling) + } + for i, child := range children[task.id] { + hasNext := i < len(children[task.id])-1 + childConnector := "└─ " + if hasNext { + childConnector = "├─ " + } + walk(child, depth+1, childAncestors, childConnector, hasNext) + } + } + for _, root := range roots { + walk(root, 0, nil, "", false) + } + // Defensive fallback for malformed/cyclic parent information. + for _, task := range m.tasks { + if !visited[task.id] { + walk(task, 0, nil, "", false) + } + } + return rows +} + +func (m *tuiModel) selectedTask() *tuiTask { + if !m.hasSelect { + return nil + } + return m.byID[m.selectedID] +} + +func (m *tuiModel) selectedIndex() int { + for i, row := range m.taskRows() { + if row.task.id == m.selectedID { + return i + } + } + return -1 +} + +func (m *tuiModel) moveSelection(delta int) { + rows := m.taskRows() + if len(rows) == 0 { + return + } + index := m.selectedIndex() + if index < 0 { + index = 0 + } + m.selectTask(min(max(index+delta, 0), len(rows)-1)) +} + +func (m *tuiModel) selectTask(index int) { + rows := m.taskRows() + if index < 0 || index >= len(rows) { + return + } + m.saveViewport() + m.selectedID = rows[index].task.id + m.hasSelect = true + m.keepSelectionVisible() + m.loadViewport() +} + +func (m *tuiModel) keepSelectionVisible() { + index := m.selectedIndex() + if index < 0 { + return + } + visible := max(newTUILayout(m.width, m.height).innerHeight-1, 1) + if index < m.listTop { + m.listTop = index + } else if index >= m.listTop+visible { + m.listTop = index - visible + 1 + } + maxTop := max(len(m.taskRows())-visible, 0) + m.listTop = min(max(m.listTop, 0), maxTop) +} + +func (m *tuiModel) resizeViewport() { + layout := newTUILayout(m.width, m.height) + m.viewport.SetWidth(layout.rightInnerWidth) + m.viewport.SetHeight(max(layout.innerHeight-1, 1)) +} + +func (m *tuiModel) loadViewport() { + task := m.selectedTask() + if task == nil { + m.viewport.SetContent("") + return + } + content := task.output + if task.truncated { + content = tuiHelpStyle.Render("… earlier output was discarded …") + "\n" + content + } + m.viewport.SetContent(content) + if task.followOutput { + m.viewport.GotoBottom() + } else { + m.viewport.SetYOffset(task.scrollOffset) + } +} + +func (m *tuiModel) saveViewport() { + task := m.selectedTask() + if task == nil { + return + } + task.scrollOffset = m.viewport.YOffset() + task.followOutput = m.viewport.AtBottom() +} + +func (m *tuiModel) updateViewport(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + m.saveViewport() + return cmd +} + +func (m *tuiModel) toggleFocus() { + if m.focus == taskPane { + m.focus = outputPane + } else { + m.focus = taskPane + } +} + +func (m *tuiModel) handleMouseClick(mouse tea.Mouse) { + layout := newTUILayout(m.width, m.height) + if mouse.Y < 0 || mouse.Y >= layout.bodyHeight { + return + } + if mouse.X >= 0 && mouse.X < layout.leftOuterWidth { + m.focus = taskPane + // Border is row 0 and the title is row 1, so tasks begin at row 2. + row := mouse.Y - 2 + if row >= 0 { + m.selectTask(m.listTop + row) + } + return + } + if mouse.X >= layout.leftOuterWidth+layout.gap { + m.focus = outputPane + } +} + +func (m *tuiModel) handleMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { + layout := newTUILayout(m.width, m.height) + if msg.Y < 0 || msg.Y >= layout.bodyHeight { + return nil + } + if msg.X < layout.leftOuterWidth { + m.focus = taskPane + switch msg.Button { + case tea.MouseWheelUp: + m.moveSelection(-1) + case tea.MouseWheelDown: + m.moveSelection(1) + } + return nil + } + if msg.X >= layout.leftOuterWidth+layout.gap { + m.focus = outputPane + return m.updateViewport(msg) + } + return nil +} + +func (m tuiModel) taskList(width, height int) string { + lines := []string{paneTitle("TASKS", "", width)} + rows := m.taskRows() + if len(rows) == 0 { + lines = append(lines, tuiHelpStyle.Render("Waiting for tasks…")) + return strings.Join(lines, "\n") + } + + end := min(len(rows), m.listTop+max(height-1, 1)) + for i := m.listTop; i < end; i++ { + row := rows[i] + branch := row.treePrefix + if lipgloss.Width(branch) > max(width/2, 3) { + branch = "… " + truncateLeft(branch, max(width/2-2, 1)) + } + selected := row.task.id == m.selectedID + marker := " " + if selected { + marker = "▌ " + } + plainPrefix := marker + branch + taskIconText(row.task.state) + " " + name := truncateRunes(row.task.name, max(width-lipgloss.Width(plainPrefix), 1)) + if selected { + lines = append(lines, tuiSelectedStyle.Width(width).Render(plainPrefix+name)) + continue + } + line := tuiTreeStyle.Render(marker+branch) + taskIcon(row.task.state) + " " + name + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func (m tuiModel) outputPanel(width int) string { + name := "" + if task := m.selectedTask(); task != nil { + name = task.name + } + position := "" + if !m.viewport.AtTop() || !m.viewport.AtBottom() { + position = fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100) + } + return paneTitle("OUTPUT · "+name, position, width) + "\n" + m.viewport.View() +} + +func paneTitle(left, right string, width int) string { + left = truncateRunes(left, max(width-lipgloss.Width(right)-1, 1)) + space := max(width-lipgloss.Width(left)-lipgloss.Width(right), 0) + return tuiTitleStyle.Render(left) + strings.Repeat(" ", space) + tuiHelpStyle.Render(right) +} + +func taskIcon(state taskState) string { + switch state { + case taskRunning: + return tuiRunningStyle.Render(taskIconText(state)) + case taskSucceeded: + return tuiSuccessStyle.Render(taskIconText(state)) + case taskFailed: + return tuiFailureStyle.Render(taskIconText(state)) + default: + return tuiHelpStyle.Render(taskIconText(state)) + } +} + +func taskIconText(state taskState) string { + switch state { + case taskRunning: + return "●" + case taskSucceeded: + return "✓" + case taskFailed: + return "✗" + default: + return "·" + } +} + +func normalizeOutput(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") +} + +func truncateRunes(s string, width int) string { + runes := []rune(s) + if len(runes) <= width { + return s + } + if width <= 1 { + return "…" + } + return string(runes[:width-1]) + "…" +} + +func truncateLeft(s string, width int) string { + runes := []rune(s) + if len(runes) <= width { + return s + } + return string(runes[len(runes)-width:]) +} + +var ( + tuiAccentColor = compat.AdaptiveColor{Light: lipgloss.Color("#006A83"), Dark: lipgloss.Color("#5FD7FF")} + tuiPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("#87909A"), Dark: lipgloss.Color("#59636E")}). + PaddingLeft(1). + PaddingRight(1) + tuiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccentColor) + tuiSelectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#10212B"), Dark: lipgloss.Color("#F4F7FA")}). + Background(compat.AdaptiveColor{Light: lipgloss.Color("#D9E8ED"), Dark: lipgloss.Color("#34444D")}) + tuiTreeStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#77818A"), Dark: lipgloss.Color("#697580")}) + tuiRunningStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#8A6500"), Dark: lipgloss.Color("#FFD75F")}) + tuiSuccessStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#257A3E"), Dark: lipgloss.Color("#5FD787")}) + tuiFailureStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#B42318"), Dark: lipgloss.Color("#FF6B6B")}) + tuiHelpStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#66717C"), Dark: lipgloss.Color("#89949F")}) +) diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go new file mode 100644 index 0000000000..5b8050a398 --- /dev/null +++ b/internal/output/tui_test.go @@ -0,0 +1,216 @@ +package output + +import ( + "context" + "errors" + "fmt" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestBuildTUI(t *testing.T) { + t.Parallel() + + got, err := BuildFor(&ast.Output{Name: "tui"}, &logger.Logger{AssumeTerm: true}) + require.NoError(t, err) + assert.IsType(t, &TUI{}, got) +} + +func TestTUIModelTracksTasksAndOutput(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: "compiling\r\ndone\r"}) + m = updateTUIModel(t, m, started(2, 0, "test")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 1}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2, err: errors.New("failed")}) + + require.Len(t, m.tasks, 2) + assert.Equal(t, taskSucceeded, m.byID[1].state) + assert.Equal(t, "compiling\ndone\n", m.byID[1].output) + assert.Equal(t, taskFailed, m.byID[2].state) + assert.Equal(t, m.width, lipgloss.Width(m.View().Content)) + assert.Equal(t, m.height, lipgloss.Height(m.View().Content)) + assert.LessOrEqual(t, lipgloss.Width(m.View().Content), m.width) + assert.LessOrEqual(t, lipgloss.Height(m.View().Content), m.height) + assert.Equal(t, tea.MouseModeCellMotion, m.View().MouseMode) + left, right := m.renderPanes(newTUILayout(m.width, m.height)) + assert.Equal(t, lipgloss.Height(left), lipgloss.Height(right)) + + m.moveSelection(1) + assert.Equal(t, uint64(2), m.selectedID) + assert.Contains(t, m.View().Content, "test") +} + +func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 40, Height: 8}) + m = updateTUIModel(t, m, started(1, 0, "a-task-with-a-fairly-long-name")) + + view := m.View().Content + assert.Equal(t, 40, lipgloss.Width(view)) + assert.Equal(t, 8, lipgloss.Height(view)) + assert.LessOrEqual(t, lipgloss.Width(view), 40) + assert.LessOrEqual(t, lipgloss.Height(view), 8) + left, right := m.renderPanes(newTUILayout(40, 8)) + assert.Equal(t, lipgloss.Height(left), lipgloss.Height(right)) +} + +func TestTUIModelKeepsConcurrentInvocationsSeparate(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "worker")) + m = updateTUIModel(t, m, started(2, 0, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "worker", data: "first"}) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "second"}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 1, err: errors.New("failed")}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + + require.Len(t, m.tasks, 2) + assert.Equal(t, "first", m.byID[1].output) + assert.Equal(t, taskFailed, m.byID[1].state) + assert.Equal(t, "second", m.byID[2].output) + assert.Equal(t, taskSucceeded, m.byID[2].state) +} + +func TestTUIModelBuildsRuntimeInvocationTree(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(5, 0, "other-root")) + m = updateTUIModel(t, m, started(2, 1, "child")) + m = updateTUIModel(t, m, started(3, 2, "grandchild")) + m = updateTUIModel(t, m, started(4, 1, "second-child")) + + rows := m.taskRows() + assert.Equal(t, []string{"root", "child", "grandchild", "second-child", "other-root"}, rowNames(rows)) + assert.Equal(t, []int{0, 1, 2, 1, 0}, rowDepths(rows)) + assert.Equal(t, []string{"", "├─ ", "│ └─ ", "└─ ", ""}, rowPrefixes(rows)) + assert.Contains(t, m.taskList(30, 10), "▌") +} + +func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) + m = updateTUIModel(t, m, started(1, 0, "first")) + m = updateTUIModel(t, m, started(2, 0, "second")) + + // The title occupies row 1 inside the border; the second task is row 3. + m = updateTUIModel(t, m, tea.MouseClickMsg{X: 5, Y: 3, Button: tea.MouseLeft}) + assert.Equal(t, uint64(2), m.selectedID) + assert.Equal(t, taskPane, m.focus) + + layout := newTUILayout(m.width, m.height) + m = updateTUIModel(t, m, tea.MouseClickMsg{X: layout.leftOuterWidth + layout.gap + 2, Y: 3, Button: tea.MouseLeft}) + assert.Equal(t, outputPane, m.focus) +} + +func TestTUIModelScrollsAndRemembersEachTaskOutput(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 10}) + m = updateTUIModel(t, m, started(1, 0, "first")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "first", data: numberedLines(30)}) + m = updateTUIModel(t, m, started(2, 0, "second")) + m.focus = outputPane + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) + + firstOffset := m.viewport.YOffset() + assert.Greater(t, firstOffset, 0) + assert.False(t, m.byID[1].followOutput) + + m.selectTask(1) + m.selectTask(0) + assert.Equal(t, firstOffset, m.viewport.YOffset()) + + layout := newTUILayout(m.width, m.height) + m = updateTUIModel(t, m, tea.MouseWheelMsg{ + X: layout.leftOuterWidth + layout.gap + 2, + Y: 4, + Button: tea.MouseWheelUp, + }) + assert.Less(t, m.viewport.YOffset(), firstOffset) +} + +func TestTUIModelQuitCancelsExecution(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + m := newTUIModel(cancel) + key := tea.KeyPressMsg{Code: 'q', Text: "q"} + next, cmd := m.Update(key) + require.NotNil(t, cmd) + assert.IsType(t, tea.QuitMsg{}, cmd()) + assert.ErrorIs(t, ctx.Err(), context.Canceled) + assert.IsType(t, tuiModel{}, next) +} + +func TestTUIOutputQueueCoalescesWrites(t *testing.T) { + t.Parallel() + + tui := &TUI{pending: make(map[uint64]pendingOutput)} + tui.enqueueOutput(7, "build", "one") + tui.enqueueOutput(7, "build", " two") + + assert.Equal(t, map[uint64]pendingOutput{7: {name: "build", data: "one two"}}, tui.drainOutput()) + assert.False(t, tui.outputQueued) +} + +func started(id, parentID uint64, name string) taskStartedMsg { + return taskStartedMsg{task: TaskInvocation{ID: id, ParentID: parentID, Name: name}} +} + +func updateTUIModel(t *testing.T, m tuiModel, msg tea.Msg) tuiModel { + t.Helper() + next, _ := m.Update(msg) + result, ok := next.(tuiModel) + require.True(t, ok) + return result +} + +func rowNames(rows []tuiTaskRow) []string { + names := make([]string, len(rows)) + for i, row := range rows { + names[i] = row.task.name + } + return names +} + +func rowDepths(rows []tuiTaskRow) []int { + depths := make([]int, len(rows)) + for i, row := range rows { + depths[i] = row.depth + } + return depths +} + +func rowPrefixes(rows []tuiTaskRow) []string { + prefixes := make([]string, len(rows)) + for i, row := range rows { + prefixes[i] = row.treePrefix + } + return prefixes +} + +func numberedLines(count int) string { + var output string + for i := range count { + output += fmt.Sprintf("line %02d\n", i) + } + return output +} diff --git a/task.go b/task.go index 654b397e7c..29bdbd8975 100644 --- a/task.go +++ b/task.go @@ -39,6 +39,16 @@ type MatchingTask struct { // Run runs Task func (e *Executor) Run(ctx context.Context, calls ...*Call) error { + return output.Run(e.Output, ctx, func(ctx context.Context) error { + return e.run(ctx, calls...) + }) +} + +func (e *Executor) run(ctx context.Context, calls ...*Call) error { + if output.IsTUI(e.Output) && e.Interactive { + return errors.New(`task: output style "tui" cannot be combined with interactive variable prompting`) + } + // check if given tasks exist for _, call := range calls { task, err := e.GetTask(call) @@ -82,6 +92,9 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { if err != nil { return err } + if output.IsTUI(e.Output) && len(watchCalls) > 0 { + return errors.New(`task: output style "tui" does not currently support watch mode`) + } g := &errgroup.Group{} if e.Failfast { @@ -159,6 +172,14 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { if err != nil { return err } + if output.IsTUI(e.Output) { + if t.Interactive { + return fmt.Errorf(`task: task %q is interactive and cannot run with output style "tui"`, t.Name()) + } + if len(t.Prompt) > 0 && !e.AssumeYes { + return fmt.Errorf(`task: task %q requires confirmation; use --yes with output style "tui"`, t.Name()) + } + } // Check if condition after CompiledTask so dynamic variables are resolved if strings.TrimSpace(t.If) != "" { @@ -203,9 +224,16 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { release := e.acquireConcurrencyLimit() defer release() - if err = e.startExecution(ctx, t, func(ctx context.Context) error { + call.invocationID = atomic.AddUint64(&e.taskInvocationID, 1) + invocation := output.TaskInvocation{ + ID: call.invocationID, + ParentID: call.parentInvocationID, + Name: t.Prefix, + } + output.TaskStarted(e.Output, invocation) + err = e.startExecution(ctx, t, func(ctx context.Context) error { e.Logger.VerboseErrf(logger.Magenta, "task: %q started\n", call.Task) - if err := e.runDeps(ctx, t); err != nil { + if err := e.runDeps(ctx, t, call.invocationID); err != nil { return err } @@ -284,7 +312,9 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { } e.Logger.VerboseErrf(logger.Magenta, "task: %q finished\n", call.Task) return nil - }); err != nil { + }) + output.TaskFinished(e.Output, call.invocationID, err) + if err != nil { return &errors.TaskRunError{TaskName: t.Name(), Err: err} } @@ -308,7 +338,7 @@ func (e *Executor) mkdir(t *ast.Task) error { return nil } -func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { +func (e *Executor) runDeps(ctx context.Context, t *ast.Task, parentInvocationID uint64) error { g := &errgroup.Group{} if e.Failfast || t.Failfast { g, ctx = errgroup.WithContext(ctx) @@ -328,7 +358,7 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { defer cancel() } - err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) + err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true, parentInvocationID: parentInvocationID}) if err != nil && timedOut(depCtx, timeout) { return timeout } @@ -395,7 +425,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in reacquire := e.releaseConcurrencyLimit() defer reacquire() - err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true}) + err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true, parentInvocationID: call.invocationID}) if err != nil && timedOut(ctx, timeout) { err = timeout } @@ -410,7 +440,8 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in return nil } - if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) { + logCommand := e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) + if logCommand && (!output.IsTUI(e.Output) || e.Dry) { e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) } @@ -427,7 +458,14 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in if err != nil { return fmt.Errorf("task: failed to get variables: %w", err) } - stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater) + stdOut, stdErr, closer := output.WrapWriter(outputWrapper, e.Stdout, e.Stderr, output.TaskInvocation{ + ID: call.invocationID, + ParentID: call.parentInvocationID, + Name: t.Prefix, + }, outputTemplater) + if logCommand && output.IsTUI(e.Output) { + e.Logger.FOutf(stdErr, logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) + } err = execext.RunCommand(ctx, &execext.RunCommandOptions{ Command: cmd.Cmd, diff --git a/tui_output_test.go b/tui_output_test.go new file mode 100644 index 0000000000..af494006c5 --- /dev/null +++ b/tui_output_test.go @@ -0,0 +1,111 @@ +package task_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/output" + "github.com/go-task/task/v3/internal/templater" +) + +func TestTaskLifecycleOutput(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + default: + deps: [first, second] + cmds: + - task: third + - echo parent + first: echo first + second: echo second + third: echo third +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Output = recorder + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "default"})) + require.Len(t, recorder.started, 4) + byName := make(map[string]output.TaskInvocation) + for _, invocation := range recorder.started { + byName[invocation.Name] = invocation + } + root := byName["default"] + assert.Zero(t, root.ParentID) + for _, name := range []string{"first", "second", "third"} { + assert.Equal(t, root.ID, byName[name].ParentID, name) + } + assert.ElementsMatch(t, []uint64{ + byName["default"].ID, + byName["first"].ID, + byName["second"].ID, + byName["third"].ID, + }, recorder.finished) + expectedOutput := map[string]string{ + "default": "parent", + "first": "first", + "second": "second", + "third": "third", + } + for name, invocation := range byName { + require.Contains(t, recorder.outputs, invocation.ID) + assert.Contains(t, recorder.outputs[invocation.ID].String(), expectedOutput[name]) + } +} + +type lifecycleRecorder struct { + mutex sync.Mutex + started []output.TaskInvocation + finished []uint64 + outputs map[uint64]*bytes.Buffer +} + +func (*lifecycleRecorder) WrapWriter(_ io.Writer, _ io.Writer, _ string, _ *templater.Cache) (io.Writer, io.Writer, output.CloseFunc) { + return io.Discard, io.Discard, func(error) error { return nil } +} + +func (r *lifecycleRecorder) WrapWriterForTask(_ io.Writer, _ io.Writer, task output.TaskInvocation, _ *templater.Cache) (io.Writer, io.Writer, output.CloseFunc) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.outputs == nil { + r.outputs = make(map[uint64]*bytes.Buffer) + } + buffer := r.outputs[task.ID] + if buffer == nil { + buffer = &bytes.Buffer{} + r.outputs[task.ID] = buffer + } + return buffer, buffer, func(error) error { return nil } +} + +func (r *lifecycleRecorder) TaskStarted(task output.TaskInvocation) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.started = append(r.started, task) +} + +func (r *lifecycleRecorder) TaskFinished(id uint64, _ error) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.finished = append(r.finished, id) +} diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index 38e0caf7f0..2c4937b1ed 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2690,12 +2690,13 @@ the shell in real-time. This is good for having live feedback for logging printed by commands, but the output can become messy if you have multiple commands running simultaneously and printing lots of stuff. -To make this more customizable, there are currently three different output +To make this more customizable, there are currently four different output options you can choose: - `interleaved` (default) - `group` - `prefixed` +- `tui` To choose another one, just set it to root in the Taskfile: @@ -2799,6 +2800,24 @@ $ task default [print-baz] baz ``` +The `tui` output opens an interactive, full-screen view. Running tasks and +their status are shown as a call tree on the left, while the output of the +selected task is shown on the right. Use Tab or the left/right arrow keys to +focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a +task. In the output pane, those keys scroll; Page Up and Page Down also scroll +the output directly. You can click a task to select it and use the mouse wheel +over either pane. Pressing `q` while tasks are still running cancels them. The +view remains open after execution completes so that output can be inspected. + +```shell +$ task --output tui build test lint +``` + +This mode requires an interactive terminal. It is intended for local use; use +one of the stream-based modes in CI or when redirecting output. Watch mode, +interactive commands, and interactive variable prompting are not currently +supported. Task confirmation prompts can be accepted up front with `--yes`. + ::: tip The `output` option can also be specified by the `--output` or `-o` flags. diff --git a/website/src/latest/docs/reference/schema.md b/website/src/latest/docs/reference/schema.md index 471d981661..11239bc11f 100644 --- a/website/src/latest/docs/reference/schema.md +++ b/website/src/latest/docs/reference/schema.md @@ -29,7 +29,7 @@ version: '3' - **Type**: `string` or `object` - **Default**: `interleaved` -- **Options**: `interleaved`, `group`, `prefixed` +- **Options**: `interleaved`, `group`, `prefixed`, `tui` - **Description**: Controls how task output is displayed ```yaml diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index f9243013d7..c5286868cf 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2692,12 +2692,13 @@ the shell in real-time. This is good for having live feedback for logging printed by commands, but the output can become messy if you have multiple commands running simultaneously and printing lots of stuff. -To make this more customizable, there are currently three different output +To make this more customizable, there are currently four different output options you can choose: - `interleaved` (default) - `group` - `prefixed` +- `tui` To choose another one, just set it to root in the Taskfile: @@ -2801,6 +2802,24 @@ $ task default [print-baz] baz ``` +The `tui` output opens an interactive, full-screen view. Running tasks and +their status are shown as a call tree on the left, while the output of the +selected task is shown on the right. Use Tab or the left/right arrow keys to +focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a +task. In the output pane, those keys scroll; Page Up and Page Down also scroll +the output directly. You can click a task to select it and use the mouse wheel +over either pane. Pressing `q` while tasks are still running cancels them. The +view remains open after execution completes so that output can be inspected. + +```shell +$ task --output tui build test lint +``` + +This mode requires an interactive terminal. It is intended for local use; use +one of the stream-based modes in CI or when redirecting output. Watch mode, +interactive commands, and interactive variable prompting are not currently +supported. Task confirmation prompts can be accepted up front with `--yes`. + ::: tip The `output` option can also be specified by the `--output` or `-o` flags. diff --git a/website/src/next/docs/reference/schema.md b/website/src/next/docs/reference/schema.md index 737ef2e951..267db66930 100644 --- a/website/src/next/docs/reference/schema.md +++ b/website/src/next/docs/reference/schema.md @@ -29,7 +29,7 @@ version: '3' - **Type**: `string` or `object` - **Default**: `interleaved` -- **Options**: `interleaved`, `group`, `prefixed` +- **Options**: `interleaved`, `group`, `prefixed`, `tui` - **Description**: Controls how task output is displayed ```yaml diff --git a/website/src/public/next-schema.json b/website/src/public/next-schema.json index 74ebfe9d39..6ab74232ac 100644 --- a/website/src/public/next-schema.json +++ b/website/src/public/next-schema.json @@ -688,7 +688,7 @@ }, "outputString": { "type": "string", - "enum": ["interleaved", "prefixed", "group"], + "enum": ["interleaved", "prefixed", "group", "tui"], "default": "interleaved" }, "outputObject": { diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 74ebfe9d39..6ab74232ac 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -688,7 +688,7 @@ }, "outputString": { "type": "string", - "enum": ["interleaved", "prefixed", "group"], + "enum": ["interleaved", "prefixed", "group", "tui"], "default": "interleaved" }, "outputObject": { From c4080fd64182f262355937f51c992aa36c88d464 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 00:36:41 +0200 Subject: [PATCH 02/46] refactor: flatten TUI task list --- call.go | 4 +- internal/output/output.go | 31 +- internal/output/tui.go | 329 +++++++++++++------- internal/output/tui_test.go | 163 +++++++--- task.go | 51 +-- taskfile/ast/output.go | 30 +- taskfile/ast/output_test.go | 27 ++ tui_output_test.go | 72 ++++- website/src/latest/docs/guide.md | 28 +- website/src/latest/docs/reference/schema.md | 5 + website/src/next/docs/guide.md | 28 +- website/src/next/docs/reference/schema.md | 5 + website/src/public/next-schema.json | 13 + website/src/public/schema.json | 13 + 14 files changed, 576 insertions(+), 223 deletions(-) create mode 100644 taskfile/ast/output_test.go diff --git a/call.go b/call.go index 63a2c5a94d..1fda396006 100644 --- a/call.go +++ b/call.go @@ -9,6 +9,6 @@ type Call struct { Silent bool Indirect bool // True if the task was called by another task - invocationID uint64 - parentInvocationID uint64 + invocationID uint64 + rootInvocationID uint64 } diff --git a/internal/output/output.go b/internal/output/output.go index a5d9c6f533..2f7c37982d 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -16,12 +16,13 @@ type Output interface { type CloseFunc func(err error) error -// TaskInvocation identifies one runtime invocation of a task. IDs are unique -// within an Executor; ParentID is zero for tasks invoked from the CLI. +// TaskInvocation identifies one runtime call to a task. IDs are unique within +// an Executor, including repeated calls to the same task. type TaskInvocation struct { - ID uint64 - ParentID uint64 + ID uint64 // Unique call ID + RootID uint64 // ID of the root call requested by the user Name string + Internal bool } // Runner is implemented by output modes that need to own the terminal while @@ -33,10 +34,18 @@ type Runner interface { // TaskLifecycle is implemented by output modes that display task state in // addition to command output. type TaskLifecycle interface { + TaskScheduled(TaskInvocation) TaskStarted(TaskInvocation) TaskFinished(id uint64, err error) } +// TaskJoinLifecycle is implemented by output modes that distinguish task calls +// from executions. A joined call waits for the execution owned by ownerID and +// does not produce its own output. +type TaskJoinLifecycle interface { + TaskJoined(id, ownerID uint64) +} + // TaskOutput is implemented by output modes that keep output for individual // task invocations. Other output modes continue to use Output.WrapWriter. type TaskOutput interface { @@ -52,6 +61,12 @@ func Run(o Output, ctx context.Context, fn func(context.Context) error) error { return fn(ctx) } +func TaskScheduled(o Output, task TaskInvocation) { + if lifecycle, ok := o.(TaskLifecycle); ok { + lifecycle.TaskScheduled(task) + } +} + func TaskStarted(o Output, task TaskInvocation) { if lifecycle, ok := o.(TaskLifecycle); ok { lifecycle.TaskStarted(task) @@ -64,6 +79,12 @@ func TaskFinished(o Output, id uint64, err error) { } } +func TaskJoined(o Output, id, ownerID uint64) { + if lifecycle, ok := o.(TaskJoinLifecycle); ok { + lifecycle.TaskJoined(id, ownerID) + } +} + // WrapWriter returns task-aware writers when the output mode supports them. func WrapWriter(o Output, stdOut, stdErr io.Writer, task TaskInvocation, cache *templater.Cache) (io.Writer, io.Writer, CloseFunc) { if taskOutput, ok := o.(TaskOutput); ok { @@ -100,7 +121,7 @@ func BuildFor(o *ast.Output, logger *logger.Logger) (Output, error) { if err := checkOutputGroupUnset(o); err != nil { return nil, err } - return NewTUI(logger) + return NewTUI(logger, o.TUI) default: return nil, fmt.Errorf(`task: output style %q not recognized`, o.Name) } diff --git a/internal/output/tui.go b/internal/output/tui.go index 3ca83f0b56..679ba273cb 100644 --- a/internal/output/tui.go +++ b/internal/output/tui.go @@ -15,6 +15,7 @@ import ( "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/internal/term" + "github.com/go-task/task/v3/taskfile/ast" ) const ( @@ -23,9 +24,10 @@ const ( ) type TUI struct { - logger *logger.Logger - input io.Reader - output io.Writer + logger *logger.Logger + input io.Reader + output io.Writer + hideInternal bool mutex sync.RWMutex program *tea.Program @@ -40,15 +42,16 @@ type pendingOutput struct { data string } -func NewTUI(log *logger.Logger) (*TUI, error) { +func NewTUI(log *logger.Logger, options ast.OutputTUI) (*TUI, error) { if !log.AssumeTerm && !term.IsTerminal() { return nil, fmt.Errorf(`task: output style "tui" requires an interactive terminal`) } return &TUI{ - logger: log, - input: log.Stdin, - output: log.Stdout, - pending: make(map[uint64]pendingOutput), + logger: log, + input: log.Stdin, + output: log.Stdout, + hideInternal: options.HideInternal, + pending: make(map[uint64]pendingOutput), }, nil } @@ -64,6 +67,10 @@ func (t *TUI) WrapWriterForTask(_ io.Writer, _ io.Writer, task TaskInvocation, _ return w, w, func(error) error { return nil } } +func (t *TUI) TaskScheduled(task TaskInvocation) { + t.send(taskScheduledMsg{task: task}) +} + func (t *TUI) TaskStarted(task TaskInvocation) { t.send(taskStartedMsg{task: task}) } @@ -72,11 +79,15 @@ func (t *TUI) TaskFinished(id uint64, err error) { t.send(taskFinishedMsg{id: id, err: err}) } +func (t *TUI) TaskJoined(id, ownerID uint64) { + t.send(taskJoinedMsg{id: id, ownerID: ownerID}) +} + func (t *TUI) Run(ctx context.Context, run func(context.Context) error) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - model := newTUIModel(cancel) + model := newTUIModel(cancel, t.hideInternal) program := tea.NewProgram( model, tea.WithInput(t.input), @@ -182,22 +193,31 @@ const ( ) type tuiTask struct { - id uint64 - parentID uint64 - name string - output string - state taskState - truncated bool + id uint64 + rootID uint64 + name string + occurrence int + internal bool + isRoot bool + hidden bool + output string + state taskState + truncated bool scrollOffset int followOutput bool } +type taskScheduledMsg struct{ task TaskInvocation } type taskStartedMsg struct{ task TaskInvocation } type taskFinishedMsg struct { id uint64 err error } +type taskJoinedMsg struct { + id uint64 + ownerID uint64 +} type taskOutputMsg struct { id uint64 name, data string @@ -206,30 +226,40 @@ type outputReadyMsg struct{ tui *TUI } type executionDoneMsg struct{ err error } type tuiModel struct { - tasks []*tuiTask - byID map[uint64]*tuiTask - selectedID uint64 - hasSelect bool - listTop int - focus paneFocus - width int - height int - viewport viewport.Model - done bool - err error - cancel context.CancelFunc -} - -func newTUIModel(cancel context.CancelFunc) tuiModel { + tasks []*tuiTask + byID map[uint64]*tuiTask + nameCounts map[tuiTaskKey]int + selectedID uint64 + hasSelect bool + listTop int + focus paneFocus + width int + height int + viewport viewport.Model + done bool + err error + cancel context.CancelFunc + hideInternal bool +} + +type tuiTaskKey struct { + rootID uint64 + name string + isRoot bool +} + +func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { view := viewport.New() view.SoftWrap = true view.MouseWheelDelta = 3 return tuiModel{ - byID: make(map[uint64]*tuiTask), - width: 100, - height: 30, - viewport: view, - cancel: cancel, + byID: make(map[uint64]*tuiTask), + nameCounts: make(map[tuiTaskKey]int), + width: 100, + height: 30, + viewport: view, + cancel: cancel, + hideInternal: hideInternal, } } @@ -244,19 +274,29 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loadViewport() m.keepSelectionVisible() return m, nil + case taskScheduledMsg: + m.scheduleTask(msg.task) + m.keepSelectionVisible() + return m, nil case taskStartedMsg: - task := m.ensureTask(msg.task.ID, msg.task.Name, msg.task.ParentID) + task := m.scheduleTask(msg.task) task.state = taskRunning m.keepSelectionVisible() return m, nil case taskFinishedMsg: - task := m.ensureTask(msg.id, "", 0) + task := m.byID[msg.id] + if task == nil { + return m, nil + } if msg.err != nil { task.state = taskFailed } else { task.state = taskSucceeded } return m, nil + case taskJoinedMsg: + m.joinTask(msg.id, msg.ownerID) + return m, nil case taskOutputMsg: m.appendOutput(msg.id, msg.name, msg.data) return m, nil @@ -316,7 +356,7 @@ func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return *m, m.updateViewport(msg) case "home", "g": if m.focus == taskPane { - m.selectTask(0) + m.selectBoundary(false) } else { m.viewport.GotoTop() m.saveViewport() @@ -324,7 +364,7 @@ func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return *m, nil case "end", "G": if m.focus == taskPane { - m.selectTask(len(m.taskRows()) - 1) + m.selectBoundary(true) } else { m.viewport.GotoBottom() m.saveViewport() @@ -412,30 +452,79 @@ func newTUILayout(width, height int) tuiLayout { } } -func (m *tuiModel) ensureTask(id uint64, name string, parentID uint64) *tuiTask { - if task, ok := m.byID[id]; ok { - if name != "" { - task.name = name - } - if parentID != 0 && parentID != id { - task.parentID = parentID - } +func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { + if task := m.byID[invocation.ID]; task != nil { return task } - if name == "" { - name = fmt.Sprintf("task %d", id) - } + isRoot := invocation.ID == invocation.RootID + key := tuiTaskKey{rootID: invocation.RootID, name: invocation.Name, isRoot: isRoot} + m.nameCounts[key]++ task := &tuiTask{ - id: id, - parentID: parentID, - name: name, + id: invocation.ID, + rootID: invocation.RootID, + name: invocation.Name, + occurrence: m.nameCounts[key], + internal: invocation.Internal, + isRoot: isRoot, + hidden: m.hideInternal && invocation.Internal && !isRoot, state: taskLog, followOutput: true, } + m.byID[invocation.ID] = task + m.tasks = append(m.tasks, task) + if m.hasSelect && m.selectedID == task.id { + m.loadViewport() + } + if !task.isRoot && !task.hidden && !m.hasSelect { + m.selectedID = task.id + m.hasSelect = true + m.loadViewport() + } + return task +} + +func (m *tuiModel) joinTask(id, ownerID uint64) { + task := m.byID[id] + if task == nil { + return + } + selected := m.hasSelect && m.selectedID == id + if selected { + m.saveViewport() + } + delete(m.byID, id) + for i, candidate := range m.tasks { + if candidate.id == id { + m.tasks = append(m.tasks[:i], m.tasks[i+1:]...) + break + } + } + key := tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot} + if m.nameCounts[key] > 1 { + m.nameCounts[key]-- + } else { + delete(m.nameCounts, key) + } + if selected { + m.selectedID = ownerID + m.hasSelect = ownerID != 0 + m.loadViewport() + } + m.keepSelectionVisible() +} + +func (m *tuiModel) ensureOutputTask(id uint64, name string) *tuiTask { + if task := m.byID[id]; task != nil { + return task + } + if name == "" { + name = fmt.Sprintf("task %d", id) + } + task := &tuiTask{id: id, name: name, state: taskLog, followOutput: true} m.byID[id] = task m.tasks = append(m.tasks, task) if !m.hasSelect { - m.selectedID = id + m.selectedID = task.id m.hasSelect = true m.loadViewport() } @@ -443,7 +532,7 @@ func (m *tuiModel) ensureTask(id uint64, name string, parentID uint64) *tuiTask } func (m *tuiModel) appendOutput(id uint64, name, data string) { - task := m.ensureTask(id, name, 0) + task := m.ensureOutputTask(id, name) if task.state == taskLog && id != 0 { task.state = taskRunning } @@ -452,11 +541,19 @@ func (m *tuiModel) appendOutput(id uint64, name, data string) { task.output = task.output[len(task.output)-maxTaskOutputLen:] task.truncated = true } - if m.hasSelect && id == m.selectedID { + if m.hasSelect && task.id == m.selectedID { m.loadViewport() } } +func (m tuiModel) taskName(task *tuiTask) string { + key := tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot} + if m.nameCounts[key] > 1 { + return fmt.Sprintf("#%d %s", task.occurrence, task.name) + } + return task.name +} + type tuiTaskRow struct { task *tuiTask depth int @@ -464,56 +561,37 @@ type tuiTaskRow struct { } func (m tuiModel) taskRows() []tuiTaskRow { - children := make(map[uint64][]*tuiTask) - var roots []*tuiTask + childrenByRoot := make(map[uint64][]*tuiTask) + var roots, standalone []*tuiTask for _, task := range m.tasks { - _, parentExists := m.byID[task.parentID] - if task.parentID == 0 || task.parentID == task.id || !parentExists { + if task.hidden { + continue + } + if task.isRoot { roots = append(roots, task) continue } - children[task.parentID] = append(children[task.parentID], task) + if task.rootID == 0 { + standalone = append(standalone, task) + } else { + childrenByRoot[task.rootID] = append(childrenByRoot[task.rootID], task) + } } rows := make([]tuiTaskRow, 0, len(m.tasks)) - visited := make(map[uint64]bool, len(m.tasks)) - var walk func(*tuiTask, int, []bool, string, bool) - walk = func(task *tuiTask, depth int, ancestorContinues []bool, connector string, hasNextSibling bool) { - if visited[task.id] { - return - } - visited[task.id] = true - var prefix strings.Builder - for _, continues := range ancestorContinues { - if continues { - prefix.WriteString("│ ") - } else { - prefix.WriteString(" ") - } - } - prefix.WriteString(connector) - rows = append(rows, tuiTaskRow{task: task, depth: depth, treePrefix: prefix.String()}) - childAncestors := ancestorContinues - if depth > 0 { - childAncestors = append(append([]bool(nil), ancestorContinues...), hasNextSibling) - } - for i, child := range children[task.id] { - hasNext := i < len(children[task.id])-1 - childConnector := "└─ " - if hasNext { - childConnector = "├─ " + for _, root := range roots { + rows = append(rows, tuiTaskRow{task: root}) + children := childrenByRoot[root.id] + for i, child := range children { + connector := "└─ " + if i < len(children)-1 { + connector = "├─ " } - walk(child, depth+1, childAncestors, childConnector, hasNext) + rows = append(rows, tuiTaskRow{task: child, depth: 1, treePrefix: connector}) } } - for _, root := range roots { - walk(root, 0, nil, "", false) - } - // Defensive fallback for malformed/cyclic parent information. - for _, task := range m.tasks { - if !visited[task.id] { - walk(task, 0, nil, "", false) - } + for _, task := range standalone { + rows = append(rows, tuiTaskRow{task: task}) } return rows } @@ -541,14 +619,24 @@ func (m *tuiModel) moveSelection(delta int) { } index := m.selectedIndex() if index < 0 { - index = 0 + if delta < 0 { + m.selectBoundary(true) + } else { + m.selectBoundary(false) + } + return + } + for index += delta; index >= 0 && index < len(rows); index += delta { + if !rows[index].task.isRoot { + m.selectTask(index) + return + } } - m.selectTask(min(max(index+delta, 0), len(rows)-1)) } func (m *tuiModel) selectTask(index int) { rows := m.taskRows() - if index < 0 || index >= len(rows) { + if index < 0 || index >= len(rows) || rows[index].task.isRoot { return } m.saveViewport() @@ -558,6 +646,25 @@ func (m *tuiModel) selectTask(index int) { m.loadViewport() } +func (m *tuiModel) selectBoundary(last bool) { + rows := m.taskRows() + if last { + for i := len(rows) - 1; i >= 0; i-- { + if !rows[i].task.isRoot { + m.selectTask(i) + return + } + } + return + } + for i, row := range rows { + if !row.task.isRoot { + m.selectTask(i) + return + } + } +} + func (m *tuiModel) keepSelectionVisible() { index := m.selectedIndex() if index < 0 { @@ -674,8 +781,11 @@ func (m tuiModel) taskList(width, height int) string { for i := m.listTop; i < end; i++ { row := rows[i] branch := row.treePrefix - if lipgloss.Width(branch) > max(width/2, 3) { - branch = "… " + truncateLeft(branch, max(width/2-2, 1)) + if row.task.isRoot { + prefix := " " + taskIcon(row.task.state) + " " + name := truncateRunes(m.taskName(row.task), max(width-lipgloss.Width(prefix), 1)) + lines = append(lines, prefix+tuiRootStyle.Render(name)) + continue } selected := row.task.id == m.selectedID marker := " " @@ -683,7 +793,7 @@ func (m tuiModel) taskList(width, height int) string { marker = "▌ " } plainPrefix := marker + branch + taskIconText(row.task.state) + " " - name := truncateRunes(row.task.name, max(width-lipgloss.Width(plainPrefix), 1)) + name := truncateRunes(m.taskName(row.task), max(width-lipgloss.Width(plainPrefix), 1)) if selected { lines = append(lines, tuiSelectedStyle.Width(width).Render(plainPrefix+name)) continue @@ -695,15 +805,15 @@ func (m tuiModel) taskList(width, height int) string { } func (m tuiModel) outputPanel(width int) string { - name := "" + title := "OUTPUT" if task := m.selectedTask(); task != nil { - name = task.name + title += " · " + m.taskName(task) } position := "" if !m.viewport.AtTop() || !m.viewport.AtBottom() { position = fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100) } - return paneTitle("OUTPUT · "+name, position, width) + "\n" + m.viewport.View() + return paneTitle(title, position, width) + "\n" + m.viewport.View() } func paneTitle(left, right string, width int) string { @@ -754,14 +864,6 @@ func truncateRunes(s string, width int) string { return string(runes[:width-1]) + "…" } -func truncateLeft(s string, width int) string { - runes := []rune(s) - if len(runes) <= width { - return s - } - return string(runes[len(runes)-width:]) -} - var ( tuiAccentColor = compat.AdaptiveColor{Light: lipgloss.Color("#006A83"), Dark: lipgloss.Color("#5FD7FF")} tuiPanelStyle = lipgloss.NewStyle(). @@ -770,6 +872,7 @@ var ( PaddingLeft(1). PaddingRight(1) tuiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccentColor) + tuiRootStyle = lipgloss.NewStyle().Bold(true) tuiSelectedStyle = lipgloss.NewStyle(). Bold(true). Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#10212B"), Dark: lipgloss.Color("#F4F7FA")}). diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index 5b8050a398..073b68f7f6 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -21,22 +21,27 @@ func TestBuildTUI(t *testing.T) { got, err := BuildFor(&ast.Output{Name: "tui"}, &logger.Logger{AssumeTerm: true}) require.NoError(t, err) assert.IsType(t, &TUI{}, got) + + got, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{HideInternal: true}}, &logger.Logger{AssumeTerm: true}) + require.NoError(t, err) + assert.True(t, got.(*TUI).hideInternal) } func TestTUIModelTracksTasksAndOutput(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}) - m = updateTUIModel(t, m, started(1, 0, "build")) - m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: "compiling\r\ndone\r"}) - m = updateTUIModel(t, m, started(2, 0, "test")) - m = updateTUIModel(t, m, taskFinishedMsg{id: 1}) - m = updateTUIModel(t, m, taskFinishedMsg{id: 2, err: errors.New("failed")}) + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "build", data: "compiling\r\ndone\r"}) + m = updateTUIModel(t, m, started(3, 1, "test")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 3, err: errors.New("failed")}) - require.Len(t, m.tasks, 2) - assert.Equal(t, taskSucceeded, m.byID[1].state) - assert.Equal(t, "compiling\ndone\n", m.byID[1].output) - assert.Equal(t, taskFailed, m.byID[2].state) + require.Len(t, m.tasks, 3) + assert.Equal(t, taskSucceeded, m.byID[2].state) + assert.Equal(t, "compiling\ndone\n", m.byID[2].output) + assert.Equal(t, taskFailed, m.byID[3].state) assert.Equal(t, m.width, lipgloss.Width(m.View().Content)) assert.Equal(t, m.height, lipgloss.Height(m.View().Content)) assert.LessOrEqual(t, lipgloss.Width(m.View().Content), m.width) @@ -46,14 +51,14 @@ func TestTUIModelTracksTasksAndOutput(t *testing.T) { assert.Equal(t, lipgloss.Height(left), lipgloss.Height(right)) m.moveSelection(1) - assert.Equal(t, uint64(2), m.selectedID) + assert.Equal(t, uint64(3), m.selectedID) assert.Contains(t, m.View().Content, "test") } func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}) + m := newTUIModel(func() {}, false) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 40, Height: 8}) m = updateTUIModel(t, m, started(1, 0, "a-task-with-a-fairly-long-name")) @@ -66,52 +71,114 @@ func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { assert.Equal(t, lipgloss.Height(left), lipgloss.Height(right)) } -func TestTUIModelKeepsConcurrentInvocationsSeparate(t *testing.T) { +func TestTUIModelKeepsRepeatedTaskCallsSeparate(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}) - m = updateTUIModel(t, m, started(1, 0, "worker")) - m = updateTUIModel(t, m, started(2, 0, "worker")) - m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "worker", data: "first"}) - m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "second"}) - m = updateTUIModel(t, m, taskFinishedMsg{id: 1, err: errors.New("failed")}) - m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, started(3, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "first"}) + m = updateTUIModel(t, m, taskOutputMsg{id: 3, name: "worker", data: " second"}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2, err: errors.New("failed")}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 3}) + + require.Len(t, m.tasks, 3) + assert.NotSame(t, m.byID[2], m.byID[3]) + assert.Equal(t, "first", m.byID[2].output) + assert.Equal(t, " second", m.byID[3].output) + assert.Equal(t, taskFailed, m.byID[2].state) + assert.Equal(t, taskSucceeded, m.byID[3].state) + assert.Equal(t, []string{"root", "worker", "worker"}, rowNames(m.taskRows())) + assert.Contains(t, m.taskList(30, 10), "#1 worker") + assert.Contains(t, m.taskList(30, 10), "#2 worker") + + m.selectTask(2) + assert.Equal(t, " second", m.selectedTask().output) + assert.Contains(t, m.viewport.View(), " second") + assert.Contains(t, m.outputPanel(30), "#2 worker") +} + +func TestTUIModelHidesCallsThatJoinAnExistingExecution(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, scheduled(3, 1, "worker", false)) + require.Len(t, m.tasks, 3) + assert.Contains(t, m.taskList(30, 10), "#1 worker") + assert.Contains(t, m.taskList(30, 10), "#2 worker") + + m.selectTask(2) + m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) require.Len(t, m.tasks, 2) - assert.Equal(t, "first", m.byID[1].output) - assert.Equal(t, taskFailed, m.byID[1].state) - assert.Equal(t, "second", m.byID[2].output) - assert.Equal(t, taskSucceeded, m.byID[2].state) + assert.Nil(t, m.byID[3]) + assert.Equal(t, uint64(2), m.selectedID) + assert.Equal(t, []string{"root", "worker"}, rowNames(m.taskRows())) + assert.NotContains(t, m.taskList(30, 10), "#1") + assert.NotContains(t, m.taskList(30, 10), "#2") } -func TestTUIModelBuildsRuntimeInvocationTree(t *testing.T) { +func TestTUIModelFlattensExecutionsUnderTheirRoot(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}) + m := newTUIModel(func() {}, false) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(5, 0, "other-root")) m = updateTUIModel(t, m, started(2, 1, "child")) - m = updateTUIModel(t, m, started(3, 2, "grandchild")) + m = updateTUIModel(t, m, started(3, 1, "grandchild")) m = updateTUIModel(t, m, started(4, 1, "second-child")) rows := m.taskRows() assert.Equal(t, []string{"root", "child", "grandchild", "second-child", "other-root"}, rowNames(rows)) - assert.Equal(t, []int{0, 1, 2, 1, 0}, rowDepths(rows)) - assert.Equal(t, []string{"", "├─ ", "│ └─ ", "└─ ", ""}, rowPrefixes(rows)) + assert.Equal(t, []int{0, 1, 1, 1, 0}, rowDepths(rows)) + assert.Equal(t, []string{"", "├─ ", "├─ ", "└─ ", ""}, rowPrefixes(rows)) assert.Contains(t, m.taskList(30, 10), "▌") } +func TestTUIModelShowsPendingTasksAndDoesNotSelectRoot(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, scheduled(1, 1, "root", false)) + assert.False(t, m.hasSelect) + m = updateTUIModel(t, m, scheduled(2, 1, "child", false)) + assert.Equal(t, taskLog, m.byID[2].state) + assert.Equal(t, uint64(2), m.selectedID) + + m.selectTask(0) + assert.Equal(t, uint64(2), m.selectedID) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + assert.Equal(t, taskSucceeded, m.byID[2].state) +} + +func TestTUIModelCanHideInternalTasks(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, true) + m = updateTUIModel(t, m, scheduled(1, 1, "root", false)) + m = updateTUIModel(t, m, scheduled(2, 1, "visible", false)) + m = updateTUIModel(t, m, scheduled(3, 1, "internal", true)) + + assert.Equal(t, []string{"root", "visible"}, rowNames(m.taskRows())) + assert.Equal(t, 1, m.nameCounts[tuiTaskKey{rootID: 1, name: "internal"}]) +} + func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}) + m := newTUIModel(func() {}, false) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - m = updateTUIModel(t, m, started(1, 0, "first")) - m = updateTUIModel(t, m, started(2, 0, "second")) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "first")) + m = updateTUIModel(t, m, started(3, 1, "second")) - // The title occupies row 1 inside the border; the second task is row 3. - m = updateTUIModel(t, m, tea.MouseClickMsg{X: 5, Y: 3, Button: tea.MouseLeft}) - assert.Equal(t, uint64(2), m.selectedID) + // The title occupies row 1; the root is row 2 and the second child is row 4. + m = updateTUIModel(t, m, tea.MouseClickMsg{X: 5, Y: 4, Button: tea.MouseLeft}) + assert.Equal(t, uint64(3), m.selectedID) assert.Equal(t, taskPane, m.focus) layout := newTUILayout(m.width, m.height) @@ -122,20 +189,21 @@ func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { func TestTUIModelScrollsAndRemembersEachTaskOutput(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}) + m := newTUIModel(func() {}, false) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 10}) - m = updateTUIModel(t, m, started(1, 0, "first")) - m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "first", data: numberedLines(30)}) - m = updateTUIModel(t, m, started(2, 0, "second")) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "first")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "first", data: numberedLines(30)}) + m = updateTUIModel(t, m, started(3, 1, "second")) m.focus = outputPane m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) firstOffset := m.viewport.YOffset() assert.Greater(t, firstOffset, 0) - assert.False(t, m.byID[1].followOutput) + assert.False(t, m.byID[2].followOutput) + m.selectTask(2) m.selectTask(1) - m.selectTask(0) assert.Equal(t, firstOffset, m.viewport.YOffset()) layout := newTUILayout(m.width, m.height) @@ -151,7 +219,7 @@ func TestTUIModelQuitCancelsExecution(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) - m := newTUIModel(cancel) + m := newTUIModel(cancel, false) key := tea.KeyPressMsg{Code: 'q', Text: "q"} next, cmd := m.Update(key) require.NotNil(t, cmd) @@ -171,8 +239,15 @@ func TestTUIOutputQueueCoalescesWrites(t *testing.T) { assert.False(t, tui.outputQueued) } -func started(id, parentID uint64, name string) taskStartedMsg { - return taskStartedMsg{task: TaskInvocation{ID: id, ParentID: parentID, Name: name}} +func started(id, rootID uint64, name string) taskStartedMsg { + if rootID == 0 { + rootID = id + } + return taskStartedMsg{task: TaskInvocation{ID: id, RootID: rootID, Name: name}} +} + +func scheduled(id, rootID uint64, name string, internal bool) taskScheduledMsg { + return taskScheduledMsg{task: TaskInvocation{ID: id, RootID: rootID, Name: name, Internal: internal}} } func updateTUIModel(t *testing.T, m tuiModel, msg tea.Msg) tuiModel { diff --git a/task.go b/task.go index 29bdbd8975..96ef8fbedb 100644 --- a/task.go +++ b/task.go @@ -137,7 +137,7 @@ func (e *Executor) splitRegularAndWatchCalls(calls ...*Call) (regularCalls []*Ca } // RunTask runs a task by its name -func (e *Executor) RunTask(ctx context.Context, call *Call) error { +func (e *Executor) RunTask(ctx context.Context, call *Call) (runErr error) { // Inject prompted vars into call if available if e.promptedVars != nil { if call.Vars == nil { @@ -181,6 +181,19 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { } } + call.invocationID = atomic.AddUint64(&e.taskInvocationID, 1) + if !call.Indirect || call.rootInvocationID == 0 { + call.rootInvocationID = call.invocationID + } + invocation := output.TaskInvocation{ + ID: call.invocationID, + RootID: call.rootInvocationID, + Name: t.Prefix, + Internal: t.Internal, + } + output.TaskScheduled(e.Output, invocation) + defer func() { output.TaskFinished(e.Output, call.invocationID, runErr) }() + // Check if condition after CompiledTask so dynamic variables are resolved if strings.TrimSpace(t.If) != "" { if err := execext.RunCommand(ctx, &execext.RunCommandOptions{ @@ -224,16 +237,11 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { release := e.acquireConcurrencyLimit() defer release() - call.invocationID = atomic.AddUint64(&e.taskInvocationID, 1) - invocation := output.TaskInvocation{ - ID: call.invocationID, - ParentID: call.parentInvocationID, - Name: t.Prefix, - } - output.TaskStarted(e.Output, invocation) - err = e.startExecution(ctx, t, func(ctx context.Context) error { + err = e.startExecution(ctx, t, call.invocationID, func(ctx context.Context) (runErr error) { + output.TaskStarted(e.Output, invocation) + e.Logger.VerboseErrf(logger.Magenta, "task: %q started\n", call.Task) - if err := e.runDeps(ctx, t, call.invocationID); err != nil { + if err := e.runDeps(ctx, t, call.rootInvocationID); err != nil { return err } @@ -313,7 +321,6 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { e.Logger.VerboseErrf(logger.Magenta, "task: %q finished\n", call.Task) return nil }) - output.TaskFinished(e.Output, call.invocationID, err) if err != nil { return &errors.TaskRunError{TaskName: t.Name(), Err: err} } @@ -338,7 +345,7 @@ func (e *Executor) mkdir(t *ast.Task) error { return nil } -func (e *Executor) runDeps(ctx context.Context, t *ast.Task, parentInvocationID uint64) error { +func (e *Executor) runDeps(ctx context.Context, t *ast.Task, rootInvocationID uint64) error { g := &errgroup.Group{} if e.Failfast || t.Failfast { g, ctx = errgroup.WithContext(ctx) @@ -358,7 +365,7 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task, parentInvocationID defer cancel() } - err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true, parentInvocationID: parentInvocationID}) + err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true, rootInvocationID: rootInvocationID}) if err != nil && timedOut(depCtx, timeout) { return timeout } @@ -425,7 +432,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in reacquire := e.releaseConcurrencyLimit() defer reacquire() - err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true, parentInvocationID: call.invocationID}) + err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true, rootInvocationID: call.rootInvocationID}) if err != nil && timedOut(ctx, timeout) { err = timeout } @@ -459,9 +466,9 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in return fmt.Errorf("task: failed to get variables: %w", err) } stdOut, stdErr, closer := output.WrapWriter(outputWrapper, e.Stdout, e.Stderr, output.TaskInvocation{ - ID: call.invocationID, - ParentID: call.parentInvocationID, - Name: t.Prefix, + ID: call.invocationID, + RootID: call.rootInvocationID, + Name: t.Prefix, }, outputTemplater) if logCommand && output.IsTUI(e.Output) { e.Logger.FOutf(stdErr, logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) @@ -512,11 +519,12 @@ func timedOut(ctx context.Context, timeout *errors.TaskTimeoutError) bool { // executionState is the outcome of a task execution, shared with the callers // that join it. err is written before done is closed; read it only once closed. type executionState struct { - done chan struct{} - err error + done chan struct{} + err error + ownerID uint64 } -func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func(ctx context.Context) error) error { +func (e *Executor) startExecution(ctx context.Context, t *ast.Task, invocationID uint64, execute func(ctx context.Context) error) error { h, err := e.GetHash(t) if err != nil { return err @@ -531,6 +539,7 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func if other, ok := e.executionHashes[h]; ok { e.executionHashesMutex.Unlock() e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h) + output.TaskJoined(e.Output, invocationID, other.ownerID) // Release our execution slot to avoid blocking other tasks while we wait reacquire := e.releaseConcurrencyLimit() @@ -556,7 +565,7 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func } } - state := &executionState{done: make(chan struct{})} + state := &executionState{done: make(chan struct{}), ownerID: invocationID} e.executionHashes[h] = state e.executionHashesMutex.Unlock() diff --git a/taskfile/ast/output.go b/taskfile/ast/output.go index 29ec58f5ab..a351ed1769 100644 --- a/taskfile/ast/output.go +++ b/taskfile/ast/output.go @@ -12,6 +12,8 @@ type Output struct { Name string `yaml:"-"` // Group specific style Group OutputGroup + // TUI specific options + TUI OutputTUI } // IsSet returns true if and only if a custom output style is set. @@ -33,18 +35,29 @@ func (s *Output) UnmarshalYAML(node *yaml.Node) error { case yaml.MappingNode: var tmp struct { Group *OutputGroup + TUI *OutputTUI } if err := node.Decode(&tmp); err != nil { return errors.NewTaskfileDecodeError(err, node) } - if tmp.Group == nil { - return errors.NewTaskfileDecodeError(nil, node).WithMessage(`output style must have the "group" key when in mapping form`) + if tmp.Group != nil && tmp.TUI != nil { + return errors.NewTaskfileDecodeError(nil, node).WithMessage(`output style must have only one of the "group" or "tui" keys`) } - *s = Output{ - Name: "group", - Group: *tmp.Group, + if tmp.Group != nil { + *s = Output{ + Name: "group", + Group: *tmp.Group, + } + return nil } - return nil + if tmp.TUI != nil { + *s = Output{ + Name: "tui", + TUI: *tmp.TUI, + } + return nil + } + return errors.NewTaskfileDecodeError(nil, node).WithMessage(`output style must have the "group" or "tui" key when in mapping form`) } return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("output") @@ -56,6 +69,11 @@ type OutputGroup struct { ErrorOnly bool `yaml:"error_only"` } +// OutputTUI contains options specific to the TUI output style. +type OutputTUI struct { + HideInternal bool `yaml:"hide_internal"` +} + // IsSet returns true if and only if a custom output style is set. func (g *OutputGroup) IsSet() bool { if g == nil { diff --git a/taskfile/ast/output_test.go b/taskfile/ast/output_test.go new file mode 100644 index 0000000000..98e52c6949 --- /dev/null +++ b/taskfile/ast/output_test.go @@ -0,0 +1,27 @@ +package ast + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +func TestOutputTUIUnmarshalYAML(t *testing.T) { + t.Parallel() + + var output Output + require.NoError(t, yaml.Unmarshal([]byte("tui:\n hide_internal: true\n"), &output)) + assert.Equal(t, "tui", output.Name) + assert.True(t, output.TUI.HideInternal) +} + +func TestOutputMappingRejectsMultipleStyles(t *testing.T) { + t.Parallel() + + var output Output + err := yaml.Unmarshal([]byte("group: {}\ntui: {}\n"), &output) + require.Error(t, err) + assert.Contains(t, err.Error(), "only one") +} diff --git a/tui_output_test.go b/tui_output_test.go index af494006c5..e88c7fd15f 100644 --- a/tui_output_test.go +++ b/tui_output_test.go @@ -27,9 +27,16 @@ tasks: cmds: - task: third - echo parent - first: echo first - second: echo second + first: + deps: [shared] + cmds: [echo first] + second: + deps: [shared] + cmds: [echo second] third: echo third + shared: + run: once + cmds: [echo shared] ` require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) @@ -45,27 +52,33 @@ tasks: e.Output = recorder require.NoError(t, e.Run(t.Context(), &task.Call{Task: "default"})) - require.Len(t, recorder.started, 4) + require.Len(t, recorder.scheduled, 6) + require.Len(t, recorder.started, 5) byName := make(map[string]output.TaskInvocation) for _, invocation := range recorder.started { byName[invocation.Name] = invocation } + assert.Equal(t, 2, countInvocations(recorder.scheduled, "shared")) root := byName["default"] - assert.Zero(t, root.ParentID) - for _, name := range []string{"first", "second", "third"} { - assert.Equal(t, root.ID, byName[name].ParentID, name) + require.Len(t, recorder.joined, 1) + for _, ownerID := range recorder.joined { + assert.Equal(t, byName["shared"].ID, ownerID) } - assert.ElementsMatch(t, []uint64{ - byName["default"].ID, - byName["first"].ID, - byName["second"].ID, - byName["third"].ID, - }, recorder.finished) + assert.Equal(t, root.ID, root.RootID) + for _, name := range []string{"first", "second", "third", "shared"} { + assert.Equal(t, root.ID, byName[name].RootID, name) + } + scheduledIDs := make([]uint64, len(recorder.scheduled)) + for i, invocation := range recorder.scheduled { + scheduledIDs[i] = invocation.ID + } + assert.ElementsMatch(t, scheduledIDs, recorder.finished) expectedOutput := map[string]string{ "default": "parent", "first": "first", "second": "second", "third": "third", + "shared": "shared", } for name, invocation := range byName { require.Contains(t, recorder.outputs, invocation.ID) @@ -74,10 +87,12 @@ tasks: } type lifecycleRecorder struct { - mutex sync.Mutex - started []output.TaskInvocation - finished []uint64 - outputs map[uint64]*bytes.Buffer + mutex sync.Mutex + scheduled []output.TaskInvocation + started []output.TaskInvocation + finished []uint64 + outputs map[uint64]*bytes.Buffer + joined map[uint64]uint64 } func (*lifecycleRecorder) WrapWriter(_ io.Writer, _ io.Writer, _ string, _ *templater.Cache) (io.Writer, io.Writer, output.CloseFunc) { @@ -104,8 +119,33 @@ func (r *lifecycleRecorder) TaskStarted(task output.TaskInvocation) { r.started = append(r.started, task) } +func (r *lifecycleRecorder) TaskScheduled(task output.TaskInvocation) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.scheduled = append(r.scheduled, task) +} + func (r *lifecycleRecorder) TaskFinished(id uint64, _ error) { r.mutex.Lock() defer r.mutex.Unlock() r.finished = append(r.finished, id) } + +func (r *lifecycleRecorder) TaskJoined(id, ownerID uint64) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.joined == nil { + r.joined = make(map[uint64]uint64) + } + r.joined[id] = ownerID +} + +func countInvocations(invocations []output.TaskInvocation, name string) int { + count := 0 + for _, invocation := range invocations { + if invocation.Name == name { + count++ + } + } + return count +} diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index 2c4937b1ed..ef9985662c 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2800,19 +2800,31 @@ $ task default [print-baz] baz ``` -The `tui` output opens an interactive, full-screen view. Running tasks and -their status are shown as a call tree on the left, while the output of the -selected task is shown on the right. Use Tab or the left/right arrow keys to -focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a -task. In the output pane, those keys scroll; Page Up and Page Down also scroll -the output directly. You can click a task to select it and use the mouse wheel -over either pane. Pressing `q` while tasks are still running cancels them. The -view remains open after execution completes so that output can be inspected. +The `tui` output opens an interactive, full-screen view. The requested root +task is shown as a non-selectable heading on the left. Tasks reached from it +appear beneath it in a one-level list and remain visible while pending, +running, or finished. Repeated calls to the same task under one root share a +single row and output view. The output of the selected task is shown on the +right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, +use the up/down arrows or `j`/`k` to select a task. In the output pane, those +keys scroll; Page Up and Page Down also scroll the output directly. You can +click a task to select it and use the mouse wheel over either pane. Pressing +`q` while tasks are still running cancels them. The view remains open after +execution completes so that output can be inspected. ```shell $ task --output tui build test lint ``` +Internal tasks are shown by default. They can be hidden when the output mode +is configured in the Taskfile: + +```yaml +output: + tui: + hide_internal: true +``` + This mode requires an interactive terminal. It is intended for local use; use one of the stream-based modes in CI or when redirecting output. Watch mode, interactive commands, and interactive variable prompting are not currently diff --git a/website/src/latest/docs/reference/schema.md b/website/src/latest/docs/reference/schema.md index 11239bc11f..359604fcb7 100644 --- a/website/src/latest/docs/reference/schema.md +++ b/website/src/latest/docs/reference/schema.md @@ -42,6 +42,11 @@ output: begin: "::group::{{.TASK}}" end: "::endgroup::" error_only: false + +# TUI options +output: + tui: + hide_internal: false ``` ### `method` diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index c5286868cf..92023a7711 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2802,19 +2802,31 @@ $ task default [print-baz] baz ``` -The `tui` output opens an interactive, full-screen view. Running tasks and -their status are shown as a call tree on the left, while the output of the -selected task is shown on the right. Use Tab or the left/right arrow keys to -focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a -task. In the output pane, those keys scroll; Page Up and Page Down also scroll -the output directly. You can click a task to select it and use the mouse wheel -over either pane. Pressing `q` while tasks are still running cancels them. The -view remains open after execution completes so that output can be inspected. +The `tui` output opens an interactive, full-screen view. The requested root +task is shown as a non-selectable heading on the left. Tasks reached from it +appear beneath it in a one-level list and remain visible while pending, +running, or finished. Repeated calls to the same task under one root share a +single row and output view. The output of the selected task is shown on the +right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, +use the up/down arrows or `j`/`k` to select a task. In the output pane, those +keys scroll; Page Up and Page Down also scroll the output directly. You can +click a task to select it and use the mouse wheel over either pane. Pressing +`q` while tasks are still running cancels them. The view remains open after +execution completes so that output can be inspected. ```shell $ task --output tui build test lint ``` +Internal tasks are shown by default. They can be hidden when the output mode +is configured in the Taskfile: + +```yaml +output: + tui: + hide_internal: true +``` + This mode requires an interactive terminal. It is intended for local use; use one of the stream-based modes in CI or when redirecting output. Watch mode, interactive commands, and interactive variable prompting are not currently diff --git a/website/src/next/docs/reference/schema.md b/website/src/next/docs/reference/schema.md index 267db66930..e659672b92 100644 --- a/website/src/next/docs/reference/schema.md +++ b/website/src/next/docs/reference/schema.md @@ -42,6 +42,11 @@ output: begin: "::group::{{.TASK}}" end: "::endgroup::" error_only: false + +# TUI options +output: + tui: + hide_internal: false ``` ### `method` diff --git a/website/src/public/next-schema.json b/website/src/public/next-schema.json index 6ab74232ac..bbd1c1aa83 100644 --- a/website/src/public/next-schema.json +++ b/website/src/public/next-schema.json @@ -693,6 +693,8 @@ }, "outputObject": { "type": "object", + "minProperties": 1, + "maxProperties": 1, "properties": { "group": { "type": "object", @@ -709,6 +711,17 @@ "default": false } } + }, + "tui": { + "type": "object", + "properties": { + "hide_internal": { + "description": "Hides internal tasks from the TUI task list", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 6ab74232ac..bbd1c1aa83 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -693,6 +693,8 @@ }, "outputObject": { "type": "object", + "minProperties": 1, + "maxProperties": 1, "properties": { "group": { "type": "object", @@ -709,6 +711,17 @@ "default": false } } + }, + "tui": { + "type": "object", + "properties": { + "hide_internal": { + "description": "Hides internal tasks from the TUI task list", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false } }, "additionalProperties": false From 0fda42eea58eac450553ff69ccedb8f98e643b89 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 01:33:03 +0200 Subject: [PATCH 03/46] feat: add TUI text selection mode --- internal/output/tui.go | 120 ++++++++++++++++++++++++++++--- internal/output/tui_test.go | 53 ++++++++++++++ website/src/latest/docs/guide.md | 14 ++-- website/src/next/docs/guide.md | 14 ++-- 4 files changed, 182 insertions(+), 19 deletions(-) diff --git a/internal/output/tui.go b/internal/output/tui.go index 679ba273cb..0accc75db5 100644 --- a/internal/output/tui.go +++ b/internal/output/tui.go @@ -240,6 +240,10 @@ type tuiModel struct { err error cancel context.CancelFunc hideInternal bool + + selectingText bool + selectionView string + selectionPage viewport.Model } type tuiTaskKey struct { @@ -268,6 +272,9 @@ func (m tuiModel) Init() tea.Cmd { return nil } func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + if m.selectingText { + m.leaveTextSelection() + } m.saveViewport() m.width, m.height = msg.Width, msg.Height m.resizeViewport() @@ -312,9 +319,15 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancel() return m, tea.Quit case tea.MouseClickMsg: + if m.selectingText { + return m, nil + } m.handleMouseClick(tea.Mouse(msg)) return m, nil case tea.MouseWheelMsg: + if m.selectingText { + return m, nil + } return m, m.handleMouseWheel(msg) case tea.KeyPressMsg: return m.handleKey(msg) @@ -324,6 +337,34 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.selectingText { + switch msg.String() { + case "c", "esc": + m.leaveTextSelection() + return *m, nil + case "q", "ctrl+c": + if !m.done { + m.cancel() + } + return *m, tea.Quit + case "up", "k": + m.selectionPage.ScrollUp(1) + case "down", "j": + m.selectionPage.ScrollDown(1) + case "pgup": + m.selectionPage.PageUp() + case "pgdown": + m.selectionPage.PageDown() + case "home", "g": + m.selectionPage.GotoTop() + case "end", "G": + m.selectionPage.GotoBottom() + default: + return *m, nil + } + m.refreshSelectionView() + return *m, nil + } switch msg.String() { case "q", "esc", "ctrl+c": if !m.done { @@ -339,6 +380,9 @@ func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "right", "l": m.focus = outputPane return *m, nil + case "c": + m.enterTextSelection() + return *m, nil case "pgup", "pgdown": m.focus = outputPane return *m, m.updateViewport(msg) @@ -379,31 +423,89 @@ func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } func (m tuiModel) View() tea.View { + content := m.renderContent() + if m.selectingText && m.selectionView != "" { + content = m.selectionView + } + view := tea.NewView(content) + view.AltScreen = true + if m.selectingText { + view.MouseMode = tea.MouseModeNone + } else { + view.MouseMode = tea.MouseModeCellMotion + } + view.WindowTitle = "Task" + return view +} + +func (m tuiModel) renderContent() string { layout := newTUILayout(m.width, m.height) left, right := m.renderPanes(layout) body := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", layout.gap), right) - help := " tab/←/→ pane • ↑/↓ select • click a task • q quit" + help := " tab/←/→ pane • ↑/↓ select • click a task • c select text • q quit" if m.focus == outputPane { - help = " tab/←/→ pane • ↑/↓ or pgup/pgdn scroll • mouse wheel • q quit" + help = " tab/←/→ pane • ↑/↓ or pgup/pgdn scroll • wheel • c select text • q quit" } helpStyle := tuiHelpStyle if m.done { if m.err != nil { - help = " execution failed • enter/q quit" + help = " execution failed • c select text • enter/q quit" helpStyle = tuiFailureStyle } else { - help = " execution complete • enter/q quit" + help = " execution complete • c select text • enter/q quit" helpStyle = tuiSuccessStyle } } help = truncateRunes(help, max(layout.width, 1)) - view := tea.NewView(body + "\n" + helpStyle.Render(help)) - view.AltScreen = true - view.MouseMode = tea.MouseModeCellMotion - view.WindowTitle = "Task" - return view + return body + "\n" + helpStyle.Render(help) +} + +func (m *tuiModel) enterTextSelection() { + m.selectingText = true + view := viewport.New( + viewport.WithWidth(max(m.width, 1)), + viewport.WithHeight(max(m.height-1, 1)), + ) + view.SoftWrap = true + if task := m.selectedTask(); task != nil { + content := task.output + if task.truncated { + content = "… earlier output was discarded …\n" + content + } + view.SetContent(content) + if m.viewport.AtBottom() { + view.GotoBottom() + } else if !m.viewport.AtTop() { + position := m.viewport.ScrollPercent() + view.GotoBottom() + view.SetYOffset(int(position * float64(view.YOffset()))) + } + } + m.selectionPage = view + m.refreshSelectionView() +} + +func (m *tuiModel) leaveTextSelection() { + if m.selectionPage.AtTop() { + m.viewport.GotoTop() + } else if m.selectionPage.AtBottom() { + m.viewport.GotoBottom() + } else { + position := m.selectionPage.ScrollPercent() + m.viewport.GotoBottom() + m.viewport.SetYOffset(int(position * float64(m.viewport.YOffset()))) + } + m.saveViewport() + m.selectingText = false + m.selectionView = "" + m.selectionPage = viewport.Model{} +} + +func (m *tuiModel) refreshSelectionView() { + help := truncateRunes(" text selection • ↑/↓ or pgup/pgdn scroll • drag to select • c/esc resume", max(m.width, 1)) + m.selectionView = m.selectionPage.View() + "\n" + tuiHelpStyle.Render(help) } func (m tuiModel) renderPanes(layout tuiLayout) (string, string) { diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index 073b68f7f6..d968d513b1 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -186,6 +186,59 @@ func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { assert.Equal(t, outputPane, m.focus) } +func TestTUIModelTextSelectionModeDisablesMouseAndFreezesView(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 12}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "first\n"}) + assert.Contains(t, m.View().Content, "c select text") + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'c', Text: "c"}) + selectionView := m.View() + assert.True(t, m.selectingText) + assert.Equal(t, tea.MouseModeNone, selectionView.MouseMode) + assert.Contains(t, selectionView.Content, "drag to select") + assert.Contains(t, selectionView.Content, "first") + assert.NotContains(t, selectionView.Content, "TASKS") + assert.NotContains(t, selectionView.Content, "╭") + + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "second\n"}) + assert.Equal(t, selectionView.Content, m.View().Content) + assert.Equal(t, "first\nsecond\n", m.byID[2].output) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.False(t, m.selectingText) + assert.Equal(t, tea.MouseModeCellMotion, m.View().MouseMode) + assert.Contains(t, m.View().Content, "second") +} + +func TestTUIModelTextSelectionModeScrollsWithKeyboard(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 10}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: numberedLines(60)}) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'c', Text: "c"}) + + require.True(t, m.selectionPage.AtBottom()) + bottomOffset := m.selectionPage.YOffset() + require.Greater(t, bottomOffset, 0) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) + assert.Less(t, m.selectionPage.YOffset(), bottomOffset) + assert.Contains(t, m.View().Content, "scroll") + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'g', Text: "g"}) + assert.True(t, m.selectionPage.AtTop()) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'G', Text: "G"}) + assert.True(t, m.selectionPage.AtBottom()) +} + func TestTUIModelScrollsAndRemembersEachTaskOutput(t *testing.T) { t.Parallel() diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index ef9985662c..c847213b3e 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2803,13 +2803,17 @@ $ task default The `tui` output opens an interactive, full-screen view. The requested root task is shown as a non-selectable heading on the left. Tasks reached from it appear beneath it in a one-level list and remain visible while pending, -running, or finished. Repeated calls to the same task under one root share a -single row and output view. The output of the selected task is shown on the -right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, +running, or finished. Repeated executions have separate rows and output views; +calls that join an existing `run: once` or `run: when_changed` execution share +its row. The output of the selected task is shown on the right. Use Tab or the +left/right arrow keys to focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a task. In the output pane, those keys scroll; Page Up and Page Down also scroll the output directly. You can -click a task to select it and use the mouse wheel over either pane. Pressing -`q` while tasks are still running cancels them. The view remains open after +click a task to select it and use the mouse wheel over either pane. Press `c` +to open a frozen, output-only view for selecting and copying text with the +terminal. The arrow keys or `j`/`k`, Page Up/Down, and `g`/`G` scroll that +view; press `c` or Escape to resume interaction. Pressing `q` +while tasks are still running cancels them. The view remains open after execution completes so that output can be inspected. ```shell diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index 92023a7711..938c3b9ae9 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2805,13 +2805,17 @@ $ task default The `tui` output opens an interactive, full-screen view. The requested root task is shown as a non-selectable heading on the left. Tasks reached from it appear beneath it in a one-level list and remain visible while pending, -running, or finished. Repeated calls to the same task under one root share a -single row and output view. The output of the selected task is shown on the -right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, +running, or finished. Repeated executions have separate rows and output views; +calls that join an existing `run: once` or `run: when_changed` execution share +its row. The output of the selected task is shown on the right. Use Tab or the +left/right arrow keys to focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a task. In the output pane, those keys scroll; Page Up and Page Down also scroll the output directly. You can -click a task to select it and use the mouse wheel over either pane. Pressing -`q` while tasks are still running cancels them. The view remains open after +click a task to select it and use the mouse wheel over either pane. Press `c` +to open a frozen, output-only view for selecting and copying text with the +terminal. The arrow keys or `j`/`k`, Page Up/Down, and `g`/`G` scroll that +view; press `c` or Escape to resume interaction. Pressing `q` +while tasks are still running cancels them. The view remains open after execution completes so that output can be inspected. ```shell From 2b110072fb03d6bbb1d7d2d1332cfa03e2823893 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 12:13:23 +0200 Subject: [PATCH 04/46] feat: improve TUI task status display --- internal/output/tui.go | 137 +++++++++++++++++++++++-------- internal/output/tui_test.go | 68 ++++++++++----- tui_output_test.go | 59 +++++++++++-- website/src/latest/docs/guide.md | 4 +- website/src/next/docs/guide.md | 4 +- 5 files changed, 208 insertions(+), 64 deletions(-) diff --git a/internal/output/tui.go b/internal/output/tui.go index 0accc75db5..b0299da7cb 100644 --- a/internal/output/tui.go +++ b/internal/output/tui.go @@ -2,6 +2,7 @@ package output import ( "context" + "errors" "fmt" "io" "strings" @@ -183,6 +184,7 @@ const ( taskRunning taskSucceeded taskFailed + taskCanceled ) type paneFocus uint8 @@ -295,7 +297,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if task == nil { return m, nil } - if msg.err != nil { + if errors.Is(msg.err, context.Canceled) { + task.state = taskCanceled + } else if msg.err != nil { task.state = taskFailed } else { task.state = taskSucceeded @@ -536,8 +540,10 @@ type tuiLayout struct { func newTUILayout(width, height int) tuiLayout { width, height = max(width, 1), max(height, 1) bodyHeight := max(height-1, 3) - gap := 1 - leftOuterWidth := min(max(width*30/100, 20), 36) + horizontalFrame := tuiPanelStyle.GetHorizontalFrameSize() + verticalFrame := tuiPanelStyle.GetVerticalFrameSize() + gap := 0 + leftOuterWidth := min(max(width*35/100, 22), 72) if right := width - gap - leftOuterWidth; right < 16 { leftOuterWidth = max(width-gap-16, 8) } @@ -548,9 +554,9 @@ func newTUILayout(width, height int) tuiLayout { gap: gap, leftOuterWidth: leftOuterWidth, rightOuterWidth: rightOuterWidth, - leftInnerWidth: max(leftOuterWidth-4, 1), - rightInnerWidth: max(rightOuterWidth-4, 1), - innerHeight: max(bodyHeight-2, 1), + leftInnerWidth: max(leftOuterWidth-horizontalFrame, 1), + rightInnerWidth: max(rightOuterWidth-horizontalFrame, 1), + innerHeight: max(bodyHeight-verticalFrame, 1), } } @@ -657,9 +663,7 @@ func (m tuiModel) taskName(task *tuiTask) string { } type tuiTaskRow struct { - task *tuiTask - depth int - treePrefix string + task *tuiTask } func (m tuiModel) taskRows() []tuiTaskRow { @@ -683,13 +687,8 @@ func (m tuiModel) taskRows() []tuiTaskRow { rows := make([]tuiTaskRow, 0, len(m.tasks)) for _, root := range roots { rows = append(rows, tuiTaskRow{task: root}) - children := childrenByRoot[root.id] - for i, child := range children { - connector := "└─ " - if i < len(children)-1 { - connector = "├─ " - } - rows = append(rows, tuiTaskRow{task: child, depth: 1, treePrefix: connector}) + for _, child := range childrenByRoot[root.id] { + rows = append(rows, tuiTaskRow{task: child}) } } for _, task := range standalone { @@ -882,25 +881,36 @@ func (m tuiModel) taskList(width, height int) string { end := min(len(rows), m.listTop+max(height-1, 1)) for i := m.listTop; i < end; i++ { row := rows[i] - branch := row.treePrefix if row.task.isRoot { - prefix := " " + taskIcon(row.task.state) + " " - name := truncateRunes(m.taskName(row.task), max(width-lipgloss.Width(prefix), 1)) - lines = append(lines, prefix+tuiRootStyle.Render(name)) + prefix := taskIcon(row.task.state) + " " + name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(prefix)) + suffix := "" + if status != "" { + suffix = " " + taskStateLabel(row.task.state, status) + } + lines = append(lines, prefix+tuiRootStyle.Render(name)+suffix) continue } selected := row.task.id == m.selectedID - marker := " " + marker := " " if selected { - marker = "▌ " + marker = "▌" } - plainPrefix := marker + branch + taskIconText(row.task.state) + " " - name := truncateRunes(m.taskName(row.task), max(width-lipgloss.Width(plainPrefix), 1)) + plainPrefix := marker + taskIconText(row.task.state) + " " + name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(plainPrefix)) if selected { - lines = append(lines, tuiSelectedStyle.Width(width).Render(plainPrefix+name)) + suffix := "" + if status != "" { + suffix = " " + status + } + lines = append(lines, tuiSelectedStyle.Width(width).Render(plainPrefix+name+suffix)) continue } - line := tuiTreeStyle.Render(marker+branch) + taskIcon(row.task.state) + " " + name + suffix := "" + if status != "" { + suffix = " " + taskStateLabel(row.task.state, status) + } + line := tuiTreeStyle.Render(marker) + taskIcon(row.task.state) + " " + name + suffix lines = append(lines, line) } return strings.Join(lines, "\n") @@ -924,19 +934,42 @@ func paneTitle(left, right string, width int) string { return tuiTitleStyle.Render(left) + strings.Repeat(" ", space) + tuiHelpStyle.Render(right) } -func taskIcon(state taskState) string { +func taskStateStyle(state taskState) lipgloss.Style { switch state { case taskRunning: - return tuiRunningStyle.Render(taskIconText(state)) + return tuiRunningStyle case taskSucceeded: - return tuiSuccessStyle.Render(taskIconText(state)) + return tuiSuccessStyle case taskFailed: - return tuiFailureStyle.Render(taskIconText(state)) + return tuiFailureStyle + case taskCanceled: + return tuiCanceledStyle default: - return tuiHelpStyle.Render(taskIconText(state)) + return tuiHelpStyle } } +func taskIcon(state taskState) string { + return taskStateStyle(state).Render(taskIconText(state)) +} + +func taskStateLabel(state taskState, label string) string { + return taskStateStyle(state).Render(label) +} + +func taskNameStatus(name string, state taskState, width int) (string, string) { + if width <= 1 { + return truncateMiddleRunes(name, max(width, 1)), "" + } + statusWidth := min(lipgloss.Width(taskStateText(state)), max(width-2, 0)) + if statusWidth == 0 { + return truncateMiddleRunes(name, width), "" + } + status := truncateRunes(taskStateText(state), statusWidth) + name = truncateMiddleRunes(name, max(width-lipgloss.Width(status)-1, 1)) + return name, status +} + func taskIconText(state taskState) string { switch state { case taskRunning: @@ -945,11 +978,28 @@ func taskIconText(state taskState) string { return "✓" case taskFailed: return "✗" + case taskCanceled: + return "■" default: return "·" } } +func taskStateText(state taskState) string { + switch state { + case taskRunning: + return "running" + case taskSucceeded: + return "success" + case taskFailed: + return "failed" + case taskCanceled: + return "canceled" + default: + return "pending" + } +} + func normalizeOutput(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") return strings.ReplaceAll(s, "\r", "\n") @@ -966,6 +1016,19 @@ func truncateRunes(s string, width int) string { return string(runes[:width-1]) + "…" } +func truncateMiddleRunes(s string, width int) string { + runes := []rune(s) + if len(runes) <= width { + return s + } + if width <= 1 { + return "…" + } + left := (width - 1) / 2 + right := width - 1 - left + return string(runes[:left]) + "…" + string(runes[len(runes)-right:]) +} + var ( tuiAccentColor = compat.AdaptiveColor{Light: lipgloss.Color("#006A83"), Dark: lipgloss.Color("#5FD7FF")} tuiPanelStyle = lipgloss.NewStyle(). @@ -973,15 +1036,17 @@ var ( BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("#87909A"), Dark: lipgloss.Color("#59636E")}). PaddingLeft(1). PaddingRight(1) + tuiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccentColor) tuiRootStyle = lipgloss.NewStyle().Bold(true) tuiSelectedStyle = lipgloss.NewStyle(). Bold(true). Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#10212B"), Dark: lipgloss.Color("#F4F7FA")}). Background(compat.AdaptiveColor{Light: lipgloss.Color("#D9E8ED"), Dark: lipgloss.Color("#34444D")}) - tuiTreeStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#77818A"), Dark: lipgloss.Color("#697580")}) - tuiRunningStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#8A6500"), Dark: lipgloss.Color("#FFD75F")}) - tuiSuccessStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#257A3E"), Dark: lipgloss.Color("#5FD787")}) - tuiFailureStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#B42318"), Dark: lipgloss.Color("#FF6B6B")}) - tuiHelpStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#66717C"), Dark: lipgloss.Color("#89949F")}) + tuiTreeStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#77818A"), Dark: lipgloss.Color("#697580")}) + tuiRunningStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#8A6500"), Dark: lipgloss.Color("#FFD75F")}) + tuiSuccessStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#257A3E"), Dark: lipgloss.Color("#5FD787")}) + tuiFailureStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#B42318"), Dark: lipgloss.Color("#FF6B6B")}) + tuiCanceledStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#5F6670"), Dark: lipgloss.Color("#AAB2BD")}) + tuiHelpStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#66717C"), Dark: lipgloss.Color("#89949F")}) ) diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index d968d513b1..bd1a40e38e 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "strings" "testing" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,6 +57,32 @@ func TestTUIModelTracksTasksAndOutput(t *testing.T) { assert.Contains(t, m.View().Content, "test") } +func TestTUIModelDistinguishesCanceledTasksAndShowsStatusWords(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, scheduled(2, 1, "pending-task", false)) + m = updateTUIModel(t, m, started(3, 1, "running-task")) + m = updateTUIModel(t, m, started(4, 1, "successful-task")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 4}) + m = updateTUIModel(t, m, started(5, 1, "failed-task")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 5, err: errors.New("failed")}) + m = updateTUIModel(t, m, started(6, 1, "canceled-task")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 6, err: fmt.Errorf("wrapped: %w", context.Canceled)}) + + assert.Equal(t, taskCanceled, m.byID[6].state) + assert.Equal(t, "■", taskIconText(taskCanceled)) + list := m.taskList(50, 20) + for _, status := range []string{"pending", "running", "success", "failed", "canceled"} { + assert.Contains(t, list, status) + } + name, status := taskNameStatus("build", taskRunning, 20) + assert.Equal(t, "build running", name+" "+status) + name, status = taskNameStatus("fail-fast-success-1s", taskSucceeded, 13) + assert.Equal(t, "fa…1s success", name+" "+status) +} + func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { t.Parallel() @@ -71,6 +99,20 @@ func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { assert.Equal(t, lipgloss.Height(left), lipgloss.Height(right)) } +func TestTUILayoutGivesWideTerminalsMoreTaskSpace(t *testing.T) { + t.Parallel() + + compact := newTUILayout(80, 24) + wide := newTUILayout(240, 24) + + assert.Zero(t, compact.gap) + assert.Greater(t, wide.leftOuterWidth, compact.leftOuterWidth) + assert.Equal(t, 72, wide.leftOuterWidth) + assert.Equal(t, compact.leftOuterWidth-tuiPanelStyle.GetHorizontalFrameSize(), compact.leftInnerWidth) + assert.Equal(t, compact.rightOuterWidth-tuiPanelStyle.GetHorizontalFrameSize(), compact.rightInnerWidth) + assert.Equal(t, compact.bodyHeight-tuiPanelStyle.GetVerticalFrameSize(), compact.innerHeight) +} + func TestTUIModelKeepsRepeatedTaskCallsSeparate(t *testing.T) { t.Parallel() @@ -134,9 +176,13 @@ func TestTUIModelFlattensExecutionsUnderTheirRoot(t *testing.T) { rows := m.taskRows() assert.Equal(t, []string{"root", "child", "grandchild", "second-child", "other-root"}, rowNames(rows)) - assert.Equal(t, []int{0, 1, 1, 1, 0}, rowDepths(rows)) - assert.Equal(t, []string{"", "├─ ", "├─ ", "└─ ", ""}, rowPrefixes(rows)) - assert.Contains(t, m.taskList(30, 10), "▌") + list := ansi.Strip(m.taskList(30, 10)) + lines := strings.Split(list, "\n") + require.GreaterOrEqual(t, len(lines), 3) + assert.True(t, strings.HasPrefix(lines[1], "● root"), lines[1]) + assert.True(t, strings.HasPrefix(lines[2], "▌● child"), lines[2]) + assert.NotContains(t, list, "├") + assert.NotContains(t, list, "└") } func TestTUIModelShowsPendingTasksAndDoesNotSelectRoot(t *testing.T) { @@ -319,22 +365,6 @@ func rowNames(rows []tuiTaskRow) []string { return names } -func rowDepths(rows []tuiTaskRow) []int { - depths := make([]int, len(rows)) - for i, row := range rows { - depths[i] = row.depth - } - return depths -} - -func rowPrefixes(rows []tuiTaskRow) []string { - prefixes := make([]string, len(rows)) - for i, row := range rows { - prefixes[i] = row.treePrefix - } - return prefixes -} - func numberedLines(count int) string { var output string for i := range count { diff --git a/tui_output_test.go b/tui_output_test.go index e88c7fd15f..882cebf4b9 100644 --- a/tui_output_test.go +++ b/tui_output_test.go @@ -2,6 +2,7 @@ package task_test import ( "bytes" + "context" "io" "os" "path/filepath" @@ -86,13 +87,53 @@ tasks: } } +func TestTaskLifecycleReportsFailfastCancellation(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + default: + failfast: true + deps: [fail, slow] + fail: + cmds: + - sleep 0.1 + - exit 1 + slow: + cmds: + - sleep 5 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Output = recorder + + require.Error(t, e.Run(t.Context(), &task.Call{Task: "default"})) + byName := make(map[string]output.TaskInvocation) + for _, invocation := range recorder.started { + byName[invocation.Name] = invocation + } + require.Contains(t, byName, "slow") + assert.ErrorIs(t, recorder.finishErrors[byName["slow"].ID], context.Canceled) +} + type lifecycleRecorder struct { - mutex sync.Mutex - scheduled []output.TaskInvocation - started []output.TaskInvocation - finished []uint64 - outputs map[uint64]*bytes.Buffer - joined map[uint64]uint64 + mutex sync.Mutex + scheduled []output.TaskInvocation + started []output.TaskInvocation + finished []uint64 + outputs map[uint64]*bytes.Buffer + joined map[uint64]uint64 + finishErrors map[uint64]error } func (*lifecycleRecorder) WrapWriter(_ io.Writer, _ io.Writer, _ string, _ *templater.Cache) (io.Writer, io.Writer, output.CloseFunc) { @@ -125,10 +166,14 @@ func (r *lifecycleRecorder) TaskScheduled(task output.TaskInvocation) { r.scheduled = append(r.scheduled, task) } -func (r *lifecycleRecorder) TaskFinished(id uint64, _ error) { +func (r *lifecycleRecorder) TaskFinished(id uint64, err error) { r.mutex.Lock() defer r.mutex.Unlock() r.finished = append(r.finished, id) + if r.finishErrors == nil { + r.finishErrors = make(map[uint64]error) + } + r.finishErrors[id] = err } func (r *lifecycleRecorder) TaskJoined(id, ownerID uint64) { diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index c847213b3e..c59a96cff6 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2805,7 +2805,9 @@ task is shown as a non-selectable heading on the left. Tasks reached from it appear beneath it in a one-level list and remain visible while pending, running, or finished. Repeated executions have separate rows and output views; calls that join an existing `run: once` or `run: when_changed` execution share -its row. The output of the selected task is shown on the right. Use Tab or the +its row. Each row shows its status as both an icon and a word, including a +distinct `canceled` state for work interrupted by fail-fast cancellation. The +output of the selected task is shown on the right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a task. In the output pane, those keys scroll; Page Up and Page Down also scroll the output directly. You can diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index 938c3b9ae9..ec079a01c5 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2807,7 +2807,9 @@ task is shown as a non-selectable heading on the left. Tasks reached from it appear beneath it in a one-level list and remain visible while pending, running, or finished. Repeated executions have separate rows and output views; calls that join an existing `run: once` or `run: when_changed` execution share -its row. The output of the selected task is shown on the right. Use Tab or the +its row. Each row shows its status as both an icon and a word, including a +distinct `canceled` state for work interrupted by fail-fast cancellation. The +output of the selected task is shown on the right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a task. In the output pane, those keys scroll; Page Up and Page Down also scroll the output directly. You can From 7963bc86ca59ad4ab79fc2b10520c5c43db677fb Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 13:42:31 +0200 Subject: [PATCH 05/46] feat: refine TUI task status presentation --- internal/output/tui.go | 52 ++++++++++++++++----- internal/output/tui_test.go | 36 +++++++++++--- taskfile/ast/output.go | 3 +- taskfile/ast/output_test.go | 3 +- website/src/latest/docs/guide.md | 8 ++-- website/src/latest/docs/reference/schema.md | 1 + website/src/next/docs/guide.md | 8 ++-- website/src/next/docs/reference/schema.md | 1 + website/src/public/next-schema.json | 6 +++ website/src/public/schema.json | 6 +++ 10 files changed, 97 insertions(+), 27 deletions(-) diff --git a/internal/output/tui.go b/internal/output/tui.go index b0299da7cb..491d441455 100644 --- a/internal/output/tui.go +++ b/internal/output/tui.go @@ -29,6 +29,7 @@ type TUI struct { input io.Reader output io.Writer hideInternal bool + statusLabels bool mutex sync.RWMutex program *tea.Program @@ -47,11 +48,20 @@ func NewTUI(log *logger.Logger, options ast.OutputTUI) (*TUI, error) { if !log.AssumeTerm && !term.IsTerminal() { return nil, fmt.Errorf(`task: output style "tui" requires an interactive terminal`) } + statusLabels := false + switch options.Status { + case "", "icons": + case "labels": + statusLabels = true + default: + return nil, fmt.Errorf(`task: invalid TUI status style %q: expected "icons" or "labels"`, options.Status) + } return &TUI{ logger: log, input: log.Stdin, output: log.Stdout, hideInternal: options.HideInternal, + statusLabels: statusLabels, pending: make(map[uint64]pendingOutput), }, nil } @@ -89,6 +99,7 @@ func (t *TUI) Run(ctx context.Context, run func(context.Context) error) error { defer cancel() model := newTUIModel(cancel, t.hideInternal) + model.statusLabels = t.statusLabels program := tea.NewProgram( model, tea.WithInput(t.input), @@ -242,6 +253,7 @@ type tuiModel struct { err error cancel context.CancelFunc hideInternal bool + statusLabels bool selectingText bool selectionView string @@ -663,7 +675,8 @@ func (m tuiModel) taskName(task *tuiTask) string { } type tuiTaskRow struct { - task *tuiTask + task *tuiTask + treePrefix string } func (m tuiModel) taskRows() []tuiTaskRow { @@ -687,8 +700,13 @@ func (m tuiModel) taskRows() []tuiTaskRow { rows := make([]tuiTaskRow, 0, len(m.tasks)) for _, root := range roots { rows = append(rows, tuiTaskRow{task: root}) - for _, child := range childrenByRoot[root.id] { - rows = append(rows, tuiTaskRow{task: child}) + children := childrenByRoot[root.id] + for i, child := range children { + prefix := "├─ " + if i == len(children)-1 { + prefix = "└─ " + } + rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix}) } } for _, task := range standalone { @@ -882,8 +900,11 @@ func (m tuiModel) taskList(width, height int) string { for i := m.listTop; i < end; i++ { row := rows[i] if row.task.isRoot { - prefix := taskIcon(row.task.state) + " " - name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(prefix)) + prefix := "" + if !m.statusLabels { + prefix = taskIcon(row.task.state) + " " + } + name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(prefix), m.statusLabels) suffix := "" if status != "" { suffix = " " + taskStateLabel(row.task.state, status) @@ -892,12 +913,12 @@ func (m tuiModel) taskList(width, height int) string { continue } selected := row.task.id == m.selectedID - marker := " " - if selected { - marker = "▌" + plainIcon := "" + if !m.statusLabels { + plainIcon = taskIconText(row.task.state) + " " } - plainPrefix := marker + taskIconText(row.task.state) + " " - name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(plainPrefix)) + plainPrefix := row.treePrefix + plainIcon + name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(plainPrefix), m.statusLabels) if selected { suffix := "" if status != "" { @@ -910,7 +931,11 @@ func (m tuiModel) taskList(width, height int) string { if status != "" { suffix = " " + taskStateLabel(row.task.state, status) } - line := tuiTreeStyle.Render(marker) + taskIcon(row.task.state) + " " + name + suffix + icon := "" + if !m.statusLabels { + icon = taskIcon(row.task.state) + " " + } + line := tuiTreeStyle.Render(row.treePrefix) + icon + name + suffix lines = append(lines, line) } return strings.Join(lines, "\n") @@ -957,7 +982,10 @@ func taskStateLabel(state taskState, label string) string { return taskStateStyle(state).Render(label) } -func taskNameStatus(name string, state taskState, width int) (string, string) { +func taskNameStatus(name string, state taskState, width int, showStatus bool) (string, string) { + if !showStatus { + return truncateMiddleRunes(name, max(width, 1)), "" + } if width <= 1 { return truncateMiddleRunes(name, max(width, 1)), "" } diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index bd1a40e38e..81d2408a52 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -24,9 +24,14 @@ func TestBuildTUI(t *testing.T) { require.NoError(t, err) assert.IsType(t, &TUI{}, got) - got, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{HideInternal: true}}, &logger.Logger{AssumeTerm: true}) + got, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{HideInternal: true, Status: "labels"}}, &logger.Logger{AssumeTerm: true}) require.NoError(t, err) assert.True(t, got.(*TUI).hideInternal) + assert.True(t, got.(*TUI).statusLabels) + + _, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{Status: "unknown"}}, &logger.Logger{AssumeTerm: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), `expected "icons" or "labels"`) } func TestTUIModelTracksTasksAndOutput(t *testing.T) { @@ -61,6 +66,7 @@ func TestTUIModelDistinguishesCanceledTasksAndShowsStatusWords(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) + m.statusLabels = true m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, scheduled(2, 1, "pending-task", false)) m = updateTUIModel(t, m, started(3, 1, "running-task")) @@ -77,12 +83,28 @@ func TestTUIModelDistinguishesCanceledTasksAndShowsStatusWords(t *testing.T) { for _, status := range []string{"pending", "running", "success", "failed", "canceled"} { assert.Contains(t, list, status) } - name, status := taskNameStatus("build", taskRunning, 20) + name, status := taskNameStatus("build", taskRunning, 20, true) assert.Equal(t, "build running", name+" "+status) - name, status = taskNameStatus("fail-fast-success-1s", taskSucceeded, 13) + name, status = taskNameStatus("fail-fast-success-1s", taskSucceeded, 13, true) assert.Equal(t, "fa…1s success", name+" "+status) } +func TestTUIStatusLabelsAreOptionalAndDisabledByDefault(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + + icons := ansi.Strip(m.taskList(30, 10)) + assert.Contains(t, icons, "└─ ● worker") + assert.NotContains(t, icons, "running") + m.statusLabels = true + labels := ansi.Strip(m.taskList(30, 10)) + assert.Contains(t, labels, "└─ worker running") + assert.NotContains(t, labels, "●") +} + func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { t.Parallel() @@ -178,11 +200,11 @@ func TestTUIModelFlattensExecutionsUnderTheirRoot(t *testing.T) { assert.Equal(t, []string{"root", "child", "grandchild", "second-child", "other-root"}, rowNames(rows)) list := ansi.Strip(m.taskList(30, 10)) lines := strings.Split(list, "\n") - require.GreaterOrEqual(t, len(lines), 3) + require.GreaterOrEqual(t, len(lines), 5) assert.True(t, strings.HasPrefix(lines[1], "● root"), lines[1]) - assert.True(t, strings.HasPrefix(lines[2], "▌● child"), lines[2]) - assert.NotContains(t, list, "├") - assert.NotContains(t, list, "└") + assert.True(t, strings.HasPrefix(lines[2], "├─ ● child"), lines[2]) + assert.True(t, strings.HasPrefix(lines[3], "├─ ● grandchild"), lines[3]) + assert.True(t, strings.HasPrefix(lines[4], "└─ ● second-child"), lines[4]) } func TestTUIModelShowsPendingTasksAndDoesNotSelectRoot(t *testing.T) { diff --git a/taskfile/ast/output.go b/taskfile/ast/output.go index a351ed1769..e50cd2583b 100644 --- a/taskfile/ast/output.go +++ b/taskfile/ast/output.go @@ -71,7 +71,8 @@ type OutputGroup struct { // OutputTUI contains options specific to the TUI output style. type OutputTUI struct { - HideInternal bool `yaml:"hide_internal"` + HideInternal bool `yaml:"hide_internal"` + Status string `yaml:"status"` } // IsSet returns true if and only if a custom output style is set. diff --git a/taskfile/ast/output_test.go b/taskfile/ast/output_test.go index 98e52c6949..ef5fa177ce 100644 --- a/taskfile/ast/output_test.go +++ b/taskfile/ast/output_test.go @@ -12,9 +12,10 @@ func TestOutputTUIUnmarshalYAML(t *testing.T) { t.Parallel() var output Output - require.NoError(t, yaml.Unmarshal([]byte("tui:\n hide_internal: true\n"), &output)) + require.NoError(t, yaml.Unmarshal([]byte("tui:\n hide_internal: true\n status: labels\n"), &output)) assert.Equal(t, "tui", output.Name) assert.True(t, output.TUI.HideInternal) + assert.Equal(t, "labels", output.TUI.Status) } func TestOutputMappingRejectsMultipleStyles(t *testing.T) { diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index c59a96cff6..913e7df90a 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2802,11 +2802,12 @@ $ task default The `tui` output opens an interactive, full-screen view. The requested root task is shown as a non-selectable heading on the left. Tasks reached from it -appear beneath it in a one-level list and remain visible while pending, +appear beneath it in a one-level tree and remain visible while pending, running, or finished. Repeated executions have separate rows and output views; calls that join an existing `run: once` or `run: when_changed` execution share -its row. Each row shows its status as both an icon and a word, including a -distinct `canceled` state for work interrupted by fail-fast cancellation. The +its row. Each row shows a status icon by default, including a distinct canceled +state for work interrupted by fail-fast cancellation. Text labels can be used +instead of icons with the `status` option. The output of the selected task is shown on the right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a task. In the output pane, those @@ -2829,6 +2830,7 @@ is configured in the Taskfile: output: tui: hide_internal: true + status: labels ``` This mode requires an interactive terminal. It is intended for local use; use diff --git a/website/src/latest/docs/reference/schema.md b/website/src/latest/docs/reference/schema.md index 359604fcb7..912cbc5a87 100644 --- a/website/src/latest/docs/reference/schema.md +++ b/website/src/latest/docs/reference/schema.md @@ -47,6 +47,7 @@ output: output: tui: hide_internal: false + status: icons # icons or labels ``` ### `method` diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index ec079a01c5..976389389b 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2804,11 +2804,12 @@ $ task default The `tui` output opens an interactive, full-screen view. The requested root task is shown as a non-selectable heading on the left. Tasks reached from it -appear beneath it in a one-level list and remain visible while pending, +appear beneath it in a one-level tree and remain visible while pending, running, or finished. Repeated executions have separate rows and output views; calls that join an existing `run: once` or `run: when_changed` execution share -its row. Each row shows its status as both an icon and a word, including a -distinct `canceled` state for work interrupted by fail-fast cancellation. The +its row. Each row shows a status icon by default, including a distinct canceled +state for work interrupted by fail-fast cancellation. Text labels can be used +instead of icons with the `status` option. The output of the selected task is shown on the right. Use Tab or the left/right arrow keys to focus a pane. In the task pane, use the up/down arrows or `j`/`k` to select a task. In the output pane, those @@ -2831,6 +2832,7 @@ is configured in the Taskfile: output: tui: hide_internal: true + status: labels ``` This mode requires an interactive terminal. It is intended for local use; use diff --git a/website/src/next/docs/reference/schema.md b/website/src/next/docs/reference/schema.md index e659672b92..012bc941c0 100644 --- a/website/src/next/docs/reference/schema.md +++ b/website/src/next/docs/reference/schema.md @@ -47,6 +47,7 @@ output: output: tui: hide_internal: false + status: icons # icons or labels ``` ### `method` diff --git a/website/src/public/next-schema.json b/website/src/public/next-schema.json index bbd1c1aa83..8b913aff8d 100644 --- a/website/src/public/next-schema.json +++ b/website/src/public/next-schema.json @@ -719,6 +719,12 @@ "description": "Hides internal tasks from the TUI task list", "type": "boolean", "default": false + }, + "status": { + "description": "Chooses whether task statuses use icons or text labels", + "type": "string", + "enum": ["icons", "labels"], + "default": "icons" } }, "additionalProperties": false diff --git a/website/src/public/schema.json b/website/src/public/schema.json index bbd1c1aa83..8b913aff8d 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -719,6 +719,12 @@ "description": "Hides internal tasks from the TUI task list", "type": "boolean", "default": false + }, + "status": { + "description": "Chooses whether task statuses use icons or text labels", + "type": "string", + "enum": ["icons", "labels"], + "default": "icons" } }, "additionalProperties": false From f36937bb0a58a3aa1c03e00bdd96c33389a92a4c Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 14:01:06 +0200 Subject: [PATCH 06/46] refactor: organize TUI implementation Split the runtime, Bubble Tea model, task state, and rendering code into cohesive files. Fix terminal-cell truncation and renumber repeated calls after execution joins. --- go.mod | 2 +- internal/output/tui.go | 896 ----------------------------------- internal/output/tui_model.go | 265 +++++++++++ internal/output/tui_tasks.go | 335 +++++++++++++ internal/output/tui_test.go | 29 +- internal/output/tui_view.go | 330 +++++++++++++ 6 files changed, 959 insertions(+), 898 deletions(-) create mode 100644 internal/output/tui_model.go create mode 100644 internal/output/tui_tasks.go create mode 100644 internal/output/tui_view.go diff --git a/go.mod b/go.mod index c6fd21f5bb..82996a832b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/chainguard-dev/git-urls v1.0.2 + github.com/charmbracelet/x/ansi v0.11.8 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dominikbraun/graph v0.23.0 github.com/elliotchance/orderedmap/v3 v3.1.1 @@ -69,7 +70,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/internal/output/tui.go b/internal/output/tui.go index 491d441455..e1b7858dfc 100644 --- a/internal/output/tui.go +++ b/internal/output/tui.go @@ -2,16 +2,11 @@ package output import ( "context" - "errors" "fmt" "io" - "strings" "sync" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/compat" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/templater" @@ -187,894 +182,3 @@ func (w *tuiWriter) Write(p []byte) (int, error) { w.tui.enqueueOutput(w.id, w.name, data) return len(p), nil } - -type taskState uint8 - -const ( - taskLog taskState = iota - taskRunning - taskSucceeded - taskFailed - taskCanceled -) - -type paneFocus uint8 - -const ( - taskPane paneFocus = iota - outputPane -) - -type tuiTask struct { - id uint64 - rootID uint64 - name string - occurrence int - internal bool - isRoot bool - hidden bool - output string - state taskState - truncated bool - - scrollOffset int - followOutput bool -} - -type taskScheduledMsg struct{ task TaskInvocation } -type taskStartedMsg struct{ task TaskInvocation } -type taskFinishedMsg struct { - id uint64 - err error -} -type taskJoinedMsg struct { - id uint64 - ownerID uint64 -} -type taskOutputMsg struct { - id uint64 - name, data string -} -type outputReadyMsg struct{ tui *TUI } -type executionDoneMsg struct{ err error } - -type tuiModel struct { - tasks []*tuiTask - byID map[uint64]*tuiTask - nameCounts map[tuiTaskKey]int - selectedID uint64 - hasSelect bool - listTop int - focus paneFocus - width int - height int - viewport viewport.Model - done bool - err error - cancel context.CancelFunc - hideInternal bool - statusLabels bool - - selectingText bool - selectionView string - selectionPage viewport.Model -} - -type tuiTaskKey struct { - rootID uint64 - name string - isRoot bool -} - -func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { - view := viewport.New() - view.SoftWrap = true - view.MouseWheelDelta = 3 - return tuiModel{ - byID: make(map[uint64]*tuiTask), - nameCounts: make(map[tuiTaskKey]int), - width: 100, - height: 30, - viewport: view, - cancel: cancel, - hideInternal: hideInternal, - } -} - -func (m tuiModel) Init() tea.Cmd { return nil } - -func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - if m.selectingText { - m.leaveTextSelection() - } - m.saveViewport() - m.width, m.height = msg.Width, msg.Height - m.resizeViewport() - m.loadViewport() - m.keepSelectionVisible() - return m, nil - case taskScheduledMsg: - m.scheduleTask(msg.task) - m.keepSelectionVisible() - return m, nil - case taskStartedMsg: - task := m.scheduleTask(msg.task) - task.state = taskRunning - m.keepSelectionVisible() - return m, nil - case taskFinishedMsg: - task := m.byID[msg.id] - if task == nil { - return m, nil - } - if errors.Is(msg.err, context.Canceled) { - task.state = taskCanceled - } else if msg.err != nil { - task.state = taskFailed - } else { - task.state = taskSucceeded - } - return m, nil - case taskJoinedMsg: - m.joinTask(msg.id, msg.ownerID) - return m, nil - case taskOutputMsg: - m.appendOutput(msg.id, msg.name, msg.data) - return m, nil - case outputReadyMsg: - for id, pending := range msg.tui.drainOutput() { - m.appendOutput(id, pending.name, pending.data) - } - return m, nil - case executionDoneMsg: - m.done, m.err = true, msg.err - return m, nil - case tea.InterruptMsg: - m.cancel() - return m, tea.Quit - case tea.MouseClickMsg: - if m.selectingText { - return m, nil - } - m.handleMouseClick(tea.Mouse(msg)) - return m, nil - case tea.MouseWheelMsg: - if m.selectingText { - return m, nil - } - return m, m.handleMouseWheel(msg) - case tea.KeyPressMsg: - return m.handleKey(msg) - } - - return m, nil -} - -func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if m.selectingText { - switch msg.String() { - case "c", "esc": - m.leaveTextSelection() - return *m, nil - case "q", "ctrl+c": - if !m.done { - m.cancel() - } - return *m, tea.Quit - case "up", "k": - m.selectionPage.ScrollUp(1) - case "down", "j": - m.selectionPage.ScrollDown(1) - case "pgup": - m.selectionPage.PageUp() - case "pgdown": - m.selectionPage.PageDown() - case "home", "g": - m.selectionPage.GotoTop() - case "end", "G": - m.selectionPage.GotoBottom() - default: - return *m, nil - } - m.refreshSelectionView() - return *m, nil - } - switch msg.String() { - case "q", "esc", "ctrl+c": - if !m.done { - m.cancel() - } - return *m, tea.Quit - case "tab", "shift+tab": - m.toggleFocus() - return *m, nil - case "left", "h": - m.focus = taskPane - return *m, nil - case "right", "l": - m.focus = outputPane - return *m, nil - case "c": - m.enterTextSelection() - return *m, nil - case "pgup", "pgdown": - m.focus = outputPane - return *m, m.updateViewport(msg) - case "up", "k": - if m.focus == taskPane { - m.moveSelection(-1) - return *m, nil - } - return *m, m.updateViewport(msg) - case "down", "j": - if m.focus == taskPane { - m.moveSelection(1) - return *m, nil - } - return *m, m.updateViewport(msg) - case "home", "g": - if m.focus == taskPane { - m.selectBoundary(false) - } else { - m.viewport.GotoTop() - m.saveViewport() - } - return *m, nil - case "end", "G": - if m.focus == taskPane { - m.selectBoundary(true) - } else { - m.viewport.GotoBottom() - m.saveViewport() - } - return *m, nil - case "enter": - if m.done { - return *m, tea.Quit - } - } - return *m, nil -} - -func (m tuiModel) View() tea.View { - content := m.renderContent() - if m.selectingText && m.selectionView != "" { - content = m.selectionView - } - view := tea.NewView(content) - view.AltScreen = true - if m.selectingText { - view.MouseMode = tea.MouseModeNone - } else { - view.MouseMode = tea.MouseModeCellMotion - } - view.WindowTitle = "Task" - return view -} - -func (m tuiModel) renderContent() string { - layout := newTUILayout(m.width, m.height) - left, right := m.renderPanes(layout) - body := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", layout.gap), right) - - help := " tab/←/→ pane • ↑/↓ select • click a task • c select text • q quit" - if m.focus == outputPane { - help = " tab/←/→ pane • ↑/↓ or pgup/pgdn scroll • wheel • c select text • q quit" - } - helpStyle := tuiHelpStyle - if m.done { - if m.err != nil { - help = " execution failed • c select text • enter/q quit" - helpStyle = tuiFailureStyle - } else { - help = " execution complete • c select text • enter/q quit" - helpStyle = tuiSuccessStyle - } - } - help = truncateRunes(help, max(layout.width, 1)) - - return body + "\n" + helpStyle.Render(help) -} - -func (m *tuiModel) enterTextSelection() { - m.selectingText = true - view := viewport.New( - viewport.WithWidth(max(m.width, 1)), - viewport.WithHeight(max(m.height-1, 1)), - ) - view.SoftWrap = true - if task := m.selectedTask(); task != nil { - content := task.output - if task.truncated { - content = "… earlier output was discarded …\n" + content - } - view.SetContent(content) - if m.viewport.AtBottom() { - view.GotoBottom() - } else if !m.viewport.AtTop() { - position := m.viewport.ScrollPercent() - view.GotoBottom() - view.SetYOffset(int(position * float64(view.YOffset()))) - } - } - m.selectionPage = view - m.refreshSelectionView() -} - -func (m *tuiModel) leaveTextSelection() { - if m.selectionPage.AtTop() { - m.viewport.GotoTop() - } else if m.selectionPage.AtBottom() { - m.viewport.GotoBottom() - } else { - position := m.selectionPage.ScrollPercent() - m.viewport.GotoBottom() - m.viewport.SetYOffset(int(position * float64(m.viewport.YOffset()))) - } - m.saveViewport() - m.selectingText = false - m.selectionView = "" - m.selectionPage = viewport.Model{} -} - -func (m *tuiModel) refreshSelectionView() { - help := truncateRunes(" text selection • ↑/↓ or pgup/pgdn scroll • drag to select • c/esc resume", max(m.width, 1)) - m.selectionView = m.selectionPage.View() + "\n" + tuiHelpStyle.Render(help) -} - -func (m tuiModel) renderPanes(layout tuiLayout) (string, string) { - leftStyle, rightStyle := tuiPanelStyle, tuiPanelStyle - if m.focus == taskPane { - leftStyle = leftStyle.BorderForeground(tuiAccentColor) - } else { - rightStyle = rightStyle.BorderForeground(tuiAccentColor) - } - left := leftStyle.Width(layout.leftOuterWidth).Height(layout.bodyHeight). - Render(m.taskList(layout.leftInnerWidth, layout.innerHeight)) - right := rightStyle.Width(layout.rightOuterWidth).Height(layout.bodyHeight). - Render(m.outputPanel(layout.rightInnerWidth)) - return left, right -} - -type tuiLayout struct { - width int - bodyHeight int - gap int - leftOuterWidth int - rightOuterWidth int - leftInnerWidth int - rightInnerWidth int - innerHeight int -} - -func newTUILayout(width, height int) tuiLayout { - width, height = max(width, 1), max(height, 1) - bodyHeight := max(height-1, 3) - horizontalFrame := tuiPanelStyle.GetHorizontalFrameSize() - verticalFrame := tuiPanelStyle.GetVerticalFrameSize() - gap := 0 - leftOuterWidth := min(max(width*35/100, 22), 72) - if right := width - gap - leftOuterWidth; right < 16 { - leftOuterWidth = max(width-gap-16, 8) - } - rightOuterWidth := max(width-gap-leftOuterWidth, 8) - return tuiLayout{ - width: width, - bodyHeight: bodyHeight, - gap: gap, - leftOuterWidth: leftOuterWidth, - rightOuterWidth: rightOuterWidth, - leftInnerWidth: max(leftOuterWidth-horizontalFrame, 1), - rightInnerWidth: max(rightOuterWidth-horizontalFrame, 1), - innerHeight: max(bodyHeight-verticalFrame, 1), - } -} - -func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { - if task := m.byID[invocation.ID]; task != nil { - return task - } - isRoot := invocation.ID == invocation.RootID - key := tuiTaskKey{rootID: invocation.RootID, name: invocation.Name, isRoot: isRoot} - m.nameCounts[key]++ - task := &tuiTask{ - id: invocation.ID, - rootID: invocation.RootID, - name: invocation.Name, - occurrence: m.nameCounts[key], - internal: invocation.Internal, - isRoot: isRoot, - hidden: m.hideInternal && invocation.Internal && !isRoot, - state: taskLog, - followOutput: true, - } - m.byID[invocation.ID] = task - m.tasks = append(m.tasks, task) - if m.hasSelect && m.selectedID == task.id { - m.loadViewport() - } - if !task.isRoot && !task.hidden && !m.hasSelect { - m.selectedID = task.id - m.hasSelect = true - m.loadViewport() - } - return task -} - -func (m *tuiModel) joinTask(id, ownerID uint64) { - task := m.byID[id] - if task == nil { - return - } - selected := m.hasSelect && m.selectedID == id - if selected { - m.saveViewport() - } - delete(m.byID, id) - for i, candidate := range m.tasks { - if candidate.id == id { - m.tasks = append(m.tasks[:i], m.tasks[i+1:]...) - break - } - } - key := tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot} - if m.nameCounts[key] > 1 { - m.nameCounts[key]-- - } else { - delete(m.nameCounts, key) - } - if selected { - m.selectedID = ownerID - m.hasSelect = ownerID != 0 - m.loadViewport() - } - m.keepSelectionVisible() -} - -func (m *tuiModel) ensureOutputTask(id uint64, name string) *tuiTask { - if task := m.byID[id]; task != nil { - return task - } - if name == "" { - name = fmt.Sprintf("task %d", id) - } - task := &tuiTask{id: id, name: name, state: taskLog, followOutput: true} - m.byID[id] = task - m.tasks = append(m.tasks, task) - if !m.hasSelect { - m.selectedID = task.id - m.hasSelect = true - m.loadViewport() - } - return task -} - -func (m *tuiModel) appendOutput(id uint64, name, data string) { - task := m.ensureOutputTask(id, name) - if task.state == taskLog && id != 0 { - task.state = taskRunning - } - task.output += normalizeOutput(data) - if len(task.output) > maxTaskOutputLen { - task.output = task.output[len(task.output)-maxTaskOutputLen:] - task.truncated = true - } - if m.hasSelect && task.id == m.selectedID { - m.loadViewport() - } -} - -func (m tuiModel) taskName(task *tuiTask) string { - key := tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot} - if m.nameCounts[key] > 1 { - return fmt.Sprintf("#%d %s", task.occurrence, task.name) - } - return task.name -} - -type tuiTaskRow struct { - task *tuiTask - treePrefix string -} - -func (m tuiModel) taskRows() []tuiTaskRow { - childrenByRoot := make(map[uint64][]*tuiTask) - var roots, standalone []*tuiTask - for _, task := range m.tasks { - if task.hidden { - continue - } - if task.isRoot { - roots = append(roots, task) - continue - } - if task.rootID == 0 { - standalone = append(standalone, task) - } else { - childrenByRoot[task.rootID] = append(childrenByRoot[task.rootID], task) - } - } - - rows := make([]tuiTaskRow, 0, len(m.tasks)) - for _, root := range roots { - rows = append(rows, tuiTaskRow{task: root}) - children := childrenByRoot[root.id] - for i, child := range children { - prefix := "├─ " - if i == len(children)-1 { - prefix = "└─ " - } - rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix}) - } - } - for _, task := range standalone { - rows = append(rows, tuiTaskRow{task: task}) - } - return rows -} - -func (m *tuiModel) selectedTask() *tuiTask { - if !m.hasSelect { - return nil - } - return m.byID[m.selectedID] -} - -func (m *tuiModel) selectedIndex() int { - for i, row := range m.taskRows() { - if row.task.id == m.selectedID { - return i - } - } - return -1 -} - -func (m *tuiModel) moveSelection(delta int) { - rows := m.taskRows() - if len(rows) == 0 { - return - } - index := m.selectedIndex() - if index < 0 { - if delta < 0 { - m.selectBoundary(true) - } else { - m.selectBoundary(false) - } - return - } - for index += delta; index >= 0 && index < len(rows); index += delta { - if !rows[index].task.isRoot { - m.selectTask(index) - return - } - } -} - -func (m *tuiModel) selectTask(index int) { - rows := m.taskRows() - if index < 0 || index >= len(rows) || rows[index].task.isRoot { - return - } - m.saveViewport() - m.selectedID = rows[index].task.id - m.hasSelect = true - m.keepSelectionVisible() - m.loadViewport() -} - -func (m *tuiModel) selectBoundary(last bool) { - rows := m.taskRows() - if last { - for i := len(rows) - 1; i >= 0; i-- { - if !rows[i].task.isRoot { - m.selectTask(i) - return - } - } - return - } - for i, row := range rows { - if !row.task.isRoot { - m.selectTask(i) - return - } - } -} - -func (m *tuiModel) keepSelectionVisible() { - index := m.selectedIndex() - if index < 0 { - return - } - visible := max(newTUILayout(m.width, m.height).innerHeight-1, 1) - if index < m.listTop { - m.listTop = index - } else if index >= m.listTop+visible { - m.listTop = index - visible + 1 - } - maxTop := max(len(m.taskRows())-visible, 0) - m.listTop = min(max(m.listTop, 0), maxTop) -} - -func (m *tuiModel) resizeViewport() { - layout := newTUILayout(m.width, m.height) - m.viewport.SetWidth(layout.rightInnerWidth) - m.viewport.SetHeight(max(layout.innerHeight-1, 1)) -} - -func (m *tuiModel) loadViewport() { - task := m.selectedTask() - if task == nil { - m.viewport.SetContent("") - return - } - content := task.output - if task.truncated { - content = tuiHelpStyle.Render("… earlier output was discarded …") + "\n" + content - } - m.viewport.SetContent(content) - if task.followOutput { - m.viewport.GotoBottom() - } else { - m.viewport.SetYOffset(task.scrollOffset) - } -} - -func (m *tuiModel) saveViewport() { - task := m.selectedTask() - if task == nil { - return - } - task.scrollOffset = m.viewport.YOffset() - task.followOutput = m.viewport.AtBottom() -} - -func (m *tuiModel) updateViewport(msg tea.Msg) tea.Cmd { - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - m.saveViewport() - return cmd -} - -func (m *tuiModel) toggleFocus() { - if m.focus == taskPane { - m.focus = outputPane - } else { - m.focus = taskPane - } -} - -func (m *tuiModel) handleMouseClick(mouse tea.Mouse) { - layout := newTUILayout(m.width, m.height) - if mouse.Y < 0 || mouse.Y >= layout.bodyHeight { - return - } - if mouse.X >= 0 && mouse.X < layout.leftOuterWidth { - m.focus = taskPane - // Border is row 0 and the title is row 1, so tasks begin at row 2. - row := mouse.Y - 2 - if row >= 0 { - m.selectTask(m.listTop + row) - } - return - } - if mouse.X >= layout.leftOuterWidth+layout.gap { - m.focus = outputPane - } -} - -func (m *tuiModel) handleMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { - layout := newTUILayout(m.width, m.height) - if msg.Y < 0 || msg.Y >= layout.bodyHeight { - return nil - } - if msg.X < layout.leftOuterWidth { - m.focus = taskPane - switch msg.Button { - case tea.MouseWheelUp: - m.moveSelection(-1) - case tea.MouseWheelDown: - m.moveSelection(1) - } - return nil - } - if msg.X >= layout.leftOuterWidth+layout.gap { - m.focus = outputPane - return m.updateViewport(msg) - } - return nil -} - -func (m tuiModel) taskList(width, height int) string { - lines := []string{paneTitle("TASKS", "", width)} - rows := m.taskRows() - if len(rows) == 0 { - lines = append(lines, tuiHelpStyle.Render("Waiting for tasks…")) - return strings.Join(lines, "\n") - } - - end := min(len(rows), m.listTop+max(height-1, 1)) - for i := m.listTop; i < end; i++ { - row := rows[i] - if row.task.isRoot { - prefix := "" - if !m.statusLabels { - prefix = taskIcon(row.task.state) + " " - } - name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(prefix), m.statusLabels) - suffix := "" - if status != "" { - suffix = " " + taskStateLabel(row.task.state, status) - } - lines = append(lines, prefix+tuiRootStyle.Render(name)+suffix) - continue - } - selected := row.task.id == m.selectedID - plainIcon := "" - if !m.statusLabels { - plainIcon = taskIconText(row.task.state) + " " - } - plainPrefix := row.treePrefix + plainIcon - name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(plainPrefix), m.statusLabels) - if selected { - suffix := "" - if status != "" { - suffix = " " + status - } - lines = append(lines, tuiSelectedStyle.Width(width).Render(plainPrefix+name+suffix)) - continue - } - suffix := "" - if status != "" { - suffix = " " + taskStateLabel(row.task.state, status) - } - icon := "" - if !m.statusLabels { - icon = taskIcon(row.task.state) + " " - } - line := tuiTreeStyle.Render(row.treePrefix) + icon + name + suffix - lines = append(lines, line) - } - return strings.Join(lines, "\n") -} - -func (m tuiModel) outputPanel(width int) string { - title := "OUTPUT" - if task := m.selectedTask(); task != nil { - title += " · " + m.taskName(task) - } - position := "" - if !m.viewport.AtTop() || !m.viewport.AtBottom() { - position = fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100) - } - return paneTitle(title, position, width) + "\n" + m.viewport.View() -} - -func paneTitle(left, right string, width int) string { - left = truncateRunes(left, max(width-lipgloss.Width(right)-1, 1)) - space := max(width-lipgloss.Width(left)-lipgloss.Width(right), 0) - return tuiTitleStyle.Render(left) + strings.Repeat(" ", space) + tuiHelpStyle.Render(right) -} - -func taskStateStyle(state taskState) lipgloss.Style { - switch state { - case taskRunning: - return tuiRunningStyle - case taskSucceeded: - return tuiSuccessStyle - case taskFailed: - return tuiFailureStyle - case taskCanceled: - return tuiCanceledStyle - default: - return tuiHelpStyle - } -} - -func taskIcon(state taskState) string { - return taskStateStyle(state).Render(taskIconText(state)) -} - -func taskStateLabel(state taskState, label string) string { - return taskStateStyle(state).Render(label) -} - -func taskNameStatus(name string, state taskState, width int, showStatus bool) (string, string) { - if !showStatus { - return truncateMiddleRunes(name, max(width, 1)), "" - } - if width <= 1 { - return truncateMiddleRunes(name, max(width, 1)), "" - } - statusWidth := min(lipgloss.Width(taskStateText(state)), max(width-2, 0)) - if statusWidth == 0 { - return truncateMiddleRunes(name, width), "" - } - status := truncateRunes(taskStateText(state), statusWidth) - name = truncateMiddleRunes(name, max(width-lipgloss.Width(status)-1, 1)) - return name, status -} - -func taskIconText(state taskState) string { - switch state { - case taskRunning: - return "●" - case taskSucceeded: - return "✓" - case taskFailed: - return "✗" - case taskCanceled: - return "■" - default: - return "·" - } -} - -func taskStateText(state taskState) string { - switch state { - case taskRunning: - return "running" - case taskSucceeded: - return "success" - case taskFailed: - return "failed" - case taskCanceled: - return "canceled" - default: - return "pending" - } -} - -func normalizeOutput(s string) string { - s = strings.ReplaceAll(s, "\r\n", "\n") - return strings.ReplaceAll(s, "\r", "\n") -} - -func truncateRunes(s string, width int) string { - runes := []rune(s) - if len(runes) <= width { - return s - } - if width <= 1 { - return "…" - } - return string(runes[:width-1]) + "…" -} - -func truncateMiddleRunes(s string, width int) string { - runes := []rune(s) - if len(runes) <= width { - return s - } - if width <= 1 { - return "…" - } - left := (width - 1) / 2 - right := width - 1 - left - return string(runes[:left]) + "…" + string(runes[len(runes)-right:]) -} - -var ( - tuiAccentColor = compat.AdaptiveColor{Light: lipgloss.Color("#006A83"), Dark: lipgloss.Color("#5FD7FF")} - tuiPanelStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("#87909A"), Dark: lipgloss.Color("#59636E")}). - PaddingLeft(1). - PaddingRight(1) - - tuiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccentColor) - tuiRootStyle = lipgloss.NewStyle().Bold(true) - tuiSelectedStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#10212B"), Dark: lipgloss.Color("#F4F7FA")}). - Background(compat.AdaptiveColor{Light: lipgloss.Color("#D9E8ED"), Dark: lipgloss.Color("#34444D")}) - tuiTreeStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#77818A"), Dark: lipgloss.Color("#697580")}) - tuiRunningStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#8A6500"), Dark: lipgloss.Color("#FFD75F")}) - tuiSuccessStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#257A3E"), Dark: lipgloss.Color("#5FD787")}) - tuiFailureStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#B42318"), Dark: lipgloss.Color("#FF6B6B")}) - tuiCanceledStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#5F6670"), Dark: lipgloss.Color("#AAB2BD")}) - tuiHelpStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#66717C"), Dark: lipgloss.Color("#89949F")}) -) diff --git a/internal/output/tui_model.go b/internal/output/tui_model.go new file mode 100644 index 0000000000..82501cf2b2 --- /dev/null +++ b/internal/output/tui_model.go @@ -0,0 +1,265 @@ +package output + +import ( + "context" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + + "github.com/go-task/task/v3/errors" +) + +type taskState uint8 + +const ( + taskPending taskState = iota + taskRunning + taskSucceeded + taskFailed + taskCanceled +) + +type paneFocus uint8 + +const ( + taskPane paneFocus = iota + outputPane +) + +type tuiTask struct { + id uint64 + rootID uint64 + name string + occurrence int + internal bool + isRoot bool + hidden bool + output string + state taskState + truncated bool + + scrollOffset int + followOutput bool +} + +type ( + taskScheduledMsg struct{ task TaskInvocation } + taskStartedMsg struct{ task TaskInvocation } + taskFinishedMsg struct { + id uint64 + err error + } +) + +type taskJoinedMsg struct { + id uint64 + ownerID uint64 +} +type taskOutputMsg struct { + id uint64 + name, data string +} +type ( + outputReadyMsg struct{ tui *TUI } + executionDoneMsg struct{ err error } +) + +type tuiModel struct { + tasks []*tuiTask + byID map[uint64]*tuiTask + nameCounts map[tuiTaskKey]int + selectedID uint64 + hasSelect bool + listTop int + focus paneFocus + width int + height int + viewport viewport.Model + done bool + err error + cancel context.CancelFunc + hideInternal bool + statusLabels bool + + selectingText bool + selectionView string + selectionPage viewport.Model +} + +type tuiTaskKey struct { + rootID uint64 + name string + isRoot bool +} + +func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { + view := viewport.New() + view.SoftWrap = true + view.MouseWheelDelta = 3 + return tuiModel{ + byID: make(map[uint64]*tuiTask), + nameCounts: make(map[tuiTaskKey]int), + width: 100, + height: 30, + viewport: view, + cancel: cancel, + hideInternal: hideInternal, + } +} + +func (m tuiModel) Init() tea.Cmd { return nil } + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if m.selectingText { + m.leaveTextSelection() + } + m.saveViewport() + m.width, m.height = msg.Width, msg.Height + m.resizeViewport() + m.loadViewport() + m.keepSelectionVisible() + return m, nil + case taskScheduledMsg: + m.scheduleTask(msg.task) + m.keepSelectionVisible() + return m, nil + case taskStartedMsg: + task := m.scheduleTask(msg.task) + task.state = taskRunning + m.keepSelectionVisible() + return m, nil + case taskFinishedMsg: + task := m.byID[msg.id] + if task == nil { + return m, nil + } + if errors.Is(msg.err, context.Canceled) { + task.state = taskCanceled + } else if msg.err != nil { + task.state = taskFailed + } else { + task.state = taskSucceeded + } + return m, nil + case taskJoinedMsg: + m.joinTask(msg.id, msg.ownerID) + return m, nil + case taskOutputMsg: + m.appendOutput(msg.id, msg.name, msg.data) + return m, nil + case outputReadyMsg: + for id, pending := range msg.tui.drainOutput() { + m.appendOutput(id, pending.name, pending.data) + } + return m, nil + case executionDoneMsg: + m.done, m.err = true, msg.err + return m, nil + case tea.InterruptMsg: + m.cancel() + return m, tea.Quit + case tea.MouseClickMsg: + if m.selectingText { + return m, nil + } + m.handleMouseClick(tea.Mouse(msg)) + return m, nil + case tea.MouseWheelMsg: + if m.selectingText { + return m, nil + } + return m, m.handleMouseWheel(msg) + case tea.KeyPressMsg: + return m.handleKey(msg) + } + + return m, nil +} + +func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.selectingText { + switch msg.String() { + case "c", "esc": + m.leaveTextSelection() + return *m, nil + case "q", "ctrl+c": + if !m.done { + m.cancel() + } + return *m, tea.Quit + case "up", "k": + m.selectionPage.ScrollUp(1) + case "down", "j": + m.selectionPage.ScrollDown(1) + case "pgup": + m.selectionPage.PageUp() + case "pgdown": + m.selectionPage.PageDown() + case "home", "g": + m.selectionPage.GotoTop() + case "end", "G": + m.selectionPage.GotoBottom() + default: + return *m, nil + } + m.refreshSelectionView() + return *m, nil + } + switch msg.String() { + case "q", "esc", "ctrl+c": + if !m.done { + m.cancel() + } + return *m, tea.Quit + case "tab", "shift+tab": + m.toggleFocus() + return *m, nil + case "left", "h": + m.focus = taskPane + return *m, nil + case "right", "l": + m.focus = outputPane + return *m, nil + case "c": + m.enterTextSelection() + return *m, nil + case "pgup", "pgdown": + m.focus = outputPane + return *m, m.updateViewport(msg) + case "up", "k": + if m.focus == taskPane { + m.moveSelection(-1) + return *m, nil + } + return *m, m.updateViewport(msg) + case "down", "j": + if m.focus == taskPane { + m.moveSelection(1) + return *m, nil + } + return *m, m.updateViewport(msg) + case "home", "g": + if m.focus == taskPane { + m.selectBoundary(false) + } else { + m.viewport.GotoTop() + m.saveViewport() + } + return *m, nil + case "end", "G": + if m.focus == taskPane { + m.selectBoundary(true) + } else { + m.viewport.GotoBottom() + m.saveViewport() + } + return *m, nil + case "enter": + if m.done { + return *m, tea.Quit + } + } + return *m, nil +} diff --git a/internal/output/tui_tasks.go b/internal/output/tui_tasks.go new file mode 100644 index 0000000000..acd33c2dae --- /dev/null +++ b/internal/output/tui_tasks.go @@ -0,0 +1,335 @@ +package output + +import ( + "fmt" + "slices" + + tea "charm.land/bubbletea/v2" +) + +func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { + if task := m.byID[invocation.ID]; task != nil { + return task + } + isRoot := invocation.ID == invocation.RootID + key := tuiTaskKey{rootID: invocation.RootID, name: invocation.Name, isRoot: isRoot} + m.nameCounts[key]++ + task := &tuiTask{ + id: invocation.ID, + rootID: invocation.RootID, + name: invocation.Name, + occurrence: m.nameCounts[key], + internal: invocation.Internal, + isRoot: isRoot, + hidden: m.hideInternal && invocation.Internal && !isRoot, + state: taskPending, + followOutput: true, + } + m.byID[invocation.ID] = task + m.tasks = append(m.tasks, task) + if m.hasSelect && m.selectedID == task.id { + m.loadViewport() + } + if !task.isRoot && !task.hidden && !m.hasSelect { + m.selectedID = task.id + m.hasSelect = true + m.loadViewport() + } + return task +} + +func (m *tuiModel) joinTask(id, ownerID uint64) { + task := m.byID[id] + if task == nil { + return + } + selected := m.hasSelect && m.selectedID == id + if selected { + m.saveViewport() + } + delete(m.byID, id) + for i, candidate := range m.tasks { + if candidate.id == id { + m.tasks = append(m.tasks[:i], m.tasks[i+1:]...) + break + } + } + m.recountTaskNames(tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot}) + if selected { + m.selectedID = ownerID + m.hasSelect = ownerID != 0 + m.loadViewport() + } + m.keepSelectionVisible() +} + +func (m *tuiModel) recountTaskNames(key tuiTaskKey) { + count := 0 + for _, task := range m.tasks { + if (tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot}) != key { + continue + } + count++ + task.occurrence = count + } + if count == 0 { + delete(m.nameCounts, key) + return + } + m.nameCounts[key] = count +} + +func (m *tuiModel) ensureOutputTask(id uint64, name string) *tuiTask { + if task := m.byID[id]; task != nil { + return task + } + if name == "" { + name = fmt.Sprintf("task %d", id) + } + task := &tuiTask{id: id, name: name, state: taskPending, followOutput: true} + m.byID[id] = task + m.tasks = append(m.tasks, task) + if !m.hasSelect { + m.selectedID = task.id + m.hasSelect = true + m.loadViewport() + } + return task +} + +func (m *tuiModel) appendOutput(id uint64, name, data string) { + task := m.ensureOutputTask(id, name) + if task.state == taskPending && id != 0 { + task.state = taskRunning + } + task.output += normalizeOutput(data) + if len(task.output) > maxTaskOutputLen { + task.output = task.output[len(task.output)-maxTaskOutputLen:] + task.truncated = true + } + if m.hasSelect && task.id == m.selectedID { + m.loadViewport() + } +} + +func (m tuiModel) taskName(task *tuiTask) string { + key := tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot} + if m.nameCounts[key] > 1 { + return fmt.Sprintf("#%d %s", task.occurrence, task.name) + } + return task.name +} + +type tuiTaskRow struct { + task *tuiTask + treePrefix string +} + +func (m tuiModel) taskRows() []tuiTaskRow { + childrenByRoot := make(map[uint64][]*tuiTask) + var roots, standalone []*tuiTask + for _, task := range m.tasks { + if task.hidden { + continue + } + if task.isRoot { + roots = append(roots, task) + continue + } + if task.rootID == 0 { + standalone = append(standalone, task) + } else { + childrenByRoot[task.rootID] = append(childrenByRoot[task.rootID], task) + } + } + + rows := make([]tuiTaskRow, 0, len(m.tasks)) + for _, root := range roots { + rows = append(rows, tuiTaskRow{task: root}) + children := childrenByRoot[root.id] + for i, child := range children { + prefix := "├─ " + if i == len(children)-1 { + prefix = "└─ " + } + rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix}) + } + } + for _, task := range standalone { + rows = append(rows, tuiTaskRow{task: task}) + } + return rows +} + +func (m *tuiModel) selectedTask() *tuiTask { + if !m.hasSelect { + return nil + } + return m.byID[m.selectedID] +} + +func (m *tuiModel) selectedIndex() int { + for i, row := range m.taskRows() { + if row.task.id == m.selectedID { + return i + } + } + return -1 +} + +func (m *tuiModel) moveSelection(delta int) { + rows := m.taskRows() + if len(rows) == 0 { + return + } + index := m.selectedIndex() + if index < 0 { + if delta < 0 { + m.selectBoundary(true) + } else { + m.selectBoundary(false) + } + return + } + for index += delta; index >= 0 && index < len(rows); index += delta { + if !rows[index].task.isRoot { + m.selectTask(index) + return + } + } +} + +func (m *tuiModel) selectTask(index int) { + rows := m.taskRows() + if index < 0 || index >= len(rows) || rows[index].task.isRoot { + return + } + m.saveViewport() + m.selectedID = rows[index].task.id + m.hasSelect = true + m.keepSelectionVisible() + m.loadViewport() +} + +func (m *tuiModel) selectBoundary(last bool) { + rows := m.taskRows() + if last { + for i := range slices.Backward(rows) { + if !rows[i].task.isRoot { + m.selectTask(i) + return + } + } + return + } + for i, row := range rows { + if !row.task.isRoot { + m.selectTask(i) + return + } + } +} + +func (m *tuiModel) keepSelectionVisible() { + index := m.selectedIndex() + if index < 0 { + return + } + visible := max(newTUILayout(m.width, m.height).innerHeight-1, 1) + if index < m.listTop { + m.listTop = index + } else if index >= m.listTop+visible { + m.listTop = index - visible + 1 + } + maxTop := max(len(m.taskRows())-visible, 0) + m.listTop = min(max(m.listTop, 0), maxTop) +} + +func (m *tuiModel) resizeViewport() { + layout := newTUILayout(m.width, m.height) + m.viewport.SetWidth(layout.rightInnerWidth) + m.viewport.SetHeight(max(layout.innerHeight-1, 1)) +} + +func (m *tuiModel) loadViewport() { + task := m.selectedTask() + if task == nil { + m.viewport.SetContent("") + return + } + content := task.output + if task.truncated { + content = tuiHelpStyle.Render("… earlier output was discarded …") + "\n" + content + } + m.viewport.SetContent(content) + if task.followOutput { + m.viewport.GotoBottom() + } else { + m.viewport.SetYOffset(task.scrollOffset) + } +} + +func (m *tuiModel) saveViewport() { + task := m.selectedTask() + if task == nil { + return + } + task.scrollOffset = m.viewport.YOffset() + task.followOutput = m.viewport.AtBottom() +} + +func (m *tuiModel) updateViewport(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + m.saveViewport() + return cmd +} + +func (m *tuiModel) toggleFocus() { + if m.focus == taskPane { + m.focus = outputPane + } else { + m.focus = taskPane + } +} + +func (m *tuiModel) handleMouseClick(mouse tea.Mouse) { + layout := newTUILayout(m.width, m.height) + if mouse.Y < 0 || mouse.Y >= layout.bodyHeight { + return + } + if mouse.X >= 0 && mouse.X < layout.leftOuterWidth { + m.focus = taskPane + // Border is row 0 and the title is row 1, so tasks begin at row 2. + row := mouse.Y - 2 + if row >= 0 { + m.selectTask(m.listTop + row) + } + return + } + if mouse.X >= layout.leftOuterWidth+layout.gap { + m.focus = outputPane + } +} + +func (m *tuiModel) handleMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { + layout := newTUILayout(m.width, m.height) + if msg.Y < 0 || msg.Y >= layout.bodyHeight { + return nil + } + if msg.X < layout.leftOuterWidth { + m.focus = taskPane + switch msg.Button { + case tea.MouseWheelUp: + m.moveSelection(-1) + case tea.MouseWheelDown: + m.moveSelection(1) + } + return nil + } + if msg.X >= layout.leftOuterWidth+layout.gap { + m.focus = outputPane + return m.updateViewport(msg) + } + return nil +} diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index 81d2408a52..a776f63644 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -135,6 +135,16 @@ func TestTUILayoutGivesWideTerminalsMoreTaskSpace(t *testing.T) { assert.Equal(t, compact.bodyHeight-tuiPanelStyle.GetVerticalFrameSize(), compact.innerHeight) } +func TestTUITextTruncationUsesTerminalCellWidth(t *testing.T) { + t.Parallel() + + assert.LessOrEqual(t, ansi.StringWidth(truncateText("界界界", 4)), 4) + middle := truncateMiddle("build-界界-target", 10) + assert.LessOrEqual(t, ansi.StringWidth(middle), 10) + assert.Contains(t, middle, "…") + assert.True(t, strings.HasSuffix(middle, "arget"), middle) +} + func TestTUIModelKeepsRepeatedTaskCallsSeparate(t *testing.T) { t.Parallel() @@ -186,6 +196,23 @@ func TestTUIModelHidesCallsThatJoinAnExistingExecution(t *testing.T) { assert.NotContains(t, m.taskList(30, 10), "#2") } +func TestTUIModelRenumbersRemainingRepeatedCallsAfterJoin(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, scheduled(3, 1, "worker", false)) + m = updateTUIModel(t, m, started(4, 1, "worker")) + m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) + + assert.Equal(t, 1, m.byID[2].occurrence) + assert.Equal(t, 2, m.byID[4].occurrence) + assert.Contains(t, m.taskList(30, 10), "#1 worker") + assert.Contains(t, m.taskList(30, 10), "#2 worker") + assert.NotContains(t, m.taskList(30, 10), "#3 worker") +} + func TestTUIModelFlattensExecutionsUnderTheirRoot(t *testing.T) { t.Parallel() @@ -214,7 +241,7 @@ func TestTUIModelShowsPendingTasksAndDoesNotSelectRoot(t *testing.T) { m = updateTUIModel(t, m, scheduled(1, 1, "root", false)) assert.False(t, m.hasSelect) m = updateTUIModel(t, m, scheduled(2, 1, "child", false)) - assert.Equal(t, taskLog, m.byID[2].state) + assert.Equal(t, taskPending, m.byID[2].state) assert.Equal(t, uint64(2), m.selectedID) m.selectTask(0) diff --git a/internal/output/tui_view.go b/internal/output/tui_view.go new file mode 100644 index 0000000000..b45588a886 --- /dev/null +++ b/internal/output/tui_view.go @@ -0,0 +1,330 @@ +package output + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" +) + +func (m tuiModel) View() tea.View { + content := m.renderContent() + if m.selectingText && m.selectionView != "" { + content = m.selectionView + } + view := tea.NewView(content) + view.AltScreen = true + if m.selectingText { + view.MouseMode = tea.MouseModeNone + } else { + view.MouseMode = tea.MouseModeCellMotion + } + view.WindowTitle = "Task" + return view +} + +func (m tuiModel) renderContent() string { + layout := newTUILayout(m.width, m.height) + left, right := m.renderPanes(layout) + body := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", layout.gap), right) + + help := " tab/←/→ pane • ↑/↓ select • click a task • c select text • q quit" + if m.focus == outputPane { + help = " tab/←/→ pane • ↑/↓ or pgup/pgdn scroll • wheel • c select text • q quit" + } + helpStyle := tuiHelpStyle + if m.done { + if m.err != nil { + help = " execution failed • c select text • enter/q quit" + helpStyle = tuiFailureStyle + } else { + help = " execution complete • c select text • enter/q quit" + helpStyle = tuiSuccessStyle + } + } + help = truncateText(help, max(layout.width, 1)) + + return body + "\n" + helpStyle.Render(help) +} + +func (m *tuiModel) enterTextSelection() { + m.selectingText = true + view := viewport.New( + viewport.WithWidth(max(m.width, 1)), + viewport.WithHeight(max(m.height-1, 1)), + ) + view.SoftWrap = true + if task := m.selectedTask(); task != nil { + content := task.output + if task.truncated { + content = "… earlier output was discarded …\n" + content + } + view.SetContent(content) + if m.viewport.AtBottom() { + view.GotoBottom() + } else if !m.viewport.AtTop() { + position := m.viewport.ScrollPercent() + view.GotoBottom() + view.SetYOffset(int(position * float64(view.YOffset()))) + } + } + m.selectionPage = view + m.refreshSelectionView() +} + +func (m *tuiModel) leaveTextSelection() { + if m.selectionPage.AtTop() { + m.viewport.GotoTop() + } else if m.selectionPage.AtBottom() { + m.viewport.GotoBottom() + } else { + position := m.selectionPage.ScrollPercent() + m.viewport.GotoBottom() + m.viewport.SetYOffset(int(position * float64(m.viewport.YOffset()))) + } + m.saveViewport() + m.selectingText = false + m.selectionView = "" + m.selectionPage = viewport.Model{} +} + +func (m *tuiModel) refreshSelectionView() { + help := truncateText(" text selection • ↑/↓ or pgup/pgdn scroll • drag to select • c/esc resume", max(m.width, 1)) + m.selectionView = m.selectionPage.View() + "\n" + tuiHelpStyle.Render(help) +} + +func (m tuiModel) renderPanes(layout tuiLayout) (string, string) { + leftStyle, rightStyle := tuiPanelStyle, tuiPanelStyle + if m.focus == taskPane { + leftStyle = leftStyle.BorderForeground(tuiAccentColor) + } else { + rightStyle = rightStyle.BorderForeground(tuiAccentColor) + } + left := leftStyle.Width(layout.leftOuterWidth).Height(layout.bodyHeight). + Render(m.taskList(layout.leftInnerWidth, layout.innerHeight)) + right := rightStyle.Width(layout.rightOuterWidth).Height(layout.bodyHeight). + Render(m.outputPanel(layout.rightInnerWidth)) + return left, right +} + +type tuiLayout struct { + width int + bodyHeight int + gap int + leftOuterWidth int + rightOuterWidth int + leftInnerWidth int + rightInnerWidth int + innerHeight int +} + +func newTUILayout(width, height int) tuiLayout { + width, height = max(width, 1), max(height, 1) + bodyHeight := max(height-1, 3) + horizontalFrame := tuiPanelStyle.GetHorizontalFrameSize() + verticalFrame := tuiPanelStyle.GetVerticalFrameSize() + gap := 0 + leftOuterWidth := min(max(width*35/100, 22), 72) + if right := width - gap - leftOuterWidth; right < 16 { + leftOuterWidth = max(width-gap-16, 8) + } + rightOuterWidth := max(width-gap-leftOuterWidth, 8) + return tuiLayout{ + width: width, + bodyHeight: bodyHeight, + gap: gap, + leftOuterWidth: leftOuterWidth, + rightOuterWidth: rightOuterWidth, + leftInnerWidth: max(leftOuterWidth-horizontalFrame, 1), + rightInnerWidth: max(rightOuterWidth-horizontalFrame, 1), + innerHeight: max(bodyHeight-verticalFrame, 1), + } +} + +func (m tuiModel) taskList(width, height int) string { + lines := []string{paneTitle("TASKS", "", width)} + rows := m.taskRows() + if len(rows) == 0 { + lines = append(lines, tuiHelpStyle.Render("Waiting for tasks…")) + return strings.Join(lines, "\n") + } + + end := min(len(rows), m.listTop+max(height-1, 1)) + for i := m.listTop; i < end; i++ { + row := rows[i] + if row.task.isRoot { + prefix := "" + if !m.statusLabels { + prefix = taskIcon(row.task.state) + " " + } + name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(prefix), m.statusLabels) + suffix := "" + if status != "" { + suffix = " " + taskStateLabel(row.task.state, status) + } + lines = append(lines, prefix+tuiRootStyle.Render(name)+suffix) + continue + } + selected := row.task.id == m.selectedID + plainIcon := "" + if !m.statusLabels { + plainIcon = taskIconText(row.task.state) + " " + } + plainPrefix := row.treePrefix + plainIcon + name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(plainPrefix), m.statusLabels) + if selected { + suffix := "" + if status != "" { + suffix = " " + status + } + lines = append(lines, tuiSelectedStyle.Width(width).Render(plainPrefix+name+suffix)) + continue + } + suffix := "" + if status != "" { + suffix = " " + taskStateLabel(row.task.state, status) + } + icon := "" + if !m.statusLabels { + icon = taskIcon(row.task.state) + " " + } + line := tuiTreeStyle.Render(row.treePrefix) + icon + name + suffix + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func (m tuiModel) outputPanel(width int) string { + title := "OUTPUT" + if task := m.selectedTask(); task != nil { + title += " · " + m.taskName(task) + } + position := "" + if !m.viewport.AtTop() || !m.viewport.AtBottom() { + position = fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100) + } + return paneTitle(title, position, width) + "\n" + m.viewport.View() +} + +func paneTitle(left, right string, width int) string { + left = truncateText(left, max(width-lipgloss.Width(right)-1, 1)) + space := max(width-lipgloss.Width(left)-lipgloss.Width(right), 0) + return tuiTitleStyle.Render(left) + strings.Repeat(" ", space) + tuiHelpStyle.Render(right) +} + +func taskStateStyle(state taskState) lipgloss.Style { + switch state { + case taskRunning: + return tuiRunningStyle + case taskSucceeded: + return tuiSuccessStyle + case taskFailed: + return tuiFailureStyle + case taskCanceled: + return tuiCanceledStyle + default: + return tuiHelpStyle + } +} + +func taskIcon(state taskState) string { + return taskStateStyle(state).Render(taskIconText(state)) +} + +func taskStateLabel(state taskState, label string) string { + return taskStateStyle(state).Render(label) +} + +func taskNameStatus(name string, state taskState, width int, showStatus bool) (string, string) { + if !showStatus { + return truncateMiddle(name, max(width, 1)), "" + } + if width <= 1 { + return truncateMiddle(name, max(width, 1)), "" + } + statusWidth := min(lipgloss.Width(taskStateText(state)), max(width-2, 0)) + if statusWidth == 0 { + return truncateMiddle(name, width), "" + } + status := truncateText(taskStateText(state), statusWidth) + name = truncateMiddle(name, max(width-lipgloss.Width(status)-1, 1)) + return name, status +} + +func taskIconText(state taskState) string { + switch state { + case taskRunning: + return "●" + case taskSucceeded: + return "✓" + case taskFailed: + return "✗" + case taskCanceled: + return "■" + default: + return "·" + } +} + +func taskStateText(state taskState) string { + switch state { + case taskRunning: + return "running" + case taskSucceeded: + return "success" + case taskFailed: + return "failed" + case taskCanceled: + return "canceled" + default: + return "pending" + } +} + +func normalizeOutput(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") +} + +func truncateText(s string, width int) string { + return ansi.Truncate(s, max(width, 0), "…") +} + +func truncateMiddle(s string, width int) string { + stringWidth := ansi.StringWidth(s) + if stringWidth <= width { + return s + } + if width <= 1 { + return ansi.Truncate("…", max(width, 0), "") + } + left := (width - 1) / 2 + right := width - 1 - left + return ansi.Cut(s, 0, left) + "…" + ansi.Cut(s, stringWidth-right, stringWidth) +} + +var ( + tuiAccentColor = compat.AdaptiveColor{Light: lipgloss.Color("#006A83"), Dark: lipgloss.Color("#5FD7FF")} + tuiPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("#87909A"), Dark: lipgloss.Color("#59636E")}). + PaddingLeft(1). + PaddingRight(1) + + tuiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccentColor) + tuiRootStyle = lipgloss.NewStyle().Bold(true) + tuiSelectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#10212B"), Dark: lipgloss.Color("#F4F7FA")}). + Background(compat.AdaptiveColor{Light: lipgloss.Color("#D9E8ED"), Dark: lipgloss.Color("#34444D")}) + tuiTreeStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#77818A"), Dark: lipgloss.Color("#697580")}) + tuiRunningStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#8A6500"), Dark: lipgloss.Color("#FFD75F")}) + tuiSuccessStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#257A3E"), Dark: lipgloss.Color("#5FD787")}) + tuiFailureStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#B42318"), Dark: lipgloss.Color("#FF6B6B")}) + tuiCanceledStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#5F6670"), Dark: lipgloss.Color("#AAB2BD")}) + tuiHelpStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#66717C"), Dark: lipgloss.Color("#89949F")}) +) From 1d3b5d97d12835d20978f22471564507a1168124 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 14:10:52 +0200 Subject: [PATCH 07/46] feat: render nested task tree in TUI --- call.go | 5 +- internal/output/output.go | 1 + internal/output/tui_model.go | 26 +++--- internal/output/tui_tasks.go | 149 +++++++++++++++++++++---------- internal/output/tui_test.go | 103 ++++++++++++++++----- internal/output/tui_view.go | 26 +++--- task.go | 30 +++++-- tui_output_test.go | 11 +++ website/src/latest/docs/guide.md | 11 +-- website/src/next/docs/guide.md | 11 +-- 10 files changed, 259 insertions(+), 114 deletions(-) diff --git a/call.go b/call.go index 1fda396006..0c077ee2ec 100644 --- a/call.go +++ b/call.go @@ -9,6 +9,7 @@ type Call struct { Silent bool Indirect bool // True if the task was called by another task - invocationID uint64 - rootInvocationID uint64 + invocationID uint64 + parentInvocationID uint64 + rootInvocationID uint64 } diff --git a/internal/output/output.go b/internal/output/output.go index 2f7c37982d..690f19d36a 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -20,6 +20,7 @@ type CloseFunc func(err error) error // an Executor, including repeated calls to the same task. type TaskInvocation struct { ID uint64 // Unique call ID + ParentID uint64 // ID of the task call that scheduled this call; zero for roots RootID uint64 // ID of the root call requested by the user Name string Internal bool diff --git a/internal/output/tui_model.go b/internal/output/tui_model.go index 82501cf2b2..1744e95829 100644 --- a/internal/output/tui_model.go +++ b/internal/output/tui_model.go @@ -27,16 +27,16 @@ const ( ) type tuiTask struct { - id uint64 - rootID uint64 - name string - occurrence int - internal bool - isRoot bool - hidden bool - output string - state taskState - truncated bool + id uint64 + parentID uint64 + name string + isRoot bool + hidden bool + shared bool + ownerID uint64 + output string + state taskState + truncated bool scrollOffset int followOutput bool @@ -87,9 +87,9 @@ type tuiModel struct { } type tuiTaskKey struct { - rootID uint64 - name string - isRoot bool + parentID uint64 + name string + isRoot bool } func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { diff --git a/internal/output/tui_tasks.go b/internal/output/tui_tasks.go index acd33c2dae..11d7c565fd 100644 --- a/internal/output/tui_tasks.go +++ b/internal/output/tui_tasks.go @@ -1,8 +1,10 @@ package output import ( + "cmp" "fmt" "slices" + "strings" tea "charm.land/bubbletea/v2" ) @@ -12,19 +14,23 @@ func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { return task } isRoot := invocation.ID == invocation.RootID - key := tuiTaskKey{rootID: invocation.RootID, name: invocation.Name, isRoot: isRoot} + key := tuiTaskKey{parentID: invocation.ParentID, name: invocation.Name, isRoot: isRoot} m.nameCounts[key]++ task := &tuiTask{ id: invocation.ID, - rootID: invocation.RootID, + parentID: invocation.ParentID, name: invocation.Name, - occurrence: m.nameCounts[key], - internal: invocation.Internal, isRoot: isRoot, hidden: m.hideInternal && invocation.Internal && !isRoot, state: taskPending, followOutput: true, } + for _, candidate := range m.tasks { + if candidate.ownerID == task.id { + task.shared = true + break + } + } m.byID[invocation.ID] = task m.tasks = append(m.tasks, task) if m.hasSelect && m.selectedID == task.id { @@ -43,42 +49,17 @@ func (m *tuiModel) joinTask(id, ownerID uint64) { if task == nil { return } - selected := m.hasSelect && m.selectedID == id - if selected { - m.saveViewport() - } - delete(m.byID, id) - for i, candidate := range m.tasks { - if candidate.id == id { - m.tasks = append(m.tasks[:i], m.tasks[i+1:]...) - break - } + task.shared = true + task.ownerID = ownerID + if owner := m.byID[ownerID]; owner != nil { + owner.shared = true } - m.recountTaskNames(tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot}) - if selected { - m.selectedID = ownerID - m.hasSelect = ownerID != 0 + if m.hasSelect && m.selectedID == id { m.loadViewport() } m.keepSelectionVisible() } -func (m *tuiModel) recountTaskNames(key tuiTaskKey) { - count := 0 - for _, task := range m.tasks { - if (tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot}) != key { - continue - } - count++ - task.occurrence = count - } - if count == 0 { - delete(m.nameCounts, key) - return - } - m.nameCounts[key] = count -} - func (m *tuiModel) ensureOutputTask(id uint64, name string) *tuiTask { if task := m.byID[id]; task != nil { return task @@ -107,26 +88,42 @@ func (m *tuiModel) appendOutput(id uint64, name, data string) { task.output = task.output[len(task.output)-maxTaskOutputLen:] task.truncated = true } - if m.hasSelect && task.id == m.selectedID { + if selected := m.selectedTask(); selected != nil && selected.id == task.id { m.loadViewport() } } func (m tuiModel) taskName(task *tuiTask) string { - key := tuiTaskKey{rootID: task.rootID, name: task.name, isRoot: task.isRoot} + key := tuiTaskKey{parentID: task.parentID, name: task.name, isRoot: task.isRoot} if m.nameCounts[key] > 1 { - return fmt.Sprintf("#%d %s", task.occurrence, task.name) + occurrence := 1 + for _, candidate := range m.tasks { + candidateKey := tuiTaskKey{parentID: candidate.parentID, name: candidate.name, isRoot: candidate.isRoot} + if candidateKey == key && candidate.id < task.id { + occurrence++ + } + } + return fmt.Sprintf("#%d %s", occurrence, task.name) } return task.name } +func (m tuiModel) taskState(task *tuiTask) taskState { + if task.ownerID != 0 { + if owner := m.byID[task.ownerID]; owner != nil { + return owner.state + } + } + return task.state +} + type tuiTaskRow struct { task *tuiTask treePrefix string } func (m tuiModel) taskRows() []tuiTaskRow { - childrenByRoot := make(map[uint64][]*tuiTask) + childrenByParent := make(map[uint64][]*tuiTask) var roots, standalone []*tuiTask for _, task := range m.tasks { if task.hidden { @@ -136,24 +133,23 @@ func (m tuiModel) taskRows() []tuiTaskRow { roots = append(roots, task) continue } - if task.rootID == 0 { + parentID := m.visibleParentID(task) + if parentID == 0 { standalone = append(standalone, task) } else { - childrenByRoot[task.rootID] = append(childrenByRoot[task.rootID], task) + childrenByParent[parentID] = append(childrenByParent[parentID], task) } } + sortTasksByID(roots) + sortTasksByID(standalone) + for _, children := range childrenByParent { + sortTasksByID(children) + } rows := make([]tuiTaskRow, 0, len(m.tasks)) for _, root := range roots { rows = append(rows, tuiTaskRow{task: root}) - children := childrenByRoot[root.id] - for i, child := range children { - prefix := "├─ " - if i == len(children)-1 { - prefix = "└─ " - } - rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix}) - } + rows = appendTaskRows(rows, root.id, nil, childrenByParent) } for _, task := range standalone { rows = append(rows, tuiTaskRow{task: task}) @@ -161,13 +157,68 @@ func (m tuiModel) taskRows() []tuiTaskRow { return rows } -func (m *tuiModel) selectedTask() *tuiTask { +func sortTasksByID(tasks []*tuiTask) { + slices.SortFunc(tasks, func(a, b *tuiTask) int { + return cmp.Compare(a.id, b.id) + }) +} + +func (m tuiModel) visibleParentID(task *tuiTask) uint64 { + parentID := task.parentID + for parentID != 0 { + parent := m.byID[parentID] + if parent == nil { + return 0 + } + if !parent.hidden { + return parentID + } + parentID = parent.parentID + } + return 0 +} + +func appendTaskRows(rows []tuiTaskRow, parentID uint64, ancestorLast []bool, childrenByParent map[uint64][]*tuiTask) []tuiTaskRow { + children := childrenByParent[parentID] + for i, child := range children { + last := i == len(children)-1 + var prefix strings.Builder + prefix.Grow((len(ancestorLast) + 1) * 3) + for _, wasLast := range ancestorLast { + if wasLast { + prefix.WriteString(" ") + } else { + prefix.WriteString("│ ") + } + } + if last { + prefix.WriteString("└─ ") + } else { + prefix.WriteString("├─ ") + } + rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix.String()}) + rows = appendTaskRows(rows, child.id, append(ancestorLast, last), childrenByParent) + } + return rows +} + +func (m *tuiModel) selectedRowTask() *tuiTask { if !m.hasSelect { return nil } return m.byID[m.selectedID] } +func (m *tuiModel) selectedTask() *tuiTask { + task := m.selectedRowTask() + if task != nil && task.ownerID != 0 { + if owner := m.byID[task.ownerID]; owner != nil { + return owner + } + } + return task +} + func (m *tuiModel) selectedIndex() int { for i, row := range m.taskRows() { if row.task.id == m.selectedID { diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index a776f63644..7122dbddc1 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -174,12 +174,14 @@ func TestTUIModelKeepsRepeatedTaskCallsSeparate(t *testing.T) { assert.Contains(t, m.outputPanel(30), "#2 worker") } -func TestTUIModelHidesCallsThatJoinAnExistingExecution(t *testing.T) { +func TestTUIModelSharesJoinedExecutionStatusAndOutput(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "shared output"}) m = updateTUIModel(t, m, scheduled(3, 1, "worker", false)) require.Len(t, m.tasks, 3) assert.Contains(t, m.taskList(30, 10), "#1 worker") @@ -188,39 +190,69 @@ func TestTUIModelHidesCallsThatJoinAnExistingExecution(t *testing.T) { m.selectTask(2) m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) - require.Len(t, m.tasks, 2) - assert.Nil(t, m.byID[3]) - assert.Equal(t, uint64(2), m.selectedID) - assert.Equal(t, []string{"root", "worker"}, rowNames(m.taskRows())) - assert.NotContains(t, m.taskList(30, 10), "#1") - assert.NotContains(t, m.taskList(30, 10), "#2") + require.Len(t, m.tasks, 3) + assert.Equal(t, uint64(2), m.byID[3].ownerID) + assert.True(t, m.byID[2].shared) + assert.True(t, m.byID[3].shared) + assert.Equal(t, []uint64{1, 2, 3}, rowIDs(m.taskRows())) + assert.Equal(t, "#1 worker", m.taskName(m.byID[2])) + assert.Equal(t, "#2 worker", m.taskName(m.byID[3])) + assert.Equal(t, uint64(3), m.selectedID) + assert.Equal(t, uint64(2), m.selectedTask().id) + assert.Equal(t, "shared output", m.selectedTask().output) + assert.Contains(t, m.outputPanel(30), "#2 worker") + assert.Equal(t, taskRunning, m.taskState(m.byID[3])) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: " continued"}) + assert.Contains(t, m.viewport.View(), "shared output continued") + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + assert.Equal(t, taskSucceeded, m.taskState(m.byID[3])) + assert.Equal(t, []string{"root", "worker", "worker"}, rowNames(m.taskRows())) + assert.Equal(t, 2, strings.Count(ansi.Strip(m.taskList(30, 10)), "↳")) } -func TestTUIModelRenumbersRemainingRepeatedCallsAfterJoin(t *testing.T) { +func TestTUIModelShowsSharedExecutionInEachTreeLocation(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) m = updateTUIModel(t, m, started(1, 0, "root")) - m = updateTUIModel(t, m, started(2, 1, "worker")) - m = updateTUIModel(t, m, scheduled(3, 1, "worker", false)) - m = updateTUIModel(t, m, started(4, 1, "worker")) + m = updateTUIModel(t, m, started(2, 1, "parent-a")) + m = updateTUIModel(t, m, started(3, 1, "parent-b")) + m = updateTUIModel(t, m, startedUnder(4, 2, 1, "shared")) + m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared", false)) + m = updateTUIModel(t, m, taskJoinedMsg{id: 5, ownerID: 4}) + + assert.Equal(t, []string{"root", "parent-a", "shared", "parent-b", "shared"}, rowNames(m.taskRows())) + list := ansi.Strip(m.taskList(40, 10)) + assert.Contains(t, list, "│ └─ ● ↳ shared") + assert.Contains(t, list, " └─ ● ↳ shared") + assert.NotContains(t, list, "#1 shared") + assert.NotContains(t, list, "#2 shared") +} + +func TestTUIModelMarksOwnerSharedWhenJoinEventArrivesFirst(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, scheduled(3, 1, "shared", false)) m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) + m = updateTUIModel(t, m, started(2, 1, "shared")) - assert.Equal(t, 1, m.byID[2].occurrence) - assert.Equal(t, 2, m.byID[4].occurrence) - assert.Contains(t, m.taskList(30, 10), "#1 worker") - assert.Contains(t, m.taskList(30, 10), "#2 worker") - assert.NotContains(t, m.taskList(30, 10), "#3 worker") + assert.True(t, m.byID[2].shared) + assert.True(t, m.byID[3].shared) + assert.Equal(t, []uint64{1, 2, 3}, rowIDs(m.taskRows())) + assert.Equal(t, "#1 shared", m.taskName(m.byID[2])) + assert.Equal(t, "#2 shared", m.taskName(m.byID[3])) } -func TestTUIModelFlattensExecutionsUnderTheirRoot(t *testing.T) { +func TestTUIModelNestsExecutionsUnderTheirParent(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(5, 0, "other-root")) m = updateTUIModel(t, m, started(2, 1, "child")) - m = updateTUIModel(t, m, started(3, 1, "grandchild")) + m = updateTUIModel(t, m, startedUnder(3, 2, 1, "grandchild")) m = updateTUIModel(t, m, started(4, 1, "second-child")) rows := m.taskRows() @@ -230,7 +262,7 @@ func TestTUIModelFlattensExecutionsUnderTheirRoot(t *testing.T) { require.GreaterOrEqual(t, len(lines), 5) assert.True(t, strings.HasPrefix(lines[1], "● root"), lines[1]) assert.True(t, strings.HasPrefix(lines[2], "├─ ● child"), lines[2]) - assert.True(t, strings.HasPrefix(lines[3], "├─ ● grandchild"), lines[3]) + assert.True(t, strings.HasPrefix(lines[3], "│ └─ ● grandchild"), lines[3]) assert.True(t, strings.HasPrefix(lines[4], "└─ ● second-child"), lines[4]) } @@ -257,9 +289,10 @@ func TestTUIModelCanHideInternalTasks(t *testing.T) { m = updateTUIModel(t, m, scheduled(1, 1, "root", false)) m = updateTUIModel(t, m, scheduled(2, 1, "visible", false)) m = updateTUIModel(t, m, scheduled(3, 1, "internal", true)) + m = updateTUIModel(t, m, scheduledUnder(4, 3, 1, "visible-descendant", false)) - assert.Equal(t, []string{"root", "visible"}, rowNames(m.taskRows())) - assert.Equal(t, 1, m.nameCounts[tuiTaskKey{rootID: 1, name: "internal"}]) + assert.Equal(t, []string{"root", "visible", "visible-descendant"}, rowNames(m.taskRows())) + assert.Equal(t, 1, m.nameCounts[tuiTaskKey{parentID: 1, name: "internal"}]) } func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { @@ -391,11 +424,27 @@ func started(id, rootID uint64, name string) taskStartedMsg { if rootID == 0 { rootID = id } - return taskStartedMsg{task: TaskInvocation{ID: id, RootID: rootID, Name: name}} + parentID := rootID + if id == rootID { + parentID = 0 + } + return startedUnder(id, parentID, rootID, name) +} + +func startedUnder(id, parentID, rootID uint64, name string) taskStartedMsg { + return taskStartedMsg{task: TaskInvocation{ID: id, ParentID: parentID, RootID: rootID, Name: name}} } func scheduled(id, rootID uint64, name string, internal bool) taskScheduledMsg { - return taskScheduledMsg{task: TaskInvocation{ID: id, RootID: rootID, Name: name, Internal: internal}} + parentID := rootID + if id == rootID { + parentID = 0 + } + return scheduledUnder(id, parentID, rootID, name, internal) +} + +func scheduledUnder(id, parentID, rootID uint64, name string, internal bool) taskScheduledMsg { + return taskScheduledMsg{task: TaskInvocation{ID: id, ParentID: parentID, RootID: rootID, Name: name, Internal: internal}} } func updateTUIModel(t *testing.T, m tuiModel, msg tea.Msg) tuiModel { @@ -414,6 +463,14 @@ func rowNames(rows []tuiTaskRow) []string { return names } +func rowIDs(rows []tuiTaskRow) []uint64 { + ids := make([]uint64, len(rows)) + for i, row := range rows { + ids[i] = row.task.id + } + return ids +} + func numberedLines(count int) string { var output string for i := range count { diff --git a/internal/output/tui_view.go b/internal/output/tui_view.go index b45588a886..8af72ebe0d 100644 --- a/internal/output/tui_view.go +++ b/internal/output/tui_view.go @@ -156,15 +156,21 @@ func (m tuiModel) taskList(width, height int) string { end := min(len(rows), m.listTop+max(height-1, 1)) for i := m.listTop; i < end; i++ { row := rows[i] + state := m.taskState(row.task) + sharedPrefix := "" + if row.task.shared { + sharedPrefix = "↳ " + } if row.task.isRoot { prefix := "" if !m.statusLabels { - prefix = taskIcon(row.task.state) + " " + prefix = taskIcon(state) + " " } - name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(prefix), m.statusLabels) + prefix += sharedPrefix + name, status := taskNameStatus(m.taskName(row.task), state, width-lipgloss.Width(prefix), m.statusLabels) suffix := "" if status != "" { - suffix = " " + taskStateLabel(row.task.state, status) + suffix = " " + taskStateLabel(state, status) } lines = append(lines, prefix+tuiRootStyle.Render(name)+suffix) continue @@ -172,10 +178,10 @@ func (m tuiModel) taskList(width, height int) string { selected := row.task.id == m.selectedID plainIcon := "" if !m.statusLabels { - plainIcon = taskIconText(row.task.state) + " " + plainIcon = taskIconText(state) + " " } - plainPrefix := row.treePrefix + plainIcon - name, status := taskNameStatus(m.taskName(row.task), row.task.state, width-lipgloss.Width(plainPrefix), m.statusLabels) + plainPrefix := row.treePrefix + plainIcon + sharedPrefix + name, status := taskNameStatus(m.taskName(row.task), state, width-lipgloss.Width(plainPrefix), m.statusLabels) if selected { suffix := "" if status != "" { @@ -186,13 +192,13 @@ func (m tuiModel) taskList(width, height int) string { } suffix := "" if status != "" { - suffix = " " + taskStateLabel(row.task.state, status) + suffix = " " + taskStateLabel(state, status) } icon := "" if !m.statusLabels { - icon = taskIcon(row.task.state) + " " + icon = taskIcon(state) + " " } - line := tuiTreeStyle.Render(row.treePrefix) + icon + name + suffix + line := tuiTreeStyle.Render(row.treePrefix) + icon + tuiTreeStyle.Render(sharedPrefix) + name + suffix lines = append(lines, line) } return strings.Join(lines, "\n") @@ -200,7 +206,7 @@ func (m tuiModel) taskList(width, height int) string { func (m tuiModel) outputPanel(width int) string { title := "OUTPUT" - if task := m.selectedTask(); task != nil { + if task := m.selectedRowTask(); task != nil { title += " · " + m.taskName(task) } position := "" diff --git a/task.go b/task.go index 96ef8fbedb..8e2990c8ff 100644 --- a/task.go +++ b/task.go @@ -187,6 +187,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) (runErr error) { } invocation := output.TaskInvocation{ ID: call.invocationID, + ParentID: call.parentInvocationID, RootID: call.rootInvocationID, Name: t.Prefix, Internal: t.Internal, @@ -241,7 +242,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) (runErr error) { output.TaskStarted(e.Output, invocation) e.Logger.VerboseErrf(logger.Magenta, "task: %q started\n", call.Task) - if err := e.runDeps(ctx, t, call.rootInvocationID); err != nil { + if err := e.runDeps(ctx, t, call.invocationID, call.rootInvocationID); err != nil { return err } @@ -345,7 +346,7 @@ func (e *Executor) mkdir(t *ast.Task) error { return nil } -func (e *Executor) runDeps(ctx context.Context, t *ast.Task, rootInvocationID uint64) error { +func (e *Executor) runDeps(ctx context.Context, t *ast.Task, parentInvocationID, rootInvocationID uint64) error { g := &errgroup.Group{} if e.Failfast || t.Failfast { g, ctx = errgroup.WithContext(ctx) @@ -365,7 +366,14 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task, rootInvocationID ui defer cancel() } - err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true, rootInvocationID: rootInvocationID}) + err := e.RunTask(depCtx, &Call{ + Task: d.Task, + Vars: d.Vars, + Silent: d.Silent, + Indirect: true, + parentInvocationID: parentInvocationID, + rootInvocationID: rootInvocationID, + }) if err != nil && timedOut(depCtx, timeout) { return timeout } @@ -432,7 +440,14 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in reacquire := e.releaseConcurrencyLimit() defer reacquire() - err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true, rootInvocationID: call.rootInvocationID}) + err := e.RunTask(ctx, &Call{ + Task: cmd.Task, + Vars: cmd.Vars, + Silent: cmd.Silent, + Indirect: true, + parentInvocationID: call.invocationID, + rootInvocationID: call.rootInvocationID, + }) if err != nil && timedOut(ctx, timeout) { err = timeout } @@ -466,9 +481,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in return fmt.Errorf("task: failed to get variables: %w", err) } stdOut, stdErr, closer := output.WrapWriter(outputWrapper, e.Stdout, e.Stderr, output.TaskInvocation{ - ID: call.invocationID, - RootID: call.rootInvocationID, - Name: t.Prefix, + ID: call.invocationID, + ParentID: call.parentInvocationID, + RootID: call.rootInvocationID, + Name: t.Prefix, }, outputTemplater) if logCommand && output.IsTUI(e.Output) { e.Logger.FOutf(stdErr, logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) diff --git a/tui_output_test.go b/tui_output_test.go index 882cebf4b9..63d6899884 100644 --- a/tui_output_test.go +++ b/tui_output_test.go @@ -66,9 +66,20 @@ tasks: assert.Equal(t, byName["shared"].ID, ownerID) } assert.Equal(t, root.ID, root.RootID) + assert.Zero(t, root.ParentID) + for _, name := range []string{"first", "second", "third"} { + assert.Equal(t, root.ID, byName[name].ParentID, name) + } for _, name := range []string{"first", "second", "third", "shared"} { assert.Equal(t, root.ID, byName[name].RootID, name) } + var sharedParentIDs []uint64 + for _, invocation := range recorder.scheduled { + if invocation.Name == "shared" { + sharedParentIDs = append(sharedParentIDs, invocation.ParentID) + } + } + assert.ElementsMatch(t, []uint64{byName["first"].ID, byName["second"].ID}, sharedParentIDs) scheduledIDs := make([]uint64, len(recorder.scheduled)) for i, invocation := range recorder.scheduled { scheduledIDs[i] = invocation.ID diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index 913e7df90a..a7c7ffa258 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2801,11 +2801,12 @@ $ task default ``` The `tui` output opens an interactive, full-screen view. The requested root -task is shown as a non-selectable heading on the left. Tasks reached from it -appear beneath it in a one-level tree and remain visible while pending, -running, or finished. Repeated executions have separate rows and output views; -calls that join an existing `run: once` or `run: when_changed` execution share -its row. Each row shows a status icon by default, including a distinct canceled +task is shown as a non-selectable heading on the left. Tasks reached from it are +nested beneath the task that invoked them and remain visible while pending, +running, or finished. Repeated executions have separate rows and output views. +Calls that join an existing `run: once` or `run: when_changed` execution remain +in each tree location, use a `↳` marker, and share the owner's status and output. +Each row shows a status icon by default, including a distinct canceled state for work interrupted by fail-fast cancellation. Text labels can be used instead of icons with the `status` option. The output of the selected task is shown on the right. Use Tab or the diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index 976389389b..45098e8453 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2803,11 +2803,12 @@ $ task default ``` The `tui` output opens an interactive, full-screen view. The requested root -task is shown as a non-selectable heading on the left. Tasks reached from it -appear beneath it in a one-level tree and remain visible while pending, -running, or finished. Repeated executions have separate rows and output views; -calls that join an existing `run: once` or `run: when_changed` execution share -its row. Each row shows a status icon by default, including a distinct canceled +task is shown as a non-selectable heading on the left. Tasks reached from it are +nested beneath the task that invoked them and remain visible while pending, +running, or finished. Repeated executions have separate rows and output views. +Calls that join an existing `run: once` or `run: when_changed` execution remain +in each tree location, use a `↳` marker, and share the owner's status and output. +Each row shows a status icon by default, including a distinct canceled state for work interrupted by fail-fast cancellation. Text labels can be used instead of icons with the `status` option. The output of the selected task is shown on the right. Use Tab or the From 763c85e3a3deff97a3ae471b88e222c04aaf9d3b Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 15:58:44 +0200 Subject: [PATCH 08/46] feat: support list and tree TUI navigators --- internal/output/tui.go | 33 +++++--- internal/output/tui_model.go | 58 +++++++------ internal/output/tui_tasks.go | 91 ++++++++++++++++++--- internal/output/tui_test.go | 36 +++++++- internal/output/tui_view.go | 2 +- taskfile/ast/output.go | 5 +- taskfile/ast/output_test.go | 3 +- website/src/latest/docs/guide.md | 11 ++- website/src/latest/docs/reference/schema.md | 1 + website/src/next/docs/guide.md | 11 ++- website/src/next/docs/reference/schema.md | 1 + 11 files changed, 190 insertions(+), 62 deletions(-) diff --git a/internal/output/tui.go b/internal/output/tui.go index e1b7858dfc..6d89eab32f 100644 --- a/internal/output/tui.go +++ b/internal/output/tui.go @@ -20,11 +20,12 @@ const ( ) type TUI struct { - logger *logger.Logger - input io.Reader - output io.Writer - hideInternal bool - statusLabels bool + logger *logger.Logger + input io.Reader + output io.Writer + hideInternal bool + statusLabels bool + taskNavigator tuiTaskNavigator mutex sync.RWMutex program *tea.Program @@ -51,13 +52,22 @@ func NewTUI(log *logger.Logger, options ast.OutputTUI) (*TUI, error) { default: return nil, fmt.Errorf(`task: invalid TUI status style %q: expected "icons" or "labels"`, options.Status) } + taskNavigator := taskNavigatorList + switch options.TaskNavigator { + case "", "list": + case "tree": + taskNavigator = taskNavigatorTree + default: + return nil, fmt.Errorf(`task: invalid TUI task navigator %q: expected "list" or "tree"`, options.TaskNavigator) + } return &TUI{ - logger: log, - input: log.Stdin, - output: log.Stdout, - hideInternal: options.HideInternal, - statusLabels: statusLabels, - pending: make(map[uint64]pendingOutput), + logger: log, + input: log.Stdin, + output: log.Stdout, + hideInternal: options.HideInternal, + statusLabels: statusLabels, + taskNavigator: taskNavigator, + pending: make(map[uint64]pendingOutput), }, nil } @@ -95,6 +105,7 @@ func (t *TUI) Run(ctx context.Context, run func(context.Context) error) error { model := newTUIModel(cancel, t.hideInternal) model.statusLabels = t.statusLabels + model.taskNavigator = t.taskNavigator program := tea.NewProgram( model, tea.WithInput(t.input), diff --git a/internal/output/tui_model.go b/internal/output/tui_model.go index 1744e95829..90683fe0a8 100644 --- a/internal/output/tui_model.go +++ b/internal/output/tui_model.go @@ -26,9 +26,17 @@ const ( outputPane ) +type tuiTaskNavigator uint8 + +const ( + taskNavigatorList tuiTaskNavigator = iota + taskNavigatorTree +) + type tuiTask struct { id uint64 parentID uint64 + rootID uint64 name string isRoot bool hidden bool @@ -65,21 +73,21 @@ type ( ) type tuiModel struct { - tasks []*tuiTask - byID map[uint64]*tuiTask - nameCounts map[tuiTaskKey]int - selectedID uint64 - hasSelect bool - listTop int - focus paneFocus - width int - height int - viewport viewport.Model - done bool - err error - cancel context.CancelFunc - hideInternal bool - statusLabels bool + tasks []*tuiTask + byID map[uint64]*tuiTask + selectedID uint64 + hasSelect bool + listTop int + focus paneFocus + width int + height int + viewport viewport.Model + done bool + err error + cancel context.CancelFunc + hideInternal bool + statusLabels bool + taskNavigator tuiTaskNavigator selectingText bool selectionView string @@ -87,9 +95,9 @@ type tuiModel struct { } type tuiTaskKey struct { - parentID uint64 - name string - isRoot bool + groupID uint64 + name string + isRoot bool } func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { @@ -97,13 +105,13 @@ func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { view.SoftWrap = true view.MouseWheelDelta = 3 return tuiModel{ - byID: make(map[uint64]*tuiTask), - nameCounts: make(map[tuiTaskKey]int), - width: 100, - height: 30, - viewport: view, - cancel: cancel, - hideInternal: hideInternal, + byID: make(map[uint64]*tuiTask), + width: 100, + height: 30, + viewport: view, + cancel: cancel, + hideInternal: hideInternal, + taskNavigator: taskNavigatorList, } } diff --git a/internal/output/tui_tasks.go b/internal/output/tui_tasks.go index 11d7c565fd..d540076a07 100644 --- a/internal/output/tui_tasks.go +++ b/internal/output/tui_tasks.go @@ -14,11 +14,10 @@ func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { return task } isRoot := invocation.ID == invocation.RootID - key := tuiTaskKey{parentID: invocation.ParentID, name: invocation.Name, isRoot: isRoot} - m.nameCounts[key]++ task := &tuiTask{ id: invocation.ID, parentID: invocation.ParentID, + rootID: invocation.RootID, name: invocation.Name, isRoot: isRoot, hidden: m.hideInternal && invocation.Internal && !isRoot, @@ -54,7 +53,12 @@ func (m *tuiModel) joinTask(id, ownerID uint64) { if owner := m.byID[ownerID]; owner != nil { owner.shared = true } - if m.hasSelect && m.selectedID == id { + if m.taskNavigator == taskNavigatorList && m.hasSelect && m.selectedID == id { + m.saveViewport() + m.selectedID = ownerID + m.hasSelect = ownerID != 0 + m.loadViewport() + } else if m.hasSelect && m.selectedID == id { m.loadViewport() } m.keepSelectionVisible() @@ -94,20 +98,35 @@ func (m *tuiModel) appendOutput(id uint64, name, data string) { } func (m tuiModel) taskName(task *tuiTask) string { - key := tuiTaskKey{parentID: task.parentID, name: task.name, isRoot: task.isRoot} - if m.nameCounts[key] > 1 { - occurrence := 1 - for _, candidate := range m.tasks { - candidateKey := tuiTaskKey{parentID: candidate.parentID, name: candidate.name, isRoot: candidate.isRoot} - if candidateKey == key && candidate.id < task.id { - occurrence++ - } + key := m.taskNameKey(task) + count, occurrence := 0, 0 + for _, candidate := range m.tasks { + if !m.taskVisible(candidate) || m.taskNameKey(candidate) != key { + continue + } + count++ + if candidate.id <= task.id { + occurrence++ } + } + if count > 1 { return fmt.Sprintf("#%d %s", occurrence, task.name) } return task.name } +func (m tuiModel) taskNameKey(task *tuiTask) tuiTaskKey { + groupID := task.rootID + if m.taskNavigator == taskNavigatorTree { + groupID = task.parentID + } + return tuiTaskKey{groupID: groupID, name: task.name, isRoot: task.isRoot} +} + +func (m tuiModel) taskVisible(task *tuiTask) bool { + return !task.hidden && (m.taskNavigator == taskNavigatorTree || task.ownerID == 0) +} + func (m tuiModel) taskState(task *tuiTask) taskState { if task.ownerID != 0 { if owner := m.byID[task.ownerID]; owner != nil { @@ -123,10 +142,58 @@ type tuiTaskRow struct { } func (m tuiModel) taskRows() []tuiTaskRow { + if m.taskNavigator == taskNavigatorTree { + return m.treeTaskRows() + } + return m.listTaskRows() +} + +func (m tuiModel) listTaskRows() []tuiTaskRow { + childrenByRoot := make(map[uint64][]*tuiTask) + var roots, standalone []*tuiTask + for _, task := range m.tasks { + if !m.taskVisible(task) { + continue + } + if task.isRoot { + roots = append(roots, task) + continue + } + if task.rootID == 0 { + standalone = append(standalone, task) + } else { + childrenByRoot[task.rootID] = append(childrenByRoot[task.rootID], task) + } + } + sortTasksByID(roots) + sortTasksByID(standalone) + for _, children := range childrenByRoot { + sortTasksByID(children) + } + + rows := make([]tuiTaskRow, 0, len(m.tasks)) + for _, root := range roots { + rows = append(rows, tuiTaskRow{task: root}) + children := childrenByRoot[root.id] + for i, child := range children { + prefix := "├─ " + if i == len(children)-1 { + prefix = "└─ " + } + rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix}) + } + } + for _, task := range standalone { + rows = append(rows, tuiTaskRow{task: task}) + } + return rows +} + +func (m tuiModel) treeTaskRows() []tuiTaskRow { childrenByParent := make(map[uint64][]*tuiTask) var roots, standalone []*tuiTask for _, task := range m.tasks { - if task.hidden { + if !m.taskVisible(task) { continue } if task.isRoot { diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index 7122dbddc1..a37f1d33f5 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -23,15 +23,21 @@ func TestBuildTUI(t *testing.T) { got, err := BuildFor(&ast.Output{Name: "tui"}, &logger.Logger{AssumeTerm: true}) require.NoError(t, err) assert.IsType(t, &TUI{}, got) + assert.Equal(t, taskNavigatorList, got.(*TUI).taskNavigator) - got, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{HideInternal: true, Status: "labels"}}, &logger.Logger{AssumeTerm: true}) + got, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{HideInternal: true, Status: "labels", TaskNavigator: "tree"}}, &logger.Logger{AssumeTerm: true}) require.NoError(t, err) assert.True(t, got.(*TUI).hideInternal) assert.True(t, got.(*TUI).statusLabels) + assert.Equal(t, taskNavigatorTree, got.(*TUI).taskNavigator) _, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{Status: "unknown"}}, &logger.Logger{AssumeTerm: true}) require.Error(t, err) assert.Contains(t, err.Error(), `expected "icons" or "labels"`) + + _, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{TaskNavigator: "unknown"}}, &logger.Logger{AssumeTerm: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), `expected "list" or "tree"`) } func TestTUIModelTracksTasksAndOutput(t *testing.T) { @@ -178,6 +184,7 @@ func TestTUIModelSharesJoinedExecutionStatusAndOutput(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) + m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "worker")) @@ -214,6 +221,7 @@ func TestTUIModelShowsSharedExecutionInEachTreeLocation(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) + m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "parent-a")) m = updateTUIModel(t, m, started(3, 1, "parent-b")) @@ -233,6 +241,7 @@ func TestTUIModelMarksOwnerSharedWhenJoinEventArrivesFirst(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) + m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, scheduled(3, 1, "shared", false)) m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) @@ -249,6 +258,7 @@ func TestTUIModelNestsExecutionsUnderTheirParent(t *testing.T) { t.Parallel() m := newTUIModel(func() {}, false) + m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(5, 0, "other-root")) m = updateTUIModel(t, m, started(2, 1, "child")) @@ -292,7 +302,29 @@ func TestTUIModelCanHideInternalTasks(t *testing.T) { m = updateTUIModel(t, m, scheduledUnder(4, 3, 1, "visible-descendant", false)) assert.Equal(t, []string{"root", "visible", "visible-descendant"}, rowNames(m.taskRows())) - assert.Equal(t, 1, m.nameCounts[tuiTaskKey{parentID: 1, name: "internal"}]) +} + +func TestTUIModelListNavigatorFlattensTasksAndCollapsesSharedCalls(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}, false) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "parent-a")) + m = updateTUIModel(t, m, started(3, 1, "parent-b")) + m = updateTUIModel(t, m, startedUnder(4, 2, 1, "shared")) + m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared", false)) + m.selectTask(4) + assert.Equal(t, uint64(5), m.selectedID) + m = updateTUIModel(t, m, taskJoinedMsg{id: 5, ownerID: 4}) + + assert.Equal(t, []uint64{1, 2, 3, 4}, rowIDs(m.taskRows())) + assert.Equal(t, uint64(4), m.selectedID) + list := ansi.Strip(m.taskList(40, 10)) + assert.Contains(t, list, "├─ ● parent-a") + assert.Contains(t, list, "├─ ● parent-b") + assert.Contains(t, list, "└─ ● shared") + assert.NotContains(t, list, "↳") + assert.NotContains(t, list, "#1 shared") } func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { diff --git a/internal/output/tui_view.go b/internal/output/tui_view.go index 8af72ebe0d..f95dcbb649 100644 --- a/internal/output/tui_view.go +++ b/internal/output/tui_view.go @@ -158,7 +158,7 @@ func (m tuiModel) taskList(width, height int) string { row := rows[i] state := m.taskState(row.task) sharedPrefix := "" - if row.task.shared { + if m.taskNavigator == taskNavigatorTree && row.task.shared { sharedPrefix = "↳ " } if row.task.isRoot { diff --git a/taskfile/ast/output.go b/taskfile/ast/output.go index e50cd2583b..57eed2d813 100644 --- a/taskfile/ast/output.go +++ b/taskfile/ast/output.go @@ -71,8 +71,9 @@ type OutputGroup struct { // OutputTUI contains options specific to the TUI output style. type OutputTUI struct { - HideInternal bool `yaml:"hide_internal"` - Status string `yaml:"status"` + HideInternal bool `yaml:"hide_internal"` + Status string `yaml:"status"` + TaskNavigator string `yaml:"task_navigator"` } // IsSet returns true if and only if a custom output style is set. diff --git a/taskfile/ast/output_test.go b/taskfile/ast/output_test.go index ef5fa177ce..3d18491e82 100644 --- a/taskfile/ast/output_test.go +++ b/taskfile/ast/output_test.go @@ -12,10 +12,11 @@ func TestOutputTUIUnmarshalYAML(t *testing.T) { t.Parallel() var output Output - require.NoError(t, yaml.Unmarshal([]byte("tui:\n hide_internal: true\n status: labels\n"), &output)) + require.NoError(t, yaml.Unmarshal([]byte("tui:\n hide_internal: true\n status: labels\n task_navigator: tree\n"), &output)) assert.Equal(t, "tui", output.Name) assert.True(t, output.TUI.HideInternal) assert.Equal(t, "labels", output.TUI.Status) + assert.Equal(t, "tree", output.TUI.TaskNavigator) } func TestOutputMappingRejectsMultipleStyles(t *testing.T) { diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index a7c7ffa258..4941ea41df 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2802,10 +2802,12 @@ $ task default The `tui` output opens an interactive, full-screen view. The requested root task is shown as a non-selectable heading on the left. Tasks reached from it are -nested beneath the task that invoked them and remain visible while pending, -running, or finished. Repeated executions have separate rows and output views. -Calls that join an existing `run: once` or `run: when_changed` execution remain -in each tree location, use a `↳` marker, and share the owner's status and output. +shown in a list and remain visible while pending, running, or finished. Repeated +executions have separate rows and output views. Calls that join an existing +`run: once` or `run: when_changed` execution share one list row. The task +navigator can instead be configured as a tree. In that mode, tasks are nested +beneath the task that invoked them, and joined calls remain in each tree location +with a `↳` marker while sharing the owner's status and output. Each row shows a status icon by default, including a distinct canceled state for work interrupted by fail-fast cancellation. Text labels can be used instead of icons with the `status` option. The @@ -2832,6 +2834,7 @@ output: tui: hide_internal: true status: labels + task_navigator: tree ``` This mode requires an interactive terminal. It is intended for local use; use diff --git a/website/src/latest/docs/reference/schema.md b/website/src/latest/docs/reference/schema.md index 912cbc5a87..2af20ae70d 100644 --- a/website/src/latest/docs/reference/schema.md +++ b/website/src/latest/docs/reference/schema.md @@ -48,6 +48,7 @@ output: tui: hide_internal: false status: icons # icons or labels + task_navigator: list # list or tree ``` ### `method` diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index 45098e8453..f51ab67f98 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2804,10 +2804,12 @@ $ task default The `tui` output opens an interactive, full-screen view. The requested root task is shown as a non-selectable heading on the left. Tasks reached from it are -nested beneath the task that invoked them and remain visible while pending, -running, or finished. Repeated executions have separate rows and output views. -Calls that join an existing `run: once` or `run: when_changed` execution remain -in each tree location, use a `↳` marker, and share the owner's status and output. +shown in a list and remain visible while pending, running, or finished. Repeated +executions have separate rows and output views. Calls that join an existing +`run: once` or `run: when_changed` execution share one list row. The task +navigator can instead be configured as a tree. In that mode, tasks are nested +beneath the task that invoked them, and joined calls remain in each tree location +with a `↳` marker while sharing the owner's status and output. Each row shows a status icon by default, including a distinct canceled state for work interrupted by fail-fast cancellation. Text labels can be used instead of icons with the `status` option. The @@ -2834,6 +2836,7 @@ output: tui: hide_internal: true status: labels + task_navigator: tree ``` This mode requires an interactive terminal. It is intended for local use; use diff --git a/website/src/next/docs/reference/schema.md b/website/src/next/docs/reference/schema.md index 012bc941c0..f6b51552fe 100644 --- a/website/src/next/docs/reference/schema.md +++ b/website/src/next/docs/reference/schema.md @@ -48,6 +48,7 @@ output: tui: hide_internal: false status: icons # icons or labels + task_navigator: list # list or tree ``` ### `method` From fe11c3d188187f3200d744a45643e127b2e9b37b Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 18:06:09 +0200 Subject: [PATCH 09/46] feat: refine TUI behavior and documentation --- internal/output/output.go | 1 - internal/output/tui.go | 4 +- internal/output/tui_model.go | 7 +- internal/output/tui_tasks.go | 43 +++++----- internal/output/tui_test.go | 80 ++++++++--------- internal/output/tui_view.go | 67 +++++++++------ task.go | 1 - taskfile/ast/output.go | 1 - taskfile/ast/output_test.go | 3 +- website/src/latest/docs/guide.md | 45 +--------- website/src/latest/docs/reference/schema.md | 9 +- website/src/next/docs/guide.md | 95 +++++++++++++-------- website/src/next/docs/reference/schema.md | 1 - website/src/public/next-schema.json | 11 +-- website/src/public/schema.json | 21 +---- 15 files changed, 168 insertions(+), 221 deletions(-) diff --git a/internal/output/output.go b/internal/output/output.go index 690f19d36a..2042da9c13 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -23,7 +23,6 @@ type TaskInvocation struct { ParentID uint64 // ID of the task call that scheduled this call; zero for roots RootID uint64 // ID of the root call requested by the user Name string - Internal bool } // Runner is implemented by output modes that need to own the terminal while diff --git a/internal/output/tui.go b/internal/output/tui.go index 6d89eab32f..ed818d4ccd 100644 --- a/internal/output/tui.go +++ b/internal/output/tui.go @@ -23,7 +23,6 @@ type TUI struct { logger *logger.Logger input io.Reader output io.Writer - hideInternal bool statusLabels bool taskNavigator tuiTaskNavigator @@ -64,7 +63,6 @@ func NewTUI(log *logger.Logger, options ast.OutputTUI) (*TUI, error) { logger: log, input: log.Stdin, output: log.Stdout, - hideInternal: options.HideInternal, statusLabels: statusLabels, taskNavigator: taskNavigator, pending: make(map[uint64]pendingOutput), @@ -103,7 +101,7 @@ func (t *TUI) Run(ctx context.Context, run func(context.Context) error) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - model := newTUIModel(cancel, t.hideInternal) + model := newTUIModel(cancel) model.statusLabels = t.statusLabels model.taskNavigator = t.taskNavigator program := tea.NewProgram( diff --git a/internal/output/tui_model.go b/internal/output/tui_model.go index 90683fe0a8..e1c0b61230 100644 --- a/internal/output/tui_model.go +++ b/internal/output/tui_model.go @@ -39,7 +39,6 @@ type tuiTask struct { rootID uint64 name string isRoot bool - hidden bool shared bool ownerID uint64 output string @@ -85,12 +84,10 @@ type tuiModel struct { done bool err error cancel context.CancelFunc - hideInternal bool statusLabels bool taskNavigator tuiTaskNavigator selectingText bool - selectionView string selectionPage viewport.Model } @@ -100,7 +97,7 @@ type tuiTaskKey struct { isRoot bool } -func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { +func newTUIModel(cancel context.CancelFunc) tuiModel { view := viewport.New() view.SoftWrap = true view.MouseWheelDelta = 3 @@ -110,7 +107,6 @@ func newTUIModel(cancel context.CancelFunc, hideInternal bool) tuiModel { height: 30, viewport: view, cancel: cancel, - hideInternal: hideInternal, taskNavigator: taskNavigatorList, } } @@ -212,7 +208,6 @@ func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { default: return *m, nil } - m.refreshSelectionView() return *m, nil } switch msg.String() { diff --git a/internal/output/tui_tasks.go b/internal/output/tui_tasks.go index d540076a07..009c6c6e9e 100644 --- a/internal/output/tui_tasks.go +++ b/internal/output/tui_tasks.go @@ -20,7 +20,6 @@ func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { rootID: invocation.RootID, name: invocation.Name, isRoot: isRoot, - hidden: m.hideInternal && invocation.Internal && !isRoot, state: taskPending, followOutput: true, } @@ -33,12 +32,12 @@ func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { m.byID[invocation.ID] = task m.tasks = append(m.tasks, task) if m.hasSelect && m.selectedID == task.id { - m.loadViewport() + m.refreshOutputView() } - if !task.isRoot && !task.hidden && !m.hasSelect { + if !task.isRoot && !m.hasSelect { m.selectedID = task.id m.hasSelect = true - m.loadViewport() + m.refreshOutputView() } return task } @@ -57,9 +56,9 @@ func (m *tuiModel) joinTask(id, ownerID uint64) { m.saveViewport() m.selectedID = ownerID m.hasSelect = ownerID != 0 - m.loadViewport() + m.refreshOutputView() } else if m.hasSelect && m.selectedID == id { - m.loadViewport() + m.refreshOutputView() } m.keepSelectionVisible() } @@ -77,7 +76,7 @@ func (m *tuiModel) ensureOutputTask(id uint64, name string) *tuiTask { if !m.hasSelect { m.selectedID = task.id m.hasSelect = true - m.loadViewport() + m.refreshOutputView() } return task } @@ -93,6 +92,14 @@ func (m *tuiModel) appendOutput(id uint64, name, data string) { task.truncated = true } if selected := m.selectedTask(); selected != nil && selected.id == task.id { + m.refreshOutputView() + } +} + +func (m *tuiModel) refreshOutputView() { + if m.selectingText { + m.syncSelectionPage() + } else { m.loadViewport() } } @@ -124,7 +131,7 @@ func (m tuiModel) taskNameKey(task *tuiTask) tuiTaskKey { } func (m tuiModel) taskVisible(task *tuiTask) bool { - return !task.hidden && (m.taskNavigator == taskNavigatorTree || task.ownerID == 0) + return m.taskNavigator == taskNavigatorTree || task.ownerID == 0 } func (m tuiModel) taskState(task *tuiTask) taskState { @@ -200,7 +207,10 @@ func (m tuiModel) treeTaskRows() []tuiTaskRow { roots = append(roots, task) continue } - parentID := m.visibleParentID(task) + parentID := task.parentID + if m.byID[parentID] == nil { + parentID = 0 + } if parentID == 0 { standalone = append(standalone, task) } else { @@ -230,21 +240,6 @@ func sortTasksByID(tasks []*tuiTask) { }) } -func (m tuiModel) visibleParentID(task *tuiTask) uint64 { - parentID := task.parentID - for parentID != 0 { - parent := m.byID[parentID] - if parent == nil { - return 0 - } - if !parent.hidden { - return parentID - } - parentID = parent.parentID - } - return 0 -} - func appendTaskRows(rows []tuiTaskRow, parentID uint64, ancestorLast []bool, childrenByParent map[uint64][]*tuiTask) []tuiTaskRow { children := childrenByParent[parentID] for i, child := range children { diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index a37f1d33f5..a1a8458d90 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -25,9 +25,8 @@ func TestBuildTUI(t *testing.T) { assert.IsType(t, &TUI{}, got) assert.Equal(t, taskNavigatorList, got.(*TUI).taskNavigator) - got, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{HideInternal: true, Status: "labels", TaskNavigator: "tree"}}, &logger.Logger{AssumeTerm: true}) + got, err = BuildFor(&ast.Output{Name: "tui", TUI: ast.OutputTUI{Status: "labels", TaskNavigator: "tree"}}, &logger.Logger{AssumeTerm: true}) require.NoError(t, err) - assert.True(t, got.(*TUI).hideInternal) assert.True(t, got.(*TUI).statusLabels) assert.Equal(t, taskNavigatorTree, got.(*TUI).taskNavigator) @@ -43,7 +42,7 @@ func TestBuildTUI(t *testing.T) { func TestTUIModelTracksTasksAndOutput(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "build")) m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "build", data: "compiling\r\ndone\r"}) @@ -71,10 +70,10 @@ func TestTUIModelTracksTasksAndOutput(t *testing.T) { func TestTUIModelDistinguishesCanceledTasksAndShowsStatusWords(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m.statusLabels = true m = updateTUIModel(t, m, started(1, 0, "root")) - m = updateTUIModel(t, m, scheduled(2, 1, "pending-task", false)) + m = updateTUIModel(t, m, scheduled(2, 1, "pending-task")) m = updateTUIModel(t, m, started(3, 1, "running-task")) m = updateTUIModel(t, m, started(4, 1, "successful-task")) m = updateTUIModel(t, m, taskFinishedMsg{id: 4}) @@ -98,7 +97,7 @@ func TestTUIModelDistinguishesCanceledTasksAndShowsStatusWords(t *testing.T) { func TestTUIStatusLabelsAreOptionalAndDisabledByDefault(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "worker")) @@ -114,7 +113,7 @@ func TestTUIStatusLabelsAreOptionalAndDisabledByDefault(t *testing.T) { func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 40, Height: 8}) m = updateTUIModel(t, m, started(1, 0, "a-task-with-a-fairly-long-name")) @@ -154,7 +153,7 @@ func TestTUITextTruncationUsesTerminalCellWidth(t *testing.T) { func TestTUIModelKeepsRepeatedTaskCallsSeparate(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "worker")) @@ -183,13 +182,13 @@ func TestTUIModelKeepsRepeatedTaskCallsSeparate(t *testing.T) { func TestTUIModelSharesJoinedExecutionStatusAndOutput(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "worker")) m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "shared output"}) - m = updateTUIModel(t, m, scheduled(3, 1, "worker", false)) + m = updateTUIModel(t, m, scheduled(3, 1, "worker")) require.Len(t, m.tasks, 3) assert.Contains(t, m.taskList(30, 10), "#1 worker") assert.Contains(t, m.taskList(30, 10), "#2 worker") @@ -220,13 +219,13 @@ func TestTUIModelSharesJoinedExecutionStatusAndOutput(t *testing.T) { func TestTUIModelShowsSharedExecutionInEachTreeLocation(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "parent-a")) m = updateTUIModel(t, m, started(3, 1, "parent-b")) m = updateTUIModel(t, m, startedUnder(4, 2, 1, "shared")) - m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared", false)) + m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared")) m = updateTUIModel(t, m, taskJoinedMsg{id: 5, ownerID: 4}) assert.Equal(t, []string{"root", "parent-a", "shared", "parent-b", "shared"}, rowNames(m.taskRows())) @@ -240,10 +239,10 @@ func TestTUIModelShowsSharedExecutionInEachTreeLocation(t *testing.T) { func TestTUIModelMarksOwnerSharedWhenJoinEventArrivesFirst(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, started(1, 0, "root")) - m = updateTUIModel(t, m, scheduled(3, 1, "shared", false)) + m = updateTUIModel(t, m, scheduled(3, 1, "shared")) m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) m = updateTUIModel(t, m, started(2, 1, "shared")) @@ -257,7 +256,7 @@ func TestTUIModelMarksOwnerSharedWhenJoinEventArrivesFirst(t *testing.T) { func TestTUIModelNestsExecutionsUnderTheirParent(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m.taskNavigator = taskNavigatorTree m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(5, 0, "other-root")) @@ -279,10 +278,10 @@ func TestTUIModelNestsExecutionsUnderTheirParent(t *testing.T) { func TestTUIModelShowsPendingTasksAndDoesNotSelectRoot(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) - m = updateTUIModel(t, m, scheduled(1, 1, "root", false)) + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, scheduled(1, 1, "root")) assert.False(t, m.hasSelect) - m = updateTUIModel(t, m, scheduled(2, 1, "child", false)) + m = updateTUIModel(t, m, scheduled(2, 1, "child")) assert.Equal(t, taskPending, m.byID[2].state) assert.Equal(t, uint64(2), m.selectedID) @@ -292,27 +291,15 @@ func TestTUIModelShowsPendingTasksAndDoesNotSelectRoot(t *testing.T) { assert.Equal(t, taskSucceeded, m.byID[2].state) } -func TestTUIModelCanHideInternalTasks(t *testing.T) { - t.Parallel() - - m := newTUIModel(func() {}, true) - m = updateTUIModel(t, m, scheduled(1, 1, "root", false)) - m = updateTUIModel(t, m, scheduled(2, 1, "visible", false)) - m = updateTUIModel(t, m, scheduled(3, 1, "internal", true)) - m = updateTUIModel(t, m, scheduledUnder(4, 3, 1, "visible-descendant", false)) - - assert.Equal(t, []string{"root", "visible", "visible-descendant"}, rowNames(m.taskRows())) -} - func TestTUIModelListNavigatorFlattensTasksAndCollapsesSharedCalls(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "parent-a")) m = updateTUIModel(t, m, started(3, 1, "parent-b")) m = updateTUIModel(t, m, startedUnder(4, 2, 1, "shared")) - m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared", false)) + m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared")) m.selectTask(4) assert.Equal(t, uint64(5), m.selectedID) m = updateTUIModel(t, m, taskJoinedMsg{id: 5, ownerID: 4}) @@ -330,7 +317,7 @@ func TestTUIModelListNavigatorFlattensTasksAndCollapsesSharedCalls(t *testing.T) func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "first")) @@ -346,10 +333,10 @@ func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { assert.Equal(t, outputPane, m.focus) } -func TestTUIModelTextSelectionModeDisablesMouseAndFreezesView(t *testing.T) { +func TestTUIModelTextSelectionModeDisablesMouseAndShowsLiveOutput(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 12}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "worker")) @@ -366,7 +353,9 @@ func TestTUIModelTextSelectionModeDisablesMouseAndFreezesView(t *testing.T) { assert.NotContains(t, selectionView.Content, "╭") m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "second\n"}) - assert.Equal(t, selectionView.Content, m.View().Content) + assert.NotEqual(t, selectionView.Content, m.View().Content) + assert.Contains(t, m.View().Content, "second") + assert.True(t, m.selectionPage.AtBottom()) assert.Equal(t, "first\nsecond\n", m.byID[2].output) m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) @@ -378,7 +367,7 @@ func TestTUIModelTextSelectionModeDisablesMouseAndFreezesView(t *testing.T) { func TestTUIModelTextSelectionModeScrollsWithKeyboard(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 10}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "worker")) @@ -390,7 +379,10 @@ func TestTUIModelTextSelectionModeScrollsWithKeyboard(t *testing.T) { require.Greater(t, bottomOffset, 0) m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) - assert.Less(t, m.selectionPage.YOffset(), bottomOffset) + scrolledOffset := m.selectionPage.YOffset() + assert.Less(t, scrolledOffset, bottomOffset) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "new output\n"}) + assert.Equal(t, scrolledOffset, m.selectionPage.YOffset()) assert.Contains(t, m.View().Content, "scroll") m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'g', Text: "g"}) @@ -402,7 +394,7 @@ func TestTUIModelTextSelectionModeScrollsWithKeyboard(t *testing.T) { func TestTUIModelScrollsAndRemembersEachTaskOutput(t *testing.T) { t.Parallel() - m := newTUIModel(func() {}, false) + m := newTUIModel(func() {}) m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 10}) m = updateTUIModel(t, m, started(1, 0, "root")) m = updateTUIModel(t, m, started(2, 1, "first")) @@ -432,7 +424,7 @@ func TestTUIModelQuitCancelsExecution(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) - m := newTUIModel(cancel, false) + m := newTUIModel(cancel) key := tea.KeyPressMsg{Code: 'q', Text: "q"} next, cmd := m.Update(key) require.NotNil(t, cmd) @@ -467,16 +459,16 @@ func startedUnder(id, parentID, rootID uint64, name string) taskStartedMsg { return taskStartedMsg{task: TaskInvocation{ID: id, ParentID: parentID, RootID: rootID, Name: name}} } -func scheduled(id, rootID uint64, name string, internal bool) taskScheduledMsg { +func scheduled(id, rootID uint64, name string) taskScheduledMsg { parentID := rootID if id == rootID { parentID = 0 } - return scheduledUnder(id, parentID, rootID, name, internal) + return scheduledUnder(id, parentID, rootID, name) } -func scheduledUnder(id, parentID, rootID uint64, name string, internal bool) taskScheduledMsg { - return taskScheduledMsg{task: TaskInvocation{ID: id, ParentID: parentID, RootID: rootID, Name: name, Internal: internal}} +func scheduledUnder(id, parentID, rootID uint64, name string) taskScheduledMsg { + return taskScheduledMsg{task: TaskInvocation{ID: id, ParentID: parentID, RootID: rootID, Name: name}} } func updateTUIModel(t *testing.T, m tuiModel, msg tea.Msg) tuiModel { diff --git a/internal/output/tui_view.go b/internal/output/tui_view.go index f95dcbb649..1757c67195 100644 --- a/internal/output/tui_view.go +++ b/internal/output/tui_view.go @@ -13,8 +13,8 @@ import ( func (m tuiModel) View() tea.View { content := m.renderContent() - if m.selectingText && m.selectionView != "" { - content = m.selectionView + if m.selectingText { + content = m.textSelectionView() } view := tea.NewView(content) view.AltScreen = true @@ -58,43 +58,60 @@ func (m *tuiModel) enterTextSelection() { viewport.WithHeight(max(m.height-1, 1)), ) view.SoftWrap = true - if task := m.selectedTask(); task != nil { - content := task.output - if task.truncated { - content = "… earlier output was discarded …\n" + content - } - view.SetContent(content) - if m.viewport.AtBottom() { - view.GotoBottom() - } else if !m.viewport.AtTop() { - position := m.viewport.ScrollPercent() - view.GotoBottom() - view.SetYOffset(int(position * float64(view.YOffset()))) - } - } m.selectionPage = view - m.refreshSelectionView() + m.selectionPage.SetContent(m.textSelectionContent()) + if m.viewport.AtBottom() { + m.selectionPage.GotoBottom() + } else if !m.viewport.AtTop() { + position := m.viewport.ScrollPercent() + m.selectionPage.GotoBottom() + m.selectionPage.SetYOffset(int(position * float64(m.selectionPage.YOffset()))) + } } func (m *tuiModel) leaveTextSelection() { - if m.selectionPage.AtTop() { + atTop := m.selectionPage.AtTop() + atBottom := m.selectionPage.AtBottom() + position := m.selectionPage.ScrollPercent() + m.selectingText = false + m.selectionPage = viewport.Model{} + m.loadViewport() + if atTop { m.viewport.GotoTop() - } else if m.selectionPage.AtBottom() { + } else if atBottom { m.viewport.GotoBottom() } else { - position := m.selectionPage.ScrollPercent() m.viewport.GotoBottom() m.viewport.SetYOffset(int(position * float64(m.viewport.YOffset()))) } m.saveViewport() - m.selectingText = false - m.selectionView = "" - m.selectionPage = viewport.Model{} } -func (m *tuiModel) refreshSelectionView() { +func (m *tuiModel) syncSelectionPage() { + atBottom := m.selectionPage.AtBottom() + offset := m.selectionPage.YOffset() + m.selectionPage.SetContent(m.textSelectionContent()) + if atBottom { + m.selectionPage.GotoBottom() + } else { + m.selectionPage.SetYOffset(offset) + } +} + +func (m *tuiModel) textSelectionContent() string { + content := "" + if task := m.selectedTask(); task != nil { + content = task.output + if task.truncated { + content = "… earlier output was discarded …\n" + content + } + } + return content +} + +func (m tuiModel) textSelectionView() string { help := truncateText(" text selection • ↑/↓ or pgup/pgdn scroll • drag to select • c/esc resume", max(m.width, 1)) - m.selectionView = m.selectionPage.View() + "\n" + tuiHelpStyle.Render(help) + return m.selectionPage.View() + "\n" + tuiHelpStyle.Render(help) } func (m tuiModel) renderPanes(layout tuiLayout) (string, string) { diff --git a/task.go b/task.go index 8e2990c8ff..9ee591ecb0 100644 --- a/task.go +++ b/task.go @@ -190,7 +190,6 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) (runErr error) { ParentID: call.parentInvocationID, RootID: call.rootInvocationID, Name: t.Prefix, - Internal: t.Internal, } output.TaskScheduled(e.Output, invocation) defer func() { output.TaskFinished(e.Output, call.invocationID, runErr) }() diff --git a/taskfile/ast/output.go b/taskfile/ast/output.go index 57eed2d813..f0bdf22f15 100644 --- a/taskfile/ast/output.go +++ b/taskfile/ast/output.go @@ -71,7 +71,6 @@ type OutputGroup struct { // OutputTUI contains options specific to the TUI output style. type OutputTUI struct { - HideInternal bool `yaml:"hide_internal"` Status string `yaml:"status"` TaskNavigator string `yaml:"task_navigator"` } diff --git a/taskfile/ast/output_test.go b/taskfile/ast/output_test.go index 3d18491e82..252972cc2a 100644 --- a/taskfile/ast/output_test.go +++ b/taskfile/ast/output_test.go @@ -12,9 +12,8 @@ func TestOutputTUIUnmarshalYAML(t *testing.T) { t.Parallel() var output Output - require.NoError(t, yaml.Unmarshal([]byte("tui:\n hide_internal: true\n status: labels\n task_navigator: tree\n"), &output)) + require.NoError(t, yaml.Unmarshal([]byte("tui:\n status: labels\n task_navigator: tree\n"), &output)) assert.Equal(t, "tui", output.Name) - assert.True(t, output.TUI.HideInternal) assert.Equal(t, "labels", output.TUI.Status) assert.Equal(t, "tree", output.TUI.TaskNavigator) } diff --git a/website/src/latest/docs/guide.md b/website/src/latest/docs/guide.md index 4941ea41df..38e0caf7f0 100644 --- a/website/src/latest/docs/guide.md +++ b/website/src/latest/docs/guide.md @@ -2690,13 +2690,12 @@ the shell in real-time. This is good for having live feedback for logging printed by commands, but the output can become messy if you have multiple commands running simultaneously and printing lots of stuff. -To make this more customizable, there are currently four different output +To make this more customizable, there are currently three different output options you can choose: - `interleaved` (default) - `group` - `prefixed` -- `tui` To choose another one, just set it to root in the Taskfile: @@ -2800,48 +2799,6 @@ $ task default [print-baz] baz ``` -The `tui` output opens an interactive, full-screen view. The requested root -task is shown as a non-selectable heading on the left. Tasks reached from it are -shown in a list and remain visible while pending, running, or finished. Repeated -executions have separate rows and output views. Calls that join an existing -`run: once` or `run: when_changed` execution share one list row. The task -navigator can instead be configured as a tree. In that mode, tasks are nested -beneath the task that invoked them, and joined calls remain in each tree location -with a `↳` marker while sharing the owner's status and output. -Each row shows a status icon by default, including a distinct canceled -state for work interrupted by fail-fast cancellation. Text labels can be used -instead of icons with the `status` option. The -output of the selected task is shown on the right. Use Tab or the -left/right arrow keys to focus a pane. In the task pane, -use the up/down arrows or `j`/`k` to select a task. In the output pane, those -keys scroll; Page Up and Page Down also scroll the output directly. You can -click a task to select it and use the mouse wheel over either pane. Press `c` -to open a frozen, output-only view for selecting and copying text with the -terminal. The arrow keys or `j`/`k`, Page Up/Down, and `g`/`G` scroll that -view; press `c` or Escape to resume interaction. Pressing `q` -while tasks are still running cancels them. The view remains open after -execution completes so that output can be inspected. - -```shell -$ task --output tui build test lint -``` - -Internal tasks are shown by default. They can be hidden when the output mode -is configured in the Taskfile: - -```yaml -output: - tui: - hide_internal: true - status: labels - task_navigator: tree -``` - -This mode requires an interactive terminal. It is intended for local use; use -one of the stream-based modes in CI or when redirecting output. Watch mode, -interactive commands, and interactive variable prompting are not currently -supported. Task confirmation prompts can be accepted up front with `--yes`. - ::: tip The `output` option can also be specified by the `--output` or `-o` flags. diff --git a/website/src/latest/docs/reference/schema.md b/website/src/latest/docs/reference/schema.md index 2af20ae70d..471d981661 100644 --- a/website/src/latest/docs/reference/schema.md +++ b/website/src/latest/docs/reference/schema.md @@ -29,7 +29,7 @@ version: '3' - **Type**: `string` or `object` - **Default**: `interleaved` -- **Options**: `interleaved`, `group`, `prefixed`, `tui` +- **Options**: `interleaved`, `group`, `prefixed` - **Description**: Controls how task output is displayed ```yaml @@ -42,13 +42,6 @@ output: begin: "::group::{{.TASK}}" end: "::endgroup::" error_only: false - -# TUI options -output: - tui: - hide_internal: false - status: icons # icons or labels - task_navigator: list # list or tree ``` ### `method` diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index f51ab67f98..db3d545fd8 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2711,6 +2711,14 @@ tasks: # ... ``` +::: tip + +The `output` option can also be specified by the `--output` or `-o` flags. + +::: + +### `group` output + The `group` output will print the entire output of a command once after it finishes, so you will not have live feedback for commands that take a long time to run. @@ -2769,7 +2777,9 @@ output-of-errors task: Failed to run task "errors": exit status 1 ``` -The `prefix` output will prefix every line printed by a command with +### `prefixed` output + +The `prefixed` output will prefix every line printed by a command with `[task-name] ` as the prefix, but you can customize the prefix for a command with the `prefix:` attribute: @@ -2802,54 +2812,67 @@ $ task default [print-baz] baz ``` -The `tui` output opens an interactive, full-screen view. The requested root -task is shown as a non-selectable heading on the left. Tasks reached from it are -shown in a list and remain visible while pending, running, or finished. Repeated -executions have separate rows and output views. Calls that join an existing -`run: once` or `run: when_changed` execution share one list row. The task -navigator can instead be configured as a tree. In that mode, tasks are nested -beneath the task that invoked them, and joined calls remain in each tree location -with a `↳` marker while sharing the owner's status and output. -Each row shows a status icon by default, including a distinct canceled -state for work interrupted by fail-fast cancellation. Text labels can be used -instead of icons with the `status` option. The -output of the selected task is shown on the right. Use Tab or the -left/right arrow keys to focus a pane. In the task pane, -use the up/down arrows or `j`/`k` to select a task. In the output pane, those -keys scroll; Page Up and Page Down also scroll the output directly. You can -click a task to select it and use the mouse wheel over either pane. Press `c` -to open a frozen, output-only view for selecting and copying text with the -terminal. The arrow keys or `j`/`k`, Page Up/Down, and `g`/`G` scroll that -view; press `c` or Escape to resume interaction. Pressing `q` -while tasks are still running cancels them. The view remains open after -execution completes so that output can be inspected. +### `tui` output -```shell -$ task --output tui build test lint -``` +The `tui` output opens an interactive, full-screen Terminal User Interface (TUI). +The left pane shows a task navigator and the right pane shows the output of the +currently selected task. + +The requested root task is a non-selectable heading. By default, all tasks +reached from that root appear below it in a list. Repeated executions have +separate entries, while calls that join an existing `run: once` or +`run: when_changed` execution share one entry. With the tree navigator, tasks +are nested beneath the task that invoked them. Joined calls then remain visible +at each location with a `↳` marker and share the owner's status and output. -Internal tasks are shown by default. They can be hidden when the output mode -is configured in the Taskfile: +The TUI output can be configured with the `tui` option in the Taskfile: ```yaml output: tui: - hide_internal: true - status: labels - task_navigator: tree + # Whether to show the task navigator as a flat list or a tree of tasks. + task_navigator: list # list or tree; default: list + + # Whether to show the status of tasks as icons or text labels. + status: icons # icons or labels; default: icons ``` +Each task shows a status icon, including a distinct canceled state for work +interrupted by fail-fast cancellation. The `labels` status option replaces the +icons with text labels. + +Use Tab or the left/right arrow keys to switch between the task navigator and +the output pane. Clicking either pane also focuses it. + +When the navigator is focused, use the up/down arrows or `j`/`k` to select a +task. You can also click a task directly. When the output pane is focused, use +the following controls to scroll: + +- Up/down arrows or `j`/`k` +- Page Up and Page Down +- `g` and `G` to jump to the beginning or end +- Mouse wheel + +Press `c` to open a live, output-only view of the selected task. Mouse reporting +is disabled in this view, allowing the terminal to select and copy its text. +Incoming output remains visible; the view follows it while at the bottom and +preserves the current position after you scroll up. The keyboard scrolling +controls above remain available. Press `c` again or Escape to return to the +two-pane view. + +```shell +$ task --output tui build test lint +``` + +Pressing `q` while tasks are running requests cancellation and closes the TUI +after Task's execution has returned. After execution finishes normally, the TUI +remains open so its output can be inspected; press Enter or `q` to close it. + This mode requires an interactive terminal. It is intended for local use; use one of the stream-based modes in CI or when redirecting output. Watch mode, interactive commands, and interactive variable prompting are not currently supported. Task confirmation prompts can be accepted up front with `--yes`. -::: tip - -The `output` option can also be specified by the `--output` or `-o` flags. - -::: - ## CI Integration ### Colored output diff --git a/website/src/next/docs/reference/schema.md b/website/src/next/docs/reference/schema.md index f6b51552fe..b912384a04 100644 --- a/website/src/next/docs/reference/schema.md +++ b/website/src/next/docs/reference/schema.md @@ -46,7 +46,6 @@ output: # TUI options output: tui: - hide_internal: false status: icons # icons or labels task_navigator: list # list or tree ``` diff --git a/website/src/public/next-schema.json b/website/src/public/next-schema.json index 8b913aff8d..d8b4d6fe80 100644 --- a/website/src/public/next-schema.json +++ b/website/src/public/next-schema.json @@ -715,16 +715,17 @@ "tui": { "type": "object", "properties": { - "hide_internal": { - "description": "Hides internal tasks from the TUI task list", - "type": "boolean", - "default": false - }, "status": { "description": "Chooses whether task statuses use icons or text labels", "type": "string", "enum": ["icons", "labels"], "default": "icons" + }, + "task_navigator": { + "description": "Chooses whether tasks use a flat list or a nested tree", + "type": "string", + "enum": ["list", "tree"], + "default": "list" } }, "additionalProperties": false diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 8b913aff8d..74ebfe9d39 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -688,13 +688,11 @@ }, "outputString": { "type": "string", - "enum": ["interleaved", "prefixed", "group", "tui"], + "enum": ["interleaved", "prefixed", "group"], "default": "interleaved" }, "outputObject": { "type": "object", - "minProperties": 1, - "maxProperties": 1, "properties": { "group": { "type": "object", @@ -711,23 +709,6 @@ "default": false } } - }, - "tui": { - "type": "object", - "properties": { - "hide_internal": { - "description": "Hides internal tasks from the TUI task list", - "type": "boolean", - "default": false - }, - "status": { - "description": "Chooses whether task statuses use icons or text labels", - "type": "string", - "enum": ["icons", "labels"], - "default": "icons" - } - }, - "additionalProperties": false } }, "additionalProperties": false From f5404ee2cf4a142331ddf92420b1041b4bd31bab Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 18:12:04 +0200 Subject: [PATCH 10/46] fix: make root task output selectable --- internal/output/tui_tasks.go | 42 +++++++++++++++++------------------- internal/output/tui_test.go | 38 +++++++++++++++++++++++++++++--- internal/output/tui_view.go | 29 ++++++++++++------------- 3 files changed, 69 insertions(+), 40 deletions(-) diff --git a/internal/output/tui_tasks.go b/internal/output/tui_tasks.go index 009c6c6e9e..093fa0ca40 100644 --- a/internal/output/tui_tasks.go +++ b/internal/output/tui_tasks.go @@ -31,13 +31,20 @@ func (m *tuiModel) scheduleTask(invocation TaskInvocation) *tuiTask { } m.byID[invocation.ID] = task m.tasks = append(m.tasks, task) - if m.hasSelect && m.selectedID == task.id { - m.refreshOutputView() - } - if !task.isRoot && !m.hasSelect { + if !m.hasSelect { m.selectedID = task.id m.hasSelect = true m.refreshOutputView() + } else if !task.isRoot { + // Prefer the first child for orchestration roots, while keeping a root + // selected if it has already produced output of its own. + selected := m.selectedRowTask() + if selected != nil && selected.isRoot && selected.output == "" { + m.selectedID = task.id + m.refreshOutputView() + } + } else if m.selectedID == task.id { + m.refreshOutputView() } return task } @@ -304,17 +311,15 @@ func (m *tuiModel) moveSelection(delta int) { } return } - for index += delta; index >= 0 && index < len(rows); index += delta { - if !rows[index].task.isRoot { - m.selectTask(index) - return - } + index += delta + if index >= 0 && index < len(rows) { + m.selectTask(index) } } func (m *tuiModel) selectTask(index int) { rows := m.taskRows() - if index < 0 || index >= len(rows) || rows[index].task.isRoot { + if index < 0 || index >= len(rows) { return } m.saveViewport() @@ -326,21 +331,14 @@ func (m *tuiModel) selectTask(index int) { func (m *tuiModel) selectBoundary(last bool) { rows := m.taskRows() - if last { - for i := range slices.Backward(rows) { - if !rows[i].task.isRoot { - m.selectTask(i) - return - } - } + if len(rows) == 0 { return } - for i, row := range rows { - if !row.task.isRoot { - m.selectTask(i) - return - } + if last { + m.selectTask(len(rows) - 1) + return } + m.selectTask(0) } func (m *tuiModel) keepSelectionVisible() { diff --git a/internal/output/tui_test.go b/internal/output/tui_test.go index a1a8458d90..31a39871e0 100644 --- a/internal/output/tui_test.go +++ b/internal/output/tui_test.go @@ -275,22 +275,54 @@ func TestTUIModelNestsExecutionsUnderTheirParent(t *testing.T) { assert.True(t, strings.HasPrefix(lines[4], "└─ ● second-child"), lines[4]) } -func TestTUIModelShowsPendingTasksAndDoesNotSelectRoot(t *testing.T) { +func TestTUIModelPrefersFirstChildButAllowsSelectingRoot(t *testing.T) { t.Parallel() m := newTUIModel(func() {}) m = updateTUIModel(t, m, scheduled(1, 1, "root")) - assert.False(t, m.hasSelect) + assert.True(t, m.hasSelect) + assert.Equal(t, uint64(1), m.selectedID) m = updateTUIModel(t, m, scheduled(2, 1, "child")) assert.Equal(t, taskPending, m.byID[2].state) assert.Equal(t, uint64(2), m.selectedID) m.selectTask(0) - assert.Equal(t, uint64(2), m.selectedID) + assert.Equal(t, uint64(1), m.selectedID) m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) assert.Equal(t, taskSucceeded, m.byID[2].state) } +func TestTUIModelMakesRootOutputAccessible(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "root", data: "root output\n"}) + + assert.Equal(t, uint64(1), m.selectedID) + assert.Contains(t, m.outputPanel(40), "OUTPUT · root") + assert.Contains(t, m.viewport.View(), "root output") + assert.Contains(t, ansi.Strip(m.taskList(30, 10)), "● root") +} + +func TestTUIModelKeepsRootSelectedWhenItProducedOutputBeforeChild(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "root", data: "root output\n"}) + m = updateTUIModel(t, m, started(2, 1, "child")) + + assert.Equal(t, uint64(1), m.selectedID) + m.moveSelection(1) + assert.Equal(t, uint64(2), m.selectedID) + m.moveSelection(-1) + assert.Equal(t, uint64(1), m.selectedID) + assert.Contains(t, m.viewport.View(), "root output") +} + func TestTUIModelListNavigatorFlattensTasksAndCollapsesSharedCalls(t *testing.T) { t.Parallel() diff --git a/internal/output/tui_view.go b/internal/output/tui_view.go index 1757c67195..8cfbb5b377 100644 --- a/internal/output/tui_view.go +++ b/internal/output/tui_view.go @@ -174,25 +174,11 @@ func (m tuiModel) taskList(width, height int) string { for i := m.listTop; i < end; i++ { row := rows[i] state := m.taskState(row.task) + selected := row.task.id == m.selectedID sharedPrefix := "" if m.taskNavigator == taskNavigatorTree && row.task.shared { sharedPrefix = "↳ " } - if row.task.isRoot { - prefix := "" - if !m.statusLabels { - prefix = taskIcon(state) + " " - } - prefix += sharedPrefix - name, status := taskNameStatus(m.taskName(row.task), state, width-lipgloss.Width(prefix), m.statusLabels) - suffix := "" - if status != "" { - suffix = " " + taskStateLabel(state, status) - } - lines = append(lines, prefix+tuiRootStyle.Render(name)+suffix) - continue - } - selected := row.task.id == m.selectedID plainIcon := "" if !m.statusLabels { plainIcon = taskIconText(state) + " " @@ -207,6 +193,19 @@ func (m tuiModel) taskList(width, height int) string { lines = append(lines, tuiSelectedStyle.Width(width).Render(plainPrefix+name+suffix)) continue } + if row.task.isRoot { + prefix := "" + if !m.statusLabels { + prefix = taskIcon(state) + " " + } + prefix += sharedPrefix + suffix := "" + if status != "" { + suffix = " " + taskStateLabel(state, status) + } + lines = append(lines, prefix+tuiRootStyle.Render(name)+suffix) + continue + } suffix := "" if status != "" { suffix = " " + taskStateLabel(state, status) From 6332d54c2aaa0d7fd7e77754ee9b3ad68dd91c14 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Tue, 1 Sep 2026 18:28:19 +0200 Subject: [PATCH 11/46] feat: configure TUI output from CLI --- internal/flags/flags.go | 37 ++++++++---- internal/flags/flags_test.go | 56 +++++++++++++++++++ setup.go | 9 +++ tui_output_test.go | 47 ++++++++++++++++ website/src/next/docs/guide.md | 15 +++-- website/src/next/docs/reference/cli.md | 26 ++++++++- .../src/next/docs/reference/environment.md | 20 ++++++- 7 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 internal/flags/flags_test.go diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 5d29bb0fe3..2f4b3c8984 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -149,6 +149,8 @@ func init() { pflag.StringVar(&Output.Group.Begin, "output-group-begin", getConfig(config, "OUTPUT_GROUP_BEGIN", func() *string { return nil }, ""), "Message template to print before a task's grouped output.") pflag.StringVar(&Output.Group.End, "output-group-end", getConfig(config, "OUTPUT_GROUP_END", func() *string { return nil }, ""), "Message template to print after a task's grouped output.") pflag.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", getConfig(config, "OUTPUT_GROUP_ERROR_ONLY", func() *bool { return nil }, false), "Swallow output from successful tasks.") + pflag.StringVar(&Output.TUI.Status, "output-tui-status", getConfig(config, "OUTPUT_TUI_STATUS", func() *string { return nil }, ""), "Sets TUI task status style: [icons|labels].") + pflag.StringVar(&Output.TUI.TaskNavigator, "output-tui-task-navigator", getConfig(config, "OUTPUT_TUI_TASK_NAVIGATOR", func() *string { return nil }, ""), "Sets TUI task navigator: [list|tree].") pflag.BoolVarP(&Color, "color", "c", getConfig(config, "COLOR", func() *bool { return config.Color }, true), "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.") pflag.IntVarP(&Concurrency, "concurrency", "C", getConfig(config, "CONCURRENCY", func() *int { return config.Concurrency }, 0), "Limit number of tasks to run concurrently.") pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.") @@ -213,16 +215,8 @@ func Validate() error { return errors.New("task: You can't set both --global and --dir") } - if Output.Name != "group" { - if Output.Group.Begin != "" { - return errors.New("task: You can't set --output-group-begin without --output=group") - } - if Output.Group.End != "" { - return errors.New("task: You can't set --output-group-end without --output=group") - } - if Output.Group.ErrorOnly { - return errors.New("task: You can't set --output-group-error-only without --output=group") - } + if err := validateOutputOptions(Output); err != nil { + return err } if List && ListAll { @@ -249,6 +243,29 @@ func Validate() error { return nil } +func validateOutputOptions(output ast.Output) error { + if output.Name != "group" { + if output.Group.Begin != "" { + return errors.New("task: You can't set --output-group-begin without --output=group") + } + if output.Group.End != "" { + return errors.New("task: You can't set --output-group-end without --output=group") + } + if output.Group.ErrorOnly { + return errors.New("task: You can't set --output-group-error-only without --output=group") + } + } + if output.Name != "tui" { + if output.TUI.Status != "" { + return errors.New("task: You can't set --output-tui-status without --output=tui") + } + if output.TUI.TaskNavigator != "" { + return errors.New("task: You can't set --output-tui-task-navigator without --output=tui") + } + } + return nil +} + // WithFlags is a special internal functional option that is used to pass flags // from the CLI into any constructor that accepts functional options. func WithFlags() task.ExecutorOption { diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go new file mode 100644 index 0000000000..4efa09d5fa --- /dev/null +++ b/internal/flags/flags_test.go @@ -0,0 +1,56 @@ +package flags + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestValidateOutputOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output ast.Output + wantError string + }{ + { + name: "group options with group output", + output: ast.Output{Name: "group", Group: ast.OutputGroup{Begin: "begin", End: "end", ErrorOnly: true}}, + }, + { + name: "TUI options with TUI output", + output: ast.Output{Name: "tui", TUI: ast.OutputTUI{Status: "labels", TaskNavigator: "tree"}}, + }, + { + name: "group option without group output", + output: ast.Output{Name: "interleaved", Group: ast.OutputGroup{Begin: "begin"}}, + wantError: "--output-group-begin without --output=group", + }, + { + name: "TUI status without TUI output", + output: ast.Output{Name: "interleaved", TUI: ast.OutputTUI{Status: "labels"}}, + wantError: "--output-tui-status without --output=tui", + }, + { + name: "TUI navigator without TUI output", + output: ast.Output{Name: "interleaved", TUI: ast.OutputTUI{TaskNavigator: "tree"}}, + wantError: "--output-tui-task-navigator without --output=tui", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateOutputOptions(test.output) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantError) + }) + } +} diff --git a/setup.go b/setup.go index e92848417a..e6e3e039af 100644 --- a/setup.go +++ b/setup.go @@ -196,6 +196,15 @@ func (e *Executor) setupLogger() { func (e *Executor) setupOutput() error { if !e.OutputStyle.IsSet() { e.OutputStyle = e.Taskfile.Output + } else if e.OutputStyle.Name == "tui" && e.Taskfile.Output.Name == "tui" { + // Selecting the TUI from the CLI should not discard its Taskfile + // configuration. Non-empty CLI or environment values remain overrides. + if e.OutputStyle.TUI.Status == "" { + e.OutputStyle.TUI.Status = e.Taskfile.Output.TUI.Status + } + if e.OutputStyle.TUI.TaskNavigator == "" { + e.OutputStyle.TUI.TaskNavigator = e.Taskfile.Output.TUI.TaskNavigator + } } var err error diff --git a/tui_output_test.go b/tui_output_test.go index 63d6899884..afd57379d7 100644 --- a/tui_output_test.go +++ b/tui_output_test.go @@ -15,8 +15,55 @@ import ( "github.com/go-task/task/v3" "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/taskfile/ast" ) +func TestTUIOutputStyleKeepsTaskfileOptions(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +output: + tui: + status: icons + task_navigator: tree +tasks: + default: echo done +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + tests := []struct { + name string + output ast.Output + want ast.OutputTUI + }{ + { + name: "CLI selects TUI mode", + output: ast.Output{Name: "tui"}, + want: ast.OutputTUI{Status: "icons", TaskNavigator: "tree"}, + }, + { + name: "CLI option overrides one Taskfile option", + output: ast.Output{Name: "tui", TUI: ast.OutputTUI{Status: "labels"}}, + want: ast.OutputTUI{Status: "labels", TaskNavigator: "tree"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithOutputStyle(test.output), + task.WithAssumeTerm(true), + ) + require.NoError(t, e.Setup()) + assert.Equal(t, test.want, e.OutputStyle.TUI) + }) + } +} + func TestTaskLifecycleOutput(t *testing.T) { t.Parallel() diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index db3d545fd8..46bdf37a5c 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2818,12 +2818,14 @@ The `tui` output opens an interactive, full-screen Terminal User Interface (TUI) The left pane shows a task navigator and the right pane shows the output of the currently selected task. -The requested root task is a non-selectable heading. By default, all tasks -reached from that root appear below it in a list. Repeated executions have -separate entries, while calls that join an existing `run: once` or -`run: when_changed` execution share one entry. With the tree navigator, tasks -are nested beneath the task that invoked them. Joined calls then remain visible -at each location with a `↳` marker and share the owner's status and output. +The requested root task appears at the top and can be selected to inspect output +from commands that it runs directly. When the root only orchestrates other +tasks, the first child is selected automatically. By default, all tasks reached +from that root appear below it in a list. Repeated executions have separate +entries, while calls that join an existing `run: once` or `run: when_changed` +execution share one entry. With the tree navigator, tasks are nested beneath the +task that invoked them. Joined calls then remain visible at each location with a +`↳` marker and share the owner's status and output. The TUI output can be configured with the `tui` option in the Taskfile: @@ -2862,6 +2864,7 @@ two-pane view. ```shell $ task --output tui build test lint +$ task --output tui --output-tui-task-navigator tree --output-tui-status labels build ``` Pressing `q` while tasks are running requests cancellation and closes the TUI diff --git a/website/src/next/docs/reference/cli.md b/website/src/next/docs/reference/cli.md index 0051797355..ca465ef361 100644 --- a/website/src/next/docs/reference/cli.md +++ b/website/src/next/docs/reference/cli.md @@ -238,7 +238,7 @@ task build --temp-dir .task-cache #### `-o, --output ` -Set output style. Available modes: `interleaved`, `group`, `prefixed`. +Set output style. Available modes: `interleaved`, `group`, `prefixed`, `tui`. - **Environment variable**: [`TASK_OUTPUT`](./environment.md#task-output) @@ -279,6 +279,30 @@ Only show command output on non-zero exit codes. task test --output group --output-group-error-only ``` +#### `--output-tui-status