feat: add interactive TUI for task execution - #3015
Conversation
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.
This file should probably be removed before merging the PR or moved to a more appropriate location maybe.
The TUI classifies a task as canceled when its completion error wraps context.Canceled. A killed process does not report that the same way everywhere: the shell interpreter surfaces the context error on Unix, while on Windows the same kill arrives as a plain non-zero exit status. On Windows every in-flight task was therefore marked failed with a spurious "exit status 1" whenever a run was interrupted or fail-fast cancellation kicked in. Consult the context when reporting a completion so the distinction is portable, wrapping the task error rather than replacing it so its message survives for display. Fixes TestTaskLifecycleReportsFailfastCancellation on Windows.
The TUI was originally conceived as a fourth output mode, so it was built as an output.Output and installed by swapping Executor.Output at runtime. Everything else about it was then discovered by type-asserting that field: two lifecycle interfaces, a task-aware writer interface and a TerminalUI marker. That conflated two unrelated things. Output describes where bytes go; it should not also carry execution lifecycle events or a capability saying who owns the terminal. The launcher settles it: --output has to stay meaningful alongside the TUI, because tasks started with ctrl+r run with Task's normal terminal output. The two are orthogonal, so the TUI cannot be an output mode. Replace the four optional interfaces with one task.Listener and an optional Executor.Listener field. A nil Listener behaves exactly as before. internal/output goes back to describing output only. Also drop --tui from the executor's error messages, which no longer knows the listener is a TUI, and name invocations with t.Name() rather than the often-empty t.Prefix.
The TUI reuses a single Executor for every launcher selection, but the map of started executions that "run: once" and "run: when_changed" calls join is only ever populated, never cleared. Running a task, returning to the launcher and running it again found the first run's finished execution and returned its result instantly: no execution, no output, a green tick. The per-task counter behind MaximumTaskCall accumulates the same way and would eventually refuse to run a task at all in a long session. Add Executor.ResetRunState to clear both, and call it for each launcher selection. Default "run: always" tasks hash to the empty string and never reached the map, which is why this went unnoticed.
Task existence is checked inside Executor.Run, which the TUI only reaches after the Bubble Tea program has started. "task --tui nope" therefore took over the terminal and reported the error inside a dashboard the user then had to quit. Resolve the requested calls before the alt screen opens so the error lands on the terminal like any other.
The dashboard gives each invocation its own output pane, so --output does not apply to tasks run inside it, but it does apply to tasks started with ctrl+r. Say so in the CLI reference. Also note in RunTask why a call that fails to resolve or compile emits no lifecycle events and so stays out of a listener's task list.
A call was announced to a Listener only after it had been resolved and compiled, so a dep that could not be resolved -- a typo in a deps list, a broken variable template, a missing required variable -- produced no events at all and never appeared in the TUI. The error reached the user only through the calling task. Announce the call before Task decides whether to run it, using the name written in the Taskfile, and re-announce with the compiled name on start so labels and included-taskfile prefixes still display correctly. An unresolvable call now appears in its own right, failed, with its error in its output pane. Announcing earlier also makes visible two paths that end without running anything: a task excluded by platforms, which used to report nothing at all, and one whose if condition was not met, which used to report success despite never running. Report both with ErrSkipped so they show as skipped rather than green. Add examples to Taskfile.tui-tests.yml covering each case.
Sharing the screen with the task list was the wrong call. Seeing that another task is twelve seconds in does not help anyone decide which environment to deploy to, and the reason I gave for it confused two things: when a question arrives, which is worth knowing, and what a reader needs while answering it, which is only the question. A blocking question is modal. Nothing else can proceed until it is answered, and taking the screen says so, where panes drawn around it suggest an interface that is still live. It also leaves room for a message whose length the Taskfile author chose, and stops an input being asked for inside a pane titled OUTPUT. Keeps the word wrapping, which a Taskfile author cannot do for us, and the header naming the task that is asking, which is the part of the context that was worth having.
A blocking question is an interruption, not a place you navigated to, and a box drawn over the interface reads that way. Filling the screen made it look like the key list, which is a reference you asked for and can dismiss; a question is neither. Title the dialog for the task that is asking, rather than putting the name at the far end of a header, since a question arriving minutes into a run needs to say who wants the answer. Positioning a layer needs a Compositor: Canvas.Compose draws into the whole canvas and ignores a layer's coordinates, which put the dialog in the corner over a blank screen. The box is capped to the terminal, so a long list of options cannot grow it past the edge.
The footer belongs to the view underneath, and that view is inert while a question is waiting: every key goes to the dialog. So the footer was offering controls that did nothing, and offering them ten rows from the box they did not apply to. Move them into the dialog and leave the footer empty while it is up. lazygit disables its global keybindings for the same reason when a confirmation is active.
Enter answered no, which was neither documented nor visible. Task's terminal prompt encodes the default in the capitalisation of "[y/N]", which is the convention people misread, and the dialog had dropped even that. List the two answers with the default highlighted, so the answer Enter gives is the one you can see. That also gives confirmations and enum variables the same interaction, rather than two to learn in one feature: choose, then Enter. Pressing y or n still answers directly, and both are listed among the keys.
The interface asks in its own dialog and does not consult --interactive, so passing it turned off did nothing and said nothing. It cannot be honoured either. Prompting is what makes a task with required variables runnable from the launcher, which has nowhere to pass one, so turning it off would leave those tasks unreachable. Refusing the combination says so instead of quietly picking one meaning. Only an explicitly passed flag is rejected. The flag defaults to false, and a default must not be mistaken for a request.
Enter closed the interface once a run had finished, and appeared nowhere among the keys. It is the confirm key in every dialog, so pressing it out of habit threw away output the run had left to read, with nothing having suggested it would. q and ctrl+c close the interface and are listed. Enter now does nothing here, which is what an unlisted key should do.
There was no way to keep a task's output. Copying puts it on the clipboard and printing puts it in the scrollback, but neither leaves anything behind, and a failed build is exactly what someone wants to keep. s writes the selected task's output to a file in the current directory. S writes every task's output to a new folder, one file per task, which is what is wanted after a run with several failures. Output is written as the command produced it, escape sequences included: cat and less -R render the colour, and what is not stripped can still be stripped later. Nothing is ever overwritten; a name already taken moves on to the next. Task names are not usable as file names. A namespace colon, a wildcard, or a label containing anything at all becomes a safe name, and a name with nothing usable in it falls back rather than producing an empty one.
Saving invented a name in the working directory, which is presumptuous: the user knows where they want a log and had no way to say. Both keys now ask in the footer, filled in with a default so Enter alone still works. The dashboard stays visible, since a path is a small enough question not to warrant a dialog. A leading ~ is expanded, and any directories the path needs are created. The path was typed deliberately, so an existing file is replaced, as a shell redirect would. Two tasks whose names clean up to the same file name still do not overwrite each other. A failed save reports why. The path is dropped from a filesystem error: it is the one just typed and still on screen, and keeping it pushed the reason off the end of the footer, leaving "save failed: mkdir /very/long /path..." with the useful half missing.
Both keys now suggest ~/logs, so output goes somewhere kept rather than scattered through whichever directory Task was run from. A saved file is <timestamp>.<task>.log. The timestamp leads so a folder of logs sorts by run, and it is spelled with dashes because a file name cannot hold colons on Windows. Every file of one save-all shares a timestamp, so a run stays together. Saving all asks only for the folder now. The files inside are named the same way a single save names its own, rather than a second scheme to learn, and a name no longer has to be invented per file. Also fixes an ineffectual assignment in the tests.
Saving all now suggests ~/logs/<timestamp>.<task>, a folder standing for one run, rather than a bare ~/logs that every run would share. The folder is named for the task that was run, not for whichever task happens to be selected, since the selection is incidental to what is being saved. Files inside drop the timestamp. The folder already says which run this was and when, and repeating it gave paths reading <timestamp>.<task>/<timestamp>.<task>.log. A single save keeps its timestamp: it has no folder to say it.
Spending the sort key on the timestamp bought ordering that ls -t already gives for free, while nothing but the name can group a task's logs together. Leading with the task buys the grouping and keeps the ordering within it. It also lets shell completion narrow on a task without having to know what date the run was.
…tory ~/logs collects a build log from every project into one folder, where they are named identically and cannot be told apart. Every project has a build task, and a timestamp does not say which repository it came from. logs/ beside the project has no such ambiguity: the folder is the project. It is visible, unlike .task, which is Task's own directory and is deleted when checksums go stale, taking anything saved there with it. The cost is that logs/ is not ignored the way .task is, so it appears in git status until added to .gitignore.
Saving beside the project left an untracked logs/ in git status, which a stray git add would commit. A directory the user did not make should not become their problem. Task now leaves a .gitignore in the logs directory it creates, so the directory ignores itself. Verified against git: nothing appears in status and nothing is added. Only a directory Task itself creates, at the head of a relative path, and only when no .gitignore is there already. A directory that already exists is the user's to manage, and an absolute path was asked for by name, so neither is touched.
The bridge between a task asking a question and the interface drawing it was untested: a channel, a mutex, and the fallback that releases a task when the interface has closed. A mistake there hangs Task rather than failing it, which is the worst way this can go wrong. Extract the wait so it can be tested directly, and cover both what it is for and what it guards against: an answer arrives, a stopped interface releases a waiting task, questions asked at once are serialised rather than deadlocking, and asking with no interface returns instead of blocking forever. Writing those found a case the code did not handle. With an answer given and the interface stopped at the same moment, select picked between them at random and could discard the answer. An answer already given now wins, the same way a finished execution wins a cancelled context in startExecution. ask duplicated the program lookup that send already does; send now reports whether anything received the message, which ask needs anyway. The FullHelp methods were unreachable: the key list is rendered through allBindings and fullHelp, and nothing used help.KeyMap.
The output pane header now carries the selected task's state, and the exit code alongside it when the task ran a command that reported one. Telling "exit 127" from "exit 1" no longer means reading the output. A task that failed because a dependency did carries that dependency's error, so the code is shown only when the error names the task itself.
The pane header's right slot held the scroll position, which now has to share it with the task's status. The position moves to the bottom right of the pane's border, where a pager puts it, so neither has to be shortened and no row of output is spent on it.
A deep tree is sometimes easier to read flattened, and the choice was only available as a flag before the run started. The key appears in the ? list rather than the footer, which has no room to spare.
tui.status and tui.task-navigator supply the defaults for the two --tui-* flags, with TASK_TUI_STATUS and TASK_TUI_TASK_NAVIGATOR alongside. They are display preferences, so a file is a better home for them than a flag repeated on every run. Whether to use the interface at all stays a flag. It needs a terminal, so a setting that turned it on by default would fail every piped or scripted run. The flags without --tui are still rejected, but the check now looks at whether the flag was passed rather than whether the value is empty, so a configured default does not fail an ordinary run.
The viewport's ScrollPercent measures position within the scrollable range, so it reads 0% at the top no matter how much of the output is already on screen. The label now counts the last visible line against the total, the way a pager does: output three lines taller than the pane starts at 73% rather than 0%, and still reaches 100% at the end.
A percentage has to be explained: three conventions are in use, and the one the viewport implements measures position within the scrollable range, which reads 0% at the top however much of the output is already on screen. A scrollbar shows position and length at once and needs no convention. The thumb replaces the border cell of each viewport row, so it costs neither a column of output nor a row for a label. It keeps the border's colour, so it still follows which pane has the focus, and a block against a thin line stays legible where a palette flattens the two. This replaces the percentage added in 694ad9d9 and 72509533.
The contributing guide points contributors at 'go run ./cmd/task --dir ./testdata/<dir>' for trying a development build by hand, so testdata/tui is where someone would look for this. Running it with --dir also keeps whatever the save keys write inside testdata/tui rather than the repository root. The name loses 'tests': nothing runs these automatically, and each task already carries a desc explaining what it demonstrates, so --list reads as a menu of them. Timestamps drop their milliseconds, which came from date's GNU-only %3N and print literally on macOS.
Fullscreen existed to get a few lines out of the dashboard, but offered no way to take fewer than all of them: y, t and s all act on the whole output. It now carries a line cursor, and v starts a selection that the cursor extends in either direction. With lines selected, y and Y copy those instead. Lines are copied as they were written rather than as they were folded to the pane, so the output is wrapped before it reaches the viewport: one viewport row is then one screen row, and a map from logical lines to rows places the cursor exactly. Moving the cursor restyles only the rows whose highlighting changes, so a large output costs nothing to walk. Selected lines are drawn without their own colours. A background cannot survive the resets inside text that colours itself, so the highlight is painted over stripped text; Y still copies the sequences. A selection pins the view, since following new output would drag it away from the lines being picked out, and is cleared if the output is trimmed from the front, which renumbers every line the cursor and anchor held. The task navigator toggle moves from v to n, leaving v to mean selection in both views, as it does in vim.
v is vim's visual mode and the key most people reach for, but its line-wise form is V, which is what this selection is; tmux copy-mode uses V for a line too. Binding both means neither habit meets a key that does nothing.
t released the terminal, printed the selected task's output into the normal scrollback and waited for Enter, so that the terminal's own selection could be used on it. Selecting lines with the keyboard does that job better: it needs no handover, works where the clipboard does not reach the whole output, and takes only the lines asked for. No other terminal interface I could find does this. The nearest are Midnight Commander's Ctrl+O, which shows the shell rather than a pane's text, and file managers that print a result on exit. The clipboard notice pointed at t when it could not confirm a copy; it now points at s, which writes the output to a file.
gotestsum parses its own flags with pflag, which reads -tags as the shorthand -t with the value 'ags' and fails with 'unknown shorthand flag'. Its usage is 'gotestsum [flags] [--] [go test flags]', so the tags belong after --. test:all had them before the package list and could never run. test:watch had them after it, which worked by position; both now say so explicitly.
It said Task refuses confirmation prompts when a client owns the screen, which stopped being true when Prompter was added: a client that sets one is asked through it. Only interactive tasks and watch mode are still refused.
Both were drawn with the same highlight, so pressing v changed nothing on screen when the cursor sat on a single line, and pressing it again to leave selection was just as invisible. Leaving by mistake is not harmless: a copy then takes the whole output instead of the lines that still looked selected. A resting cursor keeps the quiet slate background; a live selection is drawn in the accent colour. The painted style is now tracked alongside the painted span, since a selection that starts on one line changes the style without changing the span, and the repaint was being skipped. The comment and the guide claimed v again fixed the range and freed the cursor, which was never true: it cancels, as leaving Vim's visual mode does. Both now say so.
|
@andreynering I have iterated on this and made many changes, so I've rewritten the PR description from scratch. The short version of what changed:
The Windows CI failure should be gone. I'll stop here and wait for feedback now. |
task --tuiSummary
This PR adds an interactive terminal UI to Task, but it is really two separable things:
Perhaps, they could be split into two PRs, and I am happy to do that if you would prefer to look at them separately. The API is the part that deserves the closest reading: it is a v3 commitment, so whatever lands is what external clients build against for the life of v3. The TUI, by contrast, can be changed or replaced later without breaking anyone.
~6,000 of the lines added in this PR are in
internal/tui.Cost to the rest of Task is +543 −43 across 12 files, two thirds of which are the two new API files.
The new API:
ListenerandPrompterListener— watching a runlistener.go. A struct of optional callbacks rather than an interface:Scheduled(Invocation)Started(Started)Finished(Finished)Result, anerrorand a durationJoined(Joined)run: onceexecution instead of runningOutputFor(Invocation) (io.Writer, io.Writer)OwnsScreen boolI opted for a struct of callbacks, not an interface. Why? Adding a method to an interface breaks every implementation; adding a field to a struct of callbacks does not.
httptrace.ClientTraceis the precedent in the standard library.Invocationidentifies one call:ID,ParentID,RootID, theTaskname as written in the Taskfile, and a displayName(the task's label when it has one). IDs are unique per executor, so repeated calls to the same task are distinguishable, which is what makes a tree of invocations possible rather than a list of task names.Prompter— answering Task's questionsprompter.go. Two methods:Confirmcovers a task'sprompt:.Askcovers a required variable that was not supplied, and carries aVarType: a sum type that isStringVarorEnumVartoday. The type is what lets a client choose the right widget and validate the answer, and being a sum type means new kinds can be added later without breaking clients.Setting a
Prompteris itself the statement that someone is there to answer, so no terminal and no--interactiveare required. A client that wants questions refused can refuse them in its ownPrompter, which is a better place for that decision than a flag, since it can answer some and decline others.CLI usage
--tui,-T--tui-status [icons|labels]icons)--tui-task-navigator [list|tree]tree)The two display options can be set once in
.taskrc.ymlinstead of being repeated:TASK_TUI_STATUSandTASK_TUI_TASK_NAVIGATORwork as well. Whether to use the TUI at all is deliberately not a config option: it needs a terminal, so a setting that turned it on by default would break every piped or scripted run.--outputkeeps its normal meaning for tasks launched outside the TUI (see Ctrl+R below) and has no effect on tasks running inside it, where each pane shows raw command output.The launcher
Launcher screenshot
Opening
task --tuiwith no task names lists every task with a description, and a filter box above it. Typing filters on both name and description; Escape clears the filter.Filtering screenshot
Two ways to launch, because they answer different needs:
task <name>would, with Task's normal output and your--outputstyle.After a run finishes, Escape or
breturns to the launcher.The run view
Run view screenshot
The task navigator (left pane)
The task pane (or "task navigator") shows each invocation, not each task name, so a task called twice appears twice.
Two navigator styles are available:
Tree vs list view
You can switch between them with
n. The default one can be set in.taskrc.ymlwithtui.task-navigator. The default setting can be overridden on the command line with--tui-task-navigator.When multiple tasks depend on or call a shared task configured with
run: once, the shared task appears multiple times in the tree, marked with↳. In the list view, it appears only once.If the same task is called multiple times, in the list view, each invocation is numbered with a prefix
#<n>to distinguish them.Tree vs list view: run-once dependency
Each row carries a status, and an elapsed time that counts up while the task runs and settles when it finishes.
The output pane (right pane)
The output pane's header names the selected task and the current status (in the top right corner). For a failed task, the status includes the exit code between parentheses, e.g.
failed (1).A scrollbar appears on the pane's right border when the output is taller than the pane.
Help view
?lists every key available in the current view. At the moment of writing:Help screenshot
Exporting task outputs
Copy the output to the clipboard
ycopies ("yanks") the selected task's output with ANSI escape sequences stripped.Ykeeps them, for pasting somewhere that renders them.Copying uses OSC 52 and, where one exists, a clipboard helper (
wl-copy,pbcopy,xclip,xsel,clip.exe), because OSC 52 travels over SSH but is ignored by some terminals, VTE-based ones included.Save as file(s)
ssaves the selected task's output;Ssaves every task's output into a folder, one file per task.Both ask where in the footer, suggesting
logs/<task>.<timestamp>.logandlogs/<task>.<timestamp>/beside the project.Implementation notes:
less -Rshows the colourlogsdirectory that Task itself creates is given a.gitignorethat ignores itself, so it never shows up ingit status; alogsdirectory that already existed is left alone.Use full-screen view
You can use the full-screen view described below to select and copy a portion of the output.
Full-screen output view
By pressing
f, the output pane takes over the whole screen. Besides displaying the output on a larger area for an easier read, this view is useful to select and copy a portion of the whole output, without using a mouse (*). To do that:v(orV) to start a selection (theVkey comes from Vim's visual mode)yorYto copy the selection to the clipboard, with or without ANSI escape sequences, respectivelyvagain to stop selection mode.(*) Selection using a mouse is only partially supported: the selection is cleared every time you scroll with the mouse wheel, and you cannot select more text than is visible on the screen.
Full-screen mode: no active selection
Full-screen mode: active selection
Prompts and confirmations
Confirmation prompt
Required variables and
prompt:confirmations are asked in a dialog over the run view, with the dialog's own keys shown inside it and a confirmation's default answer marked. Questions are asked whenever they arise, including partway through a long run, not only before it starts.Because the TUI can always ask,
--tuibehaves as though--interactivewere set — it is thePrompterthat enables prompting, not the flag.--tui --interactive=falseis therefore rejected with an error rather than silently ignored, since the two ask for contradictory things.Open questions and limitations
-Tas a shorthand for--tui. Should we keep the-Tflag?Naming. The docs call the two-pane view "the dashboard". I don't like that name too much, and I am open to suggestions. "Run view" is used in this PR instead; it needs to be one or the other in both places.
interactive: truetasks are refused under--tui. They need the raw terminal the TUI is drawing on. I do not think this is solvable; Ctrl+R from the launcher is the way to run them.--watchis refused too, but only for want of work in the TUI, not for want of API.watchTasksalready runs tasks throughRunTask, so aListenersees a watch run today. What is missing is a decision about what the task tree should do across iterations, and a way to interrupt a watch loop that never returns.testdata/tui/Taskfile.ymlis a demo Taskfile; each task demonstrates one behaviour. No automated test uses it though. I would keep it, since it is how anyone would try this, but it can go.The
?page does not scroll, so on a terminal that is both narrow and short the tail of the key list is unreachable.Unrelated fixes and findings
task test:allandtask test:watchpassed-tagstogotestsumrather than togo test.gotestsumparses its flags with pflag, which reads-tagsas the shorthand-twith the valueagsand fails.test:allhad never worked; the line dates fromd6d51a2f(Dec 2022) and no CI job callsgotestsum, which is why it went unnoticed. Fixed here since it blocks running the suite.Not fixed, reported for information:
--watchnever resets run state between iterations, so arun: oncedependency runs on the first file change and never again, andtaskCallCountaccumulates untilMaximumTaskCalltrips. Reproduced onmain, so it is not from this PR.Executor.ResetRunState(), which this PR adds, is the one-line fix. Happy to open a separate issue, or to include it here if you would rather.Trying it
AI usage
I used Codex with ChatGPT 5.6 Sol in the initial phases of development, up until I opened this PR. I used Claude Code with Opus 5 since I first opened this PR.
Related issue
Closes #2077.
To do
Checklist
(*) I haven't reviewed the code line by line yet, but I took part in every decision, often steering the code agent in the direction I wanted. I'll check the code more in depth after I get feedback from the maintainers on the overall API and UI design.