Skip to content

feat: add interactive TUI for task execution - #3015

Open
janluke wants to merge 82 commits into
go-task:mainfrom
janluke:tui
Open

feat: add interactive TUI for task execution#3015
janluke wants to merge 82 commits into
go-task:mainfrom
janluke:tui

Conversation

@janluke

@janluke janluke commented Sep 3, 2026

Copy link
Copy Markdown

task --tui

Summary

This PR adds an interactive terminal UI to Task, but it is really two separable things:

  1. A public API that lets any client watch a run and answer the questions Task asks.
  2. A TUI built on that API, shipped inside Task.

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: Listener and Prompter

Listener — watching a run

listener.go. A struct of optional callbacks rather than an interface:

field when it fires
Scheduled(Invocation) a call is known about, before it runs
Started(Started) a call begins, with the executor's own timestamp
Finished(Finished) a call ends, with a Result, an error and a duration
Joined(Joined) a call joined an existing run: once execution instead of running
OutputFor(Invocation) (io.Writer, io.Writer) where this call's stdout and stderr should go
OwnsScreen bool the client is drawing the display; Task must not write to the terminal

I 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.ClientTrace is the precedent in the standard library.

Invocation identifies one call: ID, ParentID, RootID, the Task name as written in the Taskfile, and a display Name (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 questions

prompter.go. Two methods:

type Prompter interface {
    Confirm(task, message string) (bool, error)
    Ask(VarRequest) (any, error)
}

Confirm covers a task's prompt:.

Ask covers a required variable that was not supplied, and carries a VarType: a sum type that is StringVar or EnumVar today. 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 Prompter is itself the statement that someone is there to answer, so no terminal and no --interactive are required. A client that wants questions refused can refuse them in its own Prompter, which is a better place for that decision than a flag, since it can answer some and decline others.

CLI usage

task --tui                 # open the launcher
task --tui build test      # run these tasks in the TUI
flag meaning
--tui, -T run in the TUI
--tui-status [icons|labels] how a task's state is shown in the task pane (default icons)
--tui-task-navigator [list|tree] how tasks are arranged (default tree)

The two display options can be set once in .taskrc.yml instead of being repeated:

tui:
  status: labels
  task-navigator: list

TASK_TUI_STATUS and TASK_TUI_TASK_NAVIGATOR work 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.

--output keeps 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 launcher

Opening task --tui with 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 launcher-filter

Two ways to launch, because they answer different needs:

  • Enter runs the task inside the TUI.
  • Ctrl+R hands the terminal back and runs it exactly as task <name> would, with Task's normal output and your --output style.

After a run finishes, Escape or b returns to the launcher.

The run view

Run view screenshot run-view

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:

  • the "tree" view (default) shows the call tree;
  • the "list" view flattens it to one level per root task.
Tree vs list view
tree-view-crop list-view-crop

You can switch between them with n. The default one can be set in .taskrc.yml with tui.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
tree-view-run-once list-view-run-once

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 help
↑/↓       scroll the output
←/→/tab   switch pane
click     focus a pane
wheel     scroll the output
pgup/pgdn scroll a page
g         jump to start
G         jump to end
f         output fullscreen
y         copy output without ANSI codes
Y         copy output with ANSI codes
s         save output to a file
S         save every output to a folder
n         switch task view: tree or list
esc/b     stop, open launcher
q         stop and quit
?         show this list

Exporting task outputs

Copy the output to the clipboard

  • y copies ("yanks") the selected task's output with ANSI escape sequences stripped.
  • Y keeps 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)

  • s saves the selected task's output;
  • S saves every task's output into a folder, one file per task.

Both ask where in the footer, suggesting logs/<task>.<timestamp>.log and logs/<task>.<timestamp>/ beside the project.

Implementation notes:

  • missing directories are created automatically
  • saved output keeps its escape sequences, so less -R shows the colour
  • a logs directory that Task itself creates is given a .gitignore that ignores itself, so it never shows up in git status; a logs directory 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:

  • press v (or V) to start a selection (the V key comes from Vim's visual mode)
  • use up/down arrow keys to expand/reduce the selection
  • use y or Y to copy the selection to the clipboard, with or without ANSI escape sequences, respectively
  • press v again 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-view
Full-screen mode: active selection full-screen-view-selection

Prompts and confirmations

Confirmation prompt confirm-dialog

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, --tui behaves as though --interactive were set — it is the Prompter that enables prompting, not the flag. --tui --interactive=false is therefore rejected with an error rather than silently ignored, since the two ask for contradictory things.

Open questions and limitations

  1. -T as a shorthand for --tui. Should we keep the -T flag?

  2. 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.

  3. interactive: true tasks 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.

  4. --watch is refused too, but only for want of work in the TUI, not for want of API. watchTasks already runs tasks through RunTask, so a Listener sees 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.

  5. testdata/tui/Taskfile.yml is 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.

  6. 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:all and task test:watch passed -tags to gotestsum rather than to go test. gotestsum parses its flags with pflag, which reads -tags as the shorthand -t with the value ags and fails. test:all had never worked; the line dates from d6d51a2f (Dec 2022) and no CI job calls gotestsum, which is why it went unnoticed. Fixed here since it blocks running the suite.

  • Not fixed, reported for information: --watch never resets run state between iterations, so a run: once dependency runs on the first file change and never again, and taskCallCount accumulates until MaximumTaskCall trips. Reproduced on main, 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

go run ./cmd/task --dir ./testdata/tui --tui

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

  • Review the documentation once the design is approved/stable.

Checklist

  • I have read and followed the Contribution Guide.
  • I have disclosed the use of any AI-generated content in this pull request per the AI Usage Policy.
  • I fully understand the changes and have hand-written the description (No AI) of this pull request. (*)

(*) 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.

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.
@janluke

janluke commented Sep 6, 2026

Copy link
Copy Markdown
Author

@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 TUI no longer piggybacks on internal/output. This TUI experiment originally started as a fourth --output tui mode (this was before there was a launcher). The TUI used to implement output.Output and be discovered by type-asserting that field. When I asked the agent to change approach and add a launcher, it somehow recycled the previous code.
    Now, there's a proper public API instead: Listener for watching a run, Prompter for answering the questions Task asks. The internal/output is back to a zero diff against main. The new Listener and Prompter API is the part I would most like reviewed.

  • Prompts and confirmations work inside the TUI. Required variables and prompt: are asked in a dialog, whenever they come up, including partway through a run.

  • There are now several ways to get text out: clipboard, save to file, and keyboard line selection in the full-screen view, which is the one I would try first.

  • Many bug fixes and UI/UX improvements, including a help/keys screen, scrollbar in output pane, task duration times, status and exit code of each task in the output pane...

The Windows CI failure should be gone.

I'll stop here and wait for feedback now.

@janluke
janluke marked this pull request as ready for review September 6, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a interactive TUI powered by Bubbletea

1 participant