term — the lightweight terminal

CosMix Term (apps/term, binary term) is the maintained tabbed Mix terminal on iced over the toolkit-free cosmix-term-core. It registers the Bus name term and serves term.*. mix --gui selects Term; COSMIX_TERM_BIN remains an explicit binary override.

The Bevy BTerm frontend was retired on 2026-10-01. Its source and historical comparison gates are preserved at tag archive/bterm/2026-10-01. References to BTerm below describe the former shared-core implementation, not a shipped second frontend.

Tabs and panes

The tab strip across the top shows one button per tab and a + that opens a new one. Clicking a tab selects it. Each tab holds a tree of split panes, and clicking a pane focuses it. When a tab has more than one pane, the focused pane's border takes the design's focus-ring colour. Every other border, including a tab's only pane, takes the plain border colour. All strip and border colours come from the cosmix-design tokens.

Each tab shows its focused pane's program-set OSC title, falling back to mix. A user-pinned title overrides OSC updates until cleared. The strip does not scroll or wrap. At the default window width, past about 17 tabs the + and the later tabs run off the right edge, although up to 32 terminals can be open. Ctrl+PageUp and Ctrl+PageDown still reach every tab.

Every pane keeps a border of the same width whether or not it is focused, so moving focus never resizes a PTY. Split edges are placed on whole physical pixels, so a pane's grid is never drawn half a pixel off the display.

Only the active tab's panes are drawn. A hidden tab keeps running and holds no grid buffers. When it is shown again every pane repaints in full.

When a pane's shell exits, the pane closes. Closing the last pane of a tab closes the tab, and closing the last tab quits. A shell that exits on its own raises a desktop notification, as in bterm. Set TERM_NOTIFY=0 to turn that off.

Keys

Tab and pane chords use the modifiers shown below. Any chord that also holds Alt or Super is left alone.

KeysAction
Ctrl+Shift+Tnew tab
Ctrl+Shift+Wclose the active tab
Ctrl+PageDown / Ctrl+PageUpnext / previous tab
Right Shift+Right / Right Shift+Leftnext / previous tab
Tab / Shift+Tab (with split panes)next / previous pane in layout order, wrapping at either end
Ctrl+Tab / Ctrl+Shift+Tabnext / previous pane in the current tab, in layout order, wrapping at either end
Ctrl+Shift+Esplit the focused pane side by side
Ctrl+Shift+Osplit the focused pane top and bottom
Ctrl+Shift+Xclose the focused pane
Ctrl+Shift+arrowsfocus the nearest pane in that direction
Ctrl+Shift+Qquit

Held, these chords do not repeat. A held Ctrl+Shift+T opens one tab, and the repeats are dropped rather than sent to the shell.

Pane cycling changes only keyboard focus within the current tab, following the split tree's first pane before its second pane. Bare Tab goes to the shell when the tab has one pane; with splits it switches pane focus. Ctrl+I sends a literal tab to the shell, including for completion in a split. Left Shift+arrows retain their shell behaviour. Right Shift tab switching wraps and resets on key release or window focus loss.

Everything else goes to the focused pane's shell. That means printable text in any keyboard layout, Enter, Backspace, Tab, Escape, the arrows, Home, End, Delete, PageUp, PageDown and Ctrl+A through Ctrl+Z.

Selection and clipboard

Drag with the left button to select; double-click selects a word and triple-click a logical line. A single click clears the selection. Ctrl+Shift+C copies to CLIPBOARD; Ctrl+Shift+V and Shift+Insert paste CLIPBOARD. Completed selections also populate PRIMARY, pasted with the middle button. Hold Shift before pressing a mouse button to override an application's mouse reporting. Copy/paste chords do not repeat; plain Ctrl+C still interrupts.

Selections belong to each pane and follow their text through scrollback. Pasting returns to the live bottom and honours the application's bracketed paste mode. See selection and clipboard details for encoding, limits and Wayland requirements.

Scrollback

InputAction
Wheel up / downscroll the pane under the pointer
Shift+wheelforce local history instead of application mouse reporting
Shift+PageUp / Shift+PageDownscroll the focused pane by one page (rows minus one)
Shift+Home / Shift+Endjump to the top / bottom of the focused pane's history

