Skip to content

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),
            ),
        ),
    )

A note in a rounded box, with "Press e to edit the note in your editor" beneath it

╭──────────────────────────────────────────╮
│ # Groceries                              │
│                                          │
│ - eggs                                   │
│ - flour                                  │
╰──────────────────────────────────────────╯

Press e to edit the note in your editor     

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_stream and writes to its output_stream, standard error included, rather than to the process's standard streams. An app that draws on /dev/tty while its standard output is a pipe, as a picker run in vim $(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 env over it, or only env with clear_env, and the working directory cwd. Unlike a one-shot command, it gets no TERM of the app's choosing, since it runs on the real terminal.
  • Ctrl+C and Ctrl+\. The terminal sends SIGINT and SIGQUIT to the app as well as the program, since they share the terminal. While the program runs, the app swallows them, as POSIX's system() does, so they don't quit the app, and the program handles them as it would in a shell. A SIGINT sent 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 fg resumes 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

TerminalStreams(input: TextIO, output: TextIO)

The terminal a suspended app hands back, which a Suspend handler receives.

input instance-attribute

input: TextIO

The stream the app reads input from, input_stream on counterweight.app.app.

output instance-attribute

output: TextIO

The stream the app draws on, output_stream on counterweight.app.app.

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: Mapping[str, str] DEFAULT: frozendict()

clear_env

Leave out the app's environment, so the program gets only env.

TYPE: bool DEFAULT: False

cwd

The directory to run the program in, or None for the app's working directory.

TYPE: Path | None DEFAULT: None

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

TimedOut()

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 argv, or None for the preferred_editor.

TYPE: Sequence[str] | None DEFAULT: None

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 notes.md for Markdown, or COMMIT_EDITMSG for a commit message.

TYPE: str DEFAULT: 'edit.txt'

editor

The editor's argv, or None for the preferred_editor.

TYPE: Sequence[str] | None DEFAULT: None

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.

text instance-attribute

text: str

counterweight.programs.preferred_editor

preferred_editor(
    environ: Mapping[str, str],
) -> tuple[str, ...]

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.