Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 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
1935ab8
refactor: report how a task ended instead of encoding it in an error
janluke Sep 4, 2026
9c435b0
refactor: make the listener API extensible for external clients
janluke Sep 4, 2026
88f973e
feat: prompt for required variables from the TUI
janluke Sep 4, 2026
8eb69bb
test: add prompting examples to the manual test Taskfile
janluke Sep 4, 2026
77d6ea7
fix: show confirmation prompts when the TUI hands back the terminal
janluke Sep 4, 2026
def1c80
revert: drop the terminal handover for prompts
janluke Sep 4, 2026
90e61bd
feat: let a client answer the questions Task asks
janluke Sep 5, 2026
d995c52
fix: show why a run failed before any task started
janluke Sep 5, 2026
dbc9112
feat: ask in the output pane, and ask whenever a client can answer
janluke Sep 5, 2026
e4b7c58
revert: put the question back on the whole screen
janluke Sep 5, 2026
fdbe8d2
feat: ask as a dialog over the dashboard
janluke Sep 5, 2026
584a070
fix: put the dialog's keys in the dialog
janluke Sep 5, 2026
e4196e7
fix: show a confirmation's default answer instead of implying it
janluke Sep 5, 2026
ddc12d2
fix: reject --interactive=false with --tui rather than ignoring it
janluke Sep 5, 2026
858ec86
fix: stop Enter closing a finished dashboard
janluke Sep 5, 2026
e512275
feat: save task output to files
janluke Sep 5, 2026
68748f8
feat: ask where to save, and create the path
janluke Sep 5, 2026
cf53ff2
feat: name saved output for when it was saved
janluke Sep 5, 2026
7d15acb
feat: suggest a run folder when saving every output
janluke Sep 5, 2026
aa3329e
fix: lead a saved name with the task, not the timestamp
janluke Sep 5, 2026
6df8dd4
feat: suggest saving beside the project rather than in the home direc…
janluke Sep 5, 2026
bc51427
feat: have a logs directory Task created ignore itself
janluke Sep 5, 2026
11b2aa6
test: cover the prompt handshake, and drop dead help code
janluke Sep 5, 2026
d5656cd
feat(tui): show how the selected task ended in the output header
janluke Sep 5, 2026
4c283b0
feat(tui): put the scroll position on the output pane's frame
janluke Sep 5, 2026
31ba386
feat(tui): switch the task navigator with v
janluke Sep 5, 2026
f8e3a14
feat: configure the TUI display options in .taskrc.yml
janluke Sep 5, 2026
9a0e98c
fix(tui): count the scroll indicator against the whole output
janluke Sep 5, 2026
1f64acf
feat(tui): draw a scrollbar on the output pane's border
janluke Sep 5, 2026
45c5c65
test: move the TUI demo Taskfile into testdata
janluke Sep 5, 2026
e0ee38f
feat(tui): select and copy lines with the keyboard in fullscreen
janluke Sep 5, 2026
cef215c
feat(tui): accept V as well as v to start a selection
janluke Sep 5, 2026
c4171bb
feat(tui): drop the print-to-terminal key
janluke Sep 5, 2026
9b5b922
fix: pass build tags to go test, not to gotestsum
janluke Sep 5, 2026
518cb86
docs: drop two comments that outlived the snapshot view
janluke Sep 5, 2026
f28cb4f
docs: correct what OwnsScreen refuses
janluke Sep 5, 2026
ea25cc9
feat(tui): mark a live selection more strongly than the cursor
janluke Sep 6, 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
4 changes: 2 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,13 @@ tasks:
desc: Runs test suite with watch tests included
deps: [sleepit:build, gotestsum:install]
cmds:
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' ./... -tags 'watch'
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' -- -tags 'watch' ./...

test:all:
desc: Runs test suite with signals and watch tests included
deps: [sleepit:build, gotestsum:install]
cmds:
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' -tags 'signals watch' ./...
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' -- -tags 'signals watch' ./...

bench:checksum:
desc: Runs checksum benchmarks
Expand Down
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...)
}
3 changes: 3 additions & 0 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ type (
Compiler *Compiler
Output output.Output
OutputStyle ast.Output
Listener *Listener // Optional; observes execution and may draw the display
Prompter Prompter // Optional; answers the questions Task asks
TaskSorter sort.Sorter
UserWorkingDir string
EnableVersionCheck bool
Expand All @@ -81,6 +83,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
72 changes: 62 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", getConfig(config, "TUI_STATUS", func() *string { return config.TUI.Status }, ""), "Sets TUI task status style: [icons|labels].")
pflag.StringVar(&TUITaskNavigator, "tui-task-navigator", getConfig(config, "TUI_TASK_NAVIGATOR", func() *string { return config.TUI.TaskNavigator }, ""), "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,17 @@ 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, pflag.Lookup("tui-status").Changed, pflag.Lookup("tui-task-navigator").Changed); err != nil {
return err
}
if TUI && (List || ListAll || ListJson || Status || Summary || Watch) {
return errors.New("task: --tui cannot be combined with task listing, status, summary, or watch modes")
}
if err := validateTUIPrompting(TUI, Interactive, pflag.Lookup("interactive").Changed); err != nil {
return err
}

if List && ListAll {
Expand All @@ -249,6 +256,51 @@ 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
}

// validateTUIPrompting rejects turning prompting off while the TUI is on.
//
// The interface asks in its own dialog, so it does not consult --interactive,
// and passing it would be silently ignored. It cannot honour the flag either:
// a task requiring a variable would then be unrunnable from the launcher,
// which has nowhere to pass one.
func validateTUIPrompting(tui, interactive, interactiveSet bool) error {
if tui && interactiveSet && !interactive {
return errors.New(
"task: You can't set --interactive=false with --tui: the interface asks in its own dialog")
}
return nil
}

// validateTUIOptions rejects the interface's display options without the
// interface. Only the flags are rejected: the same settings in .taskrc.yml are
// defaults for the runs that do use it, and must not fail the ones that don't.
func validateTUIOptions(enabled, statusSet, navigatorSet bool) error {
if enabled {
return nil
}
if statusSet {
return errors.New("task: You can't set --tui-status without --tui")
}
if navigatorSet {
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
115 changes: 115 additions & 0 deletions internal/flags/flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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
statusSet bool
navigatorSet bool
wantError string
}{
{name: "TUI without options", enabled: true},
{name: "TUI with options", enabled: true, statusSet: true, navigatorSet: true},
{name: "status flag without TUI", statusSet: true, wantError: "--tui-status without --tui"},
{name: "navigator flag without TUI", navigatorSet: true, wantError: "--tui-task-navigator without --tui"},
// A .taskrc.yml default leaves the flags unchanged, so an ordinary run
// is not failed by settings that only apply to the interface.
{name: "options configured, TUI off"},
}

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

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

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

tests := []struct {
name string
tui bool
interactive bool
interactiveSet bool
wantError string
}{
{name: "TUI alone"},
{name: "TUI without the flag", tui: true},
{name: "TUI with prompting asked for", tui: true, interactive: true, interactiveSet: true},
{
name: "TUI with prompting turned off", tui: true, interactiveSet: true,
wantError: "--interactive=false with --tui",
},
// The flag defaults to false, so an unset flag must not be mistaken for
// a request to turn prompting off.
{name: "TUI with the flag left alone", tui: true, interactive: false},
// Without the TUI the flag means what it always has.
{name: "prompting turned off on its own", interactiveSet: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
err := validateTUIPrompting(test.tui, test.interactive, test.interactiveSet)
if test.wantError == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), test.wantError)
})
}
}
Loading