Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2765355
feat: add TUI output mode
janluke Aug 31, 2026
c4080fd
refactor: flatten TUI task list
janluke Aug 31, 2026
0fda42e
feat: add TUI text selection mode
janluke Aug 31, 2026
2b11007
feat: improve TUI task status display
janluke Sep 1, 2026
7963bc8
feat: refine TUI task status presentation
janluke Sep 1, 2026
f36937b
refactor: organize TUI implementation
janluke Sep 1, 2026
1d3b5d9
feat: render nested task tree in TUI
janluke Sep 1, 2026
763c85e
feat: support list and tree TUI navigators
janluke Sep 1, 2026
fe11c3d
feat: refine TUI behavior and documentation
janluke Sep 1, 2026
f5404ee
fix: make root task output selectable
janluke Sep 1, 2026
6332d54
feat: configure TUI output from CLI
janluke Sep 1, 2026
bc013db
feat: add interactive TUI launcher
janluke Sep 1, 2026
872d52f
docs: document interactive TUI invocation
janluke Sep 1, 2026
eb302d5
fix: refine TUI launcher navigation
janluke Sep 1, 2026
3163e38
fix: distinguish failed and skipped TUI tasks
janluke Sep 1, 2026
b218e80
refactor: simplify TUI launcher task list
janluke Sep 1, 2026
7f87664
feat: default TUI navigator to tree
janluke Sep 2, 2026
d65ce49
feat: improve TUI controls and launcher navigation
janluke Sep 2, 2026
c63f221
fix: clarify TUI launcher controls
janluke Sep 2, 2026
492e06e
feat: improve TUI launcher filter input
janluke Sep 2, 2026
00b70e6
feat: rename TUI copy view to fullscreen output
janluke Sep 2, 2026
b166ea7
fix: refine TUI fullscreen controls
janluke Sep 2, 2026
4fdac23
Add Taskfile with useful tests for the TUI
janluke Sep 3, 2026
7b380dc
fix: report task cancellation consistently across platforms
janluke Sep 3, 2026
07af9bc
refactor: observe task execution through a Listener instead of Output
janluke Sep 3, 2026
8fe8084
fix: re-execute run-once tasks on repeated launcher runs
janluke Sep 3, 2026
df75c32
fix: reject unknown task names before opening the TUI
janluke Sep 3, 2026
a4659f7
docs: describe how --output relates to the TUI
janluke Sep 3, 2026
3f4795b
fix: show calls that never ran in the task navigator
janluke Sep 3, 2026
5c8d503
fix: redraw progress bars in place in the output pane
janluke Sep 3, 2026
9a3794f
feat: copy and snapshot task output from the TUI
janluke Sep 3, 2026
bf60326
fix: copy to the clipboard in terminals without OSC 52
janluke Sep 3, 2026
84101bc
fix: copy task output without colour codes
janluke Sep 3, 2026
715b186
feat: copy task output with its colours on Y
janluke Sep 3, 2026
4be115f
fix: give the output snapshot a screen of its own
janluke Sep 3, 2026
82e25bd
fix: highlight the incomplete-snapshot warning
janluke Sep 3, 2026
426ff88
refactor: move key help to bubbles/help and add a key list
janluke Sep 4, 2026
ec2c24c
feat: show how long each task ran
janluke Sep 4, 2026
9d01b03
fix: make the key list readable and keep the way out first
janluke Sep 4, 2026
c573ae1
docs: name ANSI codes rather than colours in the key list
janluke Sep 4, 2026
f3af480
fix: label s by what it does, not what it makes
janluke Sep 4, 2026
dc6006d
fix: bind printing output to t and label it to terminal
janluke Sep 4, 2026
ad8e06d
fix: put the way back to the launcher in the footer
janluke Sep 4, 2026
83b8b63
fix: rank the arrow keys last and pair them in the footer
janluke Sep 4, 2026
af649ab
fix: report quick tasks in milliseconds
janluke Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions Taskfile.tui-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
version: '3'

tasks:
shared-dep-always-running:
desc: Two parents call a shared dependency with the default run behavior
deps: [parent-a-always, parent-b-always]

parent-a-always:
internal: true
deps: [shared-always]
cmds:
- echo "parent A continued after shared-always"

parent-b-always:
internal: true
deps: [shared-always]
cmds:
- echo "parent B continued after shared-always"

shared-always:
internal: true
silent: true
cmds:
- |
echo "shared-always START $(date +%H:%M:%S.%3N)"
sleep 2
echo "shared-always END $(date +%H:%M:%S.%3N)"

shared-dep-running-once:
desc: Two parents call a shared dependency configured with run once
deps: [parent-a-once, parent-b-once]

parent-a-once:
internal: true
deps: [shared-once]
cmds:
- echo "parent A continued after shared-once"

parent-b-once:
internal: true
deps: [shared-once]
cmds:
- echo "parent B continued after shared-once"

shared-once:
internal: true
run: once
silent: true
cmds:
- |
echo "shared-once START $(date +%H:%M:%S.%3N)"
sleep 2
echo "shared-once END $(date +%H:%M:%S.%3N)"

fail-fast-example:
desc: One task succeeds, one fails, and one is canceled by fail-fast
failfast: true
deps:
- success-after-1s
- fail-after-2s
- success-after-3s

success-after-1s:
internal: true
cmds:
- echo "1-second task START"
- sleep 1
- echo "1-second task SUCCESS"

fail-after-2s:
internal: true
cmds:
- echo "2-second task START"
- sleep 2
- echo "2-second task FAILING"
- exit 1

