Running Programs on the App's Terminal
An app can hand its terminal to another program, such as a pager or the user's editor,
and take it back when the program exits,
as git log hands the terminal to less and git commit hands it to an editor.
The program draws on the whole terminal and reads every key while it runs,
and the app learns how it exited.
This page covers running a program, editing a file or text in the user's editor, what the program gets while it runs, and writing a suspension of your own. To show a program's output inside the app's UI instead, see One-Shot Commands.
Running a program
Suspend.to_program is a control that
suspends the app, runs a program until it exits,
and then calls on_exit with how it ended before the app takes the terminal back and redraws.
A log viewer that pages through git log on a key press passes its state setter as on_exit:
@component
def log_viewer() -> Div:
initial: ProgramOutcome | None = None
outcome, set_outcome = use_state(initial)
def on_input(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key="l"):
return Suspend.to_program(["git", "log", "--oneline"], on_exit=set_outcome)
case _:
return None
match outcome:
case None:
status = "Press l to page through the log"
case Exited(status=0):
status = "Back from the log"
case Exited(status=status_code):
status = f"git log exited {status_code}"
case FailedToStart(reason=reason):
status = f"Couldn't start git: {reason}"
return Div(on_input=on_input, children=(Text(content=status),))
The outcome is an Exited with the program's exit status,
or a FailedToStart with the reason the operating system gave,
such as for a program that isn't installed.
on_exit runs before the app takes the terminal back, so a state setter's change shows in the app's first frame.
This is how Bubble Tea's ExecProcess works:
a command that releases the terminal, runs the program on the app's own input and output,
and calls back once it exits.
Bubble Tea sends the program's standard error to the process's,
where counterweight sends it to the app's terminal with the rest of what the program shows.
Editing a file in the user's editor
Suspend.to_edit_file opens a file in the user's editor,
and calls on_exit with how the editor exited.
The file holds the result, so a component showing it reads it again:
@component
def config_editor(path: Path) -> Div:
contents, set_contents = use_state(path.read_text)
def reload(outcome: ProgramOutcome) -> None:
# Even an editor that exits with a failing status may have saved first, so read the file again.
if isinstance(outcome, Exited):
set_contents(path.read_text())
def on_input(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key="e"):
return Suspend.to_edit_file(path, on_exit=reload)
case _:
return None
return Div(on_input=on_input, children=(Text(content=contents),))
An editor that exits with a failing status, as vim's :cq makes it, says the user abandoned the edit,
but whatever it saved before then stays saved, so the component reloads after any exit.
The editor is the user's preferred_editor,
found as git finds it: $VISUAL, then $EDITOR, then vi,
with the variable split into words as a shell splits it, so EDITOR="code --wait" works.
Pass editor to use another one.
Editing text in the user's editor
Suspend.to_edit_text edits a string in the user's editor,
as git commit edits a commit message:
it writes the text to a file in a new temporary directory, edits the file as to_edit_file does,
and calls on_edit with the text the editor left there.
A note that the user edits in their editor on a key press:
@component
def note() -> Div:
text, set_text = use_state("# Groceries\n\n- eggs\n- flour\n")
problem, set_problem = use_state("")
def on_edit(outcome: EditOutcome) -> None:
match outcome:
case Edited(text=edited):
set_text(edited)
set_problem("")
case Exited(status=status):
set_problem(f"The editor exited {status}, so the note is unchanged")
case FailedToStart(reason=reason):
set_problem(f"Couldn't start the editor: {reason}")
def on_input(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key="e"):
# Named .md, so the editor highlights it as Markdown.
return Suspend.to_edit_text(text, on_edit=on_edit, file_name="note.md")
case _:
return None
return Div(
style=col | gap(1),
on_input=on_input,
children=(
Text(content=text.rstrip("\n"), style=border | border_lightrounded | pad_x(1)),
Text(
content=problem or "Press e to edit the note in your editor",
style=text_color("red", 400) if problem else text_color("slate", 400),
),
),
)
The outcome is an Edited with the new text,
or, if the edit failed, the editor's Exited or FailedToStart.
An editor that exits with a failing status abandons the edit, as git treats it,
so :cq leaves the text as it was.
The file name sets the editor's syntax highlighting, so note.md edits as Markdown.
Many editors end a file they save with a line break, so the edited text ends with one too.
What the program gets
- The app's terminal.
The program reads from the app's
input_streamand writes to itsoutput_stream, standard error included, rather than to the process's standard streams. An app that draws on/dev/ttywhile its standard output is a pipe, as a picker run invim $(picker)does, hands the editor the terminal, not the pipe. The app hands the terminal back as a suspension does, restoring the terminal's modes, its title, and the screen the app started on, so the program starts from a terminal like the one the shell gave the app. - The app's environment, with
envover it, or onlyenvwithclear_env, and the working directorycwd. Unlike a one-shot command, it gets noTERMof the app's choosing, since it runs on the real terminal. - Ctrl+C and Ctrl+\.
The terminal sends
SIGINTandSIGQUITto the app as well as the program, since they share the terminal. While the program runs, the app swallows them, as POSIX'ssystem()does, so they don't quit the app, and the program handles them as it would in a shell. ASIGINTsent to the app from outside (kill -INT) is swallowed too. - Ctrl+Z. The program, the app, and whatever started the app stop together,
as a pipeline stops in a shell, and the shell's
fgresumes them all.
When the program exits, the app takes the terminal again and lays out at the terminal's size, so resizing the terminal while the program runs is fine.
A suspension of your own
A Suspend's handler receives the terminal the app handed back,
as TerminalStreams.
run_program, edit_file
and edit_text are the functions the controls above run, so a handler can run several programs in turn,
or edit text and then run a program on the result.
The app takes the terminal back as soon as the handler returns, which hides what the programs printed,
so a handler that runs the tests waits for Enter before it returns, as ranger and lf do after a shell command:
WAIT_FOR_ENTER = ("sh", "-c", 'printf "\\nPress Enter to go back to the app"; read line')
async def test_then_wait(terminal: TerminalStreams) -> None:
await run_program(["make", "test"], terminal)
await run_program(WAIT_FOR_ENTER, terminal)
def run_tests_on_t(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key="t"):
return Suspend(handler=test_then_wait)
case _:
return None
The app swallows Ctrl+C only while run_program runs,
so an interrupt that arrives while the handler is doing anything else quits the app,
as it does during any suspension.
API
counterweight.programs.TerminalStreams
dataclass
counterweight.programs.run_program
async
run_program(
argv: Sequence[str],
terminal: TerminalStreams,
env: Mapping[str, str] = frozendict(),
clear_env: bool = False,
cwd: Path | None = None,
) -> ProgramOutcome
Runs argv on terminal until it exits, reading from its input and writing to its output,
standard error included, as a shell runs a program in the foreground.
Run it while the app is suspended, so the program has the terminal to itself,
as Suspend.to_program does.
While it runs, the app swallows SIGINT and SIGQUIT, as POSIX's system() does:
the terminal sends the keys that interrupt and quit, Ctrl+C and Ctrl+Backslash,
to the app as well as the program, since they share it,
so they don't quit the app, while the program handles them as it would in a shell.
Ctrl+Z stops both, as it stops any program in a terminal with whatever started it,
until the shell resumes them with fg.
| PARAMETER | DESCRIPTION |
|---|---|
env
|
Variables to set in the program's environment, over the app's.
TYPE:
|
clear_env
|
Leave out the app's environment, so the program gets only
TYPE:
|
cwd
|
The directory to run the program in, or
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
ProgramOutcome
|
How the program exited, or why it couldn't start. |
counterweight.programs.ProgramOutcome
ProgramOutcome = Exited | FailedToStart
How a program that ran until it exited ended.
counterweight.programs.Exited
dataclass
Exited(status: int)
The program exited.
status
instance-attribute
status: int
Its exit status, or the negated number of the signal that ended it, as asyncio reports it.
counterweight.programs.FailedToStart
dataclass
FailedToStart(reason: str)
The program couldn't be started, such as for an argv naming a file that doesn't exist.
reason
instance-attribute
reason: str
Why, from the operating system, such as "No such file or directory".
counterweight.programs.TimedOut
dataclass
The program was still running when its timeout ran out, so it was hung up.
counterweight.programs.edit_file
async
edit_file(
path: Path,
terminal: TerminalStreams,
editor: Sequence[str] | None = None,
) -> ProgramOutcome
Edits the file at path in an editor on terminal,
running the editor with the path after its argv (see run_program).
A file that doesn't exist yet is created if the user saves it, as vim and nano do.
An editor that exits with a failing status, as :cq makes vim do, signals that the user abandoned the edit,
though what it saved before then stays saved.
| PARAMETER | DESCRIPTION |
|---|---|
editor
|
The editor's |
| RETURNS | DESCRIPTION |
|---|---|
ProgramOutcome
|
How the editor exited, or why it couldn't start. |
counterweight.programs.edit_text
async
edit_text(
text: str,
terminal: TerminalStreams,
file_name: str = "edit.txt",
editor: Sequence[str] | None = None,
) -> EditOutcome
Edits text in an editor on terminal, as git commit edits a commit message:
writes it to a file named file_name in a new temporary directory,
edits the file (see edit_file),
and reads it back if the editor exits successfully.
The directory is removed afterwards.
The text is written and read as UTF-8. Many editors, such as vim, end a file they save with a line break, which the edited text then ends with too.
| PARAMETER | DESCRIPTION |
|---|---|
file_name
|
The file's name, which an editor picks its syntax highlighting by,
such as
TYPE:
|
editor
|
The editor's |
| RETURNS | DESCRIPTION |
|---|---|
EditOutcome
|
The edited text, or how the editor exited if it didn't succeed, or why it couldn't start. |
counterweight.programs.EditOutcome
EditOutcome = Edited | Exited | FailedToStart
How an edit ended: with the edited text, or without it, if the editor exited with a failing status
(as :cq makes vim do, to abandon an edit) or couldn't start.
An Exited outcome of an edit always has a failing status.
counterweight.programs.Edited
dataclass
Edited(text: str)
The editor exited successfully, leaving text in the file.
counterweight.programs.preferred_editor
The argv of the editor the user prefers, as git finds it:
$VISUAL, then $EDITOR, skipping one that's unset or blank, then vi.
The variable is split as a shell splits words, so one with arguments, such as code --wait, works.