Scrollback keys repeat when held and apply only on the primary screen. On the alternate screen, Shift+PageUp/PageDown/Home/End go to the application as PageUp/PageDown/Home/End. Plain PageUp and PageDown still go to the shell; Ctrl+PageUp and Ctrl+PageDown still change tabs. Accepted keyboard input, pasted or Bus term.type text (including control-lane typing), and mouse button or wheel reports return the viewport to the live bottom. Motion-only reports (including button drags), empty or rejected input, and automatic VT replies leave it alone. A live cursor is shown in a scrolled viewport only when its translated row remains on screen.

Each wheel notch scrolls one line. Touchpad travel accumulates at one logical cell height per line, matching bterm. Applications that enable mouse reporting receive the wheel first; otherwise the alternate screen can translate it to cursor keys (for example in less or vim). Shift bypasses both behaviours. Ctrl+wheel continues to change the font size. The scrollback setting controls history depth.

Viewport changes mark only the affected pane dirty and arm the next redraw. Wheel scrolling, history keys and Bus term.scroll share this path: wakes coalesce without painting intermediate views, then the redraw repaints every row of the changed viewport, including when returning to the live bottom. Clean neighbouring panes keep their frames. The four-row tiny-skia bands use the viewport's translated cursor visibility, so an off-screen live cursor does not appear in history.

Font size

Font sizing follows foot. The window keeps its size and the grid gains or loses cells. Every pane is re-rasterised, and every shell is told its new size.

KeysAction
Ctrl+plus, Ctrl+equal, Ctrl+keypad plusone step larger
Ctrl+minus, Ctrl+keypad minusone step smaller
Ctrl+0, Ctrl+keypad 0back to the configured size
Ctrl+wheel up / downone step larger / smaller per notch

A step is foot's half point, which is two thirds of a logical pixel. The size stays within the 6 to 48 pixel range that font_px accepts, and stepping stops at either end. These chords repeat when held. Touchpad scrolling counts 40 logical pixels of travel as one step.

The zoomed size lasts for the life of the window. It applies to every tab and pane, including ones opened later. It survives moving the window to an output with a different scale. It is not written back to term.conf.mix, so a new window starts at the configured size.

Configuration

~/.config/cosmix/term.conf.mix is shared with bterm. It sets font_px, scrollback and cursor. TERM_FONT_PX overrides font_px for one run, and TERM_SPIKE_FONT names a monospace font file.

  • term --print-config prints the resolved settings and exits.
  • term --version prints the version and build hash and does nothing else.

Bus

term serves the core's term.* surface under the mesh-open law, like bterm. send term HELP lists the full surface. A verb and the matching chord make the same change, and the window follows a verb straight away. Every body is a JSON object, and {} means no arguments.

