When a terminal AI tool is streaming output and you try to select text with the mouse, one of two things usually happens. Either the selection vanishes the moment the screen repaints, or you manage to copy something that technically contains the text you wanted but also contains hard line wraps, box-drawing junk, line numbers, prompt chrome, and whatever else the TUI happened to be painting at the time. It’s a stupid problem, but it is real, and it shows up immediately once a terminal app becomes ambitious enough to render a live conversation rather than just print lines to stdout.
Toad takes a more serious approach. Instead of trying to preserve the terminal emulator’s native text selection while continuously repainting the screen, it moves selection up a layer. The application owns the transcript, owns the scroll state, owns the selection model, and owns clipboard export. That sounds obvious once stated. It is also the difference between “please hold Shift and hope your terminal behaves” and a UI that feels like it actually knows what text is.
This is the right idea.
The Core Distinction
There are two fundamentally different ways to copy text from a terminal app.
1. Terminal-native selection
The terminal emulator is in charge. You drag with the mouse, the terminal highlights cells on screen, and whatever is visible in that screen region is what gets copied.
This works well for plain append-only output. It works badly once an app starts doing any of the following:
- repainting prior lines
- using the alternate screen buffer
- maintaining a floating footer, status line, or input area
- wrapping long logical lines into visual lines
- mixing text with boxes, indentation gutters, or syntax-highlight spans
- streaming partial markdown that reflows as more content arrives
In other words, it works until the app becomes interesting.
2. App-owned selection
The application maintains its own model of the transcript and its own mapping from screen coordinates to logical text. The terminal is just the renderer and input transport. Mouse drags and keypresses are interpreted by the app, and the app decides what range is selected. Copying then operates over the app’s transcript model, not the terminal’s accidental visual state.
That is the Toad route.
Why the Toad Route Is Better
The biggest advantage is that it solves the right problem.
The real question is not “how do I preserve the terminal’s highlight rectangle while the screen changes?” The real question is “what text does the user mean?” Those are not the same question.
If the app owns selection, it can do all of these things cleanly:
- preserve selection while content continues streaming
- copy logical lines rather than wrapped screen lines
- exclude decorative UI from copied text
- make some widgets selectable and others not selectable
- support structured copy actions like “copy this code block” or “copy this response”
- stay sane over SSH because the transcript state lives in the remote app, not in your local terminal’s transient selection buffer
That last point matters. Over SSH, the local terminal still sends mouse and keyboard input to the remote process. If the remote app owns selection, the model still works. Clipboard transport is a separate problem, but selection itself remains coherent.
What Toad Actually Does
Toad is built on Textual, which already has a real selection model rather than treating selection as a happy accident.
The interesting part is not just that Toad renders output nicely; it is that the application and framework cooperate around selection as first-class state.
A few concrete examples from the codebase make the architecture clear.
The app listens for text selection events
In src/toad/app.py, Toad subscribes to events.TextSelected. When a selection occurs, it asks the current screen for the selected text and copies it if ui.auto_copy is enabled.
@on(events.TextSelected)
async def on_text_selected(self) -> None:
if self.settings.get("ui.auto_copy", bool):
if (selection := self.screen.get_selected_text()) is not None:
self.copy_to_clipboard(selection)
self.notify(
"Copied selection to clipboard (see settings)",
title="Automatic copy",
)
That tells you two things immediately.
First, selection is not being delegated to the terminal emulator. It is an in-app event.
Second, copied text is not whatever the terminal happened to visually highlight. The screen knows what the selected text actually is.
Selection is configurable UI behavior
In src/toad/settings_schema.py, Toad exposes:
{
"key": "auto_copy",
"title": "Automatic copy",
"help": "Automatically copy text on selection?\nDoesn't apply to text areas (use ctrl+c to copy).",
"type": "boolean",
"default": True,
}
Again, this is not accidental. Toad distinguishes between two worlds:
- conversation and output surfaces, where selection behaves like a reader/viewer
- text areas, where copy behaves like an editor
That separation is exactly what most terminal agents lack.
The conversation lives in an app-owned scroll container
In src/toad/widgets/conversation.py, Toad mounts conversation blocks into a VerticalScroll container. New widgets are posted into a persistent conversation surface, and the app anchors the scroll position to the bottom while following live output.
async def post(self, widget, *, anchor: bool = True, loading: bool = False):
await self.contents.mount(widget)
widget.loading = loading
if anchor:
self.window.anchor()
The app owns the conversation as a widget tree. That means the transcript is not merely whatever has scrolled off into the terminal’s scrollback. It is still part of the application’s state.
Streaming output goes into widgets, not raw terminal history
Agent responses in Toad are streamed into a Markdown widget via MarkdownStream.
class AgentResponse(Markdown):
def __init__(self, markdown: str | None = None) -> None:
super().__init__(markdown)
self._stream: MarkdownStream | None = None
@property
def stream(self) -> MarkdownStream:
if self._stream is None:
self._stream = self.get_stream(self)
return self._stream
async def append_fragment(self, fragment: str) -> None:
self.loading = False
await self.stream.write(fragment)
That is the right layer. Selection happens over the rendered widget content managed by the app, not over repainted terminal lines with no semantic memory of what they used to be.
Some widgets are deliberately not selectable
Toad explicitly marks some UI elements as non-selectable:
class NonSelectableLabel(Label):
ALLOW_SELECT = False
It does the same for menus, tab labels, tool headers, and other chrome. Meanwhile widgets like the embedded terminal or diff view implement get_selection(...) so copied text can be extracted logically.
This is one of the quiet advantages of app-owned selection: the app can decide what text deserves to be text.
The Escape Sequences That Make All This Possible
This architecture still runs inside a terminal. That means the app has to speak the terminal’s language. The relevant pieces are worth walking through explicitly.
1. Escape, CSI, OSC, and ST
Most terminal control sequences start with the ASCII escape byte:
ESC = 0x1b = \033
From there the main families are:
- CSI — Control Sequence Introducer
- OSC — Operating System Command
- ST — String Terminator
- BEL — bell byte, sometimes used to terminate OSC
Typical forms:
ESC [ ... # CSI
ESC ] ... # OSC
ESC \ # ST
BEL # alternate OSC terminator
You’ll see those forms repeatedly below.
2. Alternate screen buffer
Full-screen TUIs usually switch into the terminal’s alternate screen. This gives the application a private screen buffer separate from normal scrollback.
Enable alternate screen:
ESC [ ? 1049 h
Disable alternate screen:
ESC [ ? 1049 l
In escaped form:
printf '\033[?1049h' # enter alt screen
printf '\033[?1049l' # leave alt screen
This is convenient, but it immediately creates copy/selection friction if the app relies on terminal-native selection. Once you are repainting a private buffer, your terminal’s scrollback is no longer the source of truth.
Toad’s answer is not “avoid the alternate screen at all costs.” It is “if the app owns the transcript, the alternate screen stops being a conceptual trap.”
3. Mouse tracking
A terminal app that wants mouse events has to enable them explicitly.
The common mode flags are:
Basic click reporting
ESC [ ? 1000 h
Button-event tracking
ESC [ ? 1002 h
Any-motion tracking
ESC [ ? 1003 h
SGR extended mouse format
ESC [ ? 1006 h
And disable with the same codes ending in l instead of h:
ESC [ ? 1000 l
ESC [ ? 1002 l
ESC [ ? 1003 l
ESC [ ? 1006 l
Example:
printf '\033[?1000h\033[?1006h' # enable click + SGR mouse
printf '\033[?1000l\033[?1006l' # disable them
Why SGR matters: older mouse encodings are cramped and weird. SGR mode gives cleaner coordinate reporting and better handling for modern terminals.
A lot of TUI frustration comes from apps enabling mouse tracking so they can run prompts, scroll custom panes, or support click interactions — and in doing so hijacking native terminal scrolling and selection. Claude Code’s issue tracker is full of exactly this category of complaint.
App-owned selection does not eliminate the need for mouse tracking. It just makes mouse capture worth something.
4. Bracketed paste
Not strictly a selection feature, but relevant whenever terminal apps want robust input behavior.
Enable bracketed paste:
ESC [ ? 2004 h
Disable it:
ESC [ ? 2004 l
Pasted text then arrives wrapped in markers:
ESC [ 200 ~
...
ESC [ 201 ~
This lets the app distinguish real typing from pasted content. A serious terminal UI should be doing this.
5. Cursor visibility
Also not directly about selection, but part of the full-screen contract.
Hide cursor:
ESC [ ? 25 l
Show cursor:
ESC [ ? 25 h
If you are going to take over the screen, you inherit all this housekeeping.
6. OSC 52 clipboard transfer
This is the important one.
OSC 52 lets an application ask the terminal emulator to copy text into the local clipboard.
Canonical shape:
ESC ] 52 ; c ; <base64-payload> BEL
or terminated with ST:
ESC ] 52 ; c ; <base64-payload> ESC \
Example:
payload=$(printf 'hello from a remote server' | base64 | tr -d '\n')
printf '\033]52;c;%s\a' "$payload"
If your terminal supports OSC 52, your local clipboard now contains the string. That remains true even if the process emitting the sequence is running over SSH on a remote machine.
That is why OSC 52 matters so much for terminal agents.
Why base64?
Because OSC strings are control-sequence payloads and terminals are full of edge cases. Base64 avoids accidental control characters and framing breakage.
Why c?
It specifies the clipboard selection target. In practice c is the one people usually mean: the clipboard proper.
Why this is still not enough by itself
OSC 52 solves clipboard transport, not selection semantics.
If the app does not know what logical text the user intended to copy, all OSC 52 gives you is a very efficient way to put the wrong text onto the clipboard.
That is why the Toad route matters. First decide what the text is. Then use OSC 52 if available to ship it home.
How This Works Over SSH
This is the part that often confuses people.
A remote app can still own selection because the terminal connection carries both directions of the interaction.
- The local terminal sends mouse and keyboard events over SSH.
- The remote app updates its own selection state.
- The remote app redraws the highlighted selection back to the terminal.
So the selection model survives SSH just fine.
Clipboard is different. A remote server usually does not have access to your laptop clipboard. But it can emit OSC 52 to the terminal stream, and your local terminal emulator can choose to honor it.
So over SSH, the split looks like this:
- selection model — remote app-owned, works perfectly well
- clipboard delivery — depends on terminal support, OSC 52 passthrough, and whether tmux or another multiplexer mangles it
This is still much better than trying to preserve local terminal-native drag selection against a constantly repainting alternate-screen UI.
Where The Current Tools Actually Land
The cleaner comparison is not “Toad versus everyone else getting this wrong.” The cleaner comparison is that terminal agents are sorting themselves into a few recognizable architectural camps.
The broad shape now looks like this:
- Toad / Crush / much of Claude Code: app-owned selection is part of the product
- Gemini CLI: hybrid; real TUI, but willing to retreat to terminal-native selection when copying gets rough
- Goose: mostly sidesteps the issue by staying line-oriented
- OpenCode: richer client-side UI that solves the same class of problems outside the classic terminal-cell model
- Codex: transitional, moving toward more app-owned transcript and copy behavior
OpenCode is taking a different but related route
OpenCode’s latest source suggests a slightly different answer to the same underlying problem. Rather than treating the terminal as a raw cell grid and then bolting selection onto that, the current app is built as a richer client UI stack with Solid/TSX components, explicit scroll containers, and browser-like selection semantics. The important consequence is the same one Toad is chasing: text and scroll state are owned by the application layer, not left to the terminal emulator’s best guess.
A few source points are revealing.
In packages/app/src/pages/session/message-timeline.tsx, the main conversation timeline is rendered through a reversed ScrollView and surrounded with explicit wheel, touch, pointer, and keyboard handling so the app can decide when a gesture means “user is reading older content” versus “follow the live tail.” That same file wires up a jump-to-latest affordance and a staging path so backfilled history doesn’t block first paint. This is not append-only terminal output. It is a conversation viewport with policy.
In packages/ui/src/hooks/create-auto-scroll.tsx, OpenCode keeps explicit state for things like userScrolled, a manual anchor hold, and a grace window for programmatic scrolling. The hook even keeps a temporary anchor on a DOM element while the layout changes so the visible content does not jump under the user’s pointer. That is exactly the sort of machinery you end up building once you admit that “scroll to bottom unless the user is reading” is not a one-line problem.
In packages/ui/src/pierre/selection-bridge.ts, there is explicit support for restoring selection across shadow DOM boundaries and bridging line-number selection into semantic line ranges. That is not the terminal-native model at all. It is the app declaring that selection itself is part of the product surface.
OpenCode also threads selections into higher-level workflows. In file-tabs.tsx and use-session-commands.tsx, file ranges become structured context items, previews, and comment targets. Once the application owns selection semantically, it can do useful things with it besides just copying bytes.
So the OpenCode story has shifted since the early bug reports that made it look like pure copy-mode chaos. The latest source reads more like a client/server UI that is escaping the raw-terminal model entirely. Different rendering stack, same basic lesson: if the app wants stable selection, it has to own the transcript and the viewport.
Claude Code is further down this road than its public UX implies
After inspecting the shipped npm package (@anthropic-ai/claude-code@2.1.71), the interesting thing is that Claude Code is not just relying on terminal-native selection plus /copy. The bundled client contains a real in-app selection path.
The shipped TUI code:
- enters the alternate screen with
\x1B[?1049h - enables mouse modes including SGR mouse tracking (
1000,1002,1006) - maintains an internal
selectionobject in the renderer - paints the selected region over the app’s screen buffer
- marks some regions as
noSelect - implements
/copyas a structured picker over the assistant response and code blocks - supports OSC 52 clipboard copying, including tmux/screen passthrough wrappers and native fallbacks like
pbcopy,xclip,wl-copy,clip, and PowerShellSet-Clipboard
So the fair reading is that Claude Code has already built a meaningful amount of app-owned selection and copy infrastructure. The reason users still complain is not that the architecture is absent; it is that terminal UX remains unforgiving, and mouse capture, scrolling, and repaint behavior are still easy to make feel rough.
Gemini CLI is a pragmatic hybrid
After inspecting Google’s open-source gemini-cli, the first answer is simple: yes, it is absolutely a TUI. The UI code is built with Ink (render, Box, Text, useInput, useStdout), there is a MouseProvider, scroll infrastructure, alternate-screen support, and explicit terminal lifecycle hooks for suspending and resuming the app.
But the more interesting part is what Gemini’s defaults say about the problem.
In docs/reference/configuration.md and packages/cli/src/config/settingsSchema.ts, ui.useAlternateBuffer exists but defaults to false. ui.incrementalRendering is only supported when alternate buffer mode is enabled. That is a very specific trade: Gemini clearly knows how to run as a full-screen TUI, but it does not force that mode by default.
In packages/cli/src/gemini.tsx, mouse events are only enabled when alternate screen mode is active, and Ink is rendered with alternateBuffer: useAlternateBuffer. In packages/cli/src/ui/AppContainer.tsx, pressing Ctrl-S in alternate-buffer mode flips the app into a dedicated copy mode and disables mouse events so the terminal can do local selection again. The app even emits a warning toast telling the user: Press Ctrl-S to enter selection mode to copy text.
That is not the Toad route. It is a more tactical compromise.
Gemini is essentially saying:
- yes, we have a real TUI
- yes, we can use alternate screen and mouse capture
- but selection inside that mode is painful enough that we provide an explicit escape hatch back to terminal-native copying
There is more evidence of that posture. Outside alternate-buffer mode, the same hotkey does not enter copy mode; it tells the user to use Ctrl-O to expand or collapse overflowing content instead. And separately, Gemini still provides command-level clipboard paths, including a /copy command for the last AI output and clipboard helpers that support OSC 52 / TTY-based copying in SSH-style environments with native fallbacks elsewhere.
So Gemini CLI lands somewhere between the camps.
It is not pretending the terminal will magically preserve selection while the app repaints. But it is also not going fully app-owned the way Toad does. Instead it keeps a capable TUI, makes alternate screen optional, and offers explicit “selection mode” and copy commands when the terminal would otherwise fight back.
That is a sensible design, honestly. It is just a different bet. Toad says the app should own selection semantics. Gemini says the app should know when to get out of the way.
Goose mostly sidesteps the problem
Goose’s CLI is interesting because it is much less of a full-screen terminal UI than Gemini, Claude Code, or Toad. After inspecting crates/goose-cli, the core interaction model is built around rustyline for line input, cliclack for prompts and spinners, and plain stdout rendering for responses. I could not find a full-screen TUI stack here at all: no ratatui, no crossterm alternate-screen management, no mouse-capture layer, no clipboard subsystem, and no in-app selection model.
The key source files make that pretty clear.
In session/input.rs, Goose reads user input with rustyline::Editor::readline. In session/mod.rs, it creates a rustyline editor and then runs a straightforward input/handle loop. In session/output.rs, assistant text is rendered as markdown and printed to the terminal, while thinking state is shown through a spinner.
That means Goose CLI is not really taking the Toad route or the Gemini route. It is taking the older, simpler route:
- let the terminal own selection
- keep the UI mostly line-oriented
- avoid a heavy alternate-screen application shell in the first place
There is a reason this still works. If you are mostly printing transcript content to the normal terminal buffer, then terminal-native selection and scrollback remain usable by default. You are not fighting the emulator nearly as much because you have declined to build the kind of interface that creates the problem.
That is not a criticism. It is a trade.
Goose gives up some of the richer full-screen affordances these other tools chase, but in return it avoids a whole class of “why did my selection vanish while the model was streaming” nonsense. In that sense it behaves more like the control group in this comparison: a capable agent CLI that mostly stays out of the terminal’s way.
Crush is much closer to the Toad route
Crush is not shy about being a real TUI. The main entrypoint in internal/cmd/root.go boots a Bubble Tea program directly, and the UI stack under internal/ui/ is full of the usual Charm machinery: Bubble Tea models, Lip Gloss rendering, a custom list viewport, focus management, mouse filtering, and explicit chat-state handling.
The interesting part is that Crush has built a real app-owned selection path inside that TUI.
In internal/ui/model/chat.go, the chat model tracks mouse-down state, drag state, click counts, and a persistent follow flag for whether the transcript should auto-scroll. It handles:
- single click to begin selection
- double click to select a word
- triple click to select a line
- drag across items to extend the selection
- explicit highlight extraction via
HighlightContent() - visual highlight application through
applyHighlightRange(...)
That is already a very different posture from “just let the terminal deal with it.”
The copy path confirms it. In internal/ui/model/ui.go, when the mouse selection is released and a highlight exists, the UI schedules copyChatHighlightMsg, which calls copyChatHighlight(). That function copies the selected logical text to the clipboard, reports Selected text copied to clipboard, and clears the mouse-selection state afterwards.
Crush also supports keyboard-level structured copy. In internal/ui/chat/assistant.go, user.go, and tools.go, pressing c or y copies the current assistant message, user message, or tool content. And in internal/ui/common/common.go, the copy helper uses both tea.SetClipboard(text) and native clipboard writes, with the explicit comment that it aims to use OSC 52 plus native clipboard for maximum compatibility.
So Crush is not in the Goose camp, and it is not taking Gemini’s more hesitant “selection mode” compromise either. Architecturally it is much nearer to Toad and modern Claude Code: the app owns the transcript viewport, owns selection state, owns highlight rendering, and owns clipboard export.
One thing Crush does especially sensibly is scroll policy. The chat model tracks whether the view should follow the live tail, and disables that follow state when the user scrolls upward. That is the sort of boring detail that matters a lot. A selection model is only half-credible if the viewport keeps yanking itself out from under the user.
Codex still reads as transitional
Codex’s public work shows the same trend. Earlier versions leaned harder on terminal-native selection and mouse-mode toggles. Later work moved toward transcript ownership, content-anchored selection, and app-managed copy. That is the same architectural direction, even if the exact implementation differs.
The pattern is converging
Taken together, the field is less messy than it first looks.
- Toad is the cleanest statement of app-owned selection.
- Crush lands very close to that model inside Bubble Tea.
- Claude Code has already built more in-app selection and clipboard machinery than its public UX suggests.
- Gemini CLI is explicit about the compromise: a real TUI, but with deliberate escape hatches back to terminal-native copying.
- Goose avoids much of the pain by not turning the whole terminal into a constantly repainting application shell.
- OpenCode solves similar problems in a richer client UI stack rather than a classic terminal-cell model.
- Codex appears to be moving in the same direction as the more app-owned tools.
The point is not that Toad is uniquely correct and everyone else is stuck in 1998. The point is that once these tools become serious enough, they all rediscover the same underlying truth: if the app doesn’t own the transcript, the app doesn’t really own copy.
And once you see that clearly, the product decision sharpens. Explicit copy-current-response commands, code-block pickers, and export actions are still good ideas — but they work best as companions to app-owned selection, not substitutes for it.
The Tradeoffs
There is no free lunch here.
What you gain
- selection that survives streaming
- copying of logical text rather than visual debris
- better SSH behavior for selection itself
- cleaner structured copy actions
- the ability to exclude non-content UI from selection
What you pay
- more complicated rendering architecture
- more state to manage
- more code around transcript ownership
- more clipboard edge cases across terminals, SSH, tmux, and security settings
- a framework dependency that actually knows how to do this well
That last point matters. Textual gives Toad a head start because it already has a richer model for selection and rendering than the usual “repaint the whole screen and hope nobody notices” approach.
The Right Mental Model
The clean way to think about this is:
- the terminal is an I/O transport
- the app is the owner of text semantics
- OSC 52 is a clipboard transport
- alternate screen and mouse tracking are just low-level protocol details
Once you hold that line, the rest of the design decisions become easier.
If the app is rendering a conversation, then the app should know what the conversation is.
If the app knows what the conversation is, then it should know what text was selected.
If it knows what text was selected, it can copy that exact text — whether to an internal clipboard buffer, an export file, or the local clipboard via OSC 52.
That is the Toad route. It is not a small patch. It is a better answer.
What I’d Build From This
For any terminal AI tool that expects people to read and copy substantial output, the architecture should look roughly like this:
- Transcript model owned by the app
- Selection state owned by the app
- Widget-level control over selectable content
- Structured copy actions for common cases
- OSC 52 as the preferred remote clipboard path
- Fallback export and copy commands when clipboard support fails
That is a much more defensible stack than trying to keep terminal-native drag selection alive while repainting the alternate screen sixty times a second.
The terminal was never going to solve this for you. The application has to.