success-after-3s:
internal: true
cmds:
- echo "3-second task START"
- sleep 3
- echo "3-second task SUCCESS"

unresolvable-dep-example:
desc: A dep that does not exist still appears, with its own error
deps:
- compiles-fine
- typoo

compiles-fine:
internal: true
cmds:
- echo "this one resolves and runs"

skipped-deps-example:
desc: Deps skipped by platform and by an if condition, next to one that runs
deps:
- other-platform-only
- condition-not-met
- condition-met

other-platform-only:
internal: true
platforms: [plan9]
cmds:
- echo "never runs on a normal machine"

condition-not-met:
internal: true
if: 'false'
cmds:
- echo "never runs"

condition-met:
internal: true
if: 'true'
cmds:
- echo "the if condition was met, so this ran"

labelled-task-example:
desc: A dep announced by its Taskfile name, then renamed to its label
deps:
- docs

docs:
internal: true
label: Build the docs
cmds:
- echo "renamed once compilation resolved the label"

progress-bar-example:
desc: A progress bar that redraws one line, next to ordinary line output
cmds:
- |
echo "starting download"
for i in $(seq 0 5 100); do
printf "\rDownloading... %3d%%" "$i"
sleep 0.15
done
printf "\rDownloading... done \n"
echo "finished"
4 changes: 4 additions & 0 deletions call.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ type Call struct {
Vars *ast.Vars
Silent bool
Indirect bool // True if the task was called by another task

invocationID uint64
parentInvocationID uint64
rootInvocationID uint64
}
13 changes: 12 additions & 1 deletion cmd/task/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/flags"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/tui"
"github.com/go-task/task/v3/internal/version"
"github.com/go-task/task/v3/taskfile/ast"
)
Expand Down Expand Up @@ -168,7 +169,7 @@ func run() error {
calls, globals := args.Parse(cliArgsPreDash...)

// If there are no calls, run the default task instead
if len(calls) == 0 {
if len(calls) == 0 && !flags.TUI {
calls = append(calls, &task.Call{Task: "default"})
}

Expand Down Expand Up @@ -198,6 +199,16 @@ func run() error {
if flags.Status {
return e.Status(ctx, calls...)
}
if flags.TUI {
ui, err := tui.New(e.Logger, tui.Options{
Status: flags.TUIStatus,
TaskNavigator: flags.TUITaskNavigator,
})
if err != nil {
return err
}
return ui.Run(ctx, e, calls)
}

return e.Run(ctx, calls...)
}
2 changes: 2 additions & 0 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type (
Compiler *Compiler
Output output.Output
OutputStyle ast.Output
Listener Listener // Optional; observes execution and may own the terminal
TaskSorter sort.Sorter
UserWorkingDir string
EnableVersionCheck bool
Expand All @@ -81,6 +82,7 @@ type (
mkdirMutexMap map[string]*sync.Mutex
executionHashes map[string]*executionState
executionHashesMutex sync.Mutex
taskInvocationID uint64
watchedDirs *xsync.Map[string, bool]
}
TempDir struct {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 42 additions & 10 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ var (
Concurrency int
Dir string
Entrypoint string
TUI bool
TUIStatus string
TUITaskNavigator string
Output ast.Output
Color bool
Interval time.Duration
Expand Down Expand Up @@ -144,6 +147,9 @@ func init() {
pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.")
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.BoolVarP(&TUI, "tui", "T", false, "Runs Task in an interactive terminal interface.")
pflag.StringVar(&TUIStatus, "tui-status", "", "Sets TUI task status style: [icons|labels].")
pflag.StringVar(&TUITaskNavigator, "tui-task-navigator", "", "Sets TUI task navigator: [list|tree]. Defaults to tree.")
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.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.")
Expand Down Expand Up @@ -213,16 +219,14 @@ 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 err := validateTUIOptions(TUI, TUIStatus, TUITaskNavigator); err != nil {
return err
}
if TUI && (List || ListAll || ListJson || Status || Summary || Watch || Interactive) {
return errors.New("task: --tui cannot be combined with task listing, status, summary, watch, or interactive modes")
}

if List && ListAll {
Expand All @@ -249,6 +253,34 @@ 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")
}
}
return nil
}

func validateTUIOptions(enabled bool, status, navigator string) error {
if enabled {
return nil
}
if status != "" {
return errors.New("task: You can't set --tui-status without --tui")
}
if navigator != "" {
return errors.New("task: You can't set --tui-task-navigator without --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 {
Expand Down
75 changes: 75 additions & 0 deletions internal/flags/flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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: "group option without group output",
output: ast.Output{Name: "interleaved", Group: ast.OutputGroup{Begin: "begin"}},
wantError: "--output-group-begin without --output=group",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

err := validateOutputOptions(test.output)
if test.wantError == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), test.wantError)
})
}
}

func TestValidateTUIOptions(t *testing.T) {
t.Parallel()

tests := []struct {
name string
enabled bool
status string
navigator string
wantError string
}{
{name: "TUI without options", enabled: true},
{name: "TUI with options", enabled: true, status: "labels", navigator: "tree"},
{name: "status without TUI", status: "labels", wantError: "--tui-status without --tui"},
{name: "navigator without TUI", navigator: "tree", wantError: "--tui-task-navigator without --tui"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

err := validateTUIOptions(test.enabled, test.status, test.navigator)
if test.wantError == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), test.wantError)
})
}
}
Loading