VerbBodyEffect
term.tabs{}list tabs: id, active, title, cols, rows, child pid, revision, instance (this process's token)
term.tab.new{"cwd":"/absolute/directory","title":"build"} (both optional)open and select a tab; explicit cwd must exist, be searchable by the current user and never falls back; title pins the tab label
term.tab.title{"id":N,"title":"build"}pin a label; empty string clears the pin and restores the focused pane's program-set OSC title (default mix)
term.tab.move{"id":N,"index":0}reorder to a zero-based index, clamped to 0–(tab count − 1); preserve selected tab and pane
term.tab.select{"id":N}select tab N
term.tab.close{"id":N}close tab N; closing the last tab quits
term.panes{"tab":N} (optional)list that tab's panes, default active tab: id, focus within the tab, cols, rows, child pid, geometry, tab, revision, instance, and pid (the term process itself; term-core 0.8.1: term 0.3.1, bterm 0.10.1). It is self-reported: pane lines carry no free text, so a program running in a pane cannot change it, but a process that registers the Bus name term itself can claim any pid (a broker-attested owner pid is future noded work). INFO ends with the same instance=… pid=…
term.pane.split{"dir":"v"} or {"dir":"h"}split the focused pane side by side (v) or top and bottom (h)
term.pane.select{"id":N}focus pane N in the active tab
term.pane.close{}close the focused pane; the last pane closes the tab
term.snapshot{"pane":N,"tab":T,"contents":true,"scrollback_lines":100} (all optional)read a pane anywhere; default is the focused pane in the selected/active tab; contents defaults true; history defaults 0, accepts 0–10000, capped at buffered history above the live screen (offset zero)
term.type{"pane":N,"instance":I,"text":"..."} (pane or tab required, instance optional)type ASCII as keys without changing focus. pane is the safe selector. tab means that tab's active pane at delivery, so it still follows focus inside the tab; with both, the pane must belong to the tab. Pane and tab ids are per-process counters from 1, so pass back the instance that term.tabs/term.panes/INFO reported: another term process refuses rather than typing into its own pane N. Neither selector, a disagreeing pair or a foreign instance is refused {"error_code":"INVALID_ARGUMENT","message":…} and never defaults to the focused pane (term-core 0.8.0: term 0.3.0, bterm 0.10.0) — keys that follow focus land wherever focus has moved (2026-09-25). These INVALID_ARGUMENT refusals are not recorded under a request_id, so retry the same id with the argument fixed; a not-found or non-ASCII refusal IS recorded and replays
term.scroll{"pane":N,"lines":3} or {"page":-1} or {"to":"top"}move only the viewport; pane defaults to active, including selection across tabs without changing focus; exactly one of signed lines, signed page, or to (top/bottom)
term.props.watch{}subscribe to the change topics through noded first, then enable publishing with this verb (returns JSON {topics,revision}), then read state
send term term.tab.new
send term term.pane.split dir=v
send term term.panes
send term term.tab.new cwd="/tmp" title="build"
send term term.tab.title id=1 title="logs"
send term term.tab.move id=1 index=0
send term term.panes tab=1
send term term.snapshot pane=1 contents=true scrollback_lines=100
send term term.type pane=1 text="pwd\n"
send term term.props.watch

The same surface is served by bterm as bterm.*. Since term 0.2.8 the iced frontend also starts the shared native-session supervisor and serves the full target-bound lane: term.list, term.session, term.tabs, term.panes, term.snapshot, term.type, term.execute, term.exec.result/cancel, term.task.submit/result/cancel, term.operation, and term.props.get/set, along with target-bound tab/pane mutations. These requests use the allocated native-session route and its target identity, not the global targetless term service. The native route's HELP describes its arguments. Mesh callers need no additional authorisation; target identity, generation freshness and mutation-time rechecks still apply.

The lane requires local noded 0.16.8 or later with native ingress enabled, COSMIX_RUN=/run/cosmix and a traversable socket directory (0755). If ingress is unavailable, graphics still start and the supervisor reports the reason once per cause transition rather than repeating it for each pane or reconnect retry. A later cause is logged even after a startup deadline was reported. Iced shows its window without waiting for noded: account lookup, connection, and the bounded first-pane readiness wait run on a startup worker. The first shell appears when that worker finishes, with a native grant if ready, or as a graphics-only shell otherwise. Before attach, mutating targetless verbs return non-zero rc with error starting; retry after attach with the same request ID (startup refusals are not cached). Watches registered during startup receive the first tab and pane's add/select events. First-pane spawn failure exits non-zero with an error after cleanup. Native work wakes the existing iced eventfd subscription. Both frontends release Control's cleanup sender on exit, reap the panes while the native actor can still acknowledge each revoke, then stop the actor. See native-session control for discovery and request envelopes.

Explicit stale tab/pane IDs return a not-found error, never the active pane. With both snapshot selectors, the pane must belong to the tab or the call returns invalid-argument. contents:false returns the usual metadata and diagnostic timings without the screen marker or text. Scrollback is prepended after the screen marker, oldest first; rows still describes the live screen. Reading never moves the scroll offset. Snapshot text represents empty grid cells as spaces, preserving column positions and trailing blank cells in both history and live-screen rows. Text is limited to 512 KiB of encoded bytes (including allowance for JSON escaping), leaving metadata and transport headroom below the MCP 1 MiB and Bus 8 MiB limits. The budget keeps the newest complete rows: the live screen first, then as much recent history as fits. Retained rows are returned oldest first; the header reports truncated=true when the byte budget omits rows and lines_returned=N counts history and live-screen rows actually returned. A row larger than the budget returns no text rows. With contents:false, the count is zero and truncated=false. Capture copies bounded rows under the grid lock; text formatting runs after releasing the grid, terminal and tab-set locks.

Pinned and OSC titles have control characters and Unicode line/paragraph separators stripped and are capped at 256 UTF-8 bytes on a character boundary. An empty sanitised pin clears it. Titles may contain spaces; tab-list readers should delimit the title at the final cols= field, rather than tokenising it on whitespace. Effective title changes advance the separate event revision via tabs.changed and title.changed with kind=retitled; they leave the tab-set and pane-layout revisions unchanged, so pane geometry stays valid. Completion notifications use the same sanitised, possibly program-set label. Explicit cwd paths follow symlinks and resolve .. normally.

term.scroll (or bterm.scroll on bterm) returns JSON {"pane":N,"display_offset":D,"history_lines":H}. Positive lines/pages move up into history; negative values move down, clamped to available history. Each page is rows minus one, matching the keyboard chords. Integers must fit signed 64 bits; zero is a no-op. Stale pane IDs return not-found. Scrolling leaves snapshot content on the live screen and never sends PTY input. When watching, an offset change publishes term.pane.changed (or bterm.pane.changed) with kind=scrolled; a clamped no-op publishes nothing. Like other mutations it supports request_id replay and is refused on the strict diagnostic-only targetless lane.

Existing replies keep their key=value format. New title replies are retitled id=N tab=N pane=P revision=R; move replies are moved id=N index=I tab=N pane=P revision=R. Tab creation still replies opened id=N tab=N pane=P revision=R binding=.... All mutations, including title/move and pane-selected typing, accept request_id; a retry with the same verb and arguments replays the original success or state refusal (not-found, non-ASCII text, the tab limit). starting, an oversized body and validation refusals (malformed JSON, unexpected/missing/mistyped arguments, any invalid-argument/INVALID_ARGUMENT reply) are NOT recorded, so the same ID may be retried with the request fixed. Different arguments with an ID whose first attempt was recorded are a conflict. The JSON envelope limit remains 8192 bytes. Index must be an integer; invalid optional argument types are refused.

Watch publishes <service>.tabs.changed (added, removed, moved, selected, retitled), <service>.pane.changed (added, removed, selected, resized) and <service>.title.changed (retitled). Each body is {tab,pane,kind,revision}; the embedded Bus command is the topic suffix. Events cover local UI actions and Bus mutations. OSC updates wake the core; multiple updates before it handles the wake may coalesce to the latest title. Pinned titles hide OSC updates until cleared. Unchanged values and replayed mutations produce no new events. There is no timer or caller identity lease.

The watch revision is a separate monotonic event sequence, preserving the older layout revision in verb replies. Publishing uses a bounded 256-record queue and a serial, best-effort Bus sender. Subscribe before enabling watch, then read current state; on a revision gap or reconnect read state again. The watch response gives the current event revision. Events are not retained by noded; publication failures are logged at most once per 30 seconds and do not block terminal input. A dropped final event may remain undetectable until a later revision arrives; read state whenever freshness is required.

term.panes reports each pane's geometry in logical pixels, relative to the pane area below the tab strip, as bterm does. Geometry is from the last frontend layout: hidden tabs can report stale values after a window resize, or zeros after geometry invalidation. Select the tab and allow a frontend layout before relying on its geometry. Grid dimensions describe the current PTY grid and do not promise an up-to-date window layout.

term.snapshot always reads the live screen, even while a human views history. It does not move that viewport or consume pending repaint damage.

With no broker, term prints that the Bus is unavailable and works as a plain terminal.

Not yet in this frontend

  • The app-control port: app.describe, app.quit and app.controls.* are not served yet.
  • The native-session lane, see term-native-session. bterm is still its only client.
  • A verb for the font size. Until one exists, font size is set from the keyboard or the wheel only.