Embedded terminal
The editor hosts real terminals as dockable windows: terminal-based agents (Claude Code and others) and plain shells each run in their own window, sibling tabs in the bottom dock group beside Console/Assets/Source Control. It is desktop- and editor-only. Each window is one session, titled with what is running inside it. This is the code editor's one-window-per-open-file pattern.
What it is
-
A pseudo-terminal running the user's login shell (
$SHELL -lon POSIX,powershell on Windows). The login shell matters: a distributed macOS
.appbundle inherits a skinnyPATH, and-lrestores the full one sogit,claudeand other user tools resolve. The working directory is the open project's root (the home directory when no project is open). -
The child renders into a mono-font character grid with per-cell foreground/
background colour (indexed, 256-colour and 24-bit truecolour), a block cursor, bounded scrollback (5000 lines) with mouse-wheel scroll, drag selection with copy, and paste (bracketed when the app enabled it).
-
Closed by default; open a terminal from View ▸ Terminal (or **View ▸ New
Terminal**). Each session window first-appearance-docks into the bottom group; re-dock freely afterwards.
Multiple windows
Every session is its own top-level dockable window, so several agents sit as sibling tabs a click apart:
-
New Terminal — View ▸ New Terminal, the
+button in any terminalwindow's header, or a terminal tab's right-click New Terminal — spawns another session: the same login shell, in the same project working directory, inheriting the same MCP environment. Every session drains its pty every frame (bounded), so a backgrounded agent keeps running while another window is on top.
-
Opening View ▸ Terminal with no sessions spawns one; the menu item mirrors
"at least one terminal window is open", and the last window closing clears it.
-
Each window's close (×) (or the tab's right-click Close) closes the
session. Closing one whose child is still running asks for confirmation first, then terminates the whole child process tree; a window whose shell already exited closes without a prompt.
-
The session count and order are not persisted across editor runs (v1): a
fresh launch opens with a single shell.
App-aware window titles
A window's tab shows what is running inside it, from two signals:
-
the terminal title the running program sets over an OSC sequence
(
ESC ] 0 ; text BEL/ESC ] 2 ; text ST) — shells (a loginfish) and TUIs (claude,vim) announce themselves this way. It is surfaced throughEditorTerminalScreen::getTitle()(a plain string; the VT library type never leaves the.cpp); -
the foreground process name, polled at ~1 Hz (never per frame): the pty's
tcgetpgrpforeground group leader, named via libproc on macOS and/proc/<pid>/common Linux. Windows returns nothing here (the title covers the agent TUIs); the shell's own path is the floor.
Agent classification is sticky per session. A session classifies as a recognised agent from EITHER signal — the foreground process name (a prefix match) OR the title (a whole-word match, so raider never reads as aider). Once classified it STAYS that agent until the foreground process reverts to a shell (the agent exited): a live status-ticker title (an agent that streams task summaries into its window title, e.g. ✳ Check open file) can never declassify it. terminalUpdateStickyAgent is the pure transition; the panel holds the state per session.
The tab label follows from that:
-
a classified agent session shows a STABLE label — the agent's badge glyph
plus its CANONICAL display name (
Claude,Codex, …), never the moving ticker. The live VT title rides the tab's tooltip instead (hover to readCheck open file); tooltip text is filtered to codepoints the UI font can render — a leading sparkle the font lacks is dropped, not shown as?; -
an unclassified session keeps the plain-terminal behaviour: a cleaned
title (a path or path-prefixed command line trimmed to its leading app word —
/Users/me/dev/orkige→orkige,/opt/homebrew/bin/fish -l→fish; a plain title verbatim), else the cleaned foreground process name, elseTerminal N. Un-renderable symbols are stripped from that text too, so no tab ever leads with a?tofu box.
The label sits behind a stable ###terminal<id> (so a relabel keeps the window
-
docking identity while the visible part updates live). Because a dock-tab title
renders in the default UI font (which carries the icon/badge glyphs), it identifies its tenant crisply.
Agent badges
A recognised agent CLI (claude, codex, opencode, aider, gemini — a case-insensitive prefix match on the detected name) draws a small generated badge in front of the title; every other session draws the plain terminal glyph. The badge is a runtime-rasterised mark baked into the UI-font atlas under a private-use codepoint (one per agent): Claude a coral radiating-asterisk, Codex a monochrome interlocking-ring, and the rest a signature-tinted rounded square carrying the program's initial. The marks are generated procedurally (parametric strokes, never traced artwork) and render exclusively to identify the third-party program running in that session — the OS dock-icon precedent; they are not product logos and appear nowhere else. The match list names programs the user runs — the badge and label the user sees are always runtime data from the session, never a hardcoded product string. A later runtime vendor-icon discovery could replace a badge's pixels in the same atlas rect with no other change.
MCP auto-wiring
When the editor runs with its MCP endpoint enabled (--mcp-port / a token file), the terminal is the fast path to an agent that controls the very editor it lives in:
-
the spawned shell's environment carries
ORKIGE_MCP_URL(
http://127.0.0.1:<port>/mcp) and, when a token file is set,ORKIGE_MCP_TOKEN_FILE; -
each session's header carries a compact Connect button that copies the
ready-made connect command (
claude mcp add --transport http orkige $ORKIGE_MCP_URL --header "Authorization: Bearer $(cat "$ORKIGE_MCP_TOKEN_FILE")") to the clipboard; the full text is in its tooltip.
Start Claude Code in the terminal, paste the command, and it is registered against the running editor. When the MCP endpoint is off there is no env and no hint — honest silence.
The terminal itself is DELIBERATELY not exposed over MCP. A headless agent spawning shells in the editor UI is out of scope, and an MCP tool for it would launder that boundary; agents drive the editor through the MCP verbs directly.
Input
While a terminal window holds keyboard focus every key goes to the child, and the editor's own global shortcuts stand down for the frame (the same way a focused code editor swallows them):
-
printable text rides the platform IME/text-input path (UTF-8), so composed and
non-ASCII input is correct;
-
arrows, Home/End, Page Up/Down, Insert/Delete, function keys, Tab, Enter,
Backspace and Escape are encoded to their xterm sequences by a pure key encoder (
EditorTerminalKeys), honouring the app's DECCKM (application cursor keys) mode; -
Ctrl+letter sends the C0 control code (Ctrl+C→0x03, the interrupt).On macOS
Cmdstays the editor's copy/paste modifier; elsewhere copy/paste areCtrl+Shift+C/Ctrl+Shift+V, leavingCtrl+C/Ctrl+Vas control codes.
Input is queued, never truncated. A terminal accepts only about a kilobyte of pending input at a time, so a burst bigger than that — any sizeable paste — cannot be handed over in one go: TerminalPty::write appends to a FIFO queue (TerminalInputQueue), passes on as much as the child takes right now and offers the remainder again at every frame boundary beside the output drain (flushPendingWrites). Order is the contract, so a control code typed afterwards arrives after — never instead of — what is already queued, and it cannot be lost while the child's input is briefly full. Losing a tail would be worse than missing text: it strands the receiving app mid-sequence, and a bracketed paste whose closing ESC [ 201~ never arrives makes the shell treat every later keystroke as pasted text, the interrupt included. A child that never reads is bounded by the queue capacity, past which further writes are refused whole (one honest warning) rather than half-delivered.
Copy and paste
Copy/paste go through the OS pasteboard (via SDL), so text moves between the terminal and any other application:
-
Copy (
Cmd+Con macOS,Ctrl+Shift+Celsewhere) writes the drag-selectionto the clipboard. With no selection the copy chord is a no-op — on macOS
Ctrl+Cremains the interrupt (SIGINT), andCmd+Ccopies. -
Paste (
Cmd+V/Ctrl+Shift+V) writes the clipboard to the child. Itworks with plain
Cmd/Ctrl+Vregardless of the app; the bracketed-paste framing (ESC [ 200~ … ESC [ 201~) is added only when the app enabled DEC mode 2004.
The editor wires ImGui's clipboard to SDL globally, so every panel's and text field's copy/paste reaches the OS pasteboard too (not just an in-process buffer).
Query replies
A conforming terminal answers the queries apps send it — Primary Device Attributes (ESC [ c), device-status / cursor-position reports (ESC [ 5n / ESC [ 6n) and the like — on the input channel. The VT core generates those replies; the panel forwards the emitted bytes back into the pty's input (EditorTerminalScreen::setResponder). Without this a shell that probes the terminal at startup (e.g. fish) stalls a couple of seconds waiting for a Primary DA answer and then disables features, and query-driven TUI renderers degrade.
Fonts and TUI glyphs
The grid renders in the mono font, whose atlas bakes the terminal glyph blocks — Box Drawing, Block Elements, Geometric Shapes, General Punctuation (ellipsis, dashes), Arrows, Braille Patterns (spinner frames), Dingbats and Miscellaneous Symbols — so box UIs, progress bars and braille spinners render instead of ?. Because the editor uploads one static atlas, the blocks are requested up front. The system mono fonts cover only part of these (no macOS mono ships Braille), so the bundled DejaVu Sans is merged as a symbols fallback: merged glyphs never override the primary font's, so box/block art stays cell-crisp and the fallback only fills the holes (braille above all). A codepoint neither font carries (a handful of emoji-tier dingbats, e.g. U+2728 SPARKLES) draws as blank, not a ?.
Architecture
Three seams, each pure and testable, with libvterm confined to one file:
-
EditorTerminalScreen— the VT screen model: bytes in → cell grid + cursorout, plus a reply channel out (
setResponder, the query-answer bytes) and the app-announced title out (getTitle/setTitleChanged). The header speaks only the editor's cell/grid vocabulary, a plain byte-sink responder and a string title; the parsing is libvterm, quarantined in the.cpp(overlay portports/libvterm, seeDocs/ports.md). Keeping the VT core behind this seam makes it swappable. Unit-tested headlessly with scripted escape sequences (EditorTerminalScreenTests, including the DA / cursor-position replies and the OSC title). -
EditorTerminalKeys— the pure key → VT byte-sequence encoder, unit-tested asa table (
EditorTerminalKeysTests). -
EditorTerminalSession— the pure, UI-free session bookkeeping: titlecleaning, the agent classifier, tab-label composition (title vs process-name, agent detection), the post-close active index, the pure mouse→cell hit test (
terminalCellAtPoint, which the drag-selection shares so a drag past the visible rows still extends deterministically) and the agent-badge maker (per-agent private-use codepoint + signature tint + the deterministicterminalAgentBadgePixelsgenerator). Unit-tested headlessly (EditorTerminalSessionTests). The atlas wiring —EditorTheme::bakeTerminalAgentBadgesreserving one UI-font custom-rect glyph per agent and filling it from the generator — is the only ImGui-side piece. -
EditorTerminalKeys— the pure key → VT byte-sequence encoder, unit-tested asa table (
EditorTerminalKeysTests). -
EditorTerminalPty— the OS pty seam. POSIX usesopenpty+fork/execwith the child in its own session, so closing a window signals the whole process group; it also names its foreground process (
tcgetpgrp+ libproc //proc) for the window title. Windows uses ConPTY (CreatePseudoConsole+CreateProcesswith the pseudoconsole attribute) inside a Job Object so closing a window kills the child tree; UTF-8 throughout. Everything above this seam is OS-agnostic.
Output floods degrade gracefully: reads are bounded per frame (64 KB) so the UI never stalls. Closing a window or quitting the editor terminates the child.
Verification
-
EditorTerminalScreenTests/EditorTerminalKeysTests/EditorTerminalSessionTests(unit): the last covers title cleaning, the agent classifier, tab-label composition, the STICKY classification transitions (shell→agent via process or title, status-ticker titles never declassify, an agent exit reverting to a shell), the sticky tab-label rule (badge + canonical name while classified), the un-renderable codepoint filter (the sparkle-ticker tooltip and the no-tab-leads-with-?strip), the post-close active index, the mouse→cell hit test, the glyph codepoints falling in the icon atlas ranges, and the agent badges (distinct private-use codepoints, non-black signature tints, the mark stroke counts, and badge pixels that are non-empty, tinted and deterministic). -
editor_terminalselfcheck (both flavors, and Windows CI via ConPTY): spawnsa real pty running a scripted echo, asserts the grid seam (printed text + SGR colour reach the cells), types a known word through the input seam and asserts the child echoed it back, drives the paste seam (encoding + the pasted bytes reach the pty and echo, and a paste far larger than the terminal's input queue arrives WHOLE with nothing left pending), the interrupt round trip behind that burst (the C0 code signals the foreground child and the shell reports status 130 — a still-running filter would echo the question verbatim instead), the copy seam (selection text + the OS clipboard round trip via SDL, with the user's clipboard saved and restored so a test run leaves nothing behind), the reply channel (DA / cursor-position answered), the font coverage (the mono atlas bakes box drawing, block, arrows, geometric shapes, ellipsis, the merged braille spinner and the six agent-badge private-use glyphs) and the multiple-session seam (two independent grids, an OSC title on session B classified as the Claude agent with its badge codepoint, a status-ticker title left leaving the label at
Claudewhile the tooltip carries the filtered ticker, the agent exit declassifying the session, and a close that kills one child and shrinks the list), then closes and asserts the child died. Skips (exit 77) where no pty/shell is available; individual legs skip honestly where SDL video, a system mono font or a second pty is absent.
v1 limits
-
Resizing the panel re-flows the live grid but NOT the retained scrollback
(older pushed-off lines keep their old width).
-
Input larger than the terminal's input queue travels across frames at roughly
what the terminal accepts per frame (about a kilobyte), so a multi-megabyte paste takes a visible moment to finish landing. It lands COMPLETE and the UI thread never blocks, which is the trade: the alternative is a frozen editor.
-
Mouse reporting to the app is not forwarded (the app's mouse-tracking request
is detected but only affects nothing yet); selection is the mouse's job.
-
Glyph width follows the mono font's coverage; a wide glyph spans two cells but
complex grapheme widths are approximate. Colour emoji and CJK render only where the merged fonts carry the glyph; a codepoint neither carries draws blank.
- The session count and order are not persisted across editor runs.
-
The foreground-process-name fallback is POSIX-only; on Windows a session with
no OSC title reads by its numbered fallback (the agent TUIs set a title).