Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Introduction

TUICommander Documentation

Run multiple AI coding agents in parallel across isolated Git worktrees. Observe, control, and merge AI development from a single terminal workspace.

Popular articles

Browse by section

  • User Guide — install, configure, and drive TUICommander day to day
  • Architecture — how the app is put together, and how agent detection works
  • Backend — Rust subsystems: PTY, git, MCP, output parsing
  • Frontend — Solid components, stores, transport layer, terminal internals
  • API Reference — HTTP, Tauri commands, SDK, and plugin authoring
  • Developer Guide — build from source, profile, and release

Key Features

  • Terminal Management — Tabbed terminals with split panes, drag-and-drop reordering, and per-branch isolation via Git worktrees
  • AI Agent Support — Auto-detect Claude Code, Aider, Codex, Gemini CLI and more. Monitor progress, costs, and active subtasks in real time
  • Git Worktrees — Each branch gets its own working directory. Create, merge, archive, and batch-manage worktrees from the UI
  • GitHub Integration — PR badges, CI status rings, merge from the UI, post-merge cleanup, and notification bell for PR events
  • Plugin System — Extend TUICommander with JavaScript plugins. Full API for terminals, git, notifications, and status bar widgets
  • MCP Bridge — Expose app capabilities to AI agents via Model Context Protocol

The complete, always-current capability inventory lives in the Feature Reference.

TUICommander — Complete Feature Reference

Canonical capability inventory. Update this file when adding, changing, or removing user-visible features. See AGENTS.md for the maintenance requirement.

This document intentionally serves two audiences: users need a searchable overview of what exists, while LLMs and contributors need stable names, shortcuts, settings, and implementation anchors. It is an inventory, not a replacement for the chronological CHANGELOG.

Current version: 1.7.0
Last verified: 2026-07-31
Recent feature delta: See the Unreleased and 1.7.0 changelog sections for what changed recently. Keep this page focused on the current state; do not duplicate the full changelog here.

How to use this reference

  • Users: start with the relevant section, then follow its user-guide link.
  • LLMs and contributors: search for the feature name, shortcut, setting, command, or source anchor. The bullets describe current behavior, including important limitations.
  • Release work: update the relevant section when behavior changes, and add the chronological explanation to CHANGELOG.md.

User-guide map

Feature areaUser guide
Terminal, tabs, splits, and searchTerminal Features
Sidebar, repositories, and branchesSidebar · Branch Management
Git worktreesWorktrees
AI agents and agent teamsAI Agents · Agent Teams
GitHub, PRs, and CIGitHub Integration
Smart Prompts and Prompt LibrarySmart Prompts · Prompt Library
Settings and shortcutsSettings · Keyboard Shortcuts
Plugins and MCPPlugins · MCP Proxy Hub
Remote, mobile, and browser modesTUICommander Modes · Remote Access
Setup and recoveryGetting Started · Troubleshooting

1. Terminal Management

1.1 PTY Sessions

  • Up to 50 concurrent PTY sessions (configurable in Rust MAX_SESSIONS)
  • Each tab runs an independent pseudo-terminal with the user’s shell
  • Terminals are never unmounted — hidden tabs stay alive with full scroll history
  • Session persistence across app restarts (lazy restore on branch click); only agent tabs are restored — plain shell tabs are discarded and a fresh terminal is spawned instead
  • Orchestrated PTYs show a task description above the terminal alongside the last submitted user prompt; MCP callers can supply pty_description, while spawn-only orchestration schemas fall back to a compact summary of the task prompt without changing agent launch or prompt-delivery behavior
  • Agent session restore shows a clickable banner (“Agent session was active — click to resume”) instead of auto-injecting the resume command; Space/Enter resumes, other keys dismiss
  • Foreground process detection (macOS: libproc, Windows: CreateToolhelp32Snapshot)
  • PTY environment: TERM=xterm-256color, COLORTERM=truecolor, LANG=en_US.UTF-8. A parent NO_COLOR is stripped (sanitize_pty_parent_env) so a TUICommander launched from Codex does not leak that opt-out into independent sessions; per-command flags and per-agent environment can still request monochrome deliberately
  • Pause/resume PTY output (pause_pty / resume_pty Tauri commands) — suspends reader thread without killing the session

1.2 Tab Bar

  • Create: Cmd+T, + button (click = new tab, right-click = split options)
  • Close: Cmd+W, middle-click, context menu
  • Reopen last closed: Cmd+Shift+T (remembers last 10 closed tabs)
  • Switch: Cmd+1 through Cmd+9, Ctrl+Tab / Ctrl+Shift+Tab
  • Rename: double-click tab name (inline editing)
  • Reorder: drag-and-drop with visual drop indicators (works for all tab types: terminal, diff, editor, markdown, plugin panels)
  • Tab status dot (left of name): grey=idle, blue-pulse=busy, green=done, purple=unseen (completed while not viewed), orange-pulse=question (needs input), red-pulse=error
  • Tab type colors: red gradient=diff, blue gradient=editor, teal gradient=markdown, purple gradient=panel, amber gradient=remote PTY session
  • Remote PTY sessions (created via HTTP/MCP) show “PTY:” prefix and amber styling
  • Progress bar (OSC 9;4)
  • Context menu (right-click): Close Tab, Close Other Tabs, Close Tabs to the Right, Detach to Window, Copy Path (on diff/editor/markdown file tabs)
  • Context menu shortcut chords — while a context menu is open, pressing a menu item’s keyboard shortcut chord (modifier + key, or Enter) fires that action directly without needing to click. Modifier-only keystrokes (Cmd, Shift, etc.) do not close the menu so multi-key chords can form; any other non-matching key closes the menu normally
  • Detach to Window: right-click a tab to open it in a floating OS window
    • PTY session stays alive in Rust — floating window reconnects to the same session
    • Closing the floating window automatically returns the tab to the main window
    • Requires an active PTY session (disabled for tabs without a session)
  • Overflow menu on scroll arrows (right-click) shows clipped tabs; the + button always stays visible regardless of scroll position
  • Tab pinning: pinned tabs are visible across all branches (not scoped to branch key)

1.3 Split Panes

  • Vertical split: Cmd+\ (side by side)
  • Horizontal split: Cmd+Alt+\ (stacked)
  • Navigate: Alt+←/→ (vertical), Alt+↑/↓ (horizontal)
  • Close active pane: Cmd+W
  • Drag-resize divider between panes
  • Up to 6 panes in same direction (N-way split)
  • Split layout persists per branch

1.4 Zoom (Per-Terminal)

  • Zoom in: Cmd+= (+2px)
  • Zoom out: Cmd+- (-2px)
  • Reset: Cmd+0
  • Range: 8px to 32px
  • Current zoom shown in status bar

1.5 Copy & Paste

  • Copy selection: Cmd+C
  • Paste to terminal: Cmd+V
  • Trailing whitespace trimmed — All copy paths (Cmd+C, Ctrl+C, copy-on-select) strip trailing spaces from terminal rows
  • Copy on Select — When enabled (Settings > General > Terminal or Settings > Appearance), selecting text in the terminal automatically copies it to the clipboard. A brief “Copied to clipboard” confirmation appears in the status bar.
  • Copy feedback (Cmd+C) — Copying via Cmd+C shows “Copied to clipboard” in the status bar, consistent with copy-on-select and Ctrl+C paths.
  • OSC 52 clipboard writes — Terminal programs (tmux, vim, ssh yank) can set the system clipboard via the OSC 52 escape sequence. Because any displayed file/log can also emit it, each write surfaces a non-blocking “Clipboard updated by <session>” notice, and the behavior can be disabled entirely via Settings > General > Terminal > “Allow OSC 52 clipboard writes”. Suggestion chips (OSC 7770 suggest=) carrying shell metacharacters are inserted without auto-Enter so a click cannot silently execute a spoofed command.

1.6 Clear Terminal

  • Cmd+L — clears display, running processes unaffected

1.7 Clickable File Paths

  • File paths in terminal output are auto-detected and become clickable links
  • Paths validated against filesystem before activation (Rust resolve_terminal_path)
  • .md/.mdx → opens in Markdown panel; preview-capable files (HTML, PDF, images, video, audio, plain text/data) → open in the Preview tab (section 3.15); all other code files → open in the built-in code editor
  • file:// URLs are recognized in addition to plain paths — the prefix is stripped and the path resolved like any other
  • OSC 8 hyperlinks: programs that emit hyperlink escape sequences (e.g. Claude Code, modern ls) produce clickable links; hover underline spans the full link text (via terminal_hyperlink_span backend API)
  • Supports :line and :line:col suffixes for precise navigation
  • Single left-click opens the link instantly (UI-first — opening is a primary action, not gated behind a modifier); drag-select over a link still copies text without opening
  • Right-click on a link shows a context menu with Open and Copy link (copy the resolved path/URL without opening). Right-clicking elsewhere shows the standard terminal context menu
  • Recognized extensions: rs, ts, tsx, js, jsx, py, go, java, kt, swift, c, cpp, cs, rb, php, lua, zig, css, scss, html, vue, svelte, json, yaml, toml, sql, graphql, tf, sh, dockerfile, and more

1.8 Find in Content

  • Cmd+F opens search overlay — context-aware: routes to terminal, markdown tab, or diff tab based on active view
  • Terminal: incremental search with highlight decorations
  • Markdown viewer: DOM-based search with cross-element matching (finds text spanning inline tags)
  • Diff viewer: DOM-based search via SearchBar + DomSearchEngine (same engine as markdown viewer)
  • Yellow highlight for matches, orange for active match
  • Navigate matches: Enter / Cmd+G (next), Shift+Enter / Cmd+Shift+G (previous)
  • Toggle options: case sensitive, whole word, regex
  • Match counter shows “N of M” results
  • Escape closes search and refocuses content

1.9 International Keyboard Support

  • Terminal handles international keyboard input correctly
  • Rate-limit false positives reduced for non-ASCII input

1.10 Move Terminal to Worktree

  • Right-click a terminal tab → “Move to Worktree” submenu lists available worktrees (excluding the current one)
  • Selecting a worktree sends cd to the PTY; OSC 7 auto-reassigns the terminal to the target branch
  • Also available via Command Palette: dynamic “Move to worktree: <branch>” entries appear when the active terminal belongs to a repo with multiple worktrees
  • Only shown when the repo has more than one worktree
  • When a worktree is created while an agent is running, Open Worktree opens or focuses a terminal rooted there; the agent terminal remains attached to its original branch and working directory

1.11 OSC 7 CWD Tracking

  • Terminals report their current working directory via OSC 7 escape sequences
  • Parsed in the Rust backend from PTY output and stored per-session as session_cwd
  • When a terminal’s CWD falls inside a known worktree path, the session is automatically reassigned to the correct branch in the sidebar
  • Enables accurate branch association even when the user cds into a different worktree from a single terminal

1.12 Kitty Keyboard Protocol

  • Supports Kitty keyboard protocol flag 1 (disambiguate escape codes)
  • Per-session flag tracking via get_kitty_flags Tauri command
  • Enables correct handling of Shift+Enter (multi-line input), Ctrl+Backspace, and modifier key combinations in agents that request the protocol (e.g. Claude Code)

1.13 File Drag & Drop

  • Drag files from Finder/Explorer onto the terminal area or any panel
  • Uses Tauri’s native onDragDropEvent API (not HTML5 File API — Tauri webviews do not expose file paths via HTML5)
  • Active PTY session: dropped file paths are forwarded directly to the terminal as text (enables Claude Code image drops and similar workflows)
  • No active PTY session: .md/.mdx files open in Markdown viewer, preview-capable files open in the Preview tab (section 3.15), all other files open in Code Editor
  • Multiple files can be dropped at once
  • Visual overlay with dashed border appears during drag hover
  • Global dragover/drop preventDefault prevents the Tauri webview from treating drops as browser navigation (which would replace the UI with a white screen)
  • macOS file association: .md/.mdx files registered with TUICommander — double-click in Finder opens them directly
  • Drag to external apps: Drag files from the File Browser to external applications (Finder, email clients, etc.) using native OS-level drag via tauri-plugin-drag. Works alongside internal drag & drop (tab reorder, split panes)
  • Type ~ in the command palette (Cmd+P) to search text across all open terminal buffers
  • Results show terminal name, line number, and highlighted match text
  • Selecting a result switches to the correct terminal tab/pane and scrolls to the matched line (centered in viewport)
  • Minimum 3 characters after prefix
  • Also accessible via the explicit “Search Terminals” command in the palette

1.15 Refresh Terminal (Cmd+Shift+L)

  • Rebuilds the terminal renderer to fix corrupted glyphs (WebGL atlas issues, font rendering artifacts)
  • Does not clear content or affect the PTY session — purely a visual refresh
  • Action name: refresh-terminal

1.16 Terminal Bell

  • Terminal Bell — Configurable bell behavior when the terminal receives a BEL character (\x07). Four modes: none (silent), visual (screen flash animation), sound (plays the Info notification sound), both (flash + sound). Configure in Settings > Appearance.

1.17 Alternate-Screen Scrollback

  • Fullscreen apps (gh run watch, less, man, TUIs) run on the terminal’s alternate screen, which per XTerm semantics has no scrollback — output past the bottom of the window is normally lost and no scrollbar is shown
  • TUICommander keeps those lines with user-visible behavior equivalent to iTerm2’s “save lines to scrollback in alternate screen mode” option: scrollbar, wheel, and scrollbar drag all work while the app is running
  • Output is byte-faithful: only lines that genuinely scroll off the top are kept. An in-place redraw produces no history, while a refresh taller than the viewport (as emitted by gh run watch) keeps each overflowing snapshot, including repetitions
  • The alternate grid has its own bounded, ephemeral history. It is wiped on every enter/exit and never mixes with the shell’s history
  • Entering or leaving the alternate screen atomically invalidates scroll, selection, search, link, and row-cache state before the new grid is painted
  • Apps with mouse reporting (vim, htop, lazygit) still receive the wheel themselves — Shift+wheel or a scrollbar drag scrolls TUICommander’s history

1.18 Scrollback History Overlay (Experimental)

  • Read-only overlay for viewing full terminal scrollback beyond the visible buffer
  • Gated behind scrollHistoryEnabled settings flag (Settings > General > Experimental Features)
  • Content reconstructed from VtLogBuffer via LogSpan ANSI reconstruction — preserves colors, bold, underline, and other SGR attributes
  • Built as a read-only terminal overlay using ANSI reconstruction from VtLogBuffer
  • Selection & copy: select text in the overlay; auto-copies to clipboard with ::selection highlight
  • Search (Cmd+F): incremental search with match highlighting via SearchAddon, “N of M” counter, Enter/Shift+Enter navigation
  • Theme-synced: ANSI CSS variables follow the active terminal theme
  • Grid-aligned positioning matches the underlying terminal metrics

1.19 Command Blocks

Terminal output is segmented into command blocks — one per prompt+output cycle. Blocks are detected via OSC 133 shell integration markers (A/C/D sequences) or OSC 7770;block= agent-emitted markers. For Claude Code, heuristic detection synthesizes blocks from tool call headers (⏺ ToolName(args)).

  • Scrollbar marks — Color-coded indicators on the scrollbar for each command block boundary. Provides a visual map of command history at a glance
  • User-prompt scrollbar markers — A distinct green tick on the scrollbar marks each line where the user submitted a prompt to the agent (recorded from the OSC 7770 state=busy transition via userPromptLines). These are separate from command-block boundary marks and help you quickly locate your own prompts in long sessions
  • Timestamp overlay — Hold Ctrl+Cmd to reveal timestamps showing when each block started, displayed as relative time (e.g. “2m ago”)
  • Gutter click — Click the gutter area to select the entire block output for easy copying
  • Block folding — Collapse/expand block output with Cmd+Shift+. toggle. Folded blocks show a summary line. Backend stores fold state per session via set_block_fold Tauri command
  • Block-scoped search — Toggle with Cmd+Shift+B to restrict terminal search to the current block only
  • Block navigationCmd+Shift+Up/Down jumps between block boundaries
  • Block cap — Sessions are capped at 500 command blocks; oldest blocks are evicted when the cap is reached
  • Settings — Configure block features at Settings > Terminal > Blocks: show/hide timestamps, enable/disable folding

1.20 Compose Panel (Cmd+I)

A multi-line editor docked under the terminal for writing a prompt without fighting the agent’s own input box.

  • Send nowCtrl+Enter (or the ▶ button) types the text into the composer and submits it immediately, steering whatever the agent is doing
  • Queue for the next idle windowShift+Ctrl+Enter (or the ☰ button) hands the text to the backend’s idle gate instead: it is submitted at once if the agent is already idle, otherwise parked until the agent’s next busy→idle transition. This is the way to leave follow-up work for an agent mid-turn without interrupting it
  • Queue badge — the status bar shows N queued while commands are waiting; clicking it discards the whole queue. The count comes from the backend (state.queued_commands), so it is accurate across reloads and remote clients
  • Order — queued commands are typed one per idle window, in the order they were composed; a new one never overtakes one already waiting
  • Agents only — queueing is hidden for a plain shell: its idle state says nothing about which program currently owns stdin

1.21 Auto-Standby (Unix)

Idle, unfocused terminals are suspended to stop them consuming CPU and battery. A background checker (every 30s) sends SIGSTOP to the entire process group of a session — kill(-pgid, …), so children (dev servers, agent processes) are paused too, not just the shell.

  • Entry conditions (all required) — timeout enabled (> 0), tab not focused, shell state idle, no tracked agent background work, idle for at least the timeout, session startup settled, and not already in standby. Claude/Codex/Gemini/Aider/Grok additionally require confirmed idle (explicit lifecycle marker or stable ready screen); silence-only idle cannot suspend them. Grok’s adapter distinguishes its active Braille-spinner status row from the composer that remains visible throughout a turn.
  • WakeSIGCONT fires the instant the tab is focused or a message arrives for the agent; the process resumes exactly where it stopped (no session loss, no restart)
  • Safety — the process-group id is validated before signalling; an unsafe pgid is refused rather than risking a stop sent to the wrong group
  • Pause badge — suspended tabs show a pause indicator in the tab bar
  • Eventsession-standby ({ session_id, standby }) emitted on stop/wake
  • Settings — Settings > General > Auto-Standby Timeout (default 5 min; 0 disables)

2. Sidebar

2.1 Repository List

  • Add repository via + button or folder dialog
  • Click repo header to expand/collapse branch list
  • Click again to toggle icon-only mode (shows initials)
  • button: Repo Settings, Switch Branch (via context menu on main worktree), Create Worktree, Move to Group, Park Repository, Remove
  • macOS TCC access dialog: when the OS denies access to a repository directory (e.g. Desktop, Documents), a dialog explains the issue and guides the user to grant Full Disk Access in System Settings

2.2 Repository Groups

  • Named, colored groups for organizing repositories
  • Create: repo → Move to Group → New Group…
  • Move repo: drag onto group header, or repo → Move to Group → select group
  • Remove from group: repo → Move to Group → Ungrouped
  • Group context menu (right-click header): Rename, Change Color, Delete
  • Collapse/expand: click group header
  • Reorder groups: drag-and-drop
  • Color inheritance: repo color > group color > none

2.2.1 Switch Branch

Right-click the main worktree row → Switch Branch submenu to checkout a different branch. The submenu shows all local branches with a checkmark on the current one. If the working tree is dirty, prompts to stash changes first. Blocks switching when a terminal has a running process.

2.3 Branch Items

  • Click: switch to branch (shows its terminals, creates worktree if needed)
  • Double-click branch name: rename branch
  • Right-click context menu: Copy Path, Add Terminal, Create Worktree, Merge & Archive, Delete Worktree, Open in IDE, Rename Branch
  • CI ring: proportional arc segments (green=passed, red=failed, yellow=pending)
  • PR badge: colored by state (green=open, purple=merged, red=closed, gray=draft) — click for detail popover
  • Diff stats: +N / -N additions/deletions
  • Merged badge: branches merged into main show a “Merged” badge
  • Question indicator: ? icon (orange, pulsing) when agent asks a question
  • Idle indicator: branch icons turn grey when the repo has no active terminals
  • Quick switcher badge: numbered index shown when Cmd+Ctrl held
  • Remote-only branches with open PRs: shown in sidebar with PR badge and inline accordion actions (Checkout, Create Worktree). Additional actions when PR popover is open: Merge, View Diff, Approve, Dismiss
  • Dismiss/Show Dismissed: remote-only PRs can be dismissed from the sidebar; a “Show Dismissed” toggle reveals them again
  • Branch sorting: main/master/develop always first, then alphabetical; merged PR branches sorted last

2.3.1 Nested Terminal Tabs (opt-in)

  • Off by default. Enable via Settings → Appearance → Tabs → “Nested Terminal Tabs” (tab_tree_enabled).
  • When on, a branch with more than one terminal shows a collapsible list of its terminals directly under the branch row, each with a status dot (busy / idle / unseen / error / question) and the terminal name. Clicking a sub-item switches to that terminal.
  • The caret toggles the list; clicking an unfocused branch focuses it and opens the list (never collapses on a focus-switch), while re-clicking the already-focused branch toggles it.
  • Single-terminal branches stay compact — no caret, no list — and the whole feature is inert when the setting is off.

2.4 Git Quick Actions

  • Bottom of sidebar when a repo is active
  • Pull, Push, Fetch, Stash buttons — execute in active terminal

2.5 Sidebar Resize

  • Drag right edge to resize (200-500px range)
  • Toggle visibility: Cmd+[
  • Width persists across sessions

2.6 Quick Branch Switcher

  • Hold Cmd+Ctrl (macOS) or Ctrl+Alt (Win/Linux): show numbered overlay
  • Cmd+Ctrl+1-9: switch to branch by index

2.7 Park Repos

  • Right-click any repo in the sidebar to park or unpark it
  • Group park/unpark: right-click a group header or use the command palette to park or unpark all repos in a group at once
  • Parked repos are hidden from the main repository list
  • Sidebar footer button opens a popover showing all parked repos
  • Unpark a repo from the popover to restore it to the main list

2.8 Active-Only Filter

  • Toggled from the filter icon in the toolbar (next to the sidebar collapse button); the icon turns accent-colored while engaged
  • When on, the sidebar shows only repositories that have at least one open terminal — empty groups are dropped entirely (no orphaned headers)
  • An accent banner at the top of the sidebar makes it unmistakable that repos are hidden, shows a shown / total count, and offers “Show all” to clear the filter
  • If the filter hides every repo, a dedicated empty state offers “Show all”
  • Session-only (not persisted across restarts)

3. Panels

3.1 Panel System

  • File Browser, Markdown, Diff, and Plan panels are mutually exclusive — opening one closes the others
  • Ideas panel is independent (can be open alongside any of the above)
  • Subtle fade transition when closing side panels (opacity + transform animation)
  • All panels have drag-resize handles on their left edge (200-800px)
  • Min-width constraints prevent panels from collapsing (Markdown: 300px, File Browser: 200px)
  • Toggle buttons in status bar with hotkey hints visible during quick switcher

3.2 Diff Panel (Removed in 0.9.0)

Replaced by the Git Panel’s Changes tab (section 3.8). Cmd+Shift+D now opens the Git Panel

3.3 Markdown Panel (Cmd+Shift+M)

  • Renders .md and .mdx files with syntax-highlighted code blocks
  • File list from repository’s markdown files
  • Clickable file paths in terminal open .md files here
  • Auto-show: adding any markdown tab automatically opens the Markdown panel if it’s closed
  • Header bar shows file path (or title for virtual tabs) with Edit button (pencil icon) to open in CodeEditor
  • Cmd+F search: find text in rendered markdown with highlight navigation (shared SearchBar component)
  • Interactive GFM checkboxes: - [ ], - [x], and - [~] task-list items render as clickable checkboxes. Clicking cycles through unchecked → checked → in-progress → unchecked. Changes are written back to the source .md file on disk. The [~] state renders as an indeterminate (half-filled) checkbox — non-standard GFM extension for tracking in-progress items
  • Mermaid diagrams: fenced code blocks with ```mermaid are rendered as interactive SVG diagrams. Mermaid.js is lazy-loaded on first use with dark theme
  • Inline review comments (tweaks): review-comment any passage of a rendered markdown file without leaving the viewer.
    • Create: select text in the rendered markdown → a floating Comment button appears next to the selection → click it to open an inline popover and type the note (Ctrl+Enter to save)
    • View / edit / delete: commented passages are highlighted (.tweak-highlight); hovering one shows the comment in a tooltip, clicking it reopens the popover to edit or delete
    • Storage: comments live inside the .md source as HTML-comment markers — <!--tweak:begin:ID-->highlighted text<!--tweak:end:ID @<ISO-timestamp> + body + -->. They are invisible to any standard markdown renderer, survive round-trips, and are committed with the file. The only escaped sequence is --> (→ --&gt;)
    • LLM-friendly: the first comment added to a file prepends a one-time convention header explaining the format, so an AI agent reading the file understands it without external context — the intended workflow is “human highlights + comments → agent applies the feedback to the highlighted text → agent removes the markers”
    • Rendering: highlights are wrapped in the DOM after markdown parsing, so a selection that straddles inline formatting (**bold**, `code`) stays intact and the highlight spans contiguously. Shared across the Markdown panel and the PR detail popover via ContentRenderer

3.4 File Browser Panel (Cmd+E)

  • Directory tree of active repository
  • Auto-refresh: directory watcher detects external file changes (create/delete/rename) and refreshes automatically within ~1s, preserving selection
  • Navigation: ↑/↓ (navigate), Enter (open/enter dir), Backspace (parent dir)
  • Breadcrumb toolbar: always-visible path bar with click-to-navigate segments + inline sort dropdown (funnel icon)
  • Search filter: text input with * and ** glob wildcard support
  • Git status indicators: orange (modified), green (staged), blue (untracked)
  • Context menu (right-click): Copy (Cmd+C), Cut (Cmd+X), Paste (Cmd+V), Rename, Delete, Add to .gitignore
  • Keyboard shortcuts work when panel is focused (copy/cut/paste)
  • Sort dropdown: Name (alphabetical, directories first) or Date (newest first, directories first)
  • View modes: flat list (default) and tree view — toggle via toolbar buttons. Tree view shows a collapsible hierarchy with lazy-loaded subdirectories on expand. Switching to tree resets to repo root. Search always uses flat results
  • Click file to open in code editor tab

3.4.1 Content Search (Cmd+Shift+F)

  • Full-text search across file contents — toggle from filename search via the C button in the search bar
  • Options: case-sensitive, regex, whole-word
  • Results stream progressively and are grouped by file with match count per file
  • Each result row shows file path, line number, and highlighted match context
  • Click a result to open the file in the code editor at the matched line
  • Binary files and files larger than 1 MB are automatically skipped
  • Backed by search_content Tauri command; results delivered via content-search-batch events

3.5 Code Editor (CodeMirror 6)

  • Opens in main tab area when clicking a file in file browser
  • Syntax highlighting auto-detected from extension (disabled for files > 500 KB)
  • Line numbers, bracket matching, active line highlight, Tab-to-indent
  • Find/Replace: Cmd+F (find), Cmd+G / Cmd+Shift+G (next/prev), Cmd+H (replace), selection match highlighting
  • Save: Cmd+S (when editor tab is focused)
  • Read-only toggle: padlock icon in editor header
  • Unsaved changes: dot indicator in tab bar and header
  • Disk conflict detection: banner with “Reload” (discard local) or “Keep mine” options
  • Auto-reloads silently when file changes on disk and editor is clean
  • Undo/Redo: Cmd+Z / Cmd+Shift+Z with full history
  • Code folding: collapse/expand blocks via gutter arrows or Cmd+Shift+[/]
  • Auto-close brackets: typing (, [, {, ", ' inserts matching pair
  • Scroll past end: last line can scroll to the top of the viewport
  • Block selection: Alt+drag for rectangular/column selection with crosshair cursor
  • Drop cursor: ghost cursor shown when dragging text over the editor
  • Special character highlighting: invisible chars (zero-width spaces, control chars) rendered as placeholders
  • CSS color preview: inline color swatches next to hex/rgb/rgba/hsl values
  • Large-file support: files up to 250 MB open via a dedicated read path (read_file_editor / MAX_EDITOR_LARGE_FILE_SIZE). Files that exceed this cap are refused up front with an informational notice instead of hanging the UI. Standard syntax highlighting is disabled above 500 KB, but the file still opens and is fully editable
  • Inline git blame (GitLens-style): a dim italic author · relative time · summary annotation at the end of the active line, following the cursor over already-loaded blame data (fetched on load/save/repo-revision via get_file_blame, never per keystroke). Lines with uncommitted edits show You · Uncommitted changes. On by default (inline_blame_enabled config field); no annotation for external (non-repo) files

3.6 Ideas Panel (Cmd+Alt+N)

  • Quick notes / idea capture with send-to-terminal
  • Enter submits idea, Shift+Enter inserts newline
  • Per-idea actions: Edit (copies back to input), Send to Terminal (sends + return), Delete
  • Mark as used: notes sent to terminal are timestamped (usedAt) for tracking
  • Badge count: status bar toggle shows count of notes visible for the active repo
  • Per-repo filtering: notes can be tagged to a repository; untagged notes visible everywhere
  • Image paste: Ctrl+V / Cmd+V pastes clipboard images as thumbnails attached to the note
    • Images saved to config_dir()/note-images/<note-id>/ on disk
    • Thumbnails displayed inline below note text and in the input area before submit
    • Image-only notes (no text) are supported
    • Images removed from disk when the note is deleted
    • Send to terminal appends absolute image paths so AI agents can read them
    • Max 10 MB per image; accepted formats: PNG, JPEG, WebP, GIF
  • Edit preserves note identity (in-place update, no ID change)
  • Escape cancels edit mode
  • Data persisted to Rust config backend

3.7 Help Panel (Cmd+?)

  • Shows app info and links (About, GitHub, docs)
  • Keyboard shortcuts are now in Settings > Keyboard Shortcuts tab (auto-generated from actionRegistry.ts)

3.8 Git Panel (Cmd+Shift+D)

Tabbed side panel with four tabs: Changes, Log, Stashes, Branches. Replaces the former Git Operations Panel floating overlay and the standalone Diff Panel.

Changes tab:

  • Porcelain v2 working tree status via get_working_tree_status (branch, upstream, ahead/behind, stash count, staged/unstaged/untracked files)
  • Sync row: Pull, Push, Fetch buttons (background execution via run_git_command)
  • Stage / unstage individual files or stage all / unstage all
  • Discard unstaged changes (with confirmation dialog)
  • Inline commit form with message input and Amend toggle
  • Click a file row to open its diff in the diff panel
  • Status icons per file: Modified, Added, Deleted, Renamed, Untracked
  • Per-file diff counts (additions/deletions) shown inline
  • Glob filter to narrow the file list
  • Path-traversal validation on all stage/unstage/discard operations
  • History sub-panel (collapsible): per-file commit history via get_file_history (follows renames), paginated with virtual scroll
  • Blame sub-panel (collapsible): per-line blame via get_file_blame (porcelain format), age heatmap (green=recent, fading to neutral), commit metadata per line

Log tab:

  • Paginated commit log via get_commit_log (default 50, max 500)
  • Virtual scroll via @tanstack/solid-virtual for large histories
  • Canvas-based commit graph via get_commit_graph: lane assignment, Bezier curve connections, 8-color palette, ref badges (branch, tag, HEAD). Graph follows HEAD only
  • Click a commit row to expand and see its full commit message body (multi-line, untruncated) and changed files (via get_changed_files)
  • Click a file in an expanded commit to open its diff at that commit hash
  • Relative timestamps (e.g., “3h ago”)

Stashes tab:

  • List all stash entries via get_stash_list
  • Per-stash actions: Apply, Pop, Drop (via run_git_command)

Branches tab (Cmd+G — opens Git Panel directly on this tab):

  • Local and Remote branches in collapsible sections
  • Rich info per branch: ahead/behind counts (↑N ↓M), relative date, merged badge, stale dimming (branches with last commit > 30 days)
  • Prefix folding: groups branches by / separator (e.g. feature/, bugfix/), toggle to expand/collapse groups
  • Recent Branches section from git reflog
  • Inline search/filter to narrow branch list
  • Checkout (Enter / double-click): switches to the selected branch, with dirty worktree dialog (stash / force / cancel)
  • n — Create new branch (inline form, optional checkout)
  • d — Delete branch (safe + force options; refuses main branch and current branch)
  • R — Rename branch (inline edit)
  • M — Merge selected branch into current. Result is surfaced as a toast: conflict error on failure, “Already up to date” on a no-op, or a success toast with a one-click “Delete branch” action for the now-merged branch
  • r — Rebase current onto selected branch
  • P — Push branch (auto-detects missing upstream and sets tracking)
  • p — Pull current branch
  • f — Fetch all remotes
  • Context menu (right-click): Checkout, Create Branch from Here, Delete, Rename, Merge into Current, Rebase Current onto This, Push, Pull, Fetch, Compare (shows diff --name-status)
  • Delete merged: a broom button (with a count badge of how many qualify) bulk-deletes all local branches already merged into main, behind a confirm dialog listing the targets. Uses safe git branch -d per branch, so a stale merged flag can never delete unmerged work
  • Backend: get_branches_detail, delete_branch, create_branch, get_recent_branches
  • Click on sidebar “GIT” vertical label also opens Git Panel on the Branches tab

Keyboard navigation:

  • Escape to close the panel
  • Ctrl/Cmd+1–4 to switch between tabs (1=Changes, 2=Log, 3=Stashes, 4=Branches)
  • Auto-refreshes via repo revision subscription

3.9 Quick Branch Switch (Cmd+B)

  • Fuzzy-search dialog to switch branches instantly
  • Shows all local and remote branches for the active repo
  • Badges: current, remote, main branch indicators
  • Keyboard navigation: Arrow keys, Enter to switch, Escape to close
  • Remote branches auto-checkout as local tracking branch
  • Fetches live branch list via get_git_branches

3.10 Task Queue Panel (Cmd+J)

  • Task management with status tracking (pending, running, completed, failed, cancelled)
  • Drag-and-drop task reordering

3.11 Command Palette (Cmd+P)

  • Fuzzy-search across all app actions by name
  • Recency-weighted ranking: recently used actions surface first
  • Each row shows action label, category badge, and keybinding hint
  • Keyboard-navigable: ↑/↓ to move, Enter to execute, Esc to close
  • Search modes: type ! to search files by name, ? to search file contents, ~ to search across all open terminal buffers. File/content results open in editor tab (content matches jump to the matched line). Terminal results navigate to the terminal tab/pane and scroll to the matched line. Leading spaces after prefix are ignored
  • Discoverable search commands: “Search Terminals”, “Search Files”, “Search in File Contents” appear as regular palette commands and pre-fill the corresponding prefix
  • QR for Remote Mobile Connection: opens a large black-on-white QR (in a dialog) that a phone can scan to launch the mobile companion PWA. Reuses the Settings → Services & MCP connect flow (get_connect_url — token stays server-side); shows a hint when Remote Access is disabled and a network picker for multi-IP machines
  • Powered by actionRegistry.ts (ACTION_META map)

3.12 Activity Dashboard (Cmd+Shift+A)

  • Real-time view of all active terminal sessions in a compact list
  • Each row shows: terminal name, project name badge (last segment of CWD), agent type, status, last activity time
  • Sub-rows (up to one shown per terminal, in priority order):
    • currentTask (gear icon) — current agent task from status-line parsing (e.g. “Reading files”). Suppressed for Claude Code (spinner verbs are decorative)
    • agentIntent (crosshair icon) — LLM-declared intent via intent: token
    • lastPrompt (speech bubble icon) — last user prompt (>= 10 words). Shown only when no agentIntent is present
  • Status color codes: green=working, yellow=waiting, red=rate-limited, gray=idle
  • Ready input composers remain gray/idle when an agent leaves a long-lived background terminal running
  • Rate limit indicators with countdown timers
  • Click any row to switch to that terminal and close the dashboard
  • Relative timestamps auto-refresh (“2s ago”, “1m ago”)

3.13 Error Log Panel (Cmd+Shift+E)

  • Centralized log of all errors, warnings, and info messages across the app
  • Sources: App, Plugin, Git, Network, Terminal, GitHub, Dictation, Store, Config
  • Level filter tabs: All, Error, Warn, Info, Debug — uses a severity threshold: selecting a level shows that level and everything more severe (e.g. Warn shows Warn + Error intermingled). Each tab has a tooltip describing what it includes
  • Source filter dropdown to narrow by subsystem
  • Text search across all log messages
  • Each entry shows timestamp, level badge (color-coded), source tag, and message
  • Copy individual entries or all visible entries to clipboard
  • Clear button to flush the log
  • Status bar badge shows unseen error/warning count (red, resets when panel opens)
  • Global error capture: uncaught exceptions and unhandled promise rejections are automatically logged
  • Ring buffer of 1000 entries (oldest dropped when full), Rust-backed — warn/error entries survive webview reloads via push_log/get_logs Tauri commands
  • Also accessible via Command Palette: “Error log”

3.14 Plan Detection

  • Plans are detected via structured plan-file events from the output parser and via plans/ directory watcher
  • Auto-open: restores the active plan from .claude/active-plan.json on startup; new plans opened as background markdown tabs on first detection (no focus change)
  • Repo-scoped: only processes plans belonging to the active repository

3.15 Preview Tab

  • Multi-format file previewer opened from clickable file paths, drag & drop, File Browser, or Command Palette
  • File routing handled by classifyFile() in src/utils/filePreview.ts
  • Supported formats:
    • HTML — rendered in sandboxed iframe with “Open in browser” button; Cmd/Ctrl+F find-in-page uses the shared SearchBar pill (case/regex/whole-word toggles), which drives the iframe over a postMessage bridge and highlights matches in place
    • PDF — rendered via asset protocol in embedded iframe
    • Images — PNG, JPG/JPEG, GIF, WebP, SVG, AVIF, ICO, BMP — rendered as <img> via asset protocol
    • Video — MP4, WebM, OGG, MOV — rendered as <video> with native controls
    • Audio — MP3, WAV, FLAC, AAC, M4A — rendered as <audio> with native controls
    • Text / data — TXT, JSON, CSV, LOG, XML, YAML, TOML, INI, CFG, CONF — raw text in a <pre> block
  • Header bar shows shortened file path with Edit button (pencil icon — opens file in code editor) and Open externally button
  • Reload: when a web or HTML-preview tab is active, Cmd/Ctrl+R reloads its content instead of opening the Run Command dialog
  • File content auto-refreshes on repository revision bumps (git change detection)
  • Uses Tauri’s convertFileSrc() asset protocol for binary files, read_external_file IPC for text content
  • CSP allows asset: and http://asset.localhost in frame-src and media-src

3.16 Focus Mode (Cmd+Alt+Enter)

  • Hides sidebar, tab bar, and all side panels to maximize the active tab’s content area
  • Toolbar and status bar remain visible for repo/branch state and mode exit
  • Session-only (not persisted across restarts)
  • Toggle again to restore the previous layout

3.17 Detachable Panels

  • Any panel (AI Chat, Activity Dashboard, Git Panel) can be detached into a separate OS window
  • Generic system via open_panel_window / close_panel_window Rust commands with per-panel adapters
  • Two-tier sync: self-sufficient panels (Git Panel) call Rust directly; projection panels (Activity Dashboard) receive state snapshots via emitTo at 1 Hz
  • Shared PanelWindowControls component provides consistent detach/reattach/close buttons across all panels
  • Closing a detached window automatically restores the panel to the main window
  • Tab bar “Detach to Window” context menu entry for per-tab detach (PTY session stays alive in Rust)
  • Generic lifecycle functions: togglePanel(), detachPanel(), reattachPanel() replace per-panel callsites
  • uiStore.detachedPanels map tracks all detached panels (replaces former aiChatDetached boolean)

4. Toolbar

4.1 Sidebar Toggle

  • button (left side) — same as Cmd+[
  • Hotkey hint visible during quick switcher
  • Adjacent filter icon (shown while the sidebar is visible) toggles the “Active only” repo filter — see section 2.8

4.2 Branch Display

  • Center: shows repo / branch name
  • Click to open branch rename dialog

4.3 Plan File Button

  • Appears when an AI agent emits a plan file path (e.g., PLAN.md)
  • Click: .md/.mdx files open in Markdown panel; others open in IDE
  • Dismiss (×) button to hide without opening

4.4 Notification Bell

  • Bell icon with count badge when notifications are available
  • Click: opens popover listing all active notifications
  • Empty state: shows “No notifications” when nothing is pending
  • PR Updates section — types: Merged, Closed, Conflicts, CI Failed, CI Passed, Changes Requested, Ready
  • Git section — background git operation results (push, pull, fetch) with success/failure status
  • Worktrees section — worktree creation events (from MCP/agent)
  • Messages section — every toast, mirrored as it is raised, so a message that faded while the user looked elsewhere stays readable. Level and action carry over. Agent-raised MCP toasts derive their repository from the caller’s session/cwd, display its name, and retain repository scope in the bell. Controlled by “Keep toasts in the bell” (Settings > Notifications), on by default
  • Plugin activity sections — registered by plugins via activityStore
  • Click PR notification: opens full PR detail popover for that branch
  • Individual dismiss (×) per notification, section “Dismiss All”, auto-dismiss after 5min focused time

4.5 IDE Launcher

  • Button with current IDE icon — click to open repo/file in IDE
  • Dropdown: shows all detected installed IDEs, grouped by category
  • Categories: Code Editors, JetBrains, Terminals, Git Tools, System
  • JetBrains family: IntelliJ IDEA, PyCharm, WebStorm, GoLand, CLion, PhpStorm, RubyMine, Rider, DataGrip, RustRover, Android Studio, Fleet — launched via their CLI launcher (idea, pycharm, …) with --line/--column goto, falling back to open -a on macOS when the Toolbox shell scripts aren’t on PATH
  • File-capable editors (including JetBrains IDEs) open the focused file (from editor or MD tab); others open the repo
  • Custom launchers (#71): user-defined entries configured at Settings → General → Custom Launchers (name, executable on PATH or absolute, args, per-OS platform, enable toggle), shown under a “Custom” section in the dropdown. Args support placeholder tokens resolved at launch: {path}/{file} (focused file, else repo), {repo}, {fileDir} (focused file’s directory, else repo), {cwd} (focused terminal cwd, else repo), {home}, and {line}/{column} (editor cursor, default 1)
  • Run command button: Cmd+R (run), Cmd+Shift+R (edit & run)

5. Status Bar

5.1 Left Section

  • Zoom indicator: current font size (shown when != default)
  • Status info text (with pendulum ticker for overflow, pulse animation on new messages)
  • CWD path: shortened with ~/, click to copy to clipboard (shows “Copied!” feedback)
  • Unified agent badge with priority cascade:
    1. Rate limit warning (highest): count + countdown timer when sessions are rate-limited
    2. Claude Usage API ticker: live utilization from Anthropic API (click opens dashboard)
    3. PTY usage limit: weekly/session percentage from terminal output detection
    4. Agent name (lowest): icon + name of detected agent
    • Color coding: blue < 70%, yellow 70-89%, red pulsing >= 90%
    • Claude usage ticker absorbed into badge when active agent is Claude (avoids duplicate display)
  • Shared ticker area: multi-source rotating messages from plugins with source labels, counter badge (1/3 ▸), click-to-cycle, right-click popover, and priority tiers (low/normal/urgent)
  • Update badge: “Update vX.Y.Z” (click to download & install), progress percentage during download

5.2 GitHub Section (center)

  • Branch badge: name + ahead/behind counts — click for branch popover
  • PR badge: number + state color — click for PR detail popover
    • PR lifecycle filtering: CLOSED PRs hidden immediately; MERGED PRs hidden after 5 minutes of accumulated user activity
  • CI badge: ring indicator — click for PR detail popover

5.3 Right Section — Panel Toggles

  • Ideas (lightbulb icon) — Cmd+Alt+N
  • File Browser (folder icon) — Cmd+E
  • Markdown (MD icon) — Cmd+Shift+M
  • Git (diff icon) — Cmd+Shift+D (opens Git Panel)
  • Mic button (when dictation enabled): hold to record, release to transcribe

6. AI Agent Support

6.1 Supported Agents

AgentBinaryResume Command
Claude Codeclaudeclaude --resume <uuid> (session-aware) / claude --continue (fallback)
Gemini CLIgeminigemini --resume <uuid> (session-aware) / gemini --resume (fallback)
OpenCodeopencodeopencode -c
Aideraideraider --restore-chat-history
Codex CLIcodexcodex resume <uuid> (session-aware) / codex resume --last (fallback)
Ampampamp threads continue
Cursor Agentcursor-agentcursor-agent resume
Goosegoosegoose session --resume --name <uuid> (session-aware) / goose session --resume (fallback)
Droid (Factory)droid
pipipi --continue
Git (background)git

6.1.1 Session-Aware Resume

When an agent is detected running in a terminal, TUICommander automatically discovers its session ID from the filesystem and stores it per-terminal (agentSessionId). On restore, this enables session-specific resume instead of generic fallback commands.

  • Claude Code — Sessions stored as ~/.claude/projects/<slug>/<uuid>.jsonl; UUID from filename
  • Gemini CLI — Sessions stored in ~/.gemini/tmp/<hash>/chats/session-*.json; sessionId field from JSON
  • Codex CLI — Sessions stored in ~/.codex/sessions/YYYY/MM/DD/rollout-*-<UUID>.jsonl; UUID from filename. Codex does not partition by project, so candidates are filtered on the working directory recorded in the rollout’s first session_meta record — otherwise a terminal in one project would bind to another project’s session. A rollout whose cwd can’t be read is rejected rather than accepted
  • Goose — Sessions stored in SQLite (~/Library/Application Support/Block/goose/sessions/sessions.db); shell wrapper injects --name $TUIC_SESSION for deterministic binding, resume by name

Discovery runs once per terminal on null→agent transition. Multiple concurrent agents are handled via a claimed_ids deduplication list. On agent exit, the stored session ID is cleared to allow re-discovery on next launch.

Every agent rejects candidates older than 5 minutes (SESSION_MAX_AGE), so a terminal opened now never resumes a session abandoned earlier in the day.

6.1.2 TUIC_SESSION Environment Variable

Every terminal tab has a stable UUID (tuicSession) injected as the TUIC_SESSION environment variable in the PTY shell. This UUID persists across app restarts and enables:

  • Automatic session binding: Shell integration injects wrapper functions that transparently bind agent sessions to the current tab (zsh, bash, fish):
    • Claude Code: claude() adds --session-id $TUIC_SESSION; bypassed when --session-id, --resume, or --continue are explicit
    • Goose: goose() adds --name $TUIC_SESSION to session and run subcommands; bypassed when --name, -n, --resume, or -r are explicit
    • Session conflict handling: When an agent reports a session conflict (in-use or not-found), TUICommander creates a no-session-inject.$TUIC_SESSION flag file in the config directory. Shell wrappers check for this file and skip --session-id injection when it exists — avoiding PTY writes that could corrupt TUI output
  • Automatic resume: On restore, TUICommander verifies if the session file exists on disk (verify_agent_session) before using --resume $TUIC_SESSION
  • UI spawn coherence: When spawning agents via the context menu, TUIC_SESSION is used as --session-id automatically
  • Custom scripts: $TUIC_SESSION is available as a stable key for any tab-specific state

6.2 Agent Detection

  • Auto-detection from terminal output patterns
  • Multi-agent status line detection via regex patterns anchored to line start: Claude Code (*//· + task text + .../), [Running] Task format, Aider (Knight Rider scanner ░█ + token reports), Codex CLI (/ bullet spinner with time suffix), Goose (<message>... (Ctrl+C to interrupt)), Copilot CLI (// indicators), Gemini CLI (braille dots ⠋⠙⠹...)
  • Movement-based activity: BUSY is normally latched/kept by text changing above the input area, user submission, and OSC lifecycle markers, which outrank silence. Ready prompts require a stable 1.5s observation before idle.
  • Codex and Claude also use narrow semantic active markers because their current TUIs can freeze or retain an empty composer during real work. Codex scopes Working … esc to interrupt to the lowest or » composer. Claude requires a spinner-prefixed phase with an ellipsis and parenthesized progress; completed summaries remain idle-safe, and live work can supersede a premature blocking Stop-hook completion.
  • Ctrl-C/Escape are interrupt intent only; status changes after the agent confirms interruption, returns to a stable prompt, emits Stop, or exits.
  • Status lines rejected when they appear in diff output, code listings, or block comments
  • Brand SVG logos for each agent (fallback to capital letter)
  • Agent badge in status bar showing active agent
  • Binary detection: Rust probes well-known directories via resolve_cli() for reliable PATH resolution in desktop-launched apps
  • Foreground process detection: tcgetpgrp() on the PTY master fd, then proc_pidpath() to get the binary name. Handles versioned binary paths (e.g. Claude Code installs as ~/.local/share/claude/versions/2.1.87) by scanning parent directory names when the basename is not a known agent; Droid is classified explicitly so it receives the agent idle threshold.

6.3 Rate Limit Detection

  • Provider-specific regex patterns detect rate limit messages
  • Status bar warning with countdown timer
  • Per-session tracking: rate-limit events are only accepted for sessions where agent activity has been detected (prevents false warnings in plain shell sessions)
  • Auto-expire: rate limits are cleared automatically after retry_after_ms (or 120s default) without requiring agent output

6.4 Question Detection

  • Recognizes interactive prompts (yes/no, multiple choice, numbered options)
  • Tab dot turns orange (pulsing) when awaiting input; sidebar branch icon shows ? in orange
  • Prompt overlay: keyboard navigation (↑/↓, Enter, number keys 1-9, Escape)
  • Two detection strategies run in priority order:
    1. Screen-based (Strategy 1): reads the live terminal screen, finds the last chat line above the prompt box (delimited by separator lines), checks if it ends with ?. Works with Claude Code, Codex ( prompt), and Gemini (> prompt) layouts
    2. Silence-based (Strategy 2, fallback): if terminal output stops for 10s after a line ending with ?, the session is treated as awaiting input
  • Stale candidate clearing: candidates that fail screen verification are purged so the same question can re-fire in a future agent cycle
  • Echo suppression: user-typed input echoed by PTY is ignored for 500ms to prevent false question detection
  • extract_question_line() scans all changed rows (not just the last) for question text, applied in both normal and headless reader threads
  • Question state auto-clears when a status-line event fires (agent is actively working, so it’s no longer awaiting input)

6.5 Usage Limit Detection

  • Claude Code weekly and session usage percentage (from PTY output patterns)
  • Color-coded badge in status bar (blue < 70%, yellow 70-89%, red pulsing >= 90%)
  • Integrated into unified agent badge (see section 5.1)

6.6 Claude Usage Dashboard

  • Native SolidJS component (not a plugin panel — renders as a first-class tab)
  • Opens via status bar agent badge click or Cmd+Shift+A action
  • Rate Limits section: Live utilization bars from Anthropic OAuth usage API
    • 5-Hour, 7-Day, 7-Day Opus, 7-Day Sonnet, 7-Day Cowork buckets
    • Color-coded bars: green < 70%, yellow 70-89%, red >= 90%
    • Reset countdown per bucket
  • Usage Over Time chart: SVG line chart of token usage over 7 days
    • Input tokens (blue) and output tokens (red) stacked area
    • Interactive hover crosshair with tooltip
  • Insights: Session count, message totals, input/output tokens, cache stats, tokens-per-hour metric (based on real active hours from session timestamps)
  • Activity heatmap: 52-week GitHub-style contribution grid
    • Tooltip shows date, message count, and top 3 projects
  • Model Usage table: Per-model breakdown (messages, input, output, cache)
  • Projects breakdown: Per-project token usage with click to filter
  • Scope selector: Filter all analytics by project slug
  • Auto-refresh: API data polled every 5 minutes
  • Rust data layer: Incremental JSONL parsing of ~/.claude/projects/*/ transcripts
    • File-size-based cache (only new bytes parsed on each scan)
    • Cache persisted to disk as JSON for fast restarts

6.7 Intent Event Tracking

  • Agents declare work phases via intent: text (Title) tokens at column 0, colorized dim yellow in terminal output
  • Intent titles may replace spawn-assigned tab labels; only an explicit user rename locks the tab title, including after reconnect
  • Colorization is agent-gated (only applied in sessions with a detected agent) to prevent false positives
  • Structural tokens stripped from log lines served to PWA/REST consumers via LogLine::strip_structural_tokens()
  • Structured Intent events emitted for LLM-declared work phase tracking
  • Centralized debounced busy signal with completion notifications for accurate idle/active status
  • HTTP/MCP session origin survives frontend reconnects, keeping orchestration completion chimes muted when configured; BUSY→IDLE and exit share one notification per busy cycle

6.8 API Error Detection

  • Detects API errors (server errors, auth failures) from agent output and provider-level JSON error responses
  • Covers Claude Code, Aider, Codex CLI, Gemini CLI, Copilot, and raw API error JSON from providers (OpenAI, Anthropic, Google, OpenRouter, MiniMax)
  • Triggers error notification sound and logs to the Error Log Panel

6.9 Agent Configuration (Settings > Agents)

  • Agent list: All supported agents with availability status and version detection
  • Run configurations: Named command templates per agent (binary, args, env vars)
  • Default config: One run config per agent marked as default for quick launching
  • MCP bridge install: One-click install/remove of tui-mcp-bridge into agent’s native MCP config file
  • Supported MCP agents: Claude, Cursor, Windsurf, VS Code, Zed, Amp, Gemini
  • Edit agent config: Opens agent’s own configuration file in the user’s preferred IDE
  • Context menu integration: Right-click terminal > Agents submenu with per-agent run configurations
  • Busy detection: Agents submenu disabled when a process is already running in the active terminal
  • Environment Flags — Per-agent environment variables injected into every new terminal session. Configure in Settings > Agents > expand an agent > Environment Flags. Useful for setting feature flags like CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 without manual export.

6.10 Agent Teams

  • Purpose: Enables Claude Code’s Agent Teams feature to use TUIC tabs instead of tmux panes
  • Approach: Environment variable CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 injected into PTY sessions, which unlocks Claude Code’s TeamCreate/TaskCreate/SendMessage tools. Agent spawning uses direct MCP tool calls (agent spawn) instead of the deprecated it2 shim
  • Session lifecycle events: MCP-spawned sessions emit session-created and session-closed events so they automatically appear as tabs and clean up on exit
  • Settings toggle: Settings > Agents > Agent Teams
  • Suggest follow-ups: Agents can propose follow-up actions via suggest: [ A | B | C ] tokens, displayed as floating chip bar
  • Deprecated: The it2 shim approach (iTerm2 CLI emulation) is commented out — superseded by direct MCP tool spawning

6.11 Suggest Follow-up Actions

  • Protocol: Agents emit suggest: [ action1 | action2 | action3 ] at column 0 after completing a task. The whole token sits on one row, bounded by [ … ] with no nested brackets — so stray pipes/brackets in surrounding output (mermaid, markdown tables, prose) can never be mis-parsed as items
  • Token concealment: Suggest tokens are concealed in terminal output via line erasure or space replacement — the raw token never appears on screen. Concealment is agent-gated
  • Desktop: Floating chip bar (SuggestOverlay) above terminal with larger buttons and keyboard shortcut badges (19 to select, Esc to dismiss). Auto-dismiss after 30s, on typing, or on Esc
  • Mobile: Horizontal scrollable pill buttons above CommandInput in SessionDetailScreen
  • Action: Clicking a chip (or pressing its number key) sends the text to the PTY via write_pty
  • Settings: Configurable via Settings > Agents > Show suggested follow-up actions

6.12 Slash Menu Detection

  • When the user types / in a terminal, slash_mode activates and the output parser scans the bottom screen rows for slash command menus
  • Detection: 2+ consecutive rows starting with /command patterns, with highlight for the selected item
  • Produces ParsedEvent::SlashMenu { items } — used by mobile PWA to render a native bottom-sheet overlay
  • slash_mode cleared on user-input events and status-line events

6.13 Inter-Agent Messaging

  • Agent-to-agent coordination when multiple agents are spawned in parallel, carried by the agent MCP tool — there is no separate messaging tool
  • Identity: Each agent uses its $TUIC_SESSION env var (stable tab UUID) as its messaging identity. A headerless external caller may register without tuic_session to be issued an MCP-scoped UUID, or supply a stable UUID to reclaim an existing identity
  • Actions: register (announce presence, or rename/re-project an auto-bound peer), list_peers (discover other agents, optional project filter), send (message a peer by to = tuic_session), inbox (poll for messages), wait (block until new mail)
  • Dual delivery: Real-time push via MCP notifications/claude/channel over SSE into already working Claude Code turns; idle/completed managed agents and managed non-Claude agents use submitted PTY delivery even when their MCP bridge has an SSE stream; polling fallback via inbox is always available
  • Channel support: TUICommander declares experimental.claude/channel capability; spawned Claude Code agents automatically get --dangerously-load-development-channels server:tuicommander
  • Lifecycle: Peer registrations cleaned up on MCP session delete and TTL reap; PeerRegistered/PeerUnregistered events broadcast via event bus for frontend visibility
  • Limits: 64 KB max message size, 100 messages per inbox (FIFO eviction), optional project filtering for list_peers
  • TUICommander acts as the messaging hub — no external daemon needed
  • Durable task handles: agent action=spawn returns task_id and poll_interval_ms alongside session_id. The task MCP tool polls that handle without blocking — task action=get returns {task_id, status, status_message?, result?, error_detail?, poll_interval_ms} where status is working|input_required|completed|failed|cancelled (the last three final), and task action=cancel marks the task cancelled without killing the agent (session action=kill does that). Use this instead of agent action=wait / session action=wait when work runs past their 300 s cap or when the client may reconnect: the outcome is recorded by the session exit path whether or not anyone was listening. Tasks live for the TUICommander process only — they are deliberately not persisted, because a restart tears down every PTY

6.14 AI Chat Panel (Cmd+Alt+A)

  • Conversational AI companion docked on the right, streaming markdown with syntax-highlighted code blocks. Every code block has Run (sends to the attached terminal via sendCommand()), Copy, and Insert actions
  • Multi-provider: Ollama (local, auto-detected on localhost:11434 with live model list), Anthropic, OpenAI, OpenRouter, custom OpenAI-compatible endpoint. Provider abstraction via genai crate
  • Per-terminal state — each terminal tab maintains its own independent chat history, streaming state, and conversation ID (keyed by tuicSession). The header shows the active terminal’s name as a badge
  • Frozen state — when no terminal is focused (e.g. Git panel or settings active), a banner reads “No terminal focused — chat is read-only”, input is disabled, and the send button is greyed out
  • Per-turn terminal context: last context_lines rows from VtLogBuffer (ANSI-stripped, alt-screen suppressed), SessionState, recent ParsedEvents, git branch/diff. Terminal follows the focused tab automatically
  • API keys stored in OS keyring (service tuicommander-ai-chat, user api-key) — masked with eye-toggle in Settings
  • Streaming via Tauri Channel<ChatStreamEvent> (chunk/end/error); cancellable mid-stream
  • Conversation persistence: save / load / delete with in-memory cap of 100 messages per conversation. Files at <config_dir>/ai-chat-conversations/<id>.json
  • Conversation history panel — click the clock/history icon in the header to browse all saved conversations (title, terminal name, message count, date). Click a row to load it
  • Usage footer — live token counter at the bottom: prompt tokens (↑N), completion tokens (↓N), estimated cost ($X.XXXX), cache hit rate
  • Terminal context menu: Send selection to AI Chat, Explain this error. Toolbar toggle + hotkey
  • Detachable panel — click the detach icon in the header to pop the panel into a separate window (500×700). The main window shows a placeholder with “Bring back”. Cross-window sync via a Rust-side ChatRegistry using Channel<ChatEvent> fan-out — streaming chunks, messages, and errors are projected in real-time to all subscribers. Closing the detached window automatically restores the panel
  • Full user guide: docs/user-guide/ai-chat.md

6.15 AI Agent Loop (ReAct)

  • Autonomous loop that observes and acts in a terminal. Same panel as AI Chat, mode toggle in the header
  • Terminal observe tools: read_screen (text + live shell_state/awaiting_input/agent_intent), get_context (cheap orientation: shell state, cwd, git branch, last exit code), get_command_history (OSC 133 command outcomes — exit codes, durations), explain_last_failure (last failed command + captured output), get_error_fixes (known error→fix correlations), search_scrollback (regex search across screen + history, secrets redacted), get_hyperlinks (OSC 8 links on the active screen), get_semantic_zones (OSC 133 prompt/input/output zones), get_state, wait_for (regex or stability) + act tools send_input / send_key + search_code (BM25 semantic search over repo files via content_index)
  • Agent lifecycle state is backend-authoritative: sticky awaiting transitions use a lossless reducer lane, while live SSE/WS delivery remains best-effort; an unobserved shell reports agent state starting instead of idle.
  • Reactive watcheswatch_for arms a watch on the session (triggers: idle/busy/command_done/question/error/unseen/pattern); when it fires, a fresh autonomous conversation runs the supplied instructions. Approval-gated (the model cannot silently arm autonomous loops), scoped to the agent’s bound session, and bounded by max_fires/cooldown via the shared WatcherEngine (cooldown/burst/user-input-pause guards). list_watches / cancel_watch manage armed watches
  • Safety gates via the SafetyChecker trait — three verdicts: Allow, NeedsApproval { reason }, Block { reason }. Destructive commands (rm -rf, git reset --hard, git push --force, DROP TABLE, dd of=, …) surface a pending-approval card; hard-coded blocks refuse patterns like rm -rf /
  • Pause / resume / cancel between iterations with clean state transitions. Tool-call cards collapse/expand in the panel. Conversation schema v2 persists tool-call records alongside messages
  • Session knowledge store (Level 3): command outcomes (exit code, duration, CWD, classification, output snippet), auto-correlated error→fix pairs, CWD history, tui_apps_seen, terminal mode. Injected into the agent system prompt as a compact markdown summary
  • OSC 133 semantic prompts feed exact exit codes when the shell supports them; a silence-timer fallback records Inferred outcomes otherwise. Persisted to <config_dir>/ai-sessions/<session_id>.json with a 2 s debounced flush
  • SessionKnowledgeBar — collapsible footer under the panel showing live command count, last 5 outcomes with kind badges, recent errors with inferred error_type, TUI mode indicator. A History button opens the knowledge history overlay (two-pane sessions/detail browser with full-text search, errors-only filter, and 24h/7d/30d date window)
  • Experimental AI block enrichment (opt-in, Settings > AI Chat) — after each completed OSC 133 D block, a bounded mpsc worker asks the active AI provider for a one-line semantic_intent and stamps it onto the CommandOutcome (identified by stable id: u64). Rate-limited ~10/min, silent drop on full queue, never blocks the PTY path
  • TUI app detection via alternate-screen tracking (ESC[?1049h/l). TerminalMode::FullscreenTui { app_hint, depth } is set when the terminal enters vim/htop/lazygit/less/tmux/…; the agent adapts (prefers send_key + wait_for over line-oriented send_input)
  • External MCP surface — 13 tools exposed as ai_terminal_read_screen, ai_terminal_send_input, ai_terminal_send_key, ai_terminal_wait_for, ai_terminal_get_state, ai_terminal_get_context, ai_terminal_drive_agent, ai_terminal_read_file, ai_terminal_write_file, ai_terminal_edit_file, ai_terminal_list_files, ai_terminal_search_files, ai_terminal_run_command. Input operations always require user confirmation and are rejected while the internal agent loop is active on the target session
  • drive_agent — atomic send→wait→read tool. Sends a command, waits for idle/pattern, returns screen + shell state in one call. Replaces the common send_inputwait_forread_screen three-step pattern
  • Session aliases — Human-friendly aliases auto-assigned from repo directory name (e.g. tuicommandertc-1). Acronym derived from segment initials (split on -, _, ., camelCase), with collision resolution. All ai_terminal_* tools accept aliases in place of UUIDs. Visible in tab tooltips and list_sessions output. Counters reset on app restart
  • Delta cursorread_screen, drive_agent, and session action=output return a monotonic cursor field. Pass since_cursor on subsequent calls to receive only new scrollback lines since that position, avoiding full re-reads. Client-side tracking, zero server state
  • Unsafe mode — lock icon in the AI Chat header toggles unrestricted operation (TrustLevel::Unrestricted). Bypasses SafetyChecker approval and FileSandbox path jail. Confirmation dialog before activation; header turns red while active. Per-session, resets on loop end
  • Agent model overrides — per-task-phase model routing (agent_model_overrides in ai-chat-config.json). Four phases: plan, search, read, write. Each phase can use a different model to optimize cost/quality trade-offs
  • Cross-session memory injectionbuild_cross_session_section() scans all sessions whose CWD history overlaps the current session’s repo root and injects a summarised memory block into the agent system prompt. The agent inherits knowledge from prior sessions in the same repo without manual intervention
  • Cron scheduler — time-triggered agent tasks defined in Settings > AI Chat > Scheduler. Cron expressions with goals, persisted to <config_dir>/ai-cron.json. Scheduler ticks every 30 s. Tauri commands: load_scheduler_config, save_scheduler_config

6.16 Provider Registry

  • Centralized multi-provider configuration replacing per-feature provider settings
  • Supported provider types: Anthropic, OpenAI, OpenRouter, Ollama (local, auto-detected), custom OpenAI-compatible endpoints
  • Per-provider API keys stored in OS keyring via Credential::Provider variant
  • Per-provider model lists with add/remove/reorder
  • Slot resolver — logical slots (headless, chat, triage) map to concrete provider+model pairs with a configurable fallback chain
  • Legacy migration — existing ai-chat-config.json provider/model/API key settings auto-migrated to providers.json on first load
  • Settings > Providers tab — full CRUD UI: add/edit/remove providers, manage model lists, assign slots, test connections
  • All Rust consumers (ai_chat, ai_agent, headless, triage) resolve models via the registry instead of reading config directly
  • Config file: <config_dir>/providers.json

6.17 AI Diff Triage

  • LLM-powered code review panel for git diff changes
  • Progressive loading: diffs grouped by file with heuristic pre-classification (formatting-only, rename, test, config changes) before LLM analysis
  • Multi-turn conversation via TriageSession — ask follow-up questions about specific findings
  • “Diff” button on findings opens the relevant file diff in context
  • Refresh support: re-run triage when the diff changes
  • Backed by run_diff_triage Tauri command with classify_multi_turn for iterative refinement

6.18 ChoicePrompt Detection

  • New ParsedEvent::ChoicePrompt { title, options, dismiss_key, amend_key } recognises Claude-Code-style numbered confirmation menus (footer matches Esc to cancel · Tab to amend)
  • Options parsed by regex with optional cursor marker (, , >). Title heuristics require ? or a verb prefix (proceed, confirm, do you want, …) to avoid matching Markdown numbered lists. Minimum two options
  • Destructive labels (no, cancel, reject, abort, deny, don't) flagged for styling
  • Piped into SessionState.choice_prompt; dispatched to plugins via pluginRegistry.dispatchStructuredEvent("choice-prompt", …); rendered as PWA overlay
  • Single-key replies routed through sendPtyKey() (src/utils/sendCommand.ts) — never text + \r. Desktop listener plays a warning sound when the prompt arrives on an inactive tab

7. Git Integration

7.1 Repository Info

  • Branch name, remote URL, ahead/behind counts
  • Read directly from .git/ files (no subprocess for basic info)
  • Repo watcher: monitors .git/index, .git/refs/, .git/HEAD, .git/MERGE_HEAD for changes

7.2 Worktrees

  • Auto-creation on branch select (non-main branches)
  • Configurable storage strategies: sibling (__wt), app directory, inside-repo (.worktrees/), or Claude Code default (.claude/worktrees/)
  • Sci-fi themed auto-generated names
  • Three creation flows: dialog (with base ref dropdown), instant (auto-name), right-click branch (quick-clone with hybrid {branch}--{random} name)
  • Base ref selection: choose which branch to start from when creating new worktrees
  • Per-repo settings: storage strategy, prompt on create, delete branch on remove, auto-archive, orphan cleanup, PR merge strategy, after-merge behavior, PR visibility filters (hide drafts/conflicting/CI-failing)
  • Setup script: runs once after creation (e.g., npm install)
  • Archive script: runs before a worktree is archived or deleted; non-zero exit blocks the operation
  • Merge & Archive: right-click → merge branch into main, then archive or delete based on setting. Conflict cleanup reports (aborted) only when git merge --abort succeeds; if abort fails, the error includes the manual recovery command.
  • External worktree detection: monitors .git/worktrees/ for changes from CLI or other tools
  • Remove via sidebar × button or context menu (with confirmation)
  • Worktree Manager panel (Cmd+Shift+W or Command Palette → “Worktree manager”):
    • Dedicated overlay listing all worktrees across all repos with metadata: branch name, repo badge, PR state (open/merged/closed), dirty stats, last commit timestamp
    • Orphan worktree detection with warning badge and Prune action
    • Repo filter pills and text search for branch names
    • Multi-select with checkboxes and select-all for batch operations
    • Batch delete and batch merge & archive
    • Single-row actions: Open Terminal, Merge & Archive, Delete (disabled on main worktrees)

7.3 Auto-Fetch

  • Per-repo configurable interval (5/15/30/60 minutes, default: disabled)
  • Background git fetch --all via non-interactive subprocess
  • Bumps revision counter to refresh branch stats and ahead/behind counts
  • Errors logged to appLogger, never blocking
  • Master-tick architecture: single 1-minute timer checks all repos

7.4 Unified Repo Watcher

  • Single watcher per repository monitoring the entire working tree recursively (replaces separate HEAD/index watchers)
  • Uses raw notify::RecommendedWatcher with manual per-category trailing debounce
  • Event categories: Git (HEAD, refs, index, MERGE_HEAD), WorkTree (source files), Config (app config changes)
  • Each category has its own debounce window — git metadata changes propagate faster than file edits
  • Respects .gitignore rules — ignored paths do not trigger refreshes
  • Gitignore hot-reload: editing .gitignore rebuilds the ignore filter without restarting the watcher
  • When a terminal runs git checkout -b new-branch in the main working directory (not a worktree), the sidebar renames the existing branch entry in-place (preserving all terminal state) instead of creating a duplicate

7.5 Diff

  • Working tree diff and per-commit diff via Git Panel Changes tab
  • Per-file diff counts (additions/deletions) shown inline in Changes tab
  • Click a file row to view its diff
  • Side-by-side (split), unified (inline), and scroll (all files) view modes — toggle in toolbar, preference persisted
  • Scroll mode (all-files diff) — shows every changed file (staged + unstaged) in a continuous scrollable view with collapsible file sections, per-file addition/deletion stats, sticky header with totals, and clickable filenames that open in the editor. Reactively reloads on git operations via revision tracking
  • Auto-unified for new/deleted files — split view is forced to unified when the diff is one-sided
  • Word-level diff highlighting via @git-diff-view/solid with virtualized rendering
  • Hunk-level restore — hover a hunk header to reveal a revert button (discard for working tree, unstage for staged)
  • Line-level restore — click individual addition/deletion lines to select them (shift+click for ranges), then restore only the selected lines via partial patch
  • Text selection and copy enabled in diff panels (user-select: text)
  • Cmd+F search in diff tabs via SearchBar + DomSearchEngine
  • Submodule entries are filtered from working tree status (not shown as regular files)
  • Standalone DiffPanel removed in v0.9.0 (see section 3.2)

8. GitHub Integration

8.1 PR Monitoring

  • GraphQL API (replaces gh CLI for data fetching)
  • PR badge colors: green (open), purple (merged), red (closed), gray (draft)
  • Merge state: Ready to merge, Checks failing, Has conflicts, Behind base, Blocked, Draft
  • Review state: Approved, Changes requested, Review required
  • PR lifecycle rules: CLOSED PRs hidden from sidebar and status bar; MERGED PRs shown for 5 minutes of accumulated user activity then hidden
  • Auto-show PR popover filters out CLOSED and MERGED PRs (configurable in Settings > General)

8.2 CI Checks

  • Ring indicator with proportional segments
  • Individual check names and status in PR detail popover
  • Labels with GitHub-matching colors

8.3 PR Detail Popover

  • Title, number, link to GitHub
  • Author, timestamps, state, merge readiness, review decision
  • CI check details, labels, line changes, commit count
  • View Diff button: opens PR diff as a dedicated panel tab with collapsible file sections, dual line numbers, and color-coded additions/deletions
  • AI Review: reviews PR diffs from the popover and falls back to a local-clone diff when GitHub refuses to render oversized PR diffs
  • Merge button: visible when PR is open, approved, CI green — merges via GitHub API. Merge method auto-detected from repo-allowed methods; auto-fallback to squash on HTTP 405 rejection
  • Approve button: submit an approving review via GitHub API (remote-only PRs)
  • Post-merge cleanup dialog: after merge, offers checkable steps (switch to base, pull, delete local/remote branch)
  • Review button: if the branch’s active agent has a run config named “review”, spawns a terminal running the interpolated command with {pr_number}, {branch}, {base_branch}, {repo}, {pr_url}. Hidden when no matching config exists
  • Triggered from: sidebar PR badge, status bar PR badge, status bar CI badge, toolbar notification bell

8.4 PR Visibility Filters

  • Global settings (Settings > GitHub): hide draft PRs, hide conflicting PRs, hide CI-failing PRs
  • Per-repo overrides (Settings > [Repo] > PR Visibility): tri-state toggle per filter (Show / Default / Hide)
  • Default = inherit from global setting, shown in parentheses (e.g. “Draft PRs (Show)”)
  • Resolution chain: per-repo override → global setting
  • TriStateToggle component: 3-position pill switch (left=hide, center=default, right=show) matching existing toggle style

8.5 Auto-Heal

  • When a PR on a branch with an active agent terminal becomes blocked, auto-heal hands the problem to the agent:
    • CI failure — fetches failure logs and injects them with a fix prompt
    • Merge conflict (mergeable === "CONFLICTING") — injects a resolve-conflicts prompt
  • Toggle per-branch via pill-switch toggle in PR detail popover (visible when CI is failing or the PR is conflicting), styled consistently with CI check item rows
  • Fetches completed failed-job logs directly via the GitHub Actions jobs API, including when sibling jobs keep the workflow run in progress; logs are sanitized and truncated before injection
  • Waits for agent to be idle/awaiting input before injecting
  • Max 3 delivered attempts per block cycle, then stops and logs a warning; log-fetch and terminal-delivery failures do not consume the budget
  • Enabling while already blocked kicks off a heal immediately
  • Attempt counter visible in PR detail popover
  • Status tracked per-branch in BranchState.ciAutoHeal

8.6 PR Notifications

  • Types: Merged, Closed, Conflicts, CI Failed, Changes Requested, Ready
  • Toolbar bell with count badge
  • Individual dismiss or dismiss all
  • Click to open PR detail popover

8.7 Merge PR via GitHub API

  • Merge PRs directly from TUICommander without switching to GitHub web
  • Configurable merge strategy per repo: merge commit, squash, or rebase (Settings > Repository > Worktree tab)
  • Merge method auto-detected from repo’s allowed methods via GitHub API (get_repo_merge_methods); auto-fallback to squash on HTTP 405 rejection. Squash is preferred first when several methods are allowed (src/utils/prMerge.ts)
  • Merge stays available while CI is still running: canMergePr requires the PR to be open, non-draft, approved, and free of definitively failed checks — pending checks do not hide the action
  • Triggered from: PR detail popover (local branches), remote-only PR popover, Merge & Archive workflow (sidebar context menu)
  • Post-merge cleanup dialog: sequential steps executed via Rust backend (not PTY — terminal may be occupied by AI agent)
    • Switch to base branch (auto-stash if dirty — inline warning shown with “Unstash after switch” checkbox)
    • Pull base branch (ff-only)
    • Close terminals + delete local branch (safe delete, refuses default branch)
    • Delete remote branch (gracefully handles “already deleted”)
    • Steps are checkable — user can toggle which to execute
    • Per-step status reporting: pending → running → success/error
  • After-merge behavior setting for worktrees: archive (auto-archive), delete (remove), ask (show dialog)
  • When afterMerge=ask: unified cleanup dialog includes an archive/delete worktree step (with inline selector) alongside branch cleanup steps — replaces the old 3-button MergePostActionDialog

8.8 Auto-Delete Branch on PR Close

  • Per-repo setting: Off (default) / Ask / Auto
  • Triggered when GitHub polling detects PR merged or closed transition
  • If branch has a linked worktree, removes worktree first then deletes branch
  • Safety: never deletes default/main branch; dirty worktrees always escalate to ask mode
  • Uses safe git branch -d (refuses unmerged branches)
  • Deduplication prevents double-firing on the same PR

8.9 GitHub Issues Panel

  • Issues displayed in a collapsible section within the GitHub panel alongside PRs
  • Filter modes: Assigned (default), Created, Mentioned, All, Disabled
  • Filter persisted in app config (issue_filter field) and configurable in Settings > GitHub
  • Each issue shows: number, title, state (OPEN/CLOSED), author, labels, assignees, milestone, comment count, timestamps
  • Labels rendered with GitHub-matching colors (background opacity 0.7, contrast-aware text color)
  • Issue actions: Open in GitHub, Close/Reopen, Copy issue number
  • Expand accordion to see full details (milestone, assignees, labels, timestamps)
  • Skeleton loading rows shown during first fetch
  • Empty state message when no issues match the filter
  • MCP HTTP endpoint: GET /repo/issues?path=... returns issues JSON
  • MCP HTTP endpoint: POST /repo/issues/close closes an issue

8.10 GitHub Ops Dashboard

  • Dedicated GitHub Ops dashboard tab with live columns for PR review findings, auto-fix sessions, conflict assists, improvement proposals, and CI / merge readiness.
  • Improvement scans run a one-shot Headless-slot LLM pass over local repo context with focus modes: refactor, testing, and perf.
  • Proposals are notification-first: scan results emit proposals-ready over desktop events and /events SSE; no GitHub issue is created automatically.
  • Each proposal can be promoted to a GitHub issue only through an explicit user action, using the existing authenticated issue creation path.

8.11 Polling

  • Active window: every 30 seconds
  • Hidden window: every 2 minutes
  • API budget: ~2 calls/min/repo

8.12 Token Resolution

  • Priority: GH_TOKEN env → GITHUB_TOKEN env → OAuth keyring token → gh_token crate → gh auth token CLI
  • gh_token crate with empty-string bug workaround
  • Fallback to gh auth token CLI

8.13 OAuth Device Flow Login

  • One-click GitHub authentication from Settings > GitHub tab
  • Uses GitHub OAuth App Device Flow (no client secret, works on desktop)
  • Token stored in OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service)
  • Requested scope: repo
  • Shows user avatar, login name, and token source after authentication
  • Logout removes OAuth token, falls back to env/gh CLI
  • On 401: auto-clears invalid OAuth token and prompts re-auth

8.14 Multiple Accounts (github.com + GitHub Enterprise)

GitHub integration is account-centric: TUICommander can manage N accounts and each workspace repo is explicitly bound to the account that monitors it (a persisted binding, not derived live from origin).

Account kinds

  • Ambient github.com default — the account you authenticate with via the OAuth device flow above (or GH_TOKEN/gh CLI). Behaves exactly as before; a github.com-only user sees zero change.
  • Additional github.com accounts — extra named github.com logins added via the device flow (Settings → GitHub → Additional GitHub Accounts → “Add another github.com account”).
  • GitHub Enterprise Server (GHE) — added by host + a pasted Personal Access Token (no per-host OAuth App). Validated against https://{host}/api/v3/user; PAT stored in the OS keyring under github/account/{id}/token.

Repository bindings (Settings → GitHub → Repository Bindings)

  • Each workspace repo resolves to one of: Bound (shows the account + Unbind), NeedsBind (a candidate chooser — no silent origin pick when multiple GitHub remotes/accounts match), NeedsAccount (a github.com repo with no account yet → points to setup), or Unmonitored.
  • A single matching account auto-confirms; ambiguity always asks. Worktrees of a repo share the main checkout’s binding.

Per-account isolation (hybrid model)

  • github.com keeps the global breaker/viewer/rate/cooldown state byte-for-byte; each GHE account gets its own ghe_state (circuit breaker, viewer login, rate budget).
  • The poller groups active repos by account and runs one batch per account, so a 401 / rate-limit / fault on one account never opens another’s breaker or blocks its polling.
  • Cooldown keys are account-scoped ({account_id}:owner/repo for GHE; owner/repo unchanged for cloud); github.com logout clears only cloud cooldowns; removing an account drops only its token, record, bindings, and caches.

Limitations

  • REST + GraphQL (PRs, CI, issues, merge, approve, issue comments) work against bound GHE repos. gh-CLI-assisted CI-failure-log fetching (CI Auto-Heal) is disabled with a clear message for non-github.com accounts.

Backend: github_account.rs (GitHubHost, account model, binding store, resolve_repo_account), commands github_list_accounts / github_add_account / github_remove_account / github_bind_repo / github_unbind_repo / github_list_bindings / github_resolve_repo.


9. Voice Dictation

9.1 Whisper Inference

  • Local processing via whisper-rs (no cloud)
  • macOS: GPU-accelerated via Metal
  • Linux: CPU (optional CUDA/Vulkan build feature)
  • Windows: CPU-only (the whisper.cpp Vulkan backend’s shader build is broken on the Windows CI runner; vulkan will be re-enabled once stabilized)

9.2 Models

ModelSizeQuality
small~488 MBGood
small.en~488 MBGood (English-only)
large-v2~3.0 GBHighest accuracy (slow)
large-v3-turbo~1.6 GBBest (recommended, default)

9.3 Push-to-Talk

  • Default hotkey: F5 (configurable, registered globally)
  • Mic button in status bar: hold to record, release to transcribe
  • Transcribed text inserts into the focused input element (textarea, input, contenteditable); falls back to active terminal PTY when no text input has focus. Focus target captured at key-press time.

9.4 Streaming Transcription

  • Real-time partial results during push-to-talk via adaptive sliding windows
  • First partial within ~1.5s, subsequent windows grow to 3s for quality
  • VAD energy gate skips silence windows (prevents hallucination)
  • Floating toast shows partial text above status bar during recording, with a live microphone meter beside the partial text. The level is an RMS reading curved as sqrt(rms * 20) and clamped to 0–1 so ordinary speech is visible rather than pinned near zero, published through an atomic so the UI never blocks audio capture
  • 200ms audio window overlap (keep_ms) carries context across windows for continuity
  • Final transcription pass on full captured audio at key release
  • Hallucination filter (transcribe.rs) as the backstop after the RMS gate: quiet audio makes Whisper emit a subtitle credit in whatever language it guessed. Short thanks (grazie, thank you, merci, danke, спасибо, …) are dropped only when they are the entire transcript, so a dictated sentence containing one survives; channel boilerplate (amara.org, sottotitoli e revisione a cura di, thanks for watching, …) is dropped anywhere in the text. Covers all 11 languages in WHISPER_LANGUAGES because the default setting is auto

9.5 Microphone Permission Detection (macOS)

  • On first use, checks microphone permission via macOS TCC (Transparency, Consent, and Control) framework
  • Permission states: NotDetermined (will prompt), Authorized, Denied, Restricted
  • If denied, shows a dialog guiding the user to System Settings > Privacy & Security > Microphone with an “Open Settings” button
  • Linux/Windows: always returns Authorized (no TCC framework)

9.6 Configuration

  • Enable/disable, hotkey, language (auto-detect or explicit), model download
  • Audio device selection
  • Text correction dictionary (e.g., “new line” → \n)
  • Auto-send — Enable in Settings > Dictation to automatically submit (press Enter) after transcription completes.

10. Prompt Library

10.1 Access

  • Cmd+K to open drawer
  • Toolbar button

10.2 Prompts

  • Create, edit, delete saved prompts
  • Variable substitution: {{variable_name}}
  • Built-in variables: {{diff}}, {{changed_files}}, {{repo_name}}, {{branch}}, {{cwd}}
  • Custom variables prompt user for input
  • Categories: Custom, Recent, Favorites
  • Pin prompts to top
  • Search by name or content

10.3 Keyboard Navigation

  • ↑/↓: navigate, Enter: insert (restores terminal focus), Ctrl+N: new, Ctrl+E: edit, Ctrl+F: toggle favorite, Esc: close

10.4 Run Commands

  • Cmd+R: run saved command for active branch
  • Cmd+Shift+R: edit command before running
  • Configure per-repo in Settings → Repository → Scripts

10.5 Smart Prompts

AI automation layer with 29 built-in context-aware prompts. Each prompt includes a description explaining what it does. Prompts auto-resolve git context variables and execute via inject (PTY write), shell script (direct run), headless (one-shot subprocess), or API (direct LLM call) mode.

  • Open: Cmd+K or toolbar lightning bolt button
  • Drawer with category filtering (All/Custom/Recent/Favorites), search by name/description, and enable/disable toggles
  • Prompt rows show inline badges: execution mode (inject/shell/headless/api), built-in, placement tags
  • Prompts are context-aware: 31 variables auto-resolved from git, GitHub, and terminal state
  • Variable Input Dialog: unresolved variables show a compact form with variable name + description before execution
  • Edit Prompt dialog: full editor with name, description, content textarea, variable insertion dropdown (grouped by Git/GitHub/Terminal with descriptions), placement checkboxes, execution mode + inject target + auto-execute side-by-side, keyboard shortcut capture
  • Inject target: inject-mode prompts route to the Compose box (default — fills the input for review, never idle-gated) or the Terminal (sends straight to the agent, idle-gated)
  • Auto-execute (Terminal target only): when enabled, prompts send Enter immediately via agent-aware sendCommand; when disabled, text is pasted without Enter so the user can review before sending
  • API execution mode: calls LLM providers directly via HTTP API (genai crate) without terminal or agent CLI. Per-prompt system prompt field. Output routed via the same outputTarget options (clipboard, commit-message, toast, panel). Tauri-only (PWA shows “requires desktop app”)
  • LLM API config (Settings > Agents): global provider/model/API key for all API-mode prompts. Supports OpenAI, Anthropic, Gemini, OpenRouter, Ollama, and any OpenAI-compatible endpoint via custom base URL. API key stored in OS keyring. Test button validates connection

10.6 Built-in Prompts by Category

CategoryPrompts
Git & CommitSmart Commit, Commit & Push, Amend Commit, Generate Commit Message
Code ReviewReview Changes, Review Staged, Review PR, Address Review Comments
Pull RequestsCreate PR, Update PR Description, Generate PR Description
Merge & ConflictsResolve Conflicts, Merge Main Into Branch, Rebase on Main
CI & QualityFix CI Failures, Fix Lint Issues, Write Tests, Run & Fix Tests
InvestigationInvestigate Issue, What Changed?, Summarize Branch, Explain Changes
Code OperationsSuggest Refactoring, Security Audit

10.7 Context Variables

Variables are resolved from the Rust backend (resolve_context_variables) and frontend stores:

VariableSourceDescription
{branch}gitCurrent branch name
{base_branch}gitDetected default branch (main/master/develop)
{repo_name}gitRepository directory name
{repo_path}gitFull filesystem path to the repository root
{repo_owner}gitGitHub owner parsed from remote URL
{repo_slug}gitRepository name parsed from remote URL
{diff}gitFull working tree diff (truncated to 50KB)
{staged_diff}gitStaged changes diff (truncated to 50KB)
{changed_files}gitShort status output
{dirty_files_count}gitNumber of modified files (derived from changed_files)
{commit_log}gitLast 20 commits (oneline)
{last_commit}gitLast commit hash + message
{conflict_files}gitFiles with merge conflicts
{stash_list}gitStash entries
{branch_status}gitAhead/behind remote tracking branch
{remote_url}gitRemote origin URL
{current_user}gitGit config user.name
{pr_number}GitHub storePR number for current branch
{pr_title}GitHub storePR title
{pr_url}GitHub storePR URL
{pr_state}GitHub storePR state (OPEN, MERGED, CLOSED)
{pr_author}GitHub storePR author username
{pr_labels}GitHub storePR labels (comma-separated)
{pr_additions}GitHub storeLines added in PR
{pr_deletions}GitHub storeLines deleted in PR
{pr_checks}GitHub storeCI check summary (passed/failed/pending)
{merge_status}GitHub storePR mergeable status
{review_decision}GitHub storePR review decision
{agent_type}terminal storeActive agent type (claude, gemini, etc.)
{cwd}terminal storeActive terminal working directory
{issue_number}manualPrompted from user at execution time

10.8 Execution Modes

  • Inject (default): routes the resolved prompt text to the active terminal. The Target sub-option decides where:
    • Compose box (default): fills the terminal’s compose input for the user to review and send. Not idle-gated — a busy agent never blocks it, since nothing is sent until the user hits Enter.
    • Terminal: sends straight to the agent’s PTY. Checks agent idle state before sending (configurable via requiresIdle); when busy the prompt button is disabled. Honors Auto-execute — appends Enter for immediate send, or writes without Enter for review when off.
  • Shell script: executes the prompt content directly as a shell script via execute_shell_script Tauri command. No agent involved — runs content as-is via sh -c (macOS/Linux) or cmd /C (Windows) in the repo directory. Output routed via outputTarget. 60-second timeout cap. No prerequisites (no terminal, agent, or API config needed)
  • Headless: runs a one-shot subprocess via execute_headless_prompt Tauri command. Requires a per-agent headless template configured in Settings → Agents (e.g. claude -p "{prompt}"). Output routed to clipboard or toast depending on outputTarget. Falls back to inject in PWA mode. 5-minute timeout cap

10.9 UI Integration Points

LocationPrompts shownTrigger
Toolbar dropdownAll enabled prompts with toolbar placementCmd+Shift+K or lightning bolt button
Git Panel — Changes tabSmartButtonStrip with git-changes placementInline buttons above changed files
PR Detail PopoverSmartButtonStrip with pr-popover placementInline buttons in PR detail view
Command PaletteAll prompts with Smart: prefixCmd+P then type “Smart”
Branch context menuPrompts with git-branches placementRight-click branch in Branches tab

10.10 Smart Prompts Management (Cmd+Shift+K Drawer)

  • All prompt management consolidated in the Cmd+Shift+K drawer (Settings tab removed)
  • Enable/disable individual prompts via toggle button on each row
  • Edit prompt: opens modal with name, description, content, variable dropdown, placement, execution mode, auto-execute, keyboard shortcut
  • Variable insertion dropdown below content textarea: grouped by Git/GitHub/Terminal, click to insert {variable} at cursor
  • Create custom smart prompts with + New Prompt button
  • Built-in prompts show a “Reset to Default” button when content is overridden

10.11 Headless Template Configuration

  • Settings → Agents → per-agent “Headless Command Template” field
  • Template uses {prompt} placeholder for the resolved prompt text
  • Example: claude -p "{prompt}", gemini -p "{prompt}"
  • Required for headless execution mode; without it, headless prompts fall back to inject

11. Settings

11.1 General

  • Language, Default IDE, Shell
  • Confirmations: quit, close tab (only when a process is running — agents or busy shell; idle shells close immediately)
  • Power management: prevent sleep when busy
  • Updates: auto-check, check now
  • Git integration: auto-show PR popover
  • Terminal: copy-on-select toggle (auto-copy selection to clipboard)
  • Experimental Features: master toggle + per-feature sub-flags (AI Chat, AI Triage, AI Watchers, Scrollback Reflow)
  • Repository defaults: base branch, file handling, setup/run scripts, worktree defaults (storage strategy, prompt on create, etc.)

11.2 Appearance

  • Terminal theme: multiple themes, color swatches. Bundled themes include Deep Black (near-true-black background with GitHub-style ANSI accents) and Minimal Kiwi (dark green-tinted background with muted warm accents)
  • Terminal font: 11 bundled monospace fonts (JetBrains Mono default)
  • Default font size: 8-32px slider
  • Split tab mode: separate / unified
  • Tab ordering mode: grouped-by-type (default, tabs grouped by kind), terminals-first (terminals left, others freely interleaved), free (any tab anywhere)
  • Max tab name length: 10-60 slider
  • Repository groups: create, rename, delete, color-coded
  • Reset panel sizes: restore sidebar and panel widths to defaults

11.3 Services

  • HTTP API server: always active on IPC listener (Unix domain socket on macOS/Linux, named pipe \\.\pipe\tuicommander-mcp on Windows). TCP port only for remote access
  • MCP connection info: bridge sidecar auto-installs configs for supported agents (Claude Code, Cursor, etc.)
  • TUIC native tool toggles: enable/disable individual MCP tools (session, agent, task, repo, ui, plugin_dev_guide, config, debug) to restrict what AI agents can access
  • MCP Upstreams: add/edit/remove upstream MCP servers (HTTP or stdio with optional cwd), per-upstream enable/disable, reconnect, credential storage via OS keyring, live status dots, tool count and metrics. Saved upstreams auto-connect on boot
  • MCP Per-Repo Scoping: each repo can define which upstream MCP servers are relevant via an allowlist in repo settings (3-layer: per-repo > .tuic.json > defaults). Null/empty allowlist = all servers. Quick toggle via Cmd+Shift+M popup
  • Remote access: port, username, password (bcrypt hash), URL display, QR code, token duration, IPv6 dual-stack, LAN auth bypass
  • Voice dictation: full setup (see section 9)

11.4 Repository Settings (per-repo)

  • Display name
  • Worktree tab: storage strategy, prompt on create, delete branch on remove, auto-archive, orphan cleanup, PR merge strategy, after-merge action (each overridable from global defaults)
  • Scripts tab: setup script (post-worktree), run script (Cmd+R), archive script (pre-archive/delete hook)
  • Repo-local config: .tuic.json in repo root provides team-shared settings. Three-tier precedence: .tuic.json > per-repo app settings > global defaults. Scripts (setup, run, archive) are intentionally excluded from .tuic.json merging — arbitrary script execution by a checked-in file poses a security risk; scripts are always sourced from the local per-repo app settings only

11.5 Notifications

  • Master toggle, volume (0-100%)
  • Per-event: question, error, completed, warning, info
  • Test buttons per sound
  • Reset to defaults
  • Keep toasts in the bell — mirrors toasts into the bell’s Messages section (see 4.4). Outside the audio block, because the bell is visual and must stay configurable without an audio device

11.6 Keyboard Shortcuts

  • Settings > Keyboard Shortcuts tab (Cmd+, to open Settings), also accessible from Help > Keyboard Shortcuts
  • All app actions listed with their current keybinding
  • Click the pencil icon to rebind — inline key recorder with pulsing accent border
  • Conflict detection: warns when the new combo is already bound to another action, with option to replace
  • Overridden shortcuts highlighted with accent color; per-shortcut reset icon to revert to default
  • “Reset all to defaults” button at the bottom
  • Custom bindings stored in keybindings.json in the platform config directory
  • Auto-populated from actionRegistry.ts (ACTION_META map) — new actions appear automatically
  • Global Hotkey: configurable OS-level shortcut to toggle window visibility from any application. Set in the “Global Hotkey” section at the top of the Keyboard Shortcuts tab. No default — user must configure. Toggle: hidden/minimized → show+focus, visible but unfocused → focus, focused → instant hide (no dock animation). Cmd and Ctrl are distinct modifiers. Uses tauri-plugin-global-shortcut (no Accessibility permission required on macOS). Hidden in browser/PWA mode.

11.7 Agents

  • See 6.9 Agent Configuration for full details
  • Claude Usage Dashboard enable/disable toggle (under Claude agent section)

11.8 Providers

  • Settings > Providers tab for centralized AI provider management
  • See 6.16 Provider Registry for full details

12. Persistence

12.1 Rust Config Backend

All data persisted to platform config directory via Rust:

  • app_config.json — general settings
  • notification_config.json — sound settings
  • ui_prefs.json — sidebar visibility/width
  • repo_settings.json — per-repo worktree/script settings
  • repositories.json — repository list, groups, branches (shared by debug and release builds, like every other file here)
  • agents.json — per-agent run configurations
  • prompt_library.json — saved prompts
  • notes.json — ideas panel data
  • dictation_config.json — dictation settings
  • providers.json — provider registry (providers, models, slot assignments)
  • .tuic.json — repo-root team config (read-only from app, highest precedence for overridable fields)
  • claude-usage-cache.json — incremental session transcript parse cache

12.2 Hydration Safety

  • save() blocks before hydrate() completes to prevent data loss

13. Cross-Platform

13.1 Supported Platforms

  • macOS (primary), Windows, Linux

13.2 Platform Adaptations

  • CmdCtrl key abstraction
  • resolve_cli(): probes well-known directories when PATH unavailable (release builds)
  • Windows: cmd.exe shell escaping, CreateToolhelp32Snapshot for process detection
  • IDE detection: .app bundles (macOS), registry entries (Windows), PATH probing (Linux)

14. System Features

14.1 Auto-Update

  • Check for updates on startup via tauri-plugin-updater
  • Status bar badge with version
  • Download progress percentage
  • One-click install and relaunch
  • Menu: Check for Updates (app menu and Help menu)

14.2 Sleep Prevention

  • keepawake integration prevents system sleep while agents are working
  • Configurable in Settings

14.3 Splash Screen

  • Branded loading screen on app start

14.4 Confirmation Dialogs

  • In-app ConfirmDialog component replaces native Tauri ask() dialogs
  • Dark-themed to match the app (native macOS sheets render in light mode)
  • useConfirmDialog hook provides a confirm()Promise<boolean> API
  • Pre-built helpers: confirmRemoveWorktree(), confirmCloseTerminal(), confirmRemoveRepo()
  • Keyboard support: Enter to confirm, Escape to cancel

14.5 Error Handling

  • ErrorBoundary crash screen with recovery UI
  • WebGL canvas fallback (graceful degradation)
  • Error classification with backoff calculation

14.6 MCP & HTTP Server

  • REST API on localhost for external tool integration
  • Exposes terminal sessions, git operations, agent spawning
  • WebSocket streaming, Streamable HTTP transport
  • Used by Claude Code, Cursor, and other tools via MCP protocol
  • tuic-bridge ships as a Tauri sidecar; auto-installs MCP configs on first launch for Claude Code, Cursor, Windsurf, VS Code, Zed, Amp, Gemini, Codex, Grok, opencode, Droid, goose and pi — but only for the ones actually installed on the machine (see MCP auto-install)
  • Local connections use Unix domain socket (<config_dir>/mcp.sock) on macOS/Linux or named pipe (\\.\pipe\tuicommander-mcp) on Windows; TCP port reserved for remote access only
  • Unix socket lifecycle is crash-safe: RAII guard removes the socket file on Drop; bind retries 3× (×100 ms) removing any stale file before each attempt; liveness check uses a real connect() probe so a dead socket from a crashed run never blocks MCP tool loading

14.7 Cross-Repo Knowledge Base

  • Knowledge base functionality is available via the mdkb MCP upstream server (configure in MCP Upstreams settings)
  • Provides hybrid BM25 + semantic search across docs, code, symbols, and memory
  • Call graph queries (calls, callers, impact analysis) via code_graph tool
  • Requires mdkb binary on PATH (installed separately)

14.8 Code Intelligence (MDKB integration)

  • Go-to-definition: Cmd+Click on symbols in the editor navigates to the definition via mdkb_goto_definition. Holding Cmd (macOS) / Ctrl underlines the symbol under the cursor (cm-hover-link) as a click affordance; the underline clears on release or when the pointer leaves the editor, and its position is remapped through edits so it never goes stale
  • Find references: Shift+F12 finds all callers of a symbol via mdkb_references (uses code_graph callers query)
  • Symbol outline: file-level symbol tree via mdkb_outline (functions, types, structs)
  • Install/uninstall managed from Settings → General → Code Intelligence
  • is_available() checks binary existence on disk (not cached path) — survives external uninstalls
  • The daemon ping version must match the installed binary; an older detached daemon is restarted automatically after upgrades
  • Homebrew-managed installs show brew uninstall mdkb guidance instead of silent failure
  • Graceful fallback: all commands return empty results when mdkb is unavailable

14.9 macOS Dock Badge

  • Badge count for attention-requiring notifications (questions, errors)

14.9 Tailscale HTTPS

  • Auto-detects Tailscale daemon and FQDN via tailscale status --json (cross-platform)
  • Provisions TLS certificates from Tailscale Local API (Unix socket on macOS/Linux, CLI on Windows)
  • HTTP+HTTPS dual-protocol on same port via axum-server-dual-protocol
  • Graceful fallback: HTTP-only when Tailscale unavailable or HTTPS not enabled
  • QR code uses https:// scheme with Tailscale FQDN when TLS active
  • Background cert renewal every 24h with hot-reload via RustlsConfig::reload_from_pem()
  • Session cookie gets Secure flag on TLS connections
  • Settings panel shows Tailscale status with actionable guidance

15. Keyboard Shortcut Reference

Terminal

ShortcutAction
Cmd+TNew terminal tab
Cmd+WClose tab / close active split pane
Cmd+Shift+TReopen last closed tab
Cmd+1Cmd+9Switch to tab by number
Ctrl+Tab / Ctrl+Shift+TabNext / previous tab
Cmd+Ctrl+BackspaceReturn to last terminal — toggles back to the previously focused terminal, switching repo/branch if needed (focus-last-terminal)
Cmd+UJump to next waiting terminal — cycles to the next terminal awaiting input (agent question/error) across all repos/branches, switching context as needed; does nothing if none are waiting (jump-waiting-terminal)
Cmd+LClear terminal
Cmd+Shift+LRefresh terminal (fix glyphs)
Cmd+CCopy selection
Cmd+VPaste to terminal
Cmd+HomeScroll to top
Cmd+EndScroll to bottom
Shift+PageUpScroll one page up
Shift+PageDownScroll one page down
Cmd+RRun saved command
Cmd+Shift+REdit and run command
Cmd+Shift+.Toggle block folding
Cmd+Shift+UpJump to previous block
Cmd+Shift+DownJump to next block
Cmd+Shift+BToggle block-scoped search

Zoom

ShortcutAction
Cmd+=Zoom in (+2px)
Cmd+-Zoom out (-2px)
Cmd+0Reset zoom

Split Panes

ShortcutAction
Cmd+\Split vertically
Cmd+Alt+\Split horizontally
Alt+←/→Navigate vertical panes
Alt+↑/↓Navigate horizontal panes
Cmd+Shift+EnterMaximize / restore active pane
Cmd+Alt+EnterFocus mode (hide sidebar, tab bar, panels)

AI

ShortcutAction
Cmd+Alt+AToggle AI Chat panel (toggle-ai-chat)
Cmd+Enter (panel focused)Send message
Esc (panel focused)Cancel in-flight stream

Panels

ShortcutAction
Cmd+[Toggle sidebar
Cmd+Shift+DToggle Git Panel
Cmd+Shift+MToggle markdown panel
Cmd+Alt+NToggle Ideas panel
Cmd+EToggle file browser
Cmd+OOpen file… (picker)
Cmd+NNew file… (picker for name + location)
Cmd+PCommand palette
Cmd+,Open settings
Cmd+?Toggle help panel
Cmd+Shift+KPrompt library
Cmd+JTask queue
Cmd+Shift+EError log
Cmd+Shift+WWorktree manager
Cmd+Shift+AActivity dashboard
Cmd+Shift+MMCP servers popup (per-repo)
Cmd+IToggle compose panel
Cmd+Alt+LToggle outline panel

Git

ShortcutAction
Cmd+BQuick branch switch (fuzzy search)
Cmd+Shift+DGit Panel (opens on last active tab)
Cmd+GGit Panel — Branches tab

Branches Panel (when panel is focused)

ShortcutAction
/ Navigate branches
EnterCheckout selected branch
nCreate new branch
dDelete branch
RRename branch (inline edit)
MMerge selected into current
rRebase current onto selected
PPush branch
pPull current branch
fFetch all remotes

File Browser (when focused)

ShortcutAction
↑/↓Navigate files
EnterOpen file / enter directory
BackspaceGo to parent directory
Cmd+CCopy file
Cmd+XCut file
Cmd+VPaste file
Cmd+Shift+FOpen file browser and activate content search

Code Editor (when focused)

ShortcutAction
Cmd+FFind
Cmd+GFind next
Cmd+Shift+GFind previous
Cmd+HFind and replace
Cmd+SSave file

Ideas Panel (when textarea focused)

ShortcutAction
EnterSubmit idea
Shift+EnterInsert newline
Cmd+V / Ctrl+VPaste image from clipboard
EscapeCancel edit mode

Quick Switcher

ShortcutAction
Hold Cmd+CtrlShow quick switcher overlay
Cmd+Ctrl+1-9Switch to branch by index

Voice Dictation

ShortcutAction
Hold F5Push-to-talk (configurable)

Mouse Actions

ActionWhereEffect
ClickSidebar branchSwitch to branch
Double-clickSidebar branch nameRename branch
Double-clickTab nameRename tab
Right-clickTabTab context menu
Right-clickSidebar branchBranch context menu
Right-clickSidebar repo Repo context menu
Right-clickSidebar group headerGroup context menu
Right-clickFile browser entryFile context menu
Middle-clickTabClose tab
DragTabReorder tabs
DragSidebar right edgeResize sidebar
DragPanel left edgeResize panel
DragSplit pane dividerResize panes
DragRepo onto groupMove repo to group
ClickStatus bar CWD pathCopy to clipboard
ClickPR badge (sidebar/status)Open PR detail popover
ClickCI ringOpen PR detail popover
ClickToolbar bellOpen notifications popover
ClickStatus bar panel buttonsToggle panels
HoldMic button (status bar)Record dictation

Recording a Custom Combo

Every shortcut above is rebindable from Help > Keyboard Shortcuts (see docs/user-guide/keyboard-shortcuts.md). On macOS, Ctrl+Tab and F13F20 never reach the WebView — AppKit consumes the first for native tab cycling and simply does not forward the rest — so a keydown listener sees nothing. src-tauri/src/native_keys.rs installs a single NSEvent monitor that catches both and re-emits them (ctrl-tab, native-key-down), which is what makes F13F20 recordable for both per-action shortcuts and the Global Hotkey. Keys macOS itself claims before the process (F14/F15 keyboard illumination) still need remapping in System Settings.


16. Build & Release

16.1 Makefile Targets

TargetDescription
devStart development server
buildBuild production app
build-dmgBuild macOS DMG
signCode sign the app
notarizeNotarize with Apple
releaseBuild + sign + notarize
build-github-releaseBuild for GitHub release (CI)
publish-github-releasePublish GitHub release
github-releaseOne-command release
cleanClean build artifacts

16.2 CI/CD

  • GitHub Actions for cross-platform builds
  • macOS code signing and notarization
  • Linux: libasound2-dev dependency, -fPIC flags
  • Updater signing with dedicated keys

17. Plugin System

17.1 Architecture

  • Obsidian-style plugin API with 4 capability tiers
  • Built-in plugins (TypeScript, compiled with app) and external plugins (JS, loaded at runtime)
  • Hot-reload: file changes in plugin directories trigger automatic re-import
  • Per-plugin error logging with ring buffer (500 entries)
  • Capability-gated access: pty:write, pty:read, ui:markdown, ui:sound, ui:panel, ui:ticker, ui:context-menu, ui:sidebar, ui:file-icons, ui:file-preview, net:http, credentials:read, invoke:read_file, invoke:list_markdown_files, fs:read, fs:list, fs:watch, fs:write, fs:rename, fs:scan, fs:delete, exec:cli, git:read
  • CLI execution API: sandboxed execution of whitelisted CLI binaries (mdkb) with timeout and size limits
  • Filesystem API: sandboxed text read, base64 binary read, write, rename, list, tail-read, and watch operations restricted to $HOME
  • HTTP API: outbound requests scoped to manifest-declared URL patterns (SSRF prevention)
  • Credential API: cross-platform credential reading (macOS Keychain, Linux/Windows JSON file) with user consent
  • Panel API: rich HTML panels in sandboxed iframes (sandbox="allow-scripts") with structured message bridge (onMessage/send) and automatic CSS theme variable injection
  • Shared ticker system: setTicker/clearTicker API with source labels, priority tiers (low <10, normal 10-99, urgent >=100), counter badge, click-to-cycle, right-click popover
  • Agent-scoped plugins: agentTypes manifest field restricts output watchers and structured events to terminals running specific agents (e.g. ["claude"])
  • Plugin manifest fields use camelCase (minAppVersion, agentTypes, contentUri) — matches Rust serde serialization

17.2 Plugin Management (Settings > Plugins)

  • Installed tab: List all plugins with enable/disable toggle, logs viewer, uninstall button
  • Browse tab: Discover plugins from the community registry with one-click install/update
  • Enable/Disable: Persisted in AppConfig.disabled_plugin_ids
  • ZIP Installation: Install from local .zip file or HTTPS URL
  • Folder Installation: Install from a local folder (copies plugin directory into plugins dir)
  • Uninstall: Removes plugin directory (confirmation required)

17.3 Plugin Registry

  • Remote JSON registry hosted on GitHub (tuicommander-plugins repo)
  • Fetched on demand with 1-hour TTL cache
  • Version comparison for “Update available” detection
  • Install/update via download URL
  • docx-preview plugin: previews Word .docx/.dotx files as clean HTML using Mammoth.js
  • tuic://install-plugin?url=https://... — Download and install plugin (HTTPS only, confirmation dialog)
  • tuic://open-repo?path=/path — Activate a repo already in the sidebar; a folder that is not in it yet is added after one confirmation (this is what tuic <dir> sends)
  • tuic://settings?tab=plugins — Open Settings to specific tab
  • tuic://open/<path> — Open markdown file in tab (iframe SDK only, path validated against repos)
  • Focused absolute tuic://open/tuic://edit targets switch to their owning registered repository so the native file tab remains visible; background opens preserve the current repository
  • tuic://terminal?repo=<path> — Open terminal in repo (iframe SDK only)
  • tuic://cmd/{tool}/{action}?{params} — MCP gateway for external automation (scripts, Shortcuts, browser pages). Routes to the same tool/action handlers as the MCP server. Gating is default-deny:
    • Read-only / notify actions (e.g. session/list, session/status, repo/list, agent/inbox, ui/toast) run silently without a dialog
    • Destructive or unknown actions (anything not in the safe list) require a confirmation dialog before executing — prevents a malicious page from acting unattended
    • config/save and debug/invoke_js are blocked entirely and never execute even with user confirmation Source: src/deep-link-handler.ts (SAFE_COMMANDS, BLOCKED_COMMANDS); Rust backstop: deep_link_mcp_call in src-tauri/src/lib.rs

17.4.1 TUIC SDK (window.tuic)

  • Injected automatically into every plugin iframe (inline and same-origin URL mode)
  • Feature detection: if (window.tuic)tuic.version reports SDK version
  • Files: tuic.open(path, {pinned?}), tuic.edit(path, {line?}), tuic.getFile(path): Promise<string>
  • Path resolution: relative paths resolve against active repo; absolute paths match longest repo prefix; ../ traversal outside repo root is blocked
  • Repository: tuic.activeRepo() returns active repo path; tuic.onRepoChange(cb) / tuic.offRepoChange(cb) for live updates
  • Terminal: tuic.terminal(repoPath) — open terminal in repository
  • UI feedback: tuic.toast(title, {message?, level?, sound?}) — native toast notifications with optional sound (info blip, warn double-beep, error descending sweep); tuic.clipboard(text) — copy to clipboard from sandboxed iframe
  • Messaging: tuic.send(data) / tuic.onMessage(cb) — bidirectional host↔plugin communication
  • Theme: tuic.theme — current theme as JS object (camelCase CSS vars); tuic.onThemeChange(cb) for live updates
  • <a href="tuic://open/..."> and <a href="tuic://terminal?repo=..."> links intercepted automatically
  • data-pinned attribute on links sets pinned flag
  • Interactive test page: docs/examples/sdk-test.html (see docs/tuic-sdk.md for launch instructions)

17.5 Built-in Plugins

  • Plan Tracker — Detects Claude Code plan files from structured events

Note: Claude Usage Dashboard was promoted from a plugin to a native SolidJS feature (see section 6.6). It is managed via Settings > Agents > Claude > Usage Dashboard toggle.

17.6 Example External Plugins

See examples/plugins/ for reference implementations:

  • hello-world — Minimal output watcher example
  • auto-confirm — Auto-respond to Y/N prompts
  • ci-notifier — Sound notifications and markdown panels
  • repo-dashboard — Read-only state and dynamic markdown
  • report-watcher — Generic report file watcher with markdown viewer
  • claude-status — Agent-scoped plugin (agentTypes: ["claude"]) tracking usage and rate limits
  • wiz-kanban — Wiz framework plugin: kanban board for managing the workflow of plans, stories, and reviews with drag-and-drop

17.7 Claude Wakeup Plugin

Agent-scoped plugin (agentTypes: ["claude"]) that wakes Claude Code when it stalls without asking a question. Ships in plugins/claude-wakeup/.

  • Idle detection: After 20 s of shell idle with no pending question, no active sub-tasks, and no choice prompt, sends a verification message to the agent
  • Typing suppression: Every busy→idle transition resets the idle clock, so keystroke-generated shell-state blips prevent false wakes
  • Done detection (primary): Watches the busy-cycle duration after a wake — short cycle (<8 s) = agent acknowledged (“done”), long cycle (≥8 s) = agent continued working
  • Done detection (secondary): OutputWatcher fast-path for agents that emit a clean done line
  • Disarm/re-arm: Disarms after confirmed done; re-arms only when the user gives new input after the disarm timestamp and the agent works >10 s
  • Limits: Max 3 wakes per stall, max 12 per session lifetime
  • Dashboard: Markdown stats panel with wake counts, done rate, active session state, and history
  • Pause/Resume: Via Activity Center toggle (transient, not persisted)
  • Configuration: data/config.jsonidleThresholdMs, maxWakes, maxWakesEver, doneMaxBusyMs, checkIntervalMs, minBusyDurationMs, questionStaleMs, pendingTimeoutMs
  • Capabilities: pty:write, pty:read, ui:ticker, ui:markdown

18. Mobile Companion UI

Phone-optimized progressive web app for monitoring AI agents remotely. Separate SolidJS entry point (src/mobile/) served by the existing HTTP server at /mobile.

18.1 Architecture

  • Separate Vite entry point (mobile.html + src/mobile/index.tsx)
  • Shares transport layer, stores, and notification manager with desktop
  • Server-side routing: /mobile/*mobile.html, everything else → index.html
  • Session state accumulator enriches GET /sessions with question/rate-limit/busy state
  • SSE endpoint (/events) and WebSocket JSON framing for real-time updates

18.2 Sessions Screen

  • Hero metrics header: active session count + awaiting input count with large tabular-nums display
  • Elevated session cards with agent icon, status badge, project/branch, relative time
  • Rich sub-rows per card: agent intent (crosshair icon) or last prompt (speech bubble), current task (gear icon) with inline progress bar, usage limit percentage
  • Question state highlighted via inset gold box-shadow
  • Pull-to-refresh spinner via touch events
  • Loading skeletons during initial data fetch
  • Empty state with instructional hint
  • Tap card to open session detail

18.3 Session Detail Screen

  • Live output via WebSocket with format=log (VT100-extracted clean lines, auto-scrolling, 500-line buffer)
  • Semantic colorization: log lines are color-coded by type (info, warning, error, diff +/-, file paths) via classifyLine() utility
  • Search/filter in output: text search bar filters visible log lines in real time
  • Rich header: agent intent line (italic), current task line, progress bar, usage percentage (red above 80%)
  • Error bar (red tint) when last_error is set
  • Rate-limit bar (orange tint) with live countdown timer (formatRetryCountdown)
  • Suggest follow-up chips: horizontal scrollable pills from suggested_actions, tap to send
  • Slash menu overlay: frosted glass bottom sheet showing detected /command entries; tap to send Ctrl-U + command + Enter
  • Quick-action chips: Yes, No, y, n, Enter, Ctrl-C
  • TerminalKeybar: context-aware row of special key buttons above the main input. Shows Ctrl+C, Ctrl+D, Tab, Esc, Enter, arrow keys for terminal operations. When the agent is awaiting input, adds Yes/No quick-reply buttons. Consolidated from the former separate QuickActions component
  • CLI command widget: agent-specific quick commands (e.g., /compact, /status for Claude Code) accessible via expandable button
  • Text command input with 16px font (prevents iOS auto-zoom), inputmode="text"
  • Offline retry queue: write_pty calls that fail due to network disconnection are queued and retried when connectivity resumes
  • Back navigation to session list

18.4 Question Banner

  • Persistent overlay when any session has awaiting_input state
  • Shows agent name, truncated question, Yes/No quick-reply buttons
  • Visible on all screens, between top bar and content
  • Stacks multiple questions

18.5 Activity Feed

  • Chronological event feed grouped by time (NOW, EARLIER, TODAY, OLDER)
  • Reads from shared activityStore
  • Throttled grouping: items snapshot every 10s to prevent constant reordering with multiple active sessions; new items/removals trigger immediate refresh
  • Sticky section headers, tap to navigate to session

18.6 Session Management

  • Session kill: swipe or long-press a session card to kill/close the PTY session
  • New session: create a new PTY session from the sessions screen (optional shell/cwd selection)

18.7 Settings

  • Connection status: connectivity indicator with real-time Connected/Disconnected state
  • Server URL display
  • Notification sound toggle (localStorage-persisted)
  • Open Desktop UI link

18.8 PWA Support

  • Web app manifest (mobile-manifest.json) with standalone display mode
  • iOS Safari and Android Chrome Add to Home Screen support
  • apple-mobile-web-app-capable meta tags
  • PNG icons (192x192, 512x512) for PWA installability

18.8.1 Push Notifications

  • Web Push from TUICommander directly to mobile PWA clients (no relay dependency)
  • VAPID ES256 key generation on first enable, persisted in config
  • Service worker (sw.js) handles push events and notification clicks
  • PushManager.subscribe() flow with user gesture (click handler) for iOS/Firefox
  • Push subscriptions stored in push_subscriptions.json, survive restarts
  • API endpoints: POST/DELETE /api/push/subscribe, GET /api/push/vapid-key, POST /api/push/test
  • Triggers: agent awaiting_input (question, orange dot) and PtyExit (session completed, purple/unseen dot)
  • Deep link: notification click navigates to /mobile/session/<id>, opening the specific session detail
  • Delivery gate: push is sent whenever the desktop window is not focused (minimized, hidden, or on another workspace). This prevents duplicate alerts while the user is at the desktop and still wakes the PWA service worker when the phone is locked
  • Rate limited: max 1 push per session per 30 seconds
  • Stale subscriptions cleaned on HTTP 410 Gone
  • iOS standalone detection: shows “Add to Home Screen” guidance when not installed
  • HTTP detection: shows “Push requires HTTPS (enable Tailscale)” when not on HTTPS

18.9 Notification Sounds

  • Audio playback via Rust rodio crate (Tauri command play_notification_sound), replacing the previous Web Audio API approach
  • Eliminates AudioContext suspend issues on WebKit and works in headless/remote modes
  • State transition detection: question, rate-limit, error, completion
  • Completion notifications deferred 10s and suppressed when active sub-tasks are running (detected via ⏵⏵/›› mode-line prefix)
  • Sounds: question (C5→E5 chime), completion (C5→E5→G5 arpeggio), error (E4→C4), warning (A4 double-tap), info (single G5 pluck), and attention — a triangular G4→G4→E5 callback with two short knocks and a longer rise. Native and browser/PWA playback share the motif and 0.8 gain; each engine applies its own envelope. The repeated opening is immediately recognizable while the softer timbre avoids the old square buzzer’s harshness. Meant for an agent that is working unattended and is blocked on the user
  • Each sound has its own on/off toggle and Test button in Settings > Notifications, and all of them honour the global volume and chosen output device
  • Agents can raise them over MCP: ui action=toast sound="attention" (see 19.x ui tool). sound: true still means “the tone matching level”; a name overrides it. The sound plays through this scheme, so a muted sound stays muted no matter who asked for it

18.10 Visual Polish

  • Frosted glass bottom tabs: backdrop-filter: blur(20px) saturate(1.8) with semi-transparent background
  • Elevated card design: border-radius: var(--radius-xl), background: var(--bg-secondary), margin spacing
  • Safe-area-inset padding for notched devices
  • font-variant-emoji: text on output view — forces Unicode symbols (●, ○, ◉) to render as monochrome text glyphs instead of colorful emoji

18.11 Standalone CSS

  • Mobile PWA uses its own standalone stylesheet (src/mobile/mobile.css), independent from the desktop global.css
  • Shares core color palette and border radius tokens; differs in font stacks, layout approach, and iOS-specific rules
  • WebSocket state deduplication: duplicate state pushes are filtered to reduce unnecessary re-renders

19. MCP Proxy Hub

TUICommander aggregates upstream MCP servers and exposes them through its own /mcp endpoint. Any MCP client (Claude Code, Cursor, VS Code) connecting to TUIC automatically gains access to all configured upstream tools.

19.1 Architecture

  • TUIC acts as both an MCP server (to downstream clients) and an MCP client (to upstream servers)
  • All upstream tools are exposed via the single POST /mcp Streamable HTTP endpoint
  • Native TUIC tools (session, agent, task, repo, ui, plugin_dev_guide, config, debug) coexist with upstream tools
  • Tool routing: names containing __ are routed to the upstream registry; all others handled natively

19.1.1 Lazy Tool Discovery (collapse_tools)

  • When collapse_tools: true (Settings > Services & MCP > TUIC Tools > “Collapse tools”), the full tool list is replaced with 3 meta-tools: search_tools, get_tool_schema, call_tool
  • Grok sessions (clientInfo.name matching grok-shell-*) receive the same 3 meta-tools automatically because Grok rejects nested qualified names such as tuicommander__upstream__tool; this per-session compatibility mode leaves the global setting and other clients unchanged, and the bridge restores it after TUIC reconnects
  • Cuts MCP context from ~35k tokens to ~500 tokens per agent turn; agent fetches schemas on demand via BM25-ranked search
  • BM25 index backed by AppState::tool_search_index (rebuilds automatically when the tool set changes)
  • Safety filters (disabled_native_tools, upstream allow/deny) enforced at both discovery and dispatch time — agents cannot bypass filters by calling call_tool directly
  • Toggling fires notifications/tools/list_changed; compatible connected clients refresh automatically, while clients that ignore the notification may require a reconnect

19.2 Tool Namespace

  • Upstream tools are prefixed: {upstream_name}__{tool_name}
  • Double underscore (__) is the routing discriminator — native tool names never contain it
  • Tool descriptions are annotated with [via {upstream_name}] to identify origin
  • Clients always see the merged tool list in a single tools/list response

19.3 Supported Transports

  • HTTP (Streamable HTTP, spec 2025-03-26) — connects to any MCP server with an HTTP endpoint
  • Stdio — spawns local processes (npm packages, Python scripts, etc.) communicating via newline-delimited JSON-RPC

19.4 Circuit Breaker (per upstream)

  • 3 consecutive failures → circuit opens
  • Backoff: 1s → exponential growth → 60s cap
  • After 10 retry cycles without recovery → permanent Failed state
  • Recovery: successful tool call or health check resets the circuit breaker

19.5 Health Checks

  • Background task probes every Ready upstream every 60 seconds via tools/list (HTTP) or process liveness check (stdio)
  • CircuitOpen upstreams with expired backoff are also probed for recovery

19.6 Tool Filtering (per upstream)

  • Allow list: only matching tools are exposed
  • Deny list: all tools except matching ones are exposed
  • Pattern syntax: exact match or trailing-* prefix glob

19.6.1 Per-Repo Scoping

  • Each repository can define an allowlist of upstream server names in RepoSettings.mcpUpstreams
  • 3-layer merge: per-repo user settings > .tuic.json (team-shareable) > defaults (null = all servers)
  • Quick toggle via Cmd+Shift+M popup: shows all upstream servers with status, transport, tool count, and per-repo checkboxes
  • Toggling a checkbox immediately persists to repo settings (reactive, no refresh needed)

19.7 Hot-Reload

  • Adding, removing, or changing upstreams takes effect on save without restarting TUIC or AI clients
  • Config diff computed by stable id field; only changed entries are reconnected

19.8 Credential Management

  • Bearer tokens stored in OS keyring (Keychain / Credential Manager / Secret Service)
  • Keyring warm-up — resolved bearer token cached in memory after the first read. Health checks (every 60 s) and tool calls reuse the cache, invalidated on 401 and re-populated after token refresh. Eliminates repeated macOS Keychain permission prompts
  • Config file (mcp-upstreams.json) never contains secrets
  • Per-upstream credential lookup at call time
  • OAuth 2.1 token sets persisted as structured JSON in the keyring ({"type": "oauth2", "access_token", "refresh_token", "expires_at"})

19.8.1 OAuth 2.1 Upstream Authentication

  • Full RFC 9728 (Protected Resource Metadata) + RFC 8414 (Authorization Server Discovery) flow with PKCE S256
  • UpstreamAuth::OAuth2 { client_id, scopes, authorization_endpoint?, token_endpoint? } joins Bearer as a credential type; endpoints auto-discovered from the resource server’s WWW-Authenticate challenge when omitted
  • Completion via native deep link tuic://oauth-callback?code=…&state=… — callbacks never touch the WebView console
  • TokenManager shared across every HttpMcpClient refresh path with a per-upstream semaphore that defeats thundering-herd refresh. 60 s expiry margin; None expires_at treated as valid
  • UpstreamError::NeedsOAuth { www_authenticate } transitions the registry to needs_auth; Services tab shows an Authorize button
  • Auto-triggered OAuth is gated behind explicit user consent; a blocking in-app confirm dialog surfaces the Authorization Server origin and prevents the pending flow from being cancelled behind the prompt
  • Status values extended: authenticating (“Awaiting authorization…”) + needs_auth
  • Tauri commands: start_mcp_upstream_oauth, mcp_oauth_callback, cancel_mcp_upstream_oauth

19.9 Environment Sanitization (stdio)

  • Parent environment is cleared before spawning to prevent credential leakage
  • Safe allowlist re-applied: PATH, HOME, USER, LANG, LC_ALL, TMPDIR, TEMP, TMP, SHELL, TERM
  • User-configured env overrides applied on top

19.10 SSE Events

  • upstream_status_changed events emitted on status transitions (connecting, ready, circuit_open, disabled, failed)
  • tools/list_changed notification emitted when upstream tool lists change, enabling live tool-list updates for connected MCP clients
  • Delivered via GET /events SSE stream

19.11 Metrics (per upstream, lock-free)

  • call_count — total tool calls routed
  • error_count — total failed calls
  • last_latency_ms — last observed round-trip time

19.12 Validation

  • Names: must match [a-z0-9_-]+, must be unique
  • HTTP URLs: must use http:// or https:// scheme only
  • Self-referential URL detection: rejects URLs pointing to TUIC’s own MCP port
  • Stdio: command must be non-empty
  • All errors collected (not just first) and returned to caller
  • Respects sound toggle from Settings screen

20. Performance

20.1 PTY Write Coalescing

  • Paint triggers coalesced per animation frame via requestAnimationFrame (~60 repaints/sec)
  • High-throughput agent output (hundreds of events/sec) batched into single grid frame updates
  • Reduces canvas render passes during burst output
  • Flow control (pause/resume at HIGH_WATERMARK) unchanged

20.2 Async Git Commands

  • All ~25 Tauri git commands run inside tokio::task::spawn_blocking
  • Prevents git subprocess calls from blocking Tokio worker threads
  • get_changed_files merged from 2 sequential subprocesses to 1

20.3 Watcher-Driven Git Cache

  • repo_watcher (FSEvents/inotify) monitors the working tree with per-category debounce. macOS/Windows use one recursive watch; Linux splits into pruned non-recursive working-tree watches (skipping node_modules/target/gitignored, with new dirs added dynamically) plus targeted .git watches (root + refs/worktrees, never objects/logs), to avoid inotify event storms (issue #82)
  • CategoryEmitter routes events to Git, WorkTree, or Config handlers with trailing debounce
  • .gitignore-aware filtering prevents unnecessary cache invalidations
  • Cache hit ~0.2ms vs git subprocess ~20-30ms
  • 60s TTL as safety net for missed watcher events

20.4 Process Name via Syscall

  • proc_pidpath (macOS) / /proc/pid/comm (Linux) replaces ps fork
  • Eliminates ~100 fork+exec/min with 5 terminals open

20.5 MCP Concurrent Tool Calls

  • HttpMcpClient uses RwLock instead of Mutex
  • Tool calls use read lock (concurrent); only reconnect takes write lock

20.6 Serialization

  • PTY parsed events serialized once with serde_json::to_value
  • Reused for both Tauri IPC emit and event bus broadcast (was serialized twice)

20.7 Frontend Bundle Splitting

  • Vite manualChunks: terminal, codemirror, diff-view, markdown as separate chunks
  • SettingsPanel, ActivityDashboard, HelpPanel lazy-loaded with lazy() + Suspense
  • PTY read buffer increased from 4KB to 64KB for natural batching

20.8 Conditional Timers

  • StatusBar 1s timer only active when merged PR countdown or rate limit is displayed
  • ActivityDashboard snapshot signal uses default equality check (no forced re-render every 10s)

20.9 Profiling Infrastructure

  • Scripts in scripts/perf/: IPC latency, PTY throughput, CPU recording, Tokio console, memory snapshots
  • tokio-console feature flag for async task inspection
  • See docs/guides/profiling.md

20.10 Process Monitor

  • Reports CPU% and resident memory (RSS) for TUIC and every child process tree, each row attributed to the session that owns it
  • Agent lifecycle also classifies the owning process tree: meaningful background descendants keep agent_state=working while an input-ready terminal may remain shell_state=idle; persistent mdkb, tuic-bridge, and node_repl helper subtrees plus Claude’s standalone timed caffeinate -i -t <seconds> assertion are excluded by executable name or authoritative argv path. A caffeinate invocation that wraps a command remains meaningful. A ready observation waits for a newer shared process snapshot, and polling stops once no probe or background work remains
  • Unix: a single batched ps -o pid,rss,%cpu query across all PIDs (not one stat per process); Windows: per-process working-set size via the platform API
  • Three surfaces over the same data: MCP session action=process_stats, HTTP GET /process/stats (JSON { session_id, name, pid, rss_kb, cpu_pct }), and GET /process/monitor (a self-contained HTML dashboard with no build step or external assets)
  • Frontend ProcessManagerModal opens the dashboard in-app
  • Use to diagnose which agent/terminal is driving high CPU or memory

20.11 Runtime Diagnostics (CPU watchdog + diagnostic mode)

  • Always-on CPU watchdog (zero overhead when idle): polls getrusage(RUSAGE_SELF) every 5s and logs a full snapshot when TUIC’s own CPU stays above 80% for 10+ consecutive seconds. PTY children (cargo, rustc, …) are separate OS processes and don’t count toward the measurement
  • Sleep/wake aware: inter-tick gaps over 30s are treated as the machine having been asleep (lid closed) and skipped, so stale tokio-timer ticks after wake don’t trigger false spikes or idle cascades
  • Diagnostic mode (toggleable at runtime, off by default): emits a health snapshot every 30s and alerts on FD/thread growth trends. Each snapshot includes: cpu_pct (TUIC self only, via RUSAGE_SELF), children_cpu (aggregate %cpu of all PTY child process trees + the hottest individual child — note the CPU watchdog spike trigger intentionally ignores children, so a hot cargo/agent only surfaces here), thread count, FD count, PTY session count, content-index build state, semaphore permits, stuck grid_frame_in_flight sessions, event-bus subscriber count, and head_emits_suppressed (repo-watcher head-changed emits skipped by the resolved-HEAD-target guard — a climbing value signals a filesystem-event storm)
  • Control via HTTP: POST /diagnostics {"enabled":true} to toggle, GET /diagnostics for status, GET /logs?source=diagnostics to read the snapshots
  • Catches known failure patterns: IPC flush loops, content-index CPU saturation, blocked WebView JS thread (grid_frame_in_flight stuck), FD/thread leaks, and sleep/wake false-idle cascades
  • Backend: src-tauri/src/cpu_watchdog.rs

21. CLI Companion (tuic)

21.1 Overview

  • Standalone Rust binary embedded as a sidecar, installed to system PATH
  • Combines VS Code-style file opening, tmux-style session management, and agent orchestration
  • Cross-platform: macOS, Linux, Windows
  • Communicates via IPC (Unix socket / Windows named pipe) with the running TUICommander instance
  • Auto-launches TUICommander if not running

21.2 Editor Mode

  • tuic [path] — open file or directory (VS Code/Zed style)
  • tuic open --goto file:line:col — open at specific position
  • tuic open --wait — block until file closed ($EDITOR support)
  • tuic diff <a> <b> — diff view

21.3 Session Management (tmux-compatible)

  • tuic ls / tuic new / tuic kill / tuic send / tuic capture
  • tuic resize <id> WxH / tuic pause / tuic resume
  • Targets accept UUIDs, ID prefixes, or session names
  • tmux key name translation (Enter, C-c, Space, etc.)

21.4 Agent Orchestration

  • tuic agent spawn <type> <prompt> [--repo <path>] — spawn AI agent on an initial prompt
  • tuic agent ls — list running agents
  • tuic agent send <peer-uuid> <message> — deliver to a registered peer’s inbox through the registry, the same path as the MCP agent action=send tool. Reports Delivered only when something surfaced the message; an inbox_only route reads Buffered
  • tuic agent type <id> <message> — type into an agent’s terminal and submit, with the text and the Enter as separate writes (raw-mode TUIs treat a combined text\r as an unsent prefill)

21.5 tmux Compatibility Mode

  • tuic alias creates tmux → tuic symlink; argv[0] detection switches to compat mode
  • Supports: new-session, list-sessions, kill-session, kill-server, send-keys, capture-pane, resize-pane, attach-session, has-session
  • Tools expecting tmux (e.g. Claude Code --tmux) transparently use TUICommander

21.6 Installation

  • First-run prompt on app launch (one-time, dismissible)
  • Settings > General > Command Line Interface (install/uninstall button with status)
  • Auto-update on app startup (silent, no elevation prompt)
  • Paths: /usr/local/bin/tuic (macOS/Linux), %LOCALAPPDATA%\Microsoft\WindowsApps\tuic.exe (Windows)
  • tuic install-cli / tuic alias for self-service

22. Remote Daemon (tuic-remote) — Beta

22.1 Overview

  • Standalone headless binary for running TUICommander on servers without a desktop environment
  • Same HTTP/WebSocket API as the desktop app’s remote access feature
  • No Tauri dependency — pure Rust binary
  • Available as GitHub Release artifacts for Linux x64/ARM64, macOS ARM, and Windows x64

22.2 Configuration

  • Uses the same config file as the desktop app (~/.config/tuicommander/config.toml)
  • Default port: 9877 (overridable via TUIC_PORT env var)
  • --set-password flag for interactive password setup (bcrypt hashed)
  • LAN auth bypass always disabled in headless mode (security hardening)

22.3 TLS

  • Manual TLS via [services.tls] config section (cert + key PEM paths)
  • No TLS by default — use a reverse proxy or Tailscale for production

22.4 Lifecycle

  • Graceful shutdown on SIGINT/SIGTERM
  • Binds TCP, starts background tasks (MCP session reaper, upstream health checks)
  • Fails fast if port is already in use

23. SSH Tunnel Manager

23.1 Supervised Tunnels

  • Managed SSH processes with automatic lifecycle supervision
  • Tunnel states: Starting, Connected, Reconnecting, Stopped, Error
  • Health check: process must survive 500ms after spawn to be considered connected
  • Graceful shutdown: SIGTERM with 5s grace period, then SIGKILL escalation
  • SSH agent forwarding: auto-discovers SSH_AUTH_SOCK for key-based auth

23.2 Reconnection with Exponential Backoff

  • Automatic retry on retryable failures (network down, connection refused, timeout)
  • Exponential backoff: 1s base, doubling per attempt, capped at 30s
  • Jitter: +/-25% per delay to prevent thundering herd
  • Maximum 10 retries before stopping; counter resets on successful connection
  • Non-retryable failures (auth denied, host key mismatch, port in use) stop immediately

23.3 Exit Classification

  • Stderr-based pattern matching classifies SSH exit reasons (AuthFailed, HostKeyMismatch, PortInUse, ConnectionRefused, NetworkDown, Timeout, UserKilled)
  • Exit code used as fallback when stderr is empty
  • Classification drives retry decisions — only network-related failures are retried

23.4 Audit Logging

  • SQLite database with WAL mode for concurrent-safe, high-performance event logging
  • Event types: Started, Connected, Disconnected, Error, Retry, Stopped
  • Query by tunnel ID (most recent N events) or by time range
  • Automatic rotation: configurable retention period deletes old events
  • Indexed on tunnel_id and timestamp for fast lookups

23.5 Profile Configuration

  • TOML-based profiles with name, host, port, user, identity file, and port forwards
  • Global scope: <config_dir>/tunnels/*.toml — available across all repos
  • Per-repo scope: <repo>/.tuic/tunnels/*.toml — overrides global profiles with same ID
  • Forward types: Local (-L) and Remote (-R) port forwarding
  • Options: ServerAliveInterval (default 15s), ServerAliveCountMax (default 3), StrictHostKeyChecking (Yes/AcceptNew)
  • Validation: duplicate bind ports, empty fields, port range (1-65535)
  • Pre-spawn port availability check for local forwards

23.6 Tauri IPC Commands

  • All tunnel management exposed as native Tauri IPC commands (tunnels/tauri_commands.rs) — profile CRUD, start/stop, status, audit log, SSH config host parsing, SSH agent key listing
  • Desktop app uses IPC directly; browser mode falls back to HTTP endpoints

23.7 Auto-Connect

  • Profiles with auto_connect: true start automatically on app launch
  • Hydration runs once during startup, guarded against duplicate calls
  • Non-blocking: failures are logged but don’t prevent app startup

23.8 SSH Agent Detection

  • Auto-detects SSH agent type from SSH_AUTH_SOCK: 1Password, Secretive, GPG Agent, generic SSH Agent
  • Lists loaded keys via ssh-add -l (fingerprint, comment, key type)
  • Shown in the tunnel editor for identity verification

23.9 Orphan SSH Process Cleanup

  • check_local_port() distinguishes PermissionDenied (privileged ports) from AddrInUse
  • kill_ssh_on_port() finds SSH processes holding a port via lsof, verifies with ps, sends SIGTERM
  • Only kills confirmed ssh processes — never unrelated services

23.10 Statusbar Shield

  • Grey shield icon when tunnel profiles exist but none are connected
  • Green shield with count badge when tunnels are connected
  • Clicking the shield opens the Tunnels Panel

23.11 Shutdown on Exit

  • TunnelManager::shutdown_all() called on RunEvent::Exit
  • Stops all supervisors and clears the tunnel map — no orphaned SSH processes after app close

23.12 UI

  • TunnelsPanel — List of tunnel profiles with status badges and start/stop controls
  • TunnelEditorModal — Create and edit tunnel profiles with form validation; file browse dialog for identity file; remote host pre-populated from tunnel host when adding forwards; type-aware Local/Remote forward endpoint fields; numeric input mode for port fields
  • TunnelStatusBadge — Color-coded status indicator (green=connected, blue=starting, orange=reconnecting, red=error, grey=stopped)
  • Command Palettetoggle-tunnels action registered for quick access

24. Remote Connection Manager

24.1 Connection Types

  • SSH — Connects via SSH tunnel to a remote tuic-remote daemon; auto-creates port forwarding
    • Fields: host, SSH port (default 22), SSH user, optional identity file, remote daemon port (default 9877)
  • Direct — Connects to a tuic-remote daemon URL directly (for Tailscale, LAN, or VPN scenarios)
    • Fields: URL, auth username

24.2 Storage

  • Connections persisted in <config_dir>/connections.json
  • Atomic writes via temp file + rename
  • Each connection has UUID, name, transport, auth username, and enabled flag

24.3 Remote Repositories and Terminals

  • Repos can be assigned to a remote connection; sidebar shows remote badge
  • Terminals on remote repos route WebSocket I/O through the connection’s base URL
  • transport.ts routes invoke() calls based on the active connection’s connectionId
  • canvasTerminalTransport.ts supports configurable baseUrl for remote WebSocket connections
  • Health polling for direct connections; SSH connections rely on tunnel supervisor status

24.4 SSE Event Bridge

  • remoteEventBridge.ts subscribes to server-sent events from remote daemons
  • Bridges remote events (repo changes, PTY output, agent status) into local stores
  • Automatic reconnection on connection loss

25. Generators

Secure value generators accessible from the command palette (open-generators action).

25.1 Available Generators

  • Password — Configurable length, character classes (uppercase, lowercase, digits, symbols)
  • UUID v4 — Standard random UUID (RFC 4122)
  • UUID v7 — Time-ordered UUID (RFC 9562)
  • ULID — Universally unique lexicographic identifier
  • CUID2 — Collision-resistant unique identifier
  • JWT Secret — 256-bit random hex key
  • TOTP Secret — RFC 4226 base32 secret (160-bit)
  • Nano ID — URL-friendly random ID with configurable length
  • Slug — adjective-noun-NNNN random slug
  • Ed25519 Key Pair — Public + private key pair

25.2 Architecture

  • All generation happens in the Rust backend (generators.rs) via the ring crate for cryptographic randomness
  • Frontend is a modal dialog with copy-to-clipboard and regenerate actions
  • Password and Nano ID have configurable options (length, character classes)

Getting Started

What is TUICommander?

TUICommander is a desktop terminal orchestrator for running multiple AI coding agents in parallel (Claude Code, Gemini CLI, Aider, OpenCode, Codex). Built with Tauri + SolidJS + Rust, with a native terminal engine powered by alacritty_terminal.

Key capabilities:

  • Up to 50 concurrent terminal sessions with split panes and detachable tabs
  • 10 AI agents supported (Claude Code, Codex, Aider, Gemini, Amp, and more)
  • Git worktree isolation per branch
  • GitHub PR monitoring with CI status and notifications
  • Obsidian-style plugin system with community registry
  • Voice dictation with local Whisper (no cloud)
  • Command palette, configurable keybindings, prompt library
  • Built-in file browser and code editor
  • MCP HTTP server for external AI tool integration
  • Remote access from a browser on another device
  • Auto-update, 13 bundled fonts, cross-platform (macOS, Windows, Linux)

First Launch

  1. Add a repository — Click the + button at the top of the sidebar, or use the “Add Repository” option. Select a git repository folder.

  2. Select a branch — Click a branch name in the sidebar. If it’s not the main branch, TUICommander creates a git worktree so you work in an isolated copy.

  3. Start typing — The terminal is ready. Your default shell is loaded. Type commands, run AI agents, or execute scripts.

  4. Open more tabs — Press Cmd+T (macOS) or Ctrl+T (Windows/Linux) to add more terminal tabs for the same branch.

Workflow Overview

Add Repository → Select Branch → Worktree Created → Terminal Opens
                                                    ├── Run AI agent
                                                    ├── Open more tabs
                                                    ├── Split panes
                                                    └── View diffs/PRs

Each branch has its own set of terminals. When you switch branches, your previous terminals are preserved and hidden. Switch back and they reappear exactly as you left them.

The sidebar shows all your repositories and their branches.

Repository groups:

  • Repositories can be organized into named, colored groups
  • Drag a repo onto a group header to move it
  • Right-click a group to rename, change color, or delete it
  • Groups collapse/expand by clicking the group header

Repository entry:

  • Click to expand/collapse branch list
  • Click again to toggle icon-only mode (shows initials)
  • + button: Create new worktree for a new branch
  • button: Repo settings, remove, move to group

Branch entry:

  • Click: Switch to this branch (shows its terminals)
  • Double-click the branch name: Rename branch
  • CI ring: Shows CI check status (green/red/yellow segments)
  • PR badge: Shows PR number with color-coded state — click for detail popover. CLOSED and MERGED PRs are automatically hidden (MERGED PRs fade after 5 minutes of user activity).
  • Stats: Shows +additions/-deletions

Git quick actions (bottom of sidebar when a repo is active):

  • Pull, Push, Fetch, Stash buttons — run the git command in the active terminal

Next Steps

TUICommander modes

TUICommander has one backend and several ways to connect to it. The available features depend on the client mode.

ModeHow it is usedBest forMain limitations
Desktop appLaunch TUICommander normallyFull local development workflowNone of the client-side limitations below
Browser modeOpen the local HTTP server in a browserRemote control from a laptop or another desktopNative dialogs, Command Palette, global hotkeys, updater, dictation, detached windows, and some file/clipboard integrations are desktop-only
Mobile PWAOpen the mobile endpoint from a phone/tabletMonitoring agents and answering promptsDeliberately reduced UI; not a replacement for the desktop workspace
Remote daemonRun tuic-remote and connect through the remote-access flowHosting the backend on another machineRequires separate remote configuration and network/security setup

Desktop app

The desktop app runs the Tauri shell and exposes the complete feature set: native file dialogs, IDE launchers, dictation, global hotkeys, detached panels, the Command Palette, and the native updater.

For local development, use make dev or pnpm tauri dev. Frontend files use Vite HMR; Rust changes require restarting the development process.

Browser mode

The browser client uses the same backend over HTTP, WebSocket, and SSE. It is not a mock UI: sessions, terminal output, Git operations, settings, and most panels use the same backend as the desktop app.

Some desktop integrations cannot be reproduced in a browser. In particular, browser mode does not provide the native Command Palette, native file pickers, global hotkeys, detached OS windows, dictation, IDE launchers, auto-updater, or user-plugin installation from local files.

See Remote Access for setup and Troubleshooting if the browser cannot connect.

Mobile PWA

The mobile interface is optimized for observation and quick actions: inspect sessions, follow output, answer questions, and monitor agent activity. Use the desktop or full browser workspace for editing, worktree management, and complex Git operations.

Remote daemon

tuic-remote provides a standalone backend process for remote access. It is useful when the machine running the repositories is different from the machine displaying the UI.

See Remote Access for connection methods, TLS/relay options, and configuration details.

Troubleshooting

This page collects the most common problems when using TUICommander. Start with the quick checks, then inspect the application logs if the problem persists.

Quick checks

  1. Confirm that the agent binary works outside TUICommander (claude, codex, gemini, etc.).
  2. Confirm the repository is a valid Git checkout and that the selected directory is writable.
  3. Check that the terminal is using the expected shell and PATH.
  4. Restart the affected terminal tab before restarting the whole app; the PTY and other tabs can remain alive.
  5. Check the application log endpoint:
curl 'http://localhost:9876/logs?limit=200'

A second debug/test instance normally uses the next available port, such as 9877.

The agent is shown as idle or busy incorrectly

Agent state is inferred from terminal output and, for supported agents, can also be driven by native hooks. Check the following:

  • the correct agent type is selected or detected;
  • the agent is not waiting for a hidden confirmation prompt;
  • native hooks are enabled under Settings > Agents when available;
  • the terminal is not displaying a full-screen TUI whose state cannot be inferred reliably;
  • the agent has not changed its prompt format in a newer release.

If only one agent is affected, capture the terminal output and check the relevant agent page under docs/architecture/agents/ before reporting a regression.

A session was not restored

Session restore is lazy and branch-scoped. Select the repository and branch that owned the session first. Agent sessions may show a resume banner instead of executing a resume command automatically; activate the banner to continue.

Plain shell tabs are intentionally not restored as live processes after an application restart. Create a new shell tab instead.

A worktree is missing or the branch is not visible

Check the repository’s worktree list from a shell:

git worktree list

A worktree involved in a rebase, merge, cherry-pick, revert, or bisect should not be removed automatically. If the directory was deleted externally, refresh the repository state and check the application log for the removal reason.

GitHub, PR, or CI data is stale

  • Verify that gh auth status succeeds for the intended account.
  • Check the repository remote and account binding in Settings.
  • Use Fetch or refresh the GitHub panel.
  • Check whether the provider is rate-limited or temporarily unavailable.
  • For multiple GitHub accounts, confirm that the repository is bound to the correct account.

Browser mode does not show a feature

Browser mode intentionally lacks some desktop integrations. See TUICommander modes for the complete distinction.

The Command Palette, native file picker, global hotkey, dictation, IDE launcher, updater, detached OS windows, and installation of user plugins from local files require the desktop app.

For a connection problem, verify that the backend is listening on the expected port and that the browser can reach it from the same network. Inspect the browser console and the backend logs before restarting.

Clipboard or file opening behaves differently in a browser

Browser mode uses browser clipboard and file-opening APIs where native integrations are unavailable. Permissions, HTTPS requirements, popup blocking, and browser focus can affect the result. Retry the action from the desktop app to distinguish a browser limitation from a backend problem.

Dictation cannot build or start on Windows

Whisper builds require CMake and libclang. This project currently expects LLVM 18 for the Windows whisper-rs bindings. Set LIBCLANG_PATH to the LLVM bin directory before building. See Development Setup.

MCP or OAuth problems

Check the upstream server status, authorization URL, and the application log. For a local MCP endpoint, verify that the client is using the current protocol/session configuration. A client that does not support tool-list refresh may need to reconnect after MCP configuration changes.

Performance problems

Enable runtime diagnostics only while reproducing the problem:

curl -X POST http://localhost:9876/diagnostics \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true}'

curl 'http://localhost:9876/logs?source=diagnostics'

Disable diagnostics after the investigation. See Performance Profiling for a deeper investigation workflow.

What to include in a bug report

Include:

  • TUICommander version and platform;
  • desktop, browser, mobile, or remote-daemon mode;
  • agent and agent version;
  • repository/worktree context, without secrets;
  • exact reproduction steps;
  • relevant logs with tokens and credentials removed;
  • whether the issue survives creating a fresh terminal tab.

Terminal Features

Terminal Sessions

Each terminal tab runs an independent PTY (pseudo-terminal) session with your shell. Up to 50 concurrent sessions.

Creating Terminals

  • Cmd+T — New terminal for the active branch
  • + button on sidebar branch — Add terminal to specific branch
  • + button on tab bar — New terminal tab

New terminals inherit the working directory from the active branch’s worktree path.

CWD Tracking (OSC 7)

When your shell reports directory changes via OSC 7, TUICommander updates the terminal’s working directory in real time. If the new directory falls inside a different worktree, the terminal tab is automatically reassigned to the corresponding branch in the sidebar.

Terminal Lifecycle

Terminals are never unmounted from the DOM. When you switch branches or tabs, terminals are hidden but remain alive. Switch back and your process, scroll position, and output are exactly as you left them.

Closing Terminals

  • Cmd+W — Close active tab (confirmation only when a user-launched process is running — e.g. Claude Code, htop, npm; idle shells and shells still loading .zshrc close immediately)
  • Middle-click on tab — Close tab
  • Right-click → Close Tab — Context menu
  • Right-click → Close Other Tabs — Close all except this one
  • Right-click → Close Tabs to the Right — Close tabs after this one

Reopening Closed Tabs

  • Cmd+Shift+T — Reopen the last closed tab
  • Last 10 closed tabs are remembered with their name, font size, and working directory
  • Reopened tabs start a fresh shell session in the original directory

Tab Management

Tab Names

  • Default naming: “Terminal 1”, “Terminal 2”, etc.
  • Double-click a tab to rename it (inline editing)
  • Press Enter to confirm, Escape to cancel
  • Explicit custom names persist through reconnects and are never replaced by agent output
  • Spawn-assigned agent labels are base names: an intent: text (Title) marker may replace them with the current work phase
  • Orchestrated PTYs show a short task description above the terminal; the orchestrator can provide it explicitly, while integrations without a description field derive it from the task prompt. The expandable Prompt bar retains the last substantial user prompt separately

Tab Reordering

Drag tabs to reorder them. Visual drop indicators show where the tab will land.

Tab Indicators

IndicatorMeaning
Grey dot (dim)Idle — no session or command never ran
Blue pulsing dotBusy — producing output now
Green dotDone — command completed
Purple dotUnseen — completed while you were viewing another tab (clears when selected)
Orange pulsing dotQuestion — agent needs user input
Red pulsing dotError — API error or agent stuck
Question iconAgent is asking a question
Progress barOperation in progress (OSC 9;4)
Amber gradientSession created via HTTP/MCP (remote session)

Sidebar branch icons also show purple when they contain unseen terminals.

Agent activity combines native lifecycle hooks, terminal movement (text changing above the input area means the agent is active), and the visible ready prompt. Claude, Codex, Gemini, Aider, and Grok require a stable ready screen before safety-sensitive actions such as auto-standby or queued agent message delivery. Pressing Ctrl-C or Escape requests interruption but does not turn the dot green until the agent confirms the interruption, returns to its prompt, or exits.

A newly launched detected agent remains in its starting state until terminal activity is actually observed. Question and answer transitions are retained by the backend even if a browser or event-stream client temporarily falls behind.

Current Claude and Codex status lines are also recognized when their interface keeps an empty composer visible or freezes during a long tool. Completed timing summaries are not treated as work, so the indicator can still return to idle. Grok keeps its composer visible while responding, so TUICommander waits for the animated status row to disappear before treating that composer as ready.

A ready prompt means the terminal can accept input; it does not necessarily mean the agent’s turn is finished. If the agent still owns a background command, the activity indicator remains working, parent-agent idle/completed notifications are deferred, and auto-standby will not pause the session. Persistent integration helpers are ignored, so they do not keep a completed turn active indefinitely.

Tab Shortcuts

Hover a tab to see its shortcut badge: “Terminal N (Cmd+N)”. Use Cmd+1 through Cmd+9 to jump directly. Ctrl+Tab / Ctrl+Shift+Tab — Next / previous tab.

Scroll Shortcuts

ShortcutAction
Cmd+HomeScroll to top
Cmd+EndScroll to bottom
Shift+PageUpScroll one page up
Shift+PageDownScroll one page down

Scrollback in fullscreen apps

Apps that take over the screen (gh run watch, less, man, TUIs) run on the terminal’s alternate screen, which by the original terminal spec has no scrollback at all — anything printed past the bottom of the window is gone. TUICommander enables an isolated alternate-screen history, giving the same user-visible result as iTerm2’s save-to-scrollback option: the scrollbar stays available and you can reach what rolled off the top.

Two details worth knowing:

  • The scrollback of a fullscreen app is wiped when it exits, and never mixes with your shell’s history.
  • TUICommander preserves the application’s actual output. A live view that reprints a frame taller than the viewport can therefore leave repeated snapshots in history; they are not deduplicated.
  • Apps with mouse support (vim, htop, lazygit) receive the wheel themselves — use Shift+wheel or drag the scrollbar to scroll TUICommander’s history instead.

Zoom

Per-terminal font size control:

ActionShortcutEffect
Zoom inCmd+=+2px font size
Zoom outCmd+--2px font size
ResetCmd+0Back to default size

Range: 8px to 32px. Each terminal has its own zoom level. The current zoom is shown in the status bar.

Split Panes

Split the terminal area into two panes:

Creating Splits

  • Cmd+\ — Split vertically (side by side)
  • Cmd+Alt+\ — Split horizontally (stacked)

The new pane opens a fresh terminal in the same working directory. Maximum 2 panes at a time.

  • Alt+←/→ — Switch between vertical panes
  • Alt+↑/↓ — Switch between horizontal panes
  • The active pane receives keyboard input

Resizing Split Panes

Drag the divider between the two panes to adjust the split ratio. Both terminals re-fit automatically when you release.

Maximizing a Split Pane

  • Cmd+Shift+Enter — Maximize / restore active pane (zoom pane). Expands the focused pane to fill the full terminal area; press again to restore the split.

Closing Split Panes

  • Cmd+W closes the active pane and collapses back to a single pane
  • The surviving pane automatically receives focus

Split Layout Persistence

Split layouts are stored per branch. When you switch branches and come back, your split configuration is restored.

Detachable Tabs

Float any terminal into its own OS window:

  1. Right-click a tab → Detach to Window
  2. The terminal opens in an independent floating window
  3. The PTY session stays alive — the floating window reconnects to the same session

When you close the floating window, the tab automatically returns to the main window.

Requirements: The tab must have an active PTY session. Tabs without a session (e.g., just created but not connected) cannot be detached.

Find in Terminal

Search within terminal output with Cmd+F:

  1. Press Cmd+F — a search overlay appears at the top of the active terminal pane
  2. Type your search query — matches highlight as you type (yellow for all matches, orange for active match)
  3. Navigate matches:
    • Enter or Cmd+G — Next match
    • Shift+Enter or Cmd+Shift+G — Previous match
  4. Toggle search options: Case sensitive, Whole word, Regex
  5. Match counter shows “N of M” results
  6. Press Escape to close the search and refocus the terminal

Search is integrated directly with the terminal grid for accurate match highlighting.

Search text across all open terminal buffers from the command palette:

  1. Press Cmd+P and type ~ followed by your search query (e.g. ~error)
  2. Results show terminal name, line number, and highlighted match text
  3. Press Enter or click a result to switch to that terminal and scroll to the matched line (centered in viewport)
  4. Minimum 3 characters after the ~ prefix

Also accessible via the “Search Terminals” command in the palette.

Copy & Paste

  • Copy: Select text in the terminal, then Cmd+C. A “Copied to clipboard” confirmation appears in the status bar.
  • Paste: Cmd+V writes clipboard content to the active terminal

Copy on Select

When enabled (Settings > General > Terminal or Settings > Appearance), selecting text in the terminal automatically copies it to the clipboard. A brief “Copied to clipboard” confirmation appears in the status bar. This is enabled by default.

Clear Terminal

Cmd+L clears the terminal display. Running processes are unaffected.

Terminal Bell

The bell behavior when receiving BEL character (\x07) is configurable in Settings > Appearance:

  • none — silent
  • visual — brief screen flash
  • sound — plays notification sound
  • both — flash and sound

Clickable File Paths

File paths appearing in terminal output are automatically detected and become clickable links. Hover over a path to see the link underline, then click to open it.

  • .md / .mdx files open in the Markdown viewer panel
  • All other code files open in your configured IDE, at the line number if a :line or :line:col suffix is present

Paths are validated against the filesystem before becoming clickable — only real files show as links.

Recognized extensions include: .rs, .ts, .tsx, .js, .jsx, .py, .go, .java, .css, .html, .json, .yaml, .toml, .sql, and many more.

Plan File Detection

When an AI agent emits a plan file path (e.g., PLAN.md), a button appears in the toolbar showing the file name. Click it to open the plan — Markdown files open in the viewer panel, others open in the IDE. Click the dismiss button (x) to hide it.

Working with AI Agents

TUICommander detects rate limits, prompts, and status messages from AI agents:

  • Rate limit detection — Recognizes rate limit messages from Claude, Aider, Gemini, OpenCode, Codex
  • Prompt interception — Detects when agents ask yes/no questions or multiple choice
  • Status tracking — Parses token usage and timing from agent output
  • Progress indicators — Shows progress bars for long-running operations

When an agent asks a question, the tab indicator changes and a notification sound plays (if enabled). Remote HTTP/MCP workers respect Silence orchestration completions even after a frontend reload, and a single busy cycle produces at most one completion notification when idle and process exit arrive separately. Generic desktop notifications such as “needs your attention” do not by themselves mark the tab as awaiting input; TUICommander requires explicit permission, approval, or waiting-for-input wording, an agent hook, or a verified question on screen.

Queueing Follow-up Commands

The Compose panel can leave work for an agent without steering its current turn:

  • Ctrl+Enter sends immediately.
  • Shift+Ctrl+Enter or the queue button submits immediately when the agent is already idle; otherwise it waits for the next idle window.
  • Queued user commands and peer messages share one FIFO and are delivered one item per idle window in acceptance order.
  • The N queued badge counts only Compose commands. Clicking it discards those commands but never clears peer or orchestrator messages waiting in the same delivery queue.

Queueing is available only for detected agent sessions, not plain shells.

Session Restore

On restart, only terminals that had an active agent session are restored — plain shell tabs are discarded and a fresh terminal is spawned. For restored agent tabs, a clickable banner appears: “Agent session was active — click to resume.” Clicking the banner sends the agent’s resume command. Press Escape or click the x button to dismiss the banner without resuming.

Terminal output that uses the OSC 8 standard for hyperlinks (e.g., URLs emitted by ls --hyperlink) is supported. Clicking an OSC 8 hyperlink opens the URL in your system browser.

Sidebar

The sidebar is your primary navigation for repositories, branches, and git operations.

Toggle & Resize

  • Toggle visibility: Cmd+[
  • Resize: Drag the right edge (200–500px range)
  • Width persists across sessions

Repository Management

Adding Repositories

Click the + button at the top of the sidebar and select a git repository folder.

Repository Entry

Each repo shows a header with the repo name and action buttons:

  • Click the header to expand/collapse the branch list
  • Click again to toggle icon-only mode (shows repo initials — saves space)
  • button — Opens a menu with: Repo Settings, Create Worktree, Move to Group (with submenus for existing groups, Ungrouped, and New Group), Park Repository, Remove Repository
  • Right-click main worktree rowSwitch Branch submenu: shows all local branches with a checkmark on the current one. If the working tree is dirty, prompts to stash changes first. Blocks switching when a terminal has a running process.

Removing Repositories

Repo → Remove. This only removes the repo from the sidebar — it does not delete any files.

Repository Groups

Organize repos into named, colored groups.

Creating a Group

  • Repo Move to GroupNew Group…
  • Enter a name in the dialog

Moving Repos Between Groups

  • Drag a repo onto a group header
  • Or: Repo Move to Group → select a group
  • To ungroup: Repo Move to GroupUngrouped

Managing Groups

Right-click a group header for:

  • Rename — Change the group name
  • Change Color — Pick a new accent color
  • Delete — Remove the group (repos become ungrouped)

Groups can be collapsed/expanded by clicking the header, and reordered by drag-and-drop.

Branches

Selecting a Branch

Click a branch name to switch to it. This:

  1. Creates a git worktree (for non-main branches) if one doesn’t exist
  2. Shows the branch’s terminals (or creates a new one)
  3. Hides terminals from the previous branch

Branch Indicators

Each branch row can show:

IndicatorMeaning
CI ringProportional arc segments — green (passed), red (failed), yellow (pending)
PR badgeColored by state — green (open), purple (merged), red (closed), gray (draft). Click for detail popover.
Diff stats+N / -N additions and deletions
Merged badgeBranches merged into main show a “Merged” badge
Question iconAn agent in this branch’s terminal is asking a question
Grey iconNo active terminals in the repo — branch icons dim to grey

Branch Actions

  • Double-click the branch name to rename the branch
  • Right-click for context menu: Copy Path, Add Terminal, Create Worktree (for branches without a worktree), Delete Worktree, Open in IDE, Rename Branch/Worktree, Merge & Archive

Remote-Only PRs

When a repository has open PRs on branches that only exist on the remote (not checked out locally), a badge appears in the branch section. Click it to open a popover listing these PRs. Each row shows the PR number, title, and state badge. Click a row to expand an inline accordion showing PR details, with action buttons:

  • Checkout — Create a local tracking branch
  • Create Worktree — Create a worktree for the branch
  • Merge — Merge the PR via GitHub API (shown when PR is mergeable)
  • View Diff — Open PR diff in a panel tab
  • Approve — Submit an approving review

Dismiss & Show Dismissed

Remote-only PRs can be dismissed to reduce sidebar clutter. Right-click the remote PRs badge or use the “Dismiss” action in the accordion. A “Show Dismissed” toggle at the bottom reveals dismissed PRs again.

Park Repos

Temporarily hide repos you’re not actively using.

Parking

Right-click any repo in the sidebar → Park. The repo disappears from the main list.

Viewing Parked Repos

A button in the sidebar footer shows all parked repos with a count badge. Click it to open a popover listing them.

Unparking

Click Unpark on any repo in the parked repos popover. It returns to the main sidebar list.

Active-Only Filter

When you have many repos open, hide the ones you aren’t using right now.

Click the filter icon in the toolbar (next to the sidebar collapse button) to show only repositories that have at least one open terminal. The icon turns accent-colored while the filter is on, and a banner at the top of the sidebar shows how many repos are shown out of the total — click it (or “Show all”) to clear the filter. The filter is session-only and resets when you restart.

Quick Branch Switcher

Switch branches by number without the mouse:

  1. Hold Cmd+Ctrl (macOS) or Ctrl+Alt (Windows/Linux)
  2. All branches show numbered badges (1, 2, 3…)
  3. Press a number (1–9) to switch to that branch instantly
  4. Release the modifier to dismiss the overlay

Git Quick Actions

When a repo is active, the bottom of the sidebar shows quick action buttons:

  • Pullgit pull in the active terminal
  • Pushgit push
  • Fetchgit fetch
  • Stashgit stash

For more git operations (staging, commit, push, pull, stash, blame, history), use the Git Panel (Cmd+Shift+D).

File Browser & Code Editor

File Browser Panel

Toggle with Cmd+E or the folder icon in the status bar. The file browser shows the directory tree of the active repository (or linked worktree when on a worktree branch).

The file browser, Markdown viewer, and Diff panels are mutually exclusive — opening one closes any other that is open.

  • Arrow keys (Up / Down) — Move selection
  • Enter — Open a file or enter a directory
  • Backspace — Go up to the parent directory
  • .. row — Click to go up one level (appears when inside a subdirectory)
  • Breadcrumb bar — Click any path segment to jump directly to that directory; click the root / to return to the repo root

Directories are listed first, then files. The entry count is shown as a badge in the panel header.

Sorting

Click the funnel icon in the toolbar to switch sort order:

ModeOrder
Name (default)Directories first, then alphabetical
DateDirectories first, then newest modified first

Live Refresh

The panel watches the current directory for filesystem changes. When a file is created, deleted, or renamed outside the app, the listing refreshes automatically without a visible loading spinner.

Git Status Indicators

The panel header shows a legend for git status colors:

ColorLabelMeaning
OrangemodModified (unstaged changes)
GreenstagedStaged for commit
BluenewUntracked (new file)

File and directory names inherit these colors based on their git status. Gitignored entries are shown in a dimmed style.

View Modes

The file browser supports two view modes, toggled via toolbar buttons:

ModeDescription
List (default)Flat directory listing with breadcrumb navigation and .. parent entry
TreeCollapsible hierarchy with lazy-loaded subdirectories. Expand folders by clicking the chevron

Switching to tree mode resets to the repository root regardless of the current flat-mode subdirectory. When a search query is active, the view always shows flat results.

Type in the search box to search recursively across the entire repository by filename. Supports * and ** glob wildcards (e.g., *.ts, src/**/*.test.ts). Results appear with their full path. Clear the query with the × button or by emptying the input.

The search icon button to the left of the input toggles between filename mode (file icon) and content mode (magnifier icon).

Switch to content mode (magnifier icon) to search inside file contents across the repository. Results are grouped by file, showing the matching line number and a highlighted excerpt. Click any result to open the file in the editor, jumping to that line.

Content search options (shown when in content mode):

ToggleMeaning
Aa (case icon)Match case
.* (regex icon)Use regular expression
|ab| (word icon)Match whole word

A status bar below the search box shows match counts, files searched, files skipped (binary/large), and a “results limited” notice when the result set is truncated. Search begins after a short debounce and requires at least 3 characters.

File Operations (Context Menu)

Right-click any entry to open the context menu:

ActionShortcutNotes
Copy PathCopies the full absolute path to the clipboard
CopyCmd+CFiles only; stores file in the internal clipboard
CutCmd+XFiles only; cut entries are shown dimmed
PasteCmd+VPastes into the current directory; disabled when clipboard is empty
Rename…Opens a rename dialog; enter the new name and confirm
DeleteRequires confirmation; directories are deleted recursively
Add to .gitignoreAppends the entry’s path to .gitignore; disabled if already ignored

The keyboard shortcuts (Cmd+C, Cmd+X, Cmd+V) also work when the file browser has focus, without opening the context menu.

Cut + Paste performs a move (rename). Copy + Paste duplicates the file into the current directory. Pasting into the same directory where the file already exists is a no-op.

Opening Files

  • Click a file — Opens it in the code editor (see below), or in the Markdown viewer if the extension is .md or .mdx
  • Click a content search result — Opens the file and jumps to the matching line number

Panel Resize

Drag the left edge of the panel to resize it. Range: 200–800 px.


Code Editor

Clicking a non-Markdown file opens it in an in-app code editor tab in the main tab area, alongside terminal tabs.

Features

  • Syntax highlighting — Auto-detected from file extension; disabled for files larger than 500 KB
  • Line numbers, bracket matching, active line highlight, indentation support
  • SaveCmd+S saves the file when the editor tab is focused

Read-Only Mode

Click the padlock icon in the editor tab header to toggle read-only mode. When locked, the file cannot be edited.

Unsaved Changes

An unsaved-changes dot appears in both the tab bar and the editor header when the file has been modified but not saved.

Disk Conflict Detection

If the file changes on disk while you have unsaved edits, a conflict banner appears with two options:

  • Reload — Discard local edits and load the disk version
  • Keep mine — Dismiss the banner; the next save overwrites the disk version

When the editor has no unsaved changes, files reload silently when they change on disk.


Markdown Viewer

.md and .mdx files open in the Markdown viewer panel instead of the code editor. The viewer renders Markdown with syntax-highlighted code blocks.

See ai-agents.md for how AI-generated plan files are detected and surfaced as a one-click shortcut to open in the viewer.

Command Palette & Activity Dashboard

Command Palette

Open with Cmd+P (macOS) / Ctrl+P (Windows/Linux). The command palette gives you fast keyboard access to every registered action in the app.

How It Works

  1. Press Cmd+P — the palette opens with a search input focused
  2. Type to filter actions by name or category (substring match, case-insensitive)
  3. Navigate with / arrow keys
  4. Press Enter to execute the selected action
  5. Press Escape or click outside the palette to close

What You See

Each row shows:

  • Action label — What the action does (e.g., “Git panel”, “New terminal tab”)
  • Category badge — The action’s category (Terminal, Panels, Git, Navigation, Zoom, Split Panes, File Browser)
  • Keybinding hint — The assigned keyboard shortcut, if any

Search Behavior

Filtering matches against the action label and its category simultaneously. Typing “git” surfaces all Git actions; typing “panel” surfaces all panel-toggle actions regardless of category. There is no minimum query length — results update on every keystroke.

Recency Ranking

When the search box is empty, recently used actions float to the top, ordered by most recent first. Remaining actions are sorted alphabetically. The ranking persists across palette opens so your most-used commands are always one keystroke away.

Mouse Support

Hovering over a row highlights it (same as keyboard selection). Clicking a row executes the action immediately.

Powered by the Action Registry

The palette is auto-populated from actionRegistry.ts. Every action registered there — with its label, category, and keybinding — appears in the palette automatically. No manual configuration is needed, and plugin-contributed actions appear alongside built-in ones.

Search Modes

The command palette supports three search prefixes:

PrefixModeDescription
!Filename searchSearch files by name (min 1 char)
?Content searchSearch inside file contents (min 3 chars)
~Terminal searchSearch across all open terminal buffers (min 3 chars)
  • Leading spaces after the prefix are ignored (~ error = ~error)
  • File results show as a flat list with file path
  • Content matches include line number and highlighted match text
  • Terminal matches include terminal name, line number, and highlighted match text
  • Press Enter or click to open the file in an editor tab, or navigate to the terminal match
  • Terminal match navigation switches to the correct tab/pane and scrolls to the matched line
  • Delete the prefix to return to command mode
  • Search runs with a 300ms debounce
  • Footer shows !, ?, and ~ hints when in command mode

If no repository is selected, file/content modes show “No repository selected”. If no terminals are open, terminal mode shows “No terminals open”.

Discoverable Search Commands

You can also access search modes via explicit commands in the palette:

CommandAction
Search TerminalsOpens palette with ~ prefix
Search FilesOpens palette with ! prefix
Search in File ContentsOpens palette with ? prefix

These appear as regular commands in the palette — type “Search” to find them.


Activity Dashboard

Open with Cmd+Shift+A. A real-time overview of all your terminal sessions.

What You See

A compact list where each row shows:

ColumnDescription
Terminal nameThe tab name
Agent typeDetected agent (Claude, Aider, etc.) with brand icon
StatusCurrent state with color indicator
Last activityRelative timestamp (“2s ago”, “1m ago”) — auto-refreshes

Status Colors

ColorMeaning
GreenAgent is actively working
YellowAgent is waiting for input
RedAgent is rate-limited (with countdown)
GrayTerminal is idle

A terminal with a ready input composer is shown as idle even if the agent left a long-lived background terminal, such as a development server, running.

Interactions

  • Click any row — Switches to that terminal and closes the dashboard
  • Rate limit indicators — Show countdown timers for when the limit expires

The dashboard is useful when running many agents in parallel — you can spot at a glance which ones need attention, which are stalled, and which are making progress.

Keyboard Shortcuts

All shortcuts use Cmd on macOS and Ctrl on Windows/Linux unless noted.

Customizing Keybindings

From the UI

Open Help > Keyboard Shortcuts (or Cmd+? → Keyboard Shortcuts). Click the pencil icon next to any shortcut to enter recording mode, then press your new key combination. The app warns you if the combo is already used by another action. Overridden shortcuts are highlighted in accent color with a reset icon to revert individually. A “Reset all to defaults” button is at the bottom.

Extended function keys (macOS). F13F20 are recordable, both here and for the Global Hotkey. macOS never forwards them to the app’s WebView, so TUICommander captures them natively and feeds them into the recorder; if one of them still appears dead, macOS itself consumed the key first (F14/F15 drive keyboard illumination on many Macs — remap or disable that in System Settings > Keyboard). F21F24 are accepted in keybindings.json but have no macOS key code, so no Mac keyboard can produce them.

By editing the config file

You can also edit the keybindings.json file directly in your config directory:

  • macOS: ~/Library/Application Support/tuicommander/keybindings.json
  • Windows: %APPDATA%\tuicommander\keybindings.json
  • Linux: ~/.config/tuicommander/keybindings.json

The file is a JSON array of override objects. Only include shortcuts you want to change — anything not listed uses the default:

[
  { "action": "toggle-git-ops", "key": "Cmd+Shift+Y" },
  { "action": "toggle-markdown", "key": "Cmd+Shift+M" }
]
  • "key" uses Cmd as the platform-agnostic modifier (resolved to Meta on macOS, Ctrl on Win/Linux)
  • Set "key": "" or "key": null to unbind an action
  • Changes made via the UI are saved to this same file
  • The file is loaded at startup — restart TUICommander to pick up manual edits

See the action table below for all available action names.

Global Hotkey

A configurable OS-level shortcut to toggle TUICommander’s visibility from any application.

  • Configure: Help > Keyboard Shortcuts > Global Hotkey (top of the tab)
  • No default — you must set it yourself (e.g., Ctrl+Space, `Cmd+``)
  • Toggle behavior: hidden/minimized → show+focus, visible but unfocused → focus, focused → instant hide
  • Cmd and Ctrl are treated as distinct modifiers
  • Uses tauri-plugin-global-shortcut (no Accessibility permission required on macOS)
  • Not available in browser/PWA mode
  • Persists across app restarts

Terminal Operations

ShortcutAction
Cmd+TNew terminal tab
Cmd+WClose tab (or close active pane in split mode)
Cmd+Shift+TReopen last closed tab
Cmd+RRun saved command
Cmd+Shift+REdit and run command
Cmd+LClear terminal
Cmd+Shift+LRefresh terminal (fix rendering glyphs)
Cmd+FFind in terminal / diff tab
Cmd+GGit Panel — Branches tab (or Find next match when search is open)
EnterFind next match (when search is open)
Cmd+Shift+G / Shift+EnterFind previous match (when search is open)
EscapeClose search overlay
Cmd+CCopy selection
Cmd+VPaste to terminal
Cmd+HomeScroll to top
Cmd+EndScroll to bottom
Shift+PageUpScroll page up
Shift+PageDownScroll page down
Cmd+Shift+.Toggle block folding
Cmd+Shift+UpJump to previous block
Cmd+Shift+DownJump to next block
Cmd+Shift+BToggle block-scoped search

Tab Navigation

ShortcutAction
Cmd+1 through Cmd+9Switch to tab by number
Ctrl+TabNext tab
Ctrl+Shift+TabPrevious tab

Zoom

ShortcutAction
Cmd+= (or Cmd++)Zoom in (active terminal)
Cmd+-Zoom out (active terminal)
Cmd+0Reset zoom to default (active terminal)
Cmd+Shift+= (or Cmd+Shift++)Zoom in all terminals
Cmd+Shift+-Zoom out all terminals
Cmd+Shift+0Reset zoom all terminals

Font size range: 8px to 32px, step 2px per action.

Split Panes

ShortcutAction
Cmd+\Split vertically (side by side)
Cmd+Alt+\Split horizontally (stacked)
Alt+← / Alt+→Navigate panes (vertical split)
Alt+↑ / Alt+↓Navigate panes (horizontal split)
Cmd+WClose active pane (collapses to single)
Cmd+Shift+EnterMaximize / restore active pane
Cmd+Alt+EnterFocus mode — hide sidebar, tab bar, and all side panels (keeps toolbar + status bar)

Panels

ShortcutAction
Cmd+[Toggle sidebar
Cmd+Shift+DToggle Git Panel
Cmd+Shift+MToggle markdown panel
Cmd+Alt+NToggle Ideas panel
Cmd+EToggle file browser
Cmd+OOpen file… (picker)
Cmd+NNew file… (picker for name + location)
Cmd+,Open settings
Cmd+UJump to next waiting terminal
Cmd+?Toggle help panel
Cmd+Shift+KPrompt library
Cmd+KClear scrollback
Cmd+Shift+WWorktree Manager
Cmd+JTask queue
Cmd+Shift+EToggle error log
Cmd+Shift+IMCP servers popup (per-repo)

Note: File browser and Markdown panels are mutually exclusive — opening one closes the other.

ShortcutAction
Cmd+PCommand palette
Cmd+Shift+AActivity dashboard

Git

ShortcutAction
Cmd+Shift+DGit Panel (opens on last active tab)
Cmd+GGit Panel — Branches tab
Cmd+BQuick branch switch (fuzzy search)

Branches Panel (when panel is focused)

The panel is mouse-first — branch actions (create, delete, rename, merge, rebase, push, pull, fetch, checkout) live in the right-click context menu, the + New-branch button, and row double-click. Only list navigation is on the keyboard.

ShortcutAction
/ Navigate branch list
/Focus the filter
EscapeClear filter / deselect
double-clickCheckout branch
Ctrl/Cmd+1–4Switch Git Panel tab (1=Changes, 2=Log, 3=Stashes, 4=Branches)

Quick Branch Switcher

ShortcutAction
Hold Cmd+Ctrl (macOS) or Ctrl+Alt (Win/Linux)Show quick switcher overlay
Cmd+Ctrl+1-9Switch to branch by index

While holding the modifier, all branches show numbered badges. Press a number to switch instantly.

File Browser (when panel is focused)

ShortcutAction
/ Navigate files
EnterOpen file or enter directory
BackspaceGo to parent directory
Cmd+CCopy selected file
Cmd+XCut selected file
Cmd+VPaste file into current directory

Code Editor (when editor tab is focused)

ShortcutAction
Cmd+SSave file

Ideas Panel (when textarea is focused)

ShortcutAction
EnterSubmit idea
Shift+EnterInsert newline

Voice Dictation

ShortcutAction
Hold F5Push-to-talk (configurable in Settings)

Hold to record, release to transcribe and inject text into active terminal.

Tab Context Menu (Right-click on tab)

ActionShortcut
Close TabCmd+W
Close Other Tabs
Close Tabs to the Right
Copy Path— (diff/editor/markdown file tabs)
Rename Tab(double-click tab name)
Detach to Window

While a context menu is open, pressing a menu item’s shortcut chord (modifier + key, or Enter) fires that action immediately — no click needed. Modifier-only keys (Cmd, Shift, Ctrl, Alt) do not close the menu so multi-key chords can form; any other non-matching key closes the menu.

Mouse Actions

ActionWhereEffect
ClickSidebar branchSwitch to branch
Double-clickSidebar branch nameRename branch
Double-clickTab nameRename tab
Right-clickTabContext menu
Right-clickSidebar branchBranch context menu
Middle-clickTabClose tab
DragTabReorder tabs
DragSidebar right edgeResize sidebar (200-500px)
ClickPR badge / CI ringOpen PR detail popover
ClickStatus bar CWD pathCopy path to clipboard
ClickStatus bar panel buttonsToggle Git/MD/FB/Ideas panels
DragPanel left edgeResize right-side panel (200-800px)
DragSplit pane dividerResize split terminal panes

Action Names Reference (for keybindings.json)

Action NameDefault ShortcutDescription
zoom-inCmd+=Zoom in
zoom-outCmd+-Zoom out
zoom-resetCmd+0Reset zoom
zoom-in-allCmd+Shift+=Zoom in all terminals
zoom-out-allCmd+Shift+-Zoom out all terminals
zoom-reset-allCmd+Shift+0Reset zoom all terminals
new-terminalCmd+TNew terminal tab
close-terminalCmd+WClose terminal/pane
reopen-closed-tabCmd+Shift+TReopen closed tab
clear-terminalCmd+LClear terminal
refresh-terminalCmd+Shift+LRefresh terminal (fix glyphs)
run-commandCmd+RRun saved command
edit-commandCmd+Shift+REdit and run command
split-verticalCmd+\Split vertically
split-horizontalCmd+Alt+\Split horizontally
prev-tabCtrl+Shift+TabPrevious tab
next-tabCtrl+TabNext tab
focus-last-terminalCmd+Ctrl+BackspaceReturn to last terminal (toggle, across repos)
jump-waiting-terminalCmd+UJump to the next terminal awaiting input (cycles, across repos)
switch-tab-1..9Cmd+1..9Switch to tab N
toggle-sidebarCmd+[Toggle sidebar
toggle-markdownCmd+Shift+MToggle markdown panel
toggle-notesCmd+Alt+NToggle ideas panel
open-fileCmd+OOpen file picker
new-fileCmd+NCreate new file
toggle-file-browserCmd+EToggle file browser
prompt-libraryCmd+Shift+KPrompt library
toggle-settingsCmd+,Open settings
toggle-task-queueCmd+JTask queue
toggle-helpCmd+?Toggle help panel
toggle-git-opsCmd+Shift+DGit Panel
toggle-branches-tabCmd+GGit Panel — Branches tab
worktree-managerCmd+Shift+WWorktree Manager panel
quick-branch-switchCmd+BQuick branch switch
find-in-terminalCmd+FFind in terminal
command-paletteCmd+PCommand palette
activity-dashboardCmd+Shift+AActivity dashboard
toggle-error-logCmd+Shift+EToggle error log
toggle-mcp-popupCmd+Shift+IMCP servers popup (per-repo)
switch-branch-1..9Cmd+Ctrl+1..9Switch to branch N
scroll-to-topCmd+HomeScroll to top
scroll-to-bottomCmd+EndScroll to bottom
scroll-page-upShift+PageUpScroll page up
scroll-page-downShift+PageDownScroll page down
zoom-paneCmd+Shift+EnterMaximize/restore pane
toggle-focus-modeCmd+Alt+EnterFocus mode — hide sidebar/tab bar/panels
toggle-file-browser-content-searchCmd+Shift+FFile content search
toggle-diff-scrollCmd+Shift+GDiff scroll view
toggle-global-workspaceCmd+Shift+XToggle global workspace
toggle-ai-chatCmd+Alt+AToggle AI Chat panel
clear-scrollbackCmd+KClear scrollback
open-folderCmd+Shift+OOpen folder picker
open-pathCmd+Alt+OOpen path…
open-secondary-windowOpen secondary window
command-overviewCommand overview
ai-triageAI Triage
toggle-outlineCmd+Alt+LToggle outline panel
toggle-compose-panelCmd+IToggle compose panel
detach-activity-dashboardOpen Activity Dashboard in separate window
toggle-tunnelsSSH Tunnels panel
process-managerProcess Manager
open-generatorsOpen generators
show-remote-qrQR for Remote Mobile Connection
block-fold-toggleCmd+Shift+.Toggle block fold
block-prevCmd+Shift+UpPrevious command block
block-nextCmd+Shift+DownNext command block
block-search-toggleCmd+Shift+BSearch in block

Settings

Open settings with Cmd+,. Settings are organized into tabs.

General Tab

SettingDescription
LanguageUI language
Default IDEIDE for “Open in…” actions. Only installed apps are offered, grouped by category: Code Editors (VS Code, Cursor, Zed, Windsurf, Neovim, Xcode, $EDITOR), JetBrains (IntelliJ IDEA, PyCharm, WebStorm, GoLand, CLion, PhpStorm, RubyMine, Rider, DataGrip, RustRover, Android Studio, Fleet), Terminals (Ghostty, WezTerm, Alacritty, Kitty, Warp, iTerm2), Git Tools (Sourcetree, GitHub Desktop, Fork, GitKraken, Sublime Merge, Tower), System (Terminal, Finder)
Custom LaunchersDefine your own tools for the “Open in” menu. Each launcher has a name, an executable (bare name resolved on PATH, or absolute path), and arguments (one per line). Arguments may use placeholders, expanded at launch: {path}/{file} (focused file, else repo root), {fileDir} (directory of the focused file), {repo} (repo/worktree root), {cwd} (focused terminal’s working directory), {home} (your home directory), {line}/{column} (1-based editor cursor position). Args are passed verbatim (no shell parsing), so paths with spaces are safe.
ShellCustom shell path (e.g., /bin/zsh, /usr/local/bin/fish). Leave empty for system default.
Confirm before quittingShow dialog when closing app with active terminals
Confirm before closing tabAsk before closing terminal tab
Prevent sleep when busyKeep machine awake while agents are working
Auto-check for updatesCheck for new versions on startup
Auto-show PR popoverAutomatically display PR details when switching branches. Only shows for OPEN pull requests — CLOSED PRs are hidden, and MERGED PRs fade after 5 minutes of user activity.
Copy on SelectAuto-copy terminal selection to clipboard. When text is selected in the terminal, it is immediately copied. A “Copied to clipboard” confirmation appears in the status bar. Enabled by default. Also configurable in Appearance tab.
Allow OSC 52 clipboard writesLet terminal programs set the system clipboard via the OSC 52 escape sequence (used by tmux, vim, ssh yank-over-SSH, etc.). Because OSC 52 is honored from anywhere in the byte stream, a displayed file or log can also overwrite the clipboard — so a non-blocking “Clipboard updated” notice appears on every write. Disable to ignore OSC 52 entirely. Enabled by default.
Repository defaultsBase branch, file handling, setup/run scripts applied to new repos
Experimental FeaturesMaster toggle for experimental features. When enabled, shows sub-toggles: AI Chat (AI Chat panel, shortcuts, command palette entry), Scroll History (scrollback overlay with search when scrolling up in agent mode).

Appearance Tab

SettingTypeDefaultDescription
Terminal themeColor theme with preview swatches
Terminal fontJetBrains Mono13 bundled monospace fonts: Fira Code, Hack, Cascadia Code, Source Code Pro, IBM Plex Mono, Inconsolata, Ubuntu Mono, Anonymous Pro, Roboto Mono, Space Mono, Monaspace Neon, Geist Mono
Default font size8–32px slider. Applies to new terminals; existing terminals keep their zoom level.
Split tab modeSeparate or unified tab appearance
Cycle All Tab TypesOffWhen on, next/prev-tab shortcuts also cycle file/diff/markdown/editor tabs (ordered like the tab bar). Off cycles terminals only.
Nested Terminal TabsOffWhen on, a branch with more than one terminal shows a collapsible list of its terminals under its sidebar row, each with a status dot. Off by default.
Max tab name length10–60 slider
Repository groupsCreate, rename, delete, and color-code groups
Reset panel sizesRestore sidebar and panel widths to defaults
Copy on SelectbooleantrueAuto-copy terminal selection to clipboard
Allow OSC 52 clipboard writesbooleantrueHonor OSC 52 clipboard writes from terminal output (shows a notice per write)
Bell Stylenone/visual/sound/bothvisualTerminal bell behavior

Agents Tab

Each supported agent has an expandable row showing detection status, version, and MCP badge.

SettingDescription
Agent DetectionAuto-detects running agents from terminal output patterns. Shows “Available” or “Not found” for each agent.
Run ConfigurationsCustom launch configs (binary path, args, model, prompt) per agent. Add, set default, edit, or delete configurations (Edit / Delete live under the ··· menu on each row). A config named “review” enables the Review button in the PR Detail Popover — its args are interpolated with {pr_number}, {branch}, {base_branch}, {repo}, {pr_url}. The agent’s default run config also drives resume: launching / resuming the agent swaps the agent’s default binary (e.g. claude) for command and appends args after the resume flag.
MCP IntegrationInstall/remove TUICommander as MCP server for supported agents. Shows install status with a dot indicator.
Native agent hooks for status(Claude, Gemini, Codex, Grok, OpenCode) Toggle under each supported agent’s expanded row. When enabled, TUIC writes lifecycle hooks into the agent’s settings file / plugin directory so that busy/idle/awaiting state is driven directly by the agent’s own hook events (OSC 7770) rather than inferred from terminal output. An install-state badge next to the toggle shows “Hooks installed” (current), “Hooks: re-enable” (outdated — TUIC version changed), or nothing (not installed). The effect takes place on the agent’s next launch. See AI Agents — Native Hook Instrumentation for details.
Claude Usage Dashboard(Claude Code only) Toggle under Features when the Claude row is expanded. Enables rate limit monitoring, session analytics, token usage charts, activity heatmap, and per-project breakdowns. Usage data appears in the status bar agent badge and in a dedicated dashboard tab.
Agent Model OverridesPer-task-phase model routing for the AI Agent loop. Four phases: plan, search, read, write. Each phase can use a different model (e.g. a cheaper model for search, a stronger model for write). Configure in Settings > AI Chat.
Unsafe ModeWhen enabled, the agent skips all approval prompts and operates without sandbox restrictions (TrustLevel::Unrestricted). Toggle via the lock icon in the AI Chat panel header. A confirmation dialog warns before activating. The header turns red to indicate unrestricted operation.
Cron SchedulerTime-triggered agent tasks. Define cron expressions with goals in Settings > AI Chat > Scheduler. Jobs are persisted to ai-cron.json and tick every 30 s.

See AI Agents for details on agent detection, rate limits, and the usage dashboard.

GitHub Tab

GitHub authentication and token management:

SettingDescription
OAuth LoginDevice Flow login — click “Sign in with GitHub”, enter code on github.com. Token stored in OS keyring.
Auth StatusShows current login, avatar, token source (OAuth/env/CLI), and available scopes
DisconnectClear all GitHub tokens (keyring + env cache). Falls back to next available source.
DiagnosticsToken source details, scope verification, API connectivity check
Issue FilterWhich issues to show in the GitHub panel: Assigned (default), Created, Mentioned, All, or Disabled
Auto-show PR popoverAutomatically show PR detail popover when opening a branch with an active PR
Auto-delete on PR closeOff (default), Ask, or Auto — controls branch cleanup when a PR is merged/closed

Token priority: GH_TOKEN env → GITHUB_TOKEN env → OAuth keyring → gh CLI config → gh auth token subprocess.

Services & MCP Tab

HTTP API Server

Enable the HTTP API server for external tool integration:

  • Serves the REST API and MCP protocol for AI agents and automation tools
  • Local MCP connections use a Unix domain socket at <config_dir>/mcp.sock — no port configuration needed
  • AI agents connect via the tuic-bridge sidecar (auto-installed on first launch for every supported agent that is installed on the machine — see MCP bridge auto-install)
  • Shows server status (running/stopped) and active session count

TUIC Tools

Native tools exposed to AI agents via MCP. Each tool can be individually enabled or disabled to restrict what agents can access.

Manual MCP configuration (expandable) — shows the tuic-bridge binary path and a ready-to-paste JSON snippet for manually configuring MCP clients that aren’t auto-installed. Click “Copy” to copy the snippet to clipboard.

Collapse tools (checkbox) — when enabled, replaces the full tool list sent to AI agents with 3 lazy-discovery meta-tools (search_tools, get_tool_schema, call_tool). Cuts the baseline MCP context cost from ~35k tokens to ~500 tokens per agent turn; the agent fetches schemas on demand via BM25-ranked search. Default: off. Grok sessions receive this compact surface automatically for compatibility with Grok’s tool-name parser, without changing the checkbox or other clients. Toggling emits notifications/tools/list_changed; compatible clients refresh automatically, while clients that ignore the notification may require a reconnect.

Tools:

  • session — PTY terminal session management
  • git — Repository state queries
  • agent — AI agent detection and spawning
  • config — App configuration read/write
  • workspace — Repo and worktree queries
  • notify — User notifications (toast, confirm)
  • plugin_dev_guide — Plugin authoring reference

Upstream MCP Servers

Manage in Settings in the MCP popup (Cmd+Shift+I) opens this tab scrolled to this block — the block sits below the fold, so a plain tab switch would look like nothing happened.

OAuth upstreams show Authorize when consent is required. TUIC prepares the OAuth request, then displays a blocking in-app confirmation naming the authorization-server origin before opening the system browser. Cancelling that confirmation discards the pending request.

Proxy external MCP servers through TUICommander. Their tools appear prefixed as {name}__{tool}:

  • Add upstream servers via HTTP (Streamable MCP) or stdio (process) transport
  • API keys for HTTP upstreams are stored in the OS keychain
  • Live status (connecting, ready, circuit open, failed) with tool count and call metrics
  • Reconnect and remove controls per upstream
  • Per-repo scoping: each repo can define an allowlist of active upstream servers via Cmd+Shift+I popup (or repo settings). Empty/null allowlist = all servers active

Remote Access

Enable HTTP/WebSocket access from other devices on your network. See Remote Access for full setup guide.

Voice Dictation

See Voice Dictation for full details.

Keyboard Shortcuts (Help panel)

The keybinding UI lives in the Help panel (Help > Keyboard Shortcuts), not the Settings panel. Browse and rebind all app actions there:

  • Every registered action is listed with its current keybinding
  • Click any action row and press a new key combination to rebind it
  • Custom bindings are stored in keybindings.json in the platform config directory
  • Auto-populated from the action registry — new actions appear automatically
  • The Help panel notes that most shortcuts are also listed in the native system menu bar (desktop app only — browser/PWA clients have no native menu)

See Keyboard Shortcuts for the full reference and customization guide.

Plugins Tab

Install, manage, and browse plugins. See Plugins for the full guide.

  • Installed — List all plugins with enable/disable toggle, logs viewer, uninstall
  • Browse — Discover and install from the community registry

Smart Prompts Tab

Manage the AI-powered actions surfaced in the toolbar, context menus, and command palette. Reachable from the nav or directly via “Manage Smart Prompts…” in the Smart Prompts drawer.

  • Headless Agent — default agent for headless prompts; individual prompts can override it
  • Prompt list — grouped by category, with enable/disable toggle and placement/mode badges
  • Editor (click a row) — name, description, content with variable insertion, placement checkboxes, Execution Mode, inject target, Auto-execute, output target, system prompt, keyboard shortcut
  • Built-in prompts show “Reset to Default” once overridden; custom prompts can be deleted

See Smart Prompts for the full guide.

Repository Settings

Per-repository settings accessed via sidebar → “Repo Settings”.

Worktree Tab

  • Display Name — Custom name shown in sidebar
  • Base Branch — Branch to create worktrees from (auto-detect, main, master, develop)
  • Copy ignored files — Copy .gitignored files to new worktrees
  • Copy untracked files — Copy untracked files to new worktrees

Scripts Tab

  • Setup Script — Runs once after worktree creation (e.g., npm install)
  • Run Script — On-demand script launchable from toolbar with Cmd+R
  • Archive Script — Runs before a worktree is archived or deleted; non-zero exit blocks the operation

Repo-Local Config (.tuic.json)

A .tuic.json file in the repository root provides team-shareable settings that override per-repo app settings and global defaults. The file is read-only from TUICommander (edit it in your repo directly).

Precedence: .tuic.json > per-repo app settings > global defaults

Supported fields: base_branch, copy_ignored_files, copy_untracked_files, setup_script, run_script, archive_script, worktree_storage, delete_branch_on_remove, auto_archive_merged, orphan_cleanup, pr_merge_strategy, after_merge, auto_delete_on_pr_close.

User-specific settings (promptOnCreate, autoFetchIntervalMinutes) are intentionally excluded from .tuic.json.

Notification Settings

  • Enable Audio Notifications — Master toggle
  • Volume — 0-100% (applied natively by the Rust playback path). Releasing the slider plays a short preview at the new level.
  • Audio Output Device — defaults to the system output. Click Choose output device… to enumerate available outputs and pick a specific one. Enumeration is deferred until you click, because on macOS the audio device scan triggers the microphone-permission prompt — notifications never record audio.
  • Per-event toggles:
    • Agent asks question
    • Error occurred
    • Task completed
    • Warning
    • Info
    • Attention (agent needs you)
  • Test buttons — Test each sound individually. The Test button bypasses the anti-spam rate limit, so rapid A/B volume comparisons always play.
  • Silence orchestration completions — Remote HTTP/MCP workers still appear in Activity and update their tab state, but do not play a completion chime. The remote classification survives frontend reloads, and each busy cycle can notify at most once even when idle and process exit arrive separately.
  • Reset to Defaults — Restore default notification settings
  • Keep toasts in the bell — Each toast is also written to a MESSAGES section in the toolbar bell, so a message that faded while you looked at another window stays readable afterwards. The bell entry keeps the toast level (info / warning / error) and its action, if it had one. Turn this off to leave toasts transient. This setting is outside the audio block: the bell is visual, so it stays reachable on a machine with no audio output.

Attention is the distinct call-back-to-keyboard sound available to agent toasts. Native playback and the browser fallback share a triangular G4→G4→E5 motif with two short knocks and a longer rise; each engine applies its own envelope. It remains subject to the master toggle, configured volume, and its own per-event toggle; native playback also uses the selected output device.

AI Agents

TUICommander detects, monitors, and manages AI coding agents running in your terminals.

Supported Agents

AgentBinaryResume CommandSession Binding
Claude Codeclaudeclaude --continueclaude --resume $TUIC_SESSION
Codex CLIcodexcodex resume --lastcodex resume $TUIC_SESSION
Aideraideraider --restore-chat-history
Gemini CLIgeminigemini --resumegemini --resume $TUIC_SESSION
OpenCodeopencodeopencode -c
Ampampamp threads continue
Cursor Agentcursor-agentcursor-agent resume
Droid (Factory)droid
Goosegoosegoose session --resumegoose session --resume --name $TUIC_SESSION
Grokgrokgrok --continuegrok --resume <discovered id>
pipipi --continue

Agent Detection

TUICommander auto-detects which agent is running in each terminal by matching output patterns. Detection uses agent-specific status line markers:

  • Claude Code: Middle dot · (U+00B7), dingbat asterisks (U+2720–273F), or ASCII *
  • Copilot CLI: Therefore sign (U+2234), filled circle (U+25CF), empty circle (U+25CB)
  • Aider: Knight Rider scanner blocks ░█
  • Gemini CLI / Amazon Q / Cline: Braille spinners ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
  • Codex CLI: Bullets

When detected:

  • The status bar shows the agent’s brand logo and name
  • The tab indicator updates to reflect agent state
  • Rate limit and question detection activate for that provider’s patterns

Binary detection uses resolve_cli() — Rust probes well-known directories so agents are found even in release builds where the user’s shell PATH isn’t available.

Rate Limit Detection

When an agent hits a rate limit, TUICommander detects it from terminal output:

  • Status bar warning — Shows a badge with the number of rate-limited sessions and a countdown timer
  • Per-session tracking — Each session’s rate limit is tracked independently with automatic cleanup when expired
  • Provider-specific patterns — Custom regex for Claude (“overloaded”, “rate limit”), Gemini (“429”, “quota exceeded”), OpenAI (“too many requests”), and generic patterns

Question Detection

When an agent asks an interactive question (Y/N, multiple choice, numbered options), TUICommander:

  1. Changes the tab indicator to a ? icon
  2. Shows a prompt overlay with keyboard navigation:
    • ↑/↓ to navigate options
    • Enter to select
    • Number keys 1-9 for numbered options
    • Escape to dismiss
  3. Plays a notification sound (if enabled in Settings → Notifications)

For unrecognized agents, silence-based detection kicks in — if the terminal stops producing output for 10 seconds after a line ending with ?, it’s treated as a potential prompt. User-typed lines ending with ? are suppressed from question detection for 500ms (echo window) to avoid false positives from PTY echo.

Native Hook Instrumentation

Instead of inferring busy/idle/waiting from terminal output, TUICommander can drive an agent’s status directly from the agent’s own hook system. Enable it per agent in Settings → Agents → (expand an agent) → “Use native agent hooks for status”.

When enabled, TUIC writes small shell hooks into the agent’s settings file that emit OSC 7770;state=… on each lifecycle event (busy on prompt/tool start, awaiting on an approval/question prompt, idle on stop). The session state then follows the hooks precisely, and the heuristic question-detection above is suppressed for that agent (the silence-idle backstop stays on, so a crashed agent still recovers from “busy”).

Ownership is safe and reversible. Each managed hook carries a # tuic-managed-hook sentinel; enabling installs only TUIC’s entries and disabling removes only them — your own (and wiz/mdkb) hooks in the same file are never touched. The toggle is the source of truth; the effect applies on the agent’s next launch (hooks are read at startup).

AgentHooksStatus
Claude~/.claude/settings.jsonSupported
Gemini~/.gemini/settings.jsonSupported
Codex~/.codex/hooks.json + ~/.codex/config.toml ([features] hooks = true)Supported
Grok~/.grok/hooks/tuic.json (own file)Supported
OpenCode~/.config/opencode/plugin/tuic.ts (Bun/TS plugin)Supported
Others (Aider, Amp, Cursor, Goose, Droid)No hook system — stays heuristic

Platform note: Hook instrumentation is macOS/Linux only — it resolves the controlling tty via ps//dev/tty, which has no Windows equivalent. On Windows the toggle is hidden and agents keep heuristic detection (no regression).

Usage Limit Tracking

For Claude Code, TUICommander detects weekly and session usage limit messages from terminal output:

  • Unified agent badge — When Claude is the active agent, the status bar shows a single badge combining the agent icon with usage data. The badge displays rate limit countdowns (when rate-limited), Claude Usage API data (5h/7d utilization percentages), or terminal-detected usage limits, in that priority order.
    • Blue: < 70% utilization
    • Yellow: 70–89%
    • Red (pulsing): >= 90%
  • Clicking the badge opens the Claude Usage Dashboard.

This helps you pace your usage across the week.

Claude Usage Dashboard

A native feature (not a plugin) that provides detailed analytics for your Claude Code usage. Enable it in Settings > Agents > expand Claude Code > Features > Usage Dashboard.

When enabled, TUICommander polls the Claude API every 5 minutes and shows:

  • Rate limits — 5-hour and 7-day utilization bars with reset countdowns. Color-coded: green (OK), yellow (70%+), red (90%+).
  • Usage Over Time — 7-day token usage chart (input vs. output tokens) with hover tooltips.
  • Insights — Session count, message counts, input/output/cache token totals.
  • Activity heatmap — 52-week GitHub-style heatmap of daily message counts with per-project drill-down on hover.
  • Model usage — Breakdown by model (messages, input, output, cache created, cache read).
  • Per-project breakdown — All projects ranked by token usage. Click a project to filter the dashboard to that project.

The dashboard opens as a tab in the Activity Center. You can also reach it by clicking the Claude usage badge in the status bar.

Agent Teams

Agent Teams lets Claude Code spawn teammate agents as TUIC terminal tabs. Enable it in Settings > Agents > Agent Teams.

When enabled, PTY sessions receive the CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 environment variable, which unlocks Claude Code’s TeamCreate, TaskCreate, and SendMessage tools. Agent spawning uses direct MCP tool calls (agent spawn) — the earlier it2 shim approach (iTerm2 CLI emulation) is deprecated.

Spawned sessions automatically emit lifecycle events (session-created, session-closed) so they appear as tabs and clean up on exit.

Session Binding (TUIC_SESSION)

Every terminal tab has a stable UUID that persists across app restarts. This UUID is injected into the PTY shell as the TUIC_SESSION environment variable.

How It Works

  1. When a terminal tab is created, a UUID is generated via crypto.randomUUID()
  2. The UUID is saved with the tab and restored when the app restarts
  3. On PTY creation, the UUID is injected as TUIC_SESSION=<uuid> in the shell environment
  4. Agents can use $TUIC_SESSION for session-specific operations

Use Cases

Automatic session binding (Claude Code):

Shell integration automatically injects --session-id $TUIC_SESSION into every claude invocation via a shell function wrapper. You don’t need to pass it manually — just type claude and the session is bound to this tab. The wrapper is bypassed when you explicitly pass --session-id, --resume, or --continue.

# These are equivalent — the wrapper handles it transparently:
claude                              # wrapper adds --session-id $TUIC_SESSION
claude --session-id $TUIC_SESSION   # explicit, wrapper bypassed

Claude Code stores the session locally. When you restart TUICommander and switch to this branch, the session resumes automatically via claude --resume <uuid>.

Automatic session binding (Goose):

Shell integration injects --name $TUIC_SESSION into goose session and goose run subcommands. The wrapper is bypassed when you explicitly pass --name, -n, --resume, or -r.

# These are equivalent:
goose session "fix the bug"                               # wrapper adds --name $TUIC_SESSION
goose session --name $TUIC_SESSION "fix the bug"          # explicit, wrapper bypassed

Gemini CLI session binding (manual):

gemini --resume $TUIC_SESSION

Custom scripts that persist state per-tab:

# Use TUIC_SESSION as a stable key for any tab-specific state
echo "Last run: $(date)" > "/tmp/tuic-$TUIC_SESSION.log"

Automatic Resume

When TUICommander restores saved terminals after a restart, only tabs that had an active agent session (agentType set) are restored. Plain shell tabs are discarded and a fresh terminal is spawned instead. For agent tabs, TUICommander checks whether the session file exists on disk before deciding the resume strategy:

  1. Verified session — If $TUIC_SESSION maps to an existing session file (e.g. ~/.claude/projects/…/<uuid>.jsonl), the agent resumes with --resume <uuid>
  2. No session file — Falls back to the agent’s default resume behavior (e.g. claude --continue for the last session)

The resume command honours the agent’s default run config: TUICommander swaps the binary in the resume command (claude) for the run config’s command (e.g. c2) and appends the run config’s args after the resume flag. So a user with the default run config c2 --model claude-opus-4-6 will resume with c2 --resume <uuid> --model claude-opus-4-6, not claude --resume <uuid>.

UI Agent Spawn

When you spawn an agent via the context menu or command palette, TUICommander automatically uses the tab’s TUIC_SESSION as the --session-id. This ensures the spawned session is bound to the tab and will resume correctly on restart.

When the run config’s command is a custom alias, symlink, or wrapper (e.g. c2, c), the foreground-process name no longer matches "claude" in classify_agent. TUICommander compensates by pre-seeding the session’s agent_type from the run config at PTY creation time, so intent/suggest parsing and tab-title binding work from the first output line. The foreground-process detector also falls back to the pre-seeded type whenever it sees a non-shell process it doesn’t recognise, which covers aliases and wrapper scripts without requiring every name to be hardcoded.

Unsafe Mode (Unrestricted)

The AI Agent loop can run in unrestricted mode, bypassing the SafetyChecker approval flow and FileSandbox path jail. Toggle via the lock icon in the AI Chat panel header — a confirmation dialog warns that “The agent will skip all approval prompts and operate without sandbox restrictions” before activating. The header turns red to indicate the mode is active.

Use this for trusted automation tasks where approval prompts would slow down the workflow (e.g. batch refactoring inside a known repo). Unrestricted mode is per-session and resets when the agent loop ends.

Agent Cost Tracking

The AI Chat panel shows a live usage footer at the bottom of each conversation:

  • Prompt tokens (↑N) — input tokens sent to the provider
  • Completion tokens (↓N) — output tokens received
  • Estimated cost ($X.XXXX) — calculated from the provider’s per-token pricing
  • Cache hit rate — percentage of prompt tokens served from cache (when the provider supports it)

Costs are tracked per-session and reset when a new conversation starts.

Agent Model Overrides per Task Phase

The agent loop can use different models for different tool phases, optimizing cost/quality trade-offs:

PhaseDescriptionExample model
planGoal decomposition, next-step reasoningOpus, GPT-4o
searchsearch_files, search_code, list_filesHaiku, GPT-4o-mini
readread_screen, read_file, get_state, get_contextHaiku, GPT-4o-mini
writesend_input, send_key, write_file, edit_file, run_commandSonnet, GPT-4o

Configure in Settings > AI Chat > Agent model overrides. When no override is set for a phase, the default model is used.

Cron Scheduler

Time-triggered agent tasks that run on a schedule. Define jobs in Settings > AI Chat > Scheduler:

  • Cron expression — standard cron syntax (e.g. 0 */2 * * * for every 2 hours)
  • Goal — the agent goal to execute when the schedule fires

Jobs are persisted to <config_dir>/ai-cron.json. The scheduler ticks every 30 seconds and launches agent loops on matching terminals. Cron expressions are validated before saving.

Tauri commands: load_scheduler_config, save_scheduler_config.

Sleep Prevention

When agents are actively working, TUICommander can keep your machine awake:

  • Enable in SettingsGeneralPrevent sleep when busy
  • Uses the keepawake system integration
  • Automatically releases when all agents are idle

Environment Flags

Per-agent environment variables can be injected into every new terminal session. Configure in Settings > Agents > expand an agent > Environment Flags.

This is useful for enabling feature flags (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1) without manually running export commands. Flags are organized by category with toggle, enum, and number types.

Tips

  • Multiple agents on the same repo — Use split panes (Cmd+\) to run two agents side by side on the same branch
  • Different agents per branch — Each worktree is independent, so you can run Claude on one branch and Aider on another
  • Monitor all at once — Use the Activity Dashboard (Cmd+Shift+A) to see every terminal’s agent status in one view

Agent Teams

Agent Teams let Claude Code spawn teammate agents that work in parallel, each in its own TUICommander terminal tab. Teammates share a task list, communicate directly with each other, and coordinate autonomously.

How It Works

TUICommander automatically injects CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 into every PTY session. This unlocks Claude Code’s TeamCreate, TaskCreate, and SendMessage tools. When Claude Code spawns a teammate, TUICommander creates a new terminal tab via its MCP agent spawn tool — no external dependencies required.

Setup

No configuration needed. Agent Teams is enabled by default for all Claude Code sessions launched from TUICommander.

To verify it’s active, check the environment inside any terminal:

echo $CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
# Should print: 1

Usage

Tell Claude Code to create a team using natural language:

Create an agent team with 3 teammates to review this PR:
- One focused on security
- One on performance
- One on test coverage

Claude Code handles team creation, task assignment, and coordination. Each teammate appears as a separate tab in TUICommander’s sidebar.

Claude Code supports two display modes for teammates:

ModeHow it worksRequirement
In-processAll teammates run inside the lead’s terminal. Use Shift+Down to cycle between them.None
Split panesEach teammate gets its own pane.tmux or iTerm2

TUICommander works with both modes. In-process mode is the default and requires no extra setup. With split panes, each teammate appears as a separate TUICommander tab.

Key Controls (In-process Mode)

KeyAction
Shift+DownCycle to next teammate
EnterView a teammate’s session
EscapeInterrupt a teammate’s current turn
Ctrl+TToggle the shared task list

What Teams Can Do

  • Shared task list — All teammates see task status and self-claim available work
  • Direct messaging — Teammates message each other without going through the lead
  • Plan approval — Require teammates to plan before implementing; the lead reviews and approves
  • Parallel work — Each teammate has its own context window and works independently

Good Use Cases

  • Code review — Split review criteria across security, performance, and test coverage reviewers
  • Research — Multiple teammates investigate different aspects of a problem simultaneously
  • Competing hypotheses — Teammates test different debugging theories in parallel and challenge each other
  • New features — Each teammate owns a separate module with no file conflicts

Limitations

Agent Teams is an experimental Claude Code feature. Current limitations:

  • No session resumption/resume does not restore in-process teammates
  • One team per session — Clean up before starting a new team
  • No nested teams — Teammates cannot spawn their own teams
  • Token cost — Each teammate is a separate Claude instance; costs scale linearly with team size
  • File conflicts — Two teammates editing the same file leads to overwrites; assign distinct files to each

Troubleshooting

Teammates not appearing as tabs:

  • Verify the env var is set: echo $CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS should print 1
  • Check that TUICommander’s MCP server is running (status bar shows the MCP icon)

Teammates not spawning at all:

  • Claude Code decides whether to create a team based on task complexity. Be explicit: “Create an agent team with N teammates”
  • Check Claude Code version: Agent Teams requires a recent version

Too many permission prompts:

  • Pre-approve common operations in Claude Code’s permission settings before spawning teammates

Inter-Agent Messaging

TUICommander includes a built-in messaging system that lets agents in different terminal tabs communicate directly. This works alongside (and independently from) Claude Code’s native Agent Teams messaging. There is no separate swarm action: callers compose the agent and session primitives documented below.

How It Works

Every PTY session gets a stable TUIC_SESSION UUID injected as an environment variable. Agents use this as their identity to register, discover peers, and exchange messages through TUICommander’s MCP agent tool.

When a channel-enabled Claude Code worker is connected via SSE and already working, messages are pushed in real-time into that turn as MCP channel notifications (notifications/claude/channel). Idle or completed ordinary managed agents use terminal submission to start a real next turn; managed non-Claude workers do the same even when their MCP bridge has an SSE stream. Every message also lands in a buffered inbox.

Once a registered peer spawns a managed child through TUICommander, it is treated as an orchestrator. Peer results and lifecycle payloads stay in its inbox: a working orchestrator is never injected or steered, while an idle/completed orchestrator receives only one coalesced notice that a message is available and should be read with agent action=inbox ([TUIC] message available …). When that notice would cover nothing but child lifecycle events, it prints them instead ([TUIC] child agent 8c261794 is now idle; child agent 8c261794 exited (exit 0)) and no inbox read is needed — a peer message anywhere in the batch sends you back to the generic notice. An active agent wait receives the mail directly and suppresses that notice.

If writing that payload-free notice has an uncertain outcome, TUICommander retries it at most once after five seconds. A second uncertain result exhausts the wake budget for the whole unread-mail group; later and newly coalesced mail stays inbox-only until a successful agent action=inbox or agent action=wait observation clears the group. Later mail then receives a fresh initial wake and one-retry budget.

What Gets Injected Automatically

TUICommander injects these into every Claude Code PTY session — no manual configuration needed:

Variable / FlagValuePurpose
TUIC_SESSIONStable UUID per tabAgent identity for messaging
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS1Unlocks TeamCreate/TaskCreate/SendMessage
--dangerously-load-development-channels server:tuicommander(CLI flag, agent spawn only)Enables real-time channel push from TUICommander

Messaging Flow

Identity is automatic. The bridge asserts your $TUIC_SESSION at connect (x-tuic-session header → server auto-bind), so an agent spawned inside TUICommander is already a registered peer. agent action=register is only needed to set a friendly name/project, or from a standalone/external session where the env-var route is unavailable. External MCP clients do not need a plain-shell identity tab: call agent action=register without tuic_session to receive an MCP-scoped UUID, or supply an explicit UUID when the same identity must be reclaimed after reconnect. Reconnecting under a new UUID? Add replaces="<old-uuid>" or the old inbox is left behind — nothing links the two identities otherwise, and TUIC never guesses one from a name. The reply reports mail_migrated, or mail_stranded with a warning when the old identity still has a live terminal of its own.

Prefer blocking waits over polling. agent action=wait returns as soon as new mail arrives; session action=wait session_id=<id> until=idle|exited blocks on a peer’s lifecycle. Both default to 60 seconds, cap at 300000 ms, and return {met, timed_out}. They are event-driven end to end; the bridge deadline follows the requested wait instead of its ordinary ten-second timeout. A successful agent wait also returns every retained fresh message (up to the 100-message inbox capacity) and a per-recipient logical next_since cursor, so the normal path needs no separate inbox call. Ordinary idle workers receive direct terminal delivery. An idle orchestrator instead receives a payload-free agent action=inbox wake; an active wait suppresses it.

  1. Register (optional — sets name/project) — the agent reads its $TUIC_SESSION:

    agent action=register tuic_session="$TUIC_SESSION" name="worker-1" project="/path/to/repo"
    
  2. Discover peers — Find other agents connected to TUICommander:

    agent action=list_peers
    agent action=list_peers project="/path/to/repo"   # filter by repo
    
  3. Send a message — Address by the recipient’s tuic_session UUID:

    agent action=send to="<recipient-tuic-session>" message="PR review done, 3 issues found"
    
  4. Wait for and receive messages — one blocking call returns the message bodies:

    agent action=wait
    

    Omit since: the server remembers where you got to and resumes from there, so a plain wait never re-reads mail you already saw. Pass it only to override — since=0 deliberately replays the whole inbox. next_since comes back on every response, timeouts included.

  5. Check inbox directly — useful after a reported FIFO eviction or if channel push was missed:

    agent action=inbox
    agent action=inbox limit=10 since=1712000000000
    

agent action=send also returns recipient_state with the recipient’s shell_state and agent_state when the recipient is a managed PTY. External generated peers omit this field.

Automatic lifecycle notifications contain state only (idle, completed, or exited). They do not contain the worker’s result. Every worker reports completed output or a real blocker with agent action=send; use session action=output only to investigate the anomaly where a child did not send that report.

Channel Push vs Inbox

DeliveryWhenLatencyRequires
Channel pushOrdinary Claude worker has an active turn and SSE streamReal-time--dangerously-load-development-channels server:tuicommander on the recipient’s CC process
Orchestrator wakeRegistered parent is idle/completed and not waitingReal-time, coalescedManaged PTY + authoritative lifecycle
Inbox bufferAlwaysPoll-basedRegistration only

Messages are always buffered in the inbox regardless of whether another delivery path succeeds. The inbox holds up to 100 messages per agent (FIFO eviction). Individual messages are capped at 64 KB.

Using Messaging from a Standalone Claude Code Session

If you run Claude Code outside TUICommander but still want to use TUIC messaging:

  1. Connect to TUIC’s MCP server — the MCP channel is a Unix socket (Windows: named pipe), reached through the tuic-bridge stdio adapter, not a TCP port. TUICommander auto-installs this entry into each supported agent’s config; to add it by hand:

    {
      "mcpServers": {
        "tuicommander": {
          "command": "tuic-bridge",
          "args": []
        }
      }
    }
    

    The bridge finds the socket via TUIC_SOCKETmcp.sock → any mcp-*.sock in the config dir.

  2. Register identity — omit the UUID for a generated identity scoped to this MCP connection:

    agent action=register name="external-reviewer" project="/path/to/repo"
    

    Pass tuic_session="<stable-uuid>" instead when a future reconnect must reclaim the same identity. Registration never creates a PTY.

  3. Enable channel push (optional, for real-time delivery):

    claude --dangerously-load-development-channels server:tuicommander
    

Messaging vs Claude Code Native SendMessage

FeatureTUIC MessagingCC Native SendMessage
TransportMCP tool call → server-side routingFile append + polling (~/.claude/teams/)
Real-time pushYes (MCP channel notifications)No (polling only)
Cross-appAny MCP client can participateClaude Code processes only
Discoverylist_peers with project filterTeam config file
PersistenceIn-memory ring buffer (lost on TUIC restart)Files on disk (survives restart)

Both systems work simultaneously. Claude Code agents spawned by TUICommander can use either or both.

Deprecated: it2 Shim

Earlier versions of TUICommander used an it2 shell script shim that emulated iTerm2’s CLI to intercept teammate creation. This approach is deprecated — teammate spawning now uses direct MCP tool calls (agent spawn). The shim at ~/.tuicommander/bin/it2 is no longer needed.

AI Chat

AI Chat is a conversational AI companion that lives next to your terminals and sees what they see. Unlike spawning a full agent (Claude Code, Aider, …) in a PTY, the chat panel gives you a quick explain / summarise / suggest loop that shares the exact screen state of your active terminal.

Two progressive capability levels ride on the same panel:

LevelWhat it does
Chat (default)Streaming Q&A with terminal context injection. You ask, the model sees the last N lines, replies in markdown. “Run this” and “Copy” on every code block.
Agent (ReAct loop)The model acts: it reads the screen, sends input/keys, waits for patterns, asks for approval before destructive commands. Pause / resume between iterations. Built on six tools exposed both internally and as ai_terminal_* MCP tools.

The same panel switches modes — no separate UI.

Opening the panel

  • Hotkey: Cmd+Alt+A (macOS) / Ctrl+Alt+A (others) — toggle.
  • Toolbar: chat icon in the right section of the toolbar.
  • Context menu: right-click a terminal → Send selection to AI Chat or Explain this error.

The panel docks on the right. Width is remembered per window (aiChatPanelWidth).

Providers

AI Chat speaks to four provider families plus a custom endpoint. Switch in Settings > AI Chat > Provider:

ProviderDefault base URLNotes
Ollama (local)http://localhost:11434/v1/Auto-detected — the settings tab shows live status and the model list pulled from GET /api/tags. No API key required.
Anthropichttps://api.anthropic.comDirect Messages API. API key from Anthropic console.
OpenAIhttps://api.openai.com/v1Chat Completions.
OpenRouterhttps://openrouter.ai/api/v1Single key, many models.
Custom(editable)Any OpenAI-compatible endpoint.

Model recommendations

Use caseLocal (Ollama)API
Enrichment / triageQwen 2.5 Coder 3B (Q4_K_M)Haiku, GPT-4o-mini
Explain output, quick Q&AQwen 2.5 7B, Llama 3.3 8BHaiku, GPT-4o-mini
Generate commands, review diffsQwen3-Coder 14BSonnet, GPT-4o
Agent loop (tool calling)DeepSeek R1 32B, Qwen 27BSonnet, Opus

API keys are stored in the OS keyring under service tuicommander-ai-chat — never written to disk in plaintext.

Local MLX models (Apple Silicon)

On Apple Silicon Macs, MLX models run natively via the MLX framework and are significantly faster than GGUF models for small inference tasks like enrichment and triage.

Option A: mlx_lm.server (recommended for enrichment)

Serves an MLX model with an OpenAI-compatible API. Configure as a Custom provider in TUIC.

# Install
pipx install mlx-lm

# Start server (port 8899, Qwen 2.5 Coder 3B 4-bit)
mlx_lm.server --model mlx-community/Qwen2.5-Coder-3B-Instruct-4bit --port 8899

In Settings > AI Chat > Providers, add a Custom provider with base URL http://127.0.0.1:8899/v1/ and model name mlx-community/Qwen2.5-Coder-3B-Instruct-4bit. Assign it to the Triage slot.

Benchmark (M4): ~120 tok/s generation vs ~98 tok/s for the same model via Ollama GGUF.

Option B: Ollama with MLX tags

Some models have native MLX tags on the Ollama registry (e.g. qwen3.5:4b-mlx-bf16). These run on Ollama’s built-in MLX runner automatically. Check available tags with ollama pull <model>:<size>-mlx-<dtype>.

Option C: MLX → GGUF conversion

For MLX models without Ollama MLX tags, convert to GGUF and import into Ollama:

# 1. Download MLX model
pipx run --spec huggingface_hub hf download \
  mlx-community/Qwen2.5-Coder-3B-Instruct-4bit \
  --local-dir /tmp/model-mlx

# 2. Dequantize MLX → HF safetensors
mlx_lm.convert --hf-path /tmp/model-mlx --mlx-path /tmp/model-hf -d

# 3. Convert HF → GGUF (requires llama.cpp + torch)
git clone --depth 1 https://github.com/ggerganov/llama.cpp /tmp/llama.cpp
cd /tmp/llama.cpp && cmake -B build -DGGML_METAL=ON && cmake --build build --target llama-quantize -j
python3 convert_hf_to_gguf.py /tmp/model-hf --outtype f16 --outfile /tmp/model-f16.gguf
./build/bin/llama-quantize /tmp/model-f16.gguf /tmp/model-q4.gguf Q4_K_M

# 4. Import into Ollama
cat > /tmp/Modelfile <<'EOF'
FROM /tmp/model-q4.gguf
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
"""
PARAMETER stop "<|im_end|>"
PARAMETER stop "<|endoftext|>"
EOF
ollama create my-model -f /tmp/Modelfile

Note: double quantization (MLX 4-bit → bf16 → GGUF Q4_K_M) introduces quality loss. Prefer Option A for MLX models or pull the native GGUF from Ollama when available (ollama pull qwen2.5-coder:3b).

Context injection

Every turn the backend assembles a compact context from the currently-attached terminal:

  • Clean screen text — last context_lines rows from the VtLogBuffer (ANSI-stripped, TUI alternate-screen suppressed). Default: 150. Tune in settings.
  • Session state — shell busy / idle, CWD, last exit code, detected agent type, terminal mode (Shell vs FullscreenTui).
  • Recent parsed events — errors, questions, rate limits, status lines.
  • Git context — branch, short diff stats, staged file list (same variables Smart Prompts use).
  • Session knowledge (when the agent has been active) — compact markdown summary of recent command outcomes, error→fix pairs, TUI apps seen.

The panel follows the focused terminal automatically — the header shows the active terminal’s name as a badge. When no terminal is focused (e.g. a Git or settings tab is active), the panel enters frozen state: a banner reads “No terminal focused — chat is read-only”, the input placeholder changes to “Focus a terminal first…”, and the send button is disabled. Focus any terminal tab to resume.

Conversations

  • Per-terminal state — each terminal tab maintains its own independent chat history, streaming state, and conversation ID (keyed by tuicSession). Switching tabs switches the conversation. Messages sent from a tab always target that tab’s PTY session.
  • Hard cap: 100 messages per conversation in memory; older messages are evicted FIFO. Saved conversations keep the full history on disk.
  • Streaming uses a Tauri Channel<ChatStreamEvent> — you see tokens as they arrive. Cancel mid-stream with the stop button or cancel_ai_chat.
  • Conversation history panel — click the clock/history icon in the header to open a slide-in list of all saved conversations. Each row shows the title, terminal session name, message count, and date. Click a row to load that conversation into the current terminal’s chat.

Run-this, copy, and actions

Every fenced code block in the AI reply has a small toolbar:

ActionEffect
RunSends the block to the attached terminal via sendCommand() (handles Ink raw mode). Disabled when no terminal is attached.
CopyClipboard.
InsertPrepends the block to the current prompt input (for refinement).

Language hints in the fence control button visibility — a ```text block hides Run.

Agent mode (ReAct)

Flip the panel into agent mode via the header toggle or the command palette (Agent: start). Give it a goal (“set up pnpm and install deps”, “fix the failing test”) and press Enter. The loop:

  1. Assemble context.
  2. Ask the LLM with six tools available: read_screen, send_input, send_key, wait_for, get_state, get_context.
  3. Dispatch tool calls — each appears as a collapsible card in the panel.
  4. Record outcomes into the session knowledge store.
  5. Stop on end_turn or when cancelled.

Safety gates

The SafetyChecker trait inspects every would-be send_input. Three verdicts:

  • Allow — common commands, ls, git status, cargo build, editor launches …
  • NeedsApproval — destructive patterns (rm -rf, git reset --hard, git push --force, DROP TABLE, dd of=, package uninstall …). The panel shows a Pending approval card with a one-line reason. Approve / reject with a click or approve_agent_action.
  • Block — hard-coded refusals (e.g. rm -rf /, :(){ :|:& };:).

Pause / resume any time — the loop cleanly stops between iterations.

Session knowledge

As the agent runs, the SessionKnowledgeBar footer shows live telemetry:

  • Commands run this session (count).
  • Last 5 outcomes with kind badges (Success / Error / TuiLaunched / Timeout …).
  • Last 5 errors with inferred error_type (e.g. rust-error-borrow, npm-missing-module).
  • TUI mode indicator + list of TUI apps seen.

OSC 133 semantic prompts (OSC 133;A/B/C/D) feed accurate exit codes when the shell supports them (modern bash/zsh/fish with the integration enabled). Without OSC 133, the PTY silence timer records an Inferred outcome so the loop still learns.

Knowledge persists to <config_dir>/ai-sessions/<session_id>.json with a 2 s debounced background flush. Reopening a session rehydrates the store.

Knowledge history overlay

Click History next to the SessionKnowledgeBar to open a two-pane browser over every persisted session on disk — not just the currently active one. Useful for “find the command that fixed the build error last week”:

  • Sessions list (left) — sorted by most recent activity, showing command count, error count, and last CWD.
  • Detail pane (right) — one card per command with kind badge, timestamp, exit code, duration, CWD, output snippet, and a copy button.
  • Filters — debounced full-text search (matches command, output, inferred error_type, and semantic_intent), errors only checkbox, date window (24h / 7d / 30d / all).

Esc closes. Backed by the list_knowledge_sessions + get_knowledge_session_detail Tauri commands.

External MCP surface (ai_terminal_* tools)

The same six ReAct tools are exposed to external MCP clients (Claude Code, Cursor, …) through the TUICommander MCP server:

ToolPurpose
ai_terminal_read_screenLast N rows of clean text (secrets redacted).
ai_terminal_send_inputSend a command — always prompts for user confirmation.
ai_terminal_send_keySend a single special key — always prompts for confirmation.
ai_terminal_wait_forWait for regex match or screen stability.
ai_terminal_get_stateStructured SessionState.
ai_terminal_get_contextCheap orientation: shell state, cwd, git branch, last exit code, agent type.

Input tools are refused while the internal agent loop is active on that session, so an external agent can’t fight the internal one for the same PTY.

Settings reference (Settings > AI Chat)

FieldStored inNotes
Providerai-chat-config.jsonollama / anthropic / openai / openrouter / custom
Modelai-chat-config.jsonFree-text; settings tab populates suggestions per provider
Base URLai-chat-config.jsonPre-filled per provider, editable
Temperatureai-chat-config.jsonDefault 0.7
Agent model overridesai-chat-config.json (agent_model_overrides)Per-task-phase model routing. Keys: plan, search, read, write (matching ToolPhase). Values: model name strings. When set, the agent loop selects the model based on the current tool phase instead of using a single model for all iterations.
Context linesai-chat-config.jsonDefault 150. Raise for richer context, lower for smaller prompts.
API keyOS keyring (tuicommander-ai-chat / api-key)Masked with eye-toggle. “Test connection” validates the key + base URL.
Experimental: enrich command blocksai-chat-config.json (experimental_ai_block_enrichment)Default off. When on, each completed OSC 133 block is sent to the provider for a one-line semantic_intent. Rate-limited to ~10/min, silent on failure.

Keyboard shortcuts

ShortcutAction
Cmd+Alt+AToggle panel
Cmd+Enter (panel focused)Send message
Esc (panel focused)Cancel in-flight stream
(palette) Agent: start / stop / pause / resumeAgent-mode control

Files & storage

PathPurpose
<config_dir>/ai-chat-config.jsonProvider, model, base URL, temperature, context budget
<config_dir>/ai-chat-conversations/<id>.jsonSaved conversation bodies
<config_dir>/ai-sessions/<session_id>.jsonPer-session knowledge store (browsable from the History overlay)
OS keyring (tuicommander-ai-chat / api-key)Provider API key

See also

Smart Prompts

One-click AI automation for common git and code tasks. Smart Prompts inject context-aware commands into your active agent terminal, run headless one-shot operations, execute shell scripts directly, or call LLM APIs.

What are Smart Prompts?

Smart Prompts are context-aware automation shortcuts that turn repetitive developer workflows into single-click actions. They automatically resolve git context (branch, diff, changed files, PR data) and deliver a well-crafted prompt to your AI agent — no manual typing, no copy-pasting, no context switching.

TUICommander ships with 24 built-in prompts covering commit workflows, code review, PR management, CI fixes, and code investigation. Each prompt includes a description explaining what it does, so you always know what will happen before clicking.

How to Use

Toolbar Dropdown

Press Cmd+Shift+K (Ctrl+Shift+K on Windows/Linux) or click the lightning bolt icon in the toolbar. The dropdown shows all enabled prompts grouped by category with a search bar at the top.

Git Panel — Changes Tab

The SmartButtonStrip appears above the changed files list. Quick access to prompts like Smart Commit, Review Changes, and Write Tests.

PR Detail Popover

Click a PR badge in the sidebar to open the popover. The SmartButtonStrip shows PR-specific prompts: Review PR, Address Review Comments, Fix CI Failures, Update PR Description.

Command Palette

Open with Cmd+P and type “Smart” to see all smart prompts prefixed with “Smart:”.

Branch Context Menu

Right-click a branch in the Branches tab (Cmd+G) for branch-specific prompts like Create PR, Merge Main Into Branch, and Summarize Branch.

Built-in Prompts

CategoryPrompts
Git & CommitSmart Commit, Commit & Push, Amend Commit, Generate Commit Message
Code ReviewReview Changes, Review Staged, Review PR, Address Review Comments
Pull RequestsCreate PR, Update PR Description, Generate PR Description
Merge & ConflictsResolve Conflicts, Merge Main Into Branch, Rebase on Main
CI & QualityFix CI Failures, Fix Lint Issues, Write Tests, Run & Fix Tests
InvestigationInvestigate Issue, What Changed?, Summarize Branch, Explain Changes
Code OperationsSuggest Refactoring, Security Audit

Customizing Smart Prompts

Open Settings > Smart Prompts to manage prompts:

  • Enable/disable individual prompts (disabled prompts are hidden from all UI surfaces)
  • Edit prompt content — built-in prompts show a “Reset to default” button to revert your changes
  • Create your own smart prompts with the same placement options and variable system
  • View each prompt’s placement (toolbar, git-changes, pr-popover, git-branches) and execution mode

Status Feedback

When prompts cannot execute, the dropdown shows a status banner at the top explaining why:

  • “No active terminal” — open a terminal first
  • “No AI agent detected” — the active terminal has no agent running
  • “Agent is busy” — wait for the current operation to finish

Items are visually dimmed but visible, so you can still browse what’s available.

Context Variables

Prompts use {variable_name} syntax. Most variables are auto-resolved at execution time — no manual input needed.

Git Context (from Rust backend)

VariableDescription
{branch}Current branch name
{base_branch}Default branch (main/master/develop)
{repo_name}Repository directory name
{repo_path}Full filesystem path to the repository root
{diff}Working tree diff (truncated to 50KB)
{staged_diff}Staged changes diff (truncated to 50KB)
{changed_files}Short status output
{commit_log}Last 20 commits
{last_commit}Last commit hash + message
{conflict_files}Files with merge conflicts
{stash_list}Stash entries

GitHub/PR Context (from frontend stores)

VariableDescription
{pr_number}PR number for current branch
{pr_title}PR title
{pr_url}GitHub pull request URL
{pr_state}PR state: OPEN, MERGED, or CLOSED
{pr_checks}CI check summary (e.g. “3 passed, 1 failed”)
{merge_status}Merge status: MERGEABLE, CONFLICTING, or BEHIND
{review_decision}Review status: APPROVED, CHANGES_REQUESTED, or REVIEW_REQUIRED

Agent/Terminal Context

VariableDescription
{agent_type}Active agent type (claude, aider, codex, etc.)
{cwd}Active terminal working directory

Manual Input Variables

VariableDescription
{issue_number}GitHub issue number to investigate

Variable Input Dialog

When a prompt contains variables that cannot be auto-resolved, a Variable Input Dialog appears before execution. Each field shows the variable name and a human-readable description, so you know exactly what to fill in. Pre-populated suggestions are shown where available.

Execution Modes

Inject Mode (Default)

The resolved prompt is written directly into the active terminal’s PTY — as if you typed it. The agent processes it like any other input. Before sending, TUICommander checks that the agent is idle (configurable per prompt).

Shell Script Mode

Executes the prompt content as a shell script directly — no AI agent involved. The content runs via the system shell (sh -c on macOS/Linux, cmd /C on Windows) in the active repository’s directory.

Useful for automating repetitive CLI tasks: pruning orphan branches, running linters, collecting metrics, or any command pipeline you’d otherwise type manually. Context variables like {branch} and {repo_path} are resolved before execution.

Output is routed based on the prompt’s output target (clipboard, toast, panel, or returned in result). Timeout: 60 seconds.

Headless Mode

Runs a one-shot subprocess without using the terminal. Useful for quick operations like generating a commit message. Output is routed to the clipboard or shown as a toast notification.

Setup: Go to Settings > Agents and configure the “Headless Command Template” for each agent type. The template uses {prompt} as a placeholder:

  • Claude: claude -p "{prompt}"
  • Gemini: gemini -p "{prompt}"

Without a template, headless prompts automatically fall back to inject mode.

Note: Headless mode is not available in the Mobile Companion (PWA) — prompts fall back to inject mode automatically.

API Mode

Calls LLM providers directly via HTTP API without terminal or agent CLI. Supports an optional system prompt per prompt. Output routed via the same output target options. Requires LLM API configuration in Settings > Agents.

Smart Prompts Library

The Smart Prompts Library stores and manages all your prompt templates — both the 24 built-in AI automation prompts and your custom templates. Prompts are injected directly into the active agent terminal or run headless for quick one-shot operations.

Looking for one-click AI automation? See Smart Prompts for the full guide on built-in automation prompts, context variables, and headless execution.

Opening the Drawer

  • Cmd+Shift+K — Toggle the prompt library drawer
  • Toolbar button — Prompt library icon in the main toolbar

Browsing and Searching

When the drawer opens, the search input is focused automatically. Type to filter prompts by name, description, or content. Matching is case-insensitive and searches all three fields simultaneously.

Use the category tabs to narrow the list:

TabShows
AllEvery saved prompt, sorted by most recently used
CustomUser-created prompts
FavoritesPrompts you have starred
RecentLast 10 prompts you used

Keyboard Navigation

KeyAction
/ Move selection up/down
EnterInsert selected prompt into terminal
Double-clickInsert and immediately execute (adds newline)
Ctrl+N / Cmd+NCreate a new prompt
Ctrl+E / Cmd+EEdit the selected prompt
Ctrl+F / Cmd+FToggle favorite on the selected prompt
EscapeClose the drawer

Creating a Prompt

  1. Open the drawer (Cmd+Shift+K) and click + New Prompt, or press Ctrl+N/Cmd+N
  2. Fill in the fields:
    • Name (required) — shown in the list
    • Description — optional subtitle, also searchable
    • Content (required) — the text to insert; use {{variable}} for dynamic values
    • Keyboard Shortcut — optional global shortcut to trigger this prompt directly
  3. Click Save

Editing and Deleting

  • Click the pencil icon on any prompt row, or select it and press Ctrl+E/Cmd+E
  • Click the trash icon to delete — a confirmation dialog appears before deletion

Variable Substitution

Use {{variable_name}} placeholders in prompt content. When you send a prompt that contains variables, a dialog appears asking you to fill in each value before injection.

cd {{project_dir}} && cargo test -- {{test_filter}}

Built-in Variables

These are resolved automatically by the backend when present:

VariableValue
{{diff}}Current git diff
{{changed_files}}List of changed files
{{repo_name}}Repository name
{{branch}}Current branch name
{{cwd}}Current working directory

Custom Variables

Any {{name}} not in the built-in list becomes a custom input field in the variable dialog. You can optionally add a description and default value per variable when editing the prompt — the description appears as placeholder text in the dialog.

Inserting with Variables

The variable dialog offers two actions:

  • Insert — writes the resolved text to the terminal input line (you can review before pressing Enter)
  • Insert & Run — appends a newline, sending the command immediately

Favorites and Pinning

Click the star icon on any prompt row to toggle its favorite status. Favorited prompts appear at the top of any list view with a prefix and are accessible via the Favorites category tab.

Recently Used

The Recent tab shows the last 10 prompts you sent, in order of use. Recency is also used to sort the All view — most recently used prompts appear first.

Sending to Terminal

Selecting a prompt (click or Enter) writes its content to the currently active terminal. If the prompt has no variables, it is injected immediately. If it does, the variable dialog appears first.

The drawer closes automatically after a successful injection and focus returns to the terminal.


Run Commands

A lighter-weight alternative for per-branch one-off commands:

  • Cmd+R — Run the saved command for the active branch
  • Cmd+Shift+R — Edit the command before running

Configure run commands in Settings → Repository → Scripts tab.

Voice Dictation

TUICommander includes local voice-to-text using Whisper AI. All processing happens on your machine — no cloud services.

Setup

  1. Open Settings → Dictation
  2. Enable dictation
  3. Download a Whisper model (recommended: large-v3-turbo, ~1.6 GB)
  4. Wait for download to complete (progress shown in UI)
  5. Optionally configure language and hotkey

Usage

Push-to-talk workflow:

  1. Hold the dictation hotkey (default: F5) or the mic button in the status bar
  2. Speak your text
  3. Release the key/button
  4. Transcribed text is inserted into the focused input element (textarea, input, or contenteditable). If no text input has focus, the text falls back to the active terminal PTY. The focus target is captured at key-press time.

The hotkey works globally — even when TUICommander is not focused.

Models

ModelSizeQuality
small~488 MBGood
small.en~488 MBGood (English-only)
large-v2~3.0 GBHighest accuracy (slow)
large-v3-turbo~1.6 GBBest (recommended, default)

Models are downloaded to <config_dir>/models/ and cached between sessions.

Languages

Auto-detect (default), or set explicitly: English, Spanish, French, German, Italian, Portuguese, Dutch, Japanese, Chinese, Korean, Russian.

Text Corrections

Configure word replacements applied after transcription:

SpokenReplaced with
“new line”\n
“tab”\t
“period”.

Add custom corrections in Settings → Dictation → Corrections.

Audio Device

Select which microphone to use from the dropdown in dictation settings. Lists all available input devices.

Platform Notes

  • macOS: GPU-accelerated transcription via Metal
  • Windows: GPU-accelerated transcription via Vulkan
  • Linux: CPU-only (optional CUDA/Vulkan build feature)
  • Microphone permission is requested on first use (not at app startup)

Status Indicators

IndicatorMeaning
Mic button (status bar)Click/hold to start recording
Recording animationAudio is being captured
Live level meter in the dictation previewThe selected microphone is receiving sound; it appears immediately while recording
Processing spinnerWhisper is transcribing
Model downloadingProgress bar with percentage

Hotkey Configuration

Change the push-to-talk hotkey in Settings → Dictation. The hotkey is registered globally via Tauri’s global-shortcut plugin.

Default: F5

Auto-Send

Enable ‘Auto-send’ in Settings > Dictation to automatically press Enter after the transcribed text is inserted into the terminal. Useful when dictating commands.

Branch Management

TUICommander has a built-in Branches tab inside the Git Panel. It lets you view, create, delete, rename, merge, rebase, push, pull, and compare branches — all without leaving the app.

Opening the Branch Panel

Three ways to open it:

  • Cmd+G — Opens the Git Panel directly on the Branches tab
  • Click the “GIT” vertical label in the sidebar — Opens the Git Panel on the Branches tab
  • Cmd+Shift+D, then click the “Branches” tab header — Opens the Git Panel on the last active tab; click Branches to switch

Branch List Overview

The panel shows two collapsible sections:

  • Local — all branches in your local repo
  • Remote — tracking branches from all remotes

Each branch row displays:

  • Branch name
  • Ahead/behind counts relative to its upstream (e.g. ↑2 ↓1)
  • Relative date of the last commit (e.g. “3h ago”, “2d ago”)
  • Merged badge — shown on branches already merged into the default branch
  • Stale dimming — branches with no commit in the last 30 days appear dimmed

A Recent Branches section at the top shows recently checked-out branches from the git reflog, for quick re-access.

Prefix Folding

When you have many branches following a naming convention (feature/, bugfix/, chore/), prefix folding groups them automatically:

  • Branches sharing a common /-delimited prefix collapse into a folder row (e.g. feature/ (5))
  • Click the folder row (or press / ) to expand or collapse it
  • The toggle button in the panel header enables or disables prefix folding globally

Search / Filter

Type in the search bar at the top of the panel to filter branches by name. The filter applies to all sections simultaneously. Press Escape to clear the search.

Keyboard Operations

The Branches panel is mouse-first: all branch actions live in the right-click context menu, the + (New branch) button, and row double-click. Only list navigation is on the keyboard:

KeyAction
/ Navigate branches
/Focus the filter
EscapeClear filter / deselect

Switching between Git Panel tabs:

KeyTab
Ctrl/Cmd+1Changes
Ctrl/Cmd+2Log
Ctrl/Cmd+3Stashes
Ctrl/Cmd+4Branches

Checkout

Double-click any branch (or right-click → Checkout) to check it out.

If your working tree has uncommitted changes, a dialog appears with three options:

  • Stash — automatically stashes changes, then checks out
  • Force — discards changes and checks out
  • Cancel — aborts the checkout

Create Branch

Click the + (New branch) button in the panel header to open the inline branch creation form:

  1. Type the new branch name
  2. Optionally change the start point (defaults to HEAD)
  3. Toggle “Checkout after create” (on by default)
  4. Press Enter to confirm or Escape to cancel

Delete Branch

Right-click a branch → Delete.

  • Uses safe delete (git branch -d) by default — refuses to delete unmerged branches
  • A confirmation prompt lets you switch to force-delete (git branch -D) if needed
  • Deleting the current branch or the default branch (main, master, develop) is blocked

Delete merged branches (bulk)

When one or more local branches are already merged into the default branch, a broom button (with a count badge) appears in the Branches tab header. Click it to delete them all at once, after confirming a dialog that lists the targets. Each deletion uses safe git branch -d, so a branch that isn’t truly merged is kept rather than force-deleted.

Rename Branch

Right-click a branch → Rename to edit the branch name inline. The current name is pre-filled. Press Enter to confirm or Escape to cancel.

Merge

Right-click a branch → Merge into Current to merge it into the current branch. The merge runs in the background, and the result is shown as a toast: a conflict error on failure, “Already up to date” when there was nothing to merge, or a success toast with a one-click Delete branch action to clean up the branch you just merged.

Rebase

Right-click a branch → Rebase Current onto This to rebase the current branch onto the selected branch. Runs in the background; conflicts are reported.

Push

Right-click a branch → Push. If no upstream is set, TUICommander automatically configures the tracking relationship (--set-upstream origin <branch>).

Pull

Right-click a branch → Pull to pull the current branch from its upstream.

Fetch

Right-click a branch → Fetch to fetch all remotes (git fetch --all).

Context Menu

Right-click any branch for the full context menu:

ActionDescription
CheckoutSwitch to this branch
Create Branch from HereCreate a new branch starting from this commit
DeleteDelete branch (safe by default)
RenameRename inline
Merge into CurrentMerge this branch into the current one
Rebase Current onto ThisRebase current branch onto this one
PushPush this branch
PullPull this branch
FetchFetch all remotes
CompareShow git diff --name-status between this branch and current

Stale and Merged Indicators

  • Stale (dimmed): the branch has no commits in the last 30 days — a visual cue that it may be abandoned
  • Merged badge: the branch has been merged into the default branch and is safe to delete

Git Worktrees

TUICommander uses git worktrees to give each branch an isolated working directory.

What Are Worktrees?

Git worktrees let you check out multiple branches simultaneously, each in its own directory. Instead of stashing or committing before switching branches, each branch has its own complete copy of the files.

How TUICommander Uses Them

When you click a non-main branch in the sidebar:

  1. TUICommander creates a git worktree for that branch
  2. A terminal opens in the worktree directory
  3. You work independently without affecting other branches

Main branches (main, master, develop) use the original repository directory — no worktree is created.

Worktree Storage Strategies

Configure where worktrees are stored (Settings → Git & GitHub → Worktree Defaults → Storage):

StrategyLocationUse case
Sibling (default){repo_parent}/{repo_name}__wt/Keeps worktrees near the repo
App directory~/Library/Application Support/tuicommander/worktrees/{repo_name}/Centralised storage
Inside repo{repo_path}/.worktrees/Self-contained, add to .gitignore
Claude Code default{repo_path}/.claude/worktrees/Compatible with Claude Code’s native EnterWorktree

Override per-repo in Settings → Repository → Worktree.

Creating Worktrees

From the + Button (with prompt)

Click + next to a repository name. A dialog opens where you can:

  • Type a new branch name (creates branch + worktree)
  • Select an existing branch from the list
  • Choose a “Start from” base ref (default branch, or any local branch)
  • Generate a random sci-fi name

From the + Button (instant mode)

When “Prompt on create” is off (Settings → Git & GitHub → Worktree Defaults), clicking + instantly creates a worktree with an auto-generated name based on the default branch.

From Branch Right-Click (quick-clone)

Right-click any non-main branch without a worktree → Create Worktree. This creates a new branch named {source}--{random-name} based on the selected branch, with a worktree directory.

Worktree Settings

Global defaults apply to all repos. Per-repo overrides take precedence when set.

Global Defaults (Settings → Git & GitHub → Worktree Defaults)

SettingOptionsDefault
StorageSibling / App directory / Inside repo / Claude Code defaultSibling
Prompt on createOn / OffOn
Delete branch on removeOn / OffOn
Auto-archive mergedOn / OffOff
Orphan cleanupAsk before removing / Auto-remove / KeepAsk
PR merge strategyMerge / Squash / RebaseMerge
After mergeArchive / Delete / AskArchive

Per-Repository Overrides (Settings → Repository → Worktree)

Each setting can use the global default or be overridden for a specific repository.

Merge & Archive

Right-click a worktree branch → Merge & Archive to:

  1. Merge the branch into the main branch
  2. Handle the worktree based on the “After merge” setting:
    • Archive: Moves the worktree directory to __archived/ — the whole directory, uncommitted changes included (accessible but removed from sidebar)
    • Delete: Removes the worktree and branch entirely. Anything not committed is gone
    • Ask: Merge succeeds, then you choose what to do

The merge uses --no-edit for a clean fast-forward or merge commit. If conflicts are detected, TUICommander attempts git merge --abort and leaves the worktree intact. If that abort fails, the error message tells you the repository may still be conflicted and includes the manual abort command.

Uncommitted work in the worktree

Both Archive and Delete remove the worktree, so TUICommander asks first whenever the worktree is not known to be clean — whether or not the branch carries commits, and whether the cleanup was started by hand or by Auto-archive merged. The confirmation names what happens to the work: archived files travel to __archived/, deleted files do not come back. If the check itself cannot run, that counts as “not clean” and the cleanup still stops.

The automatic sweep never asks — it keeps a dirty worktree and reports it in the status line (kept N with uncommitted work).

When using Ask mode, the cleanup dialog detects uncommitted changes and auto-stashes them during the branch switch. An “Unstash after switch” checkbox lets you restore changes on the target branch. That stash covers the base repository; the warning under the worktree step is about the branch’s own directory, which is a different place.

Archive Script

A per-repo lifecycle hook that runs before a worktree is archived or deleted. Configure it in Settings → Repository → Scripts tab, or via .tuic.json (archive_script field).

  • The script runs in the worktree directory that is about to be removed
  • If the script exits with a non-zero code, the archive/delete operation is blocked and an error is shown
  • Use cases: backing up local data, cleaning up resources, notifying external systems
  • The script is invoked via the platform shell (sh -c on macOS/Linux, cmd /C on Windows)

Moving Terminals Between Worktrees

Right-click a terminal tab → Move to Worktree to move it to a different worktree. The terminal will cd into the target worktree path, and the tab automatically reassigns to the new branch in the sidebar.

Also available via Command Palette — type “move to worktree” to see available targets for the active terminal.

Removing Worktrees

  • Sidebar × button on a non-main branch — Removes worktree and branch entry
  • Right-click → Delete Worktree — Context menu option
  • Both prompt for confirmation

Removing a worktree:

  1. Closes all terminals associated with that branch
  2. Runs git worktree remove to clean up
  3. Removes the branch entry from the sidebar
  4. If branch deletion was requested but git branch -d keeps the branch because it is not safely merged, shows a status message that the worktree was removed and the branch was kept

Worktree Manager Panel

Open the Worktree Manager with Cmd+Shift+W (or via the Command Palette → “Worktree Manager”). It shows a unified view of all worktrees across your repositories.

What It Shows

Each worktree row displays:

  • Branch name and repository badge
  • Dirty status — file additions/deletions, or “clean”
  • PR state — open (with PR number), merged, or closed
  • Last commit timestamp — relative time since last activity
  • Main badge — marks the main branch (actions disabled)

Orphan worktrees (detached HEAD or deleted branch) appear at the bottom with a warning badge and a Prune button to clean them up.

Filtering

  • Repo pills — Click a repository name to filter by repo (appears when you have multiple repos)
  • Text search — Type in the search field to filter branches by name
  • Filters compose: selecting a repo and typing text shows only matching branches in that repo

Single-Row Actions

Each worktree row has action buttons (visible on the right):

  • >_ — Open a terminal in the worktree directory
  • — Merge the branch into main and archive (disabled for main branches)
  • — Delete the worktree and branch (disabled for main branches)

Batch Operations

Select multiple worktrees using the checkboxes (shown when more than one selectable worktree exists). A batch bar appears with:

  • Merge & Archive (N) — Merges and archives all selected branches
  • Delete (N) — Deletes all selected worktrees

Use the Select All checkbox in the toolbar to toggle all non-main worktrees.

MCP Worktree Creation (AI Agents)

AI agents connected via MCP can create worktrees using repo action=worktree_create.

When TUICommander receives a worktree creation event while the active terminal is running an agent, the confirmation offers Open Worktree. Accepting selects an existing terminal in the new worktree or creates one when needed. The running agent stays in its original terminal, branch, and working directory; TUICommander does not relabel or interrupt it.

Claude Code — Agent Bridge

Claude Code cannot change its working directory mid-session. When CC creates a worktree via MCP, the response includes a cc_agent_hint field with:

  • worktree_path — Absolute path to the worktree directory
  • suggested_prompt — Instructions for spawning a subagent that works in the worktree using absolute paths

CC should spawn a subagent (Agent tool) with the suggested prompt. The subagent uses Read, Edit, Glob, Grep with absolute file paths and cd <path> && ... for shell commands.

Other MCP Clients

Non-Claude Code MCP clients receive the standard {worktree_path, branch} response without the cc_agent_hint field. These clients can change into the worktree directory directly.

External Worktree Detection

TUICommander monitors .git/worktrees/ for changes. Worktrees created outside the app (via CLI or other tools) are detected and appear in the sidebar after the next refresh.

Branch Switching

Switching branches in TUICommander does not change the working directory of existing terminals. Each branch’s terminals stay in their worktree path.

When you switch branches:

  • Previous branch’s terminals are hidden (but remain alive)
  • New branch’s terminals are shown
  • If the new branch has no terminals, a fresh one is created

GitHub Integration

TUICommander monitors your GitHub PRs and CI status automatically.

Authentication

TUICommander needs a GitHub token to access PRs and CI status. You have two options:

  1. Open Settings > GitHub
  2. Click “Login with GitHub”
  3. A code appears — it’s auto-copied to your clipboard
  4. Your browser opens GitHub’s authorization page
  5. Paste the code and authorize
  6. Done — the token is stored securely in your OS keyring

This method automatically requests the correct scope (repo) and works with private repositories and organization repos.

Option 2: gh CLI

If you prefer, install the gh CLI and run gh auth login. TUICommander will use the gh token automatically.

Token Priority

When multiple sources are available, TUICommander uses this priority:

  1. GH_TOKEN environment variable
  2. GITHUB_TOKEN environment variable
  3. OAuth token (from Settings login)
  4. gh CLI token

Multiple Accounts (github.com + GitHub Enterprise)

TUICommander is account-centric: you can monitor repos across several GitHub accounts at once — additional github.com logins and GitHub Enterprise Server (GHE) instances — and bind each repo to the account that should monitor it. If you only use one github.com account, nothing changes; skip this section.

Adding accounts

Open Settings > GitHub > Additional GitHub Accounts. The account you logged in with above is your default.

  • Another github.com account — click “Add another github.com account” and complete the device flow (same as the primary login).
  • GitHub Enterprise Server — under “Add Enterprise account”, enter the host (e.g. github.mycompany.com) and a Personal Access Token with repo scope, then click Add account. The PAT is validated against your GHE server and stored in the OS keyring. (GHE uses a PAT because there is no per-host OAuth App.)

Each account shows its login, avatar, and a source badge. Remove an account to delete its token, its repo bindings, and its cached state.

Binding repos to accounts

Open Settings > GitHub > Repository Bindings. Each workspace repo shows how it resolves:

  • Bound — the repo is monitored by the shown account. Click Unbind to detach it.
  • Needs binding — the repo matches more than one account or GitHub remote. TUICommander does not guess origin; pick the right account/remote from the chooser to bind it.
  • Needs account — a github.com repo with no account configured yet; add one first.

A repo with exactly one matching account is bound automatically. Worktrees share their main checkout’s binding.

How multiple accounts behave

  • Each account polls independently with its own rate-limit budget and circuit breaker, so a rate limit or auth failure on one account never stalls the others.
  • Limitation: CI Auto-Heal (which shells out to gh) is disabled for GHE-bound repos and shows a clear message. PRs, CI status, issues, merge, and approve all work against bound GHE repos.

Requirements

  • GitHub authentication (see above)
  • Repository with a GitHub remote

PR Monitoring

When you have a branch with an open pull request, the sidebar shows:

PR Badge

A colored badge next to the branch name showing the PR number:

ColorState
GreenOpen PR
PurpleMerged
RedClosed
Gray/dimDraft

Click the PR badge to open the PR detail popover.

CI Ring

A circular indicator showing CI check status:

SegmentColorMeaning
Green arcPassed checks
Red arcFailed checks
Yellow arcPending checks

The ring segments are proportional to the number of checks in each state. Click to see detailed CI check information.

Diff Stats

Per-branch addition/deletion counts shown as +N / -N next to the branch name.

PR Detail Popover

Click a PR badge or CI ring to open the detail popover. Shows:

  • PR title and number with link to GitHub
  • Author and timestamps (created, last updated)
  • State indicators:
    • Draft/Open/Merged/Closed
    • Merge readiness (Ready to merge, Has conflicts, Behind base, Blocked)
    • Review decision (Approved, Changes requested, Review required)
  • CI check details — Individual check names and status
  • Labels — With GitHub-matching colors
  • Line changes — Total additions and deletions
  • Commit count

PR Notifications (Toolbar Bell)

When any branch has a PR event that needs attention, a bell icon with a count badge appears in the toolbar.

Click the bell to see all active notifications in a popover list. Each notification shows the repo, branch, and event type.

Notification Types

TypeMeaning
MergedPR was merged
ClosedPR was closed without merge
ConflictsMerge conflicts detected
CI FailedOne or more CI checks failed
Changes Req.Reviewer requested changes
ReadyPR is ready to merge (all checks pass, approved)

Interacting with Notifications

  • Click a notification item — Opens the full PR detail popover for that branch
  • Click the dismiss (x) button on an item — Dismiss that single notification
  • Click “Dismiss All” — Clear all notifications at once

PR Badge on Sidebar Branches

Click the colored PR status badge on any branch in the sidebar to open the PR detail popover directly.

Polling

GitHub data is polled automatically:

  • Active window: Every 30 seconds
  • Hidden window: Every 2 minutes (reduced to save API budget)
  • API budget: ~2 calls/min/repo = 1,200/hr for 10 repos (GitHub limit: 5,000/hr)

Polling starts automatically when a repository with a GitHub remote is active.

Merge State Classification

StateLabelMeaning
MERGEABLE + CLEANReady to mergeAll checks pass, no conflicts
MERGEABLE + UNSTABLEChecks failingMergeable but some checks fail
CONFLICTINGHas conflictsMerge conflicts with base branch
BEHINDBehind baseBase branch has newer commits
BLOCKEDBlockedBranch protection prevents merge
DRAFTDraftPR is in draft state

CI Auto-Heal

Enable Auto-heal in a blocked PR’s detail popover to send failed GitHub Actions job logs to the agent terminal assigned to that branch. A completed failed job can trigger healing while other jobs in the same workflow are still running. The displayed three-attempt budget counts only prompts successfully delivered to the agent; log-fetch and terminal-delivery errors do not consume it.

Review State Classification

DecisionLabel
APPROVEDApproved
CHANGES_REQUESTEDChanges requested
REVIEW_REQUIREDReview required

Auto-Delete Branch on PR Close

When a PR is merged or closed on GitHub, TUICommander can automatically clean up the corresponding local branch. Configure per-repo in Settings > Repository Settings or set a global default in Settings > General > Repository Defaults.

ModeBehavior
Off (default)No action taken
AskShows a confirmation dialog before deleting
AutoDeletes silently; falls back to Ask if worktree has uncommitted changes

Safety guarantees:

  • The default/main branch is never deleted
  • If a branch has a linked worktree, the worktree is removed first
  • Uses safe git branch -d — refuses to delete branches with unmerged commits
  • Dirty worktrees (uncommitted changes) always escalate to Ask mode, even when set to Auto

Remote-Only Pull Requests

When a branch exists only on the remote (not checked out locally) but has an open PR, it still appears in the sidebar with a PR badge. These “remote-only” PRs support inline accordion actions:

  • Checkout — Creates a local tracking branch from the remote
  • Create Worktree — Creates a worktree for the branch

PR Detail Popover Actions

Clicking the PR badge on any branch (local or remote-only) opens the detail popover. Available actions:

ButtonWhen ShownWhat It Does
View DiffAlwaysOpens PR diff in a dedicated panel tab
MergePR is open, approved, CI greenMerges via GitHub API (auto-detects allowed merge method)
ApproveRemote-only PRsSubmits an approving review via GitHub API

Post-Merge Cleanup

After merging a PR from the popover, a cleanup dialog appears with checkable steps:

  1. Switch to base branch — if the working directory has uncommitted changes, they are automatically stashed. An inline warning shows with an optional “Unstash after switch” checkbox
  2. Pull base branch — fast-forward only
  3. Delete local branch — closes terminals first, refuses to delete default branch
  4. Delete remote branch — gracefully handles “already deleted”

Steps execute sequentially via the Rust backend (not PTY — your terminal may be occupied by an AI agent). Each step shows live status: pending → running → success/error.

Dismiss & Show Dismissed

Remote-only PRs can be dismissed from the sidebar to reduce clutter. A “Show Dismissed” toggle in the sidebar reveals them again.

GitHub Issues

The GitHub panel shows issues alongside PRs in a unified view.

Issue Filter

Control which issues appear using the filter dropdown in Settings > GitHub or directly in the panel:

FilterShows
Assigned (default)Issues assigned to you
CreatedIssues you opened
MentionedIssues that mention you
AllAll open issues in the repo
DisabledHides the issues section

The filter setting persists across sessions.

Issue Details

Expand an issue to see:

  • Labels with GitHub-matching colors
  • Assignees and milestone
  • Comment count and timestamps (created/updated)

Issue Actions

ActionDescription
Open in GitHubOpens the issue in your browser
Close / ReopenChanges issue state via GitHub API
Copy numberCopies #123 to clipboard

Panel Keyboard Navigation

The GitHub panel is keyboard-navigable without ever moving focus off the panel itself:

KeyAction
↓ / ↑Move between rows, walking across the My Pull Requests, Pull Requests and Issues sections in order. Rows of a collapsed section are skipped.
EnterExpand or collapse the highlighted row
EscapeCollapse an expanded issue, or close the panel when nothing is expanded

Section collapse state is remembered per section and survives closing the panel and restarting the app (stored in the UI prefs alongside panel widths). A section that has never been toggled keeps its own default: PR sections start collapsed when empty, Issues starts open.

Troubleshooting

No PR data showing:

  1. Check gh auth status — must be authenticated
  2. Check repository has a GitHub remote (git remote -v)
  3. Check that gh pr list works in the repo directory

Stale data:

  • Click the refresh button or switch away and back to the branch
  • Polling updates every 30 seconds automatically

tuic CLI

The tuic command line tool lets you control TUICommander from the terminal. It combines the best of VS Code’s code CLI, Zed’s editor integration, and tmux’s session management into a single binary.

Installation

From the app: Settings > General > Command Line Interface > Install tuic CLI

First launch: TUICommander offers to install the CLI on first run.

From the CLI itself: tuic install-cli

The binary is installed to:

  • macOS: /usr/local/bin/tuic (requires admin password)
  • Linux: /usr/local/bin/tuic (requires sudo)
  • Windows: %LOCALAPPDATA%\Microsoft\WindowsApps\tuic.exe (no admin needed)

The CLI auto-updates silently when TUICommander starts — no manual update needed.

Opening Files and Repos

# Open a file (launches TUICommander if not running)
tuic file.rs

# Open at specific line and column
tuic file.rs:42
tuic file.rs:42:10
tuic open --goto file.rs:42

# Open the current directory as a repo (adds it to the sidebar and activates it)
tuic .
tuic /path/to/project

# Open with --wait (for use as $EDITOR)
tuic open --wait file.rs

# Diff two files
tuic diff old.rs new.rs

A directory is treated as a repo, not as a terminal: it lands in the sidebar and becomes the active repo. A folder TUICommander does not know yet is confirmed once in the app before it is added — after that, tuic . activates it silently. Use tuic new when what you want is a shell.

Using as $EDITOR

export EDITOR="tuic open --wait"
git commit  # opens commit message in TUICommander

Session Management

These commands mirror tmux semantics:

# List all sessions (short IDs; --json for scripts)
tuic ls
tuic ls --json

# Create a new session
tuic new
tuic new -n "my-session"
tuic new -n "build" /path/to/repo

# Create a session and run something in it
tuic run pnpm dev
tuic run -n "tests" cargo nextest run

# Send input to a session
tuic send <id-or-name> "make test" Enter

# Capture session output
tuic capture <id-or-name>
tuic capture <id-or-name> -n 50          # last 50 lines
tuic capture <id-or-name> --format raw

# Kill a session
tuic kill <id-or-name>

# Resize a session
tuic resize <id-or-name> 120x40

# Pause/resume output
tuic pause <id-or-name>
tuic resume <id-or-name>

Session targets accept full UUIDs, ID prefixes (the short ID tuic ls prints), exact names, or a name prefix — case-insensitive. An ambiguous target is rejected rather than guessed.

Sending keys

Each argument is either a key name or literal text — matched whole, never as a substring, so tuic send build "Enter the room" types the sentence instead of pressing Return mid-word. Adjacent literals are joined with a single space.

Key names: Enter, Space, Tab, Escape, BSpace, Up, Down, Left, Right, Home, End, PageUp, PageDown, and any C-<letter> (C-c, C-d, C-u, …).

Agent Orchestration

# Spawn an AI agent (the prompt is required — the agent starts on it)
tuic agent spawn claude "review the failing tests"
tuic agent spawn codex "add a changelog entry" --repo /path/to/repo

# List running agents
tuic agent ls

# Deliver a message to a registered peer's INBOX (peer registry)
tuic agent send <peer-uuid> "fix the tests"

# Type a prompt into an agent's TERMINAL and submit it (no peer routing)
tuic agent type <id-or-name> "fix the tests"

Two delivery channels, chosen explicitly

tuic agent send and tuic agent type are not interchangeable, and neither guesses which one you meant.

CommandRouteTargetUse when
tuic agent sendpeer registry → recipient inboxa registered peer’s tuic_session UUIDthe recipient is an orchestrator or any peer, including one with no terminal of its own
tuic agent typePTY writea session ID or nameyou want the text to appear in a terminal and be submitted
tuic sendPTY writea session ID or nameraw keys, no agent framing (see Sending keys)

tuic agent send is the CLI counterpart of the MCP agent action=send tool and uses the same delivery path, so both report the same delivery_path and both land the payload exactly once. It exits non-zero — with the registry’s own message — when the recipient is not registered or the message is empty.

Acceptance is not delivery, and the output says which one you got:

Delivered to <peer> (sse_channel_and_inbox)

means something surfaced the message — a waiter, the SSE channel, or the recipient’s terminal. Whereas:

Buffered for <peer> (inbox_only) — unread until the recipient polls its inbox
warning: Recipient has NO terminal and no active wait: nothing will wake it. …

means the registry took the message but nothing will wake the recipient: it sits unread until that peer calls agent action=wait/inbox. Both exit 0, because the registry accepted the message in both cases — do not block on an answer after a Buffered line.

tuic agent type keeps the agent-safe framing: the text and the Enter are sent as separate PTY writes, because a raw-mode Ink TUI treats a combined text\r as a prefill and leaves it unsent. tuic send does not do this.

tuic agent send must run inside a TUICommander session (it reads $TUIC_SESSION to identify the sender). It binds that identity when it is free; when the agent in that pane is itself connected over MCP it already owns the identity, so the CLI registers an anonymous sender named <session> (cli) rather than stealing a live binding.

tmux Compatibility

tuic can act as a drop-in replacement for tmux. When invoked as tmux (via symlink), it translates tmux commands to TUICommander equivalents.

Setting Up the Alias

# Create tmux -> tuic symlink
tuic alias

# Remove the alias (restores original tmux if installed)
tuic alias --remove

Supported tmux Commands

When invoked as tmux, the following commands are supported:

tmux CommandBehavior
tmuxCreate new session in cwd
tmux new-session -s nameCreate named session
tmux list-sessionsList sessions
tmux kill-session -t targetKill session
tmux kill-serverKill all sessions
tmux send-keys -t target "cmd" EnterSend input
tmux capture-pane -t targetCapture output
tmux resize-pane -t target -x 120 -y 40Resize
tmux attach-sessionFocus TUICommander window
tmux has-session -t targetCheck if session exists (exit code)

Key names are translated: Enter, Space, Tab, Escape, C-c, C-d, C-z, etc.

System Commands

# Check TUICommander status — version, session/agent counts, and which
# sessions are waiting on you right now
tuic status

# Install CLI to system PATH
tuic install-cli
tuic install-cli --path /custom/path

# Create/remove tmux alias
tuic alias
tuic alias --remove

IPC Architecture

The CLI communicates with TUICommander via IPC:

  • macOS/Linux: Unix domain socket at ~/.config/com.tuic.commander/mcp.sock
  • Windows: Named pipe at \\.\pipe\tuicommander-mcp

Override with $TUIC_SOCKET environment variable.

If TUICommander is not running, tuic open and tuic new will launch it automatically.

Plugins

TUICommander has an Obsidian-style plugin system. Plugins can watch terminal output, push notifications to the Activity Center, render markdown panels, control PTY sessions, and more.

Installing Plugins

From the Community Registry

  1. Open Settings (Cmd+,) → Plugins tab → Browse
  2. Browse available plugins — each shows name, description, and author
  3. Click Install on any plugin
  4. The plugin is downloaded and activated immediately

An “Update available” badge appears when a newer version exists in the registry.

Registry plugins include file viewers such as DOCX Preview, which opens Word .docx/.dotx files from the File Browser as an HTML preview tab.

From a ZIP File

  1. Open SettingsPluginsInstalled
  2. Click Install from file…
  3. Select a .zip archive containing the plugin

Click a link like tuic://install-plugin?url=https://example.com/plugin.zip — TUICommander shows a confirmation dialog, then downloads and installs the plugin. Only HTTPS URLs are accepted.

Manual Installation

Copy the plugin directory to:

  • macOS: ~/Library/Application Support/com.tuic.commander/plugins/my-plugin/
  • Linux: ~/.config/tuicommander/plugins/my-plugin/
  • Windows: %APPDATA%/com.tuic.commander/plugins/my-plugin/

A plugin directory contains at minimum manifest.json and main.js.

Managing Plugins

Settings → Plugins → Installed

The Installed tab lists all plugins (built-in and external):

  • Toggle switch — Enable or disable a plugin. Disabled plugins are not loaded but remain installed.
  • Logs — Click to expand the plugin’s log viewer. Shows recent activity and errors (500-entry ring buffer).
  • Uninstall — Remove the plugin directory (confirmation required). Built-in plugins cannot be uninstalled.

Error count badges appear on plugins that have logged errors.

Built-in Plugins

TUICommander ships with built-in plugins (e.g., Plan Tracker). These show a “Built-in” badge in the list. They can be disabled but not uninstalled.

How Plugins Work

Plugins interact with the app through a PluginHost API organized in 4 capability tiers:

TierAccessExamples
1Always availableWatch terminal output, add Activity Center items, provide markdown content
2Always availableRead repository list, active branch, terminal sessions (read-only)
3Requires capabilitySend input to terminals (pty:write — raw writePty or agent-aware sendAgentInput), open markdown panels (ui:markdown), play sounds (ui:sound), read/list/watch files (fs:read, fs:list, fs:watch)
4Requires capabilityInvoke whitelisted Tauri commands (invoke:read_file, invoke:list_markdown_files)

Capabilities are declared in the plugin’s manifest.json. A plugin without pty:write cannot send input to your terminals.

Activity Center

The toolbar bell icon is the Activity Center. Plugins contribute sections and items here:

  • Sections — Grouped headings (e.g., “ACTIVE PLAN”, “CI STATUS”)
  • Items — Individual notifications with icon, title, subtitle
  • Actions — Click an item to open its detail (usually a markdown panel), or dismiss it

The bell shows a count badge when there are active items.

Hot Reload

When you edit a plugin’s files, TUICommander detects the change and automatically reloads the plugin — no restart needed. Save main.js and see changes in seconds.

Example Plugins

TUICommander ships with example plugins in examples/plugins/:

PluginWhat it does
hello-worldMinimal example — watches terminal output, adds Activity Center items
auto-confirmAuto-responds to Y/N prompts in terminal
ci-notifierSound notifications and markdown panels for CI events
repo-dashboardReads repo state, generates dynamic markdown summaries
report-watcherWatches terminal for generated report files, shows them in Activity Center

Writing Your Own Plugin

See the Plugin Authoring Guide for the full API reference, manifest format, capability details, structured event types, and testing patterns.

Troubleshooting

ProblemFix
Plugin not appearingCheck that manifest.json exists and id matches the directory name
“Requires app version X.Y.Z”Update TUICommander or lower minAppVersion in the manifest
“Requires capability X”Add the capability to the capabilities array in manifest.json
Changes not taking effectSave the file again to trigger hot reload, or restart the app
Plugin errorsCheck Settings → Plugins → Logs for the plugin’s error log

MCP Proxy Hub

TUICommander can act as a universal MCP (Model Context Protocol) proxy. Instead of configuring the same MCP servers in Claude Code, Cursor, and VS Code separately, you configure them once in TUICommander. All your AI clients connect to TUICommander’s single /mcp endpoint and get access to every upstream tool automatically.

How It Works

Claude Code ──┐
Cursor ───────┼──▶  TUICommander /mcp  ──┬──▶ GitHub MCP
VS Code ──────┘                           ├──▶ Filesystem MCP
                                          └──▶ Any MCP server

When a tool call arrives at TUICommander’s MCP endpoint, it routes the request to the correct upstream server and returns the result. Upstream tools appear prefixed with the server name — for example, a tool called search_code from an upstream named github becomes github__search_code.

The MCP server must be enabled (Settings > Services & MCP > MCP Server).

Adding an Upstream Server

Open Settings > Services & MCP > MCP Upstreams. Click Add Server and fill in:

HTTP Server

Use this for MCP servers that expose a Streamable HTTP endpoint.

FieldExampleNotes
NamegithubLowercase letters, digits, hyphens, underscores only
TypeHTTP
URLhttps://mcp.example.com/mcpMust be http:// or https://
Timeout30Seconds per request. 0 = no timeout
EnabledOnUncheck to disable without removing

Stdio Server

Use this for locally installed MCP servers (npm packages, Python scripts, etc.) that communicate over stdin/stdout.

FieldExampleNotes
NamefilesystemSame naming rules as above
TypeStdio
CommandnpxExecutable name or full path
Args-y @modelcontextprotocol/server-filesystemSpace-separated
EnvALLOWED_PATHS=/home/userOptional extra environment variables
EnabledOn

Click Save. TUICommander connects immediately — no restart required.

Server Names

The server name becomes the namespace prefix for all its tools. Choose names that are:

  • Descriptive and short (github, filesystem, db)
  • Lowercase only
  • No spaces, dots, or capital letters — only [a-z0-9_-]
  • Unique (no two servers can share a name)

Authentication

For HTTP upstream servers that require a Bearer token:

  1. Go to Settings > Services & MCP > MCP Upstreams
  2. Find your server in the list
  3. Click the key icon next to it
  4. Enter your token

The token is stored in the OS keyring (Keychain on macOS, Credential Manager on Windows) — never in the config file.

To remove a credential, click the key icon and leave the field empty, then save.

Tool Filtering

You can restrict which tools from an upstream are exposed to downstream clients. Edit a server and set the filter:

Allow list — only these tools are exposed:

Mode: allow
Patterns: read_*, list_*, get_*

Deny list — all tools except these are exposed:

Mode: deny
Patterns: delete_*, rm, drop_*, exec_*

Patterns support a trailing * for prefix matching. Exact names also work. There is no other wildcard syntax.

Upstream Status

Each upstream server has a status indicator:

StatusMeaning
ConnectingHandshake in progress
ReadyConnected, tools available
Circuit OpenToo many failures, retrying with backoff
DisabledDisabled by you in config
FailedPermanently failed — manual reconnect needed

Circuit breaker: If an upstream fails 3 times consecutively, TUICommander stops sending requests to it briefly. Retries use exponential backoff starting at 1 second, capping at 60 seconds. After 10 retry cycles without recovery, the server is marked Failed.

To reconnect a Failed server, click Reconnect next to its name in the settings panel.

Health Checks

TUICommander probes every Ready upstream every 60 seconds to verify it is still responding. If a probe fails, the circuit breaker activates. If a Circuit Open server’s backoff has expired, the health check also attempts recovery.

Hot-Reload

Adding, removing, or changing upstream servers takes effect immediately when you click Save. TUICommander computes a diff and only reconnects servers that actually changed — unchanged servers are never interrupted.

Troubleshooting

The upstream shows “Failed”

  1. Check the server URL or command is correct.
  2. Verify the server process is running (for stdio servers).
  3. Check credentials are set if the server requires authentication.
  4. Click Reconnect to retry.

Tools are not appearing

  • The upstream must be in Ready status for its tools to be included.
  • Check that a tool filter is not hiding the tools you expect.
  • Reconnect and check the error log (Cmd+Shift+E) for initialization errors.

“Circular proxy” error

The HTTP URL you configured points to TUICommander’s own MCP port. This would create an infinite loop. Use a different URL or port.

“Invalid URL scheme” error

Only http:// and https:// URLs are accepted. Other schemes (ftp, file, javascript, etc.) are rejected for security.

Stdio server crashes immediately

  • Confirm the command exists on PATH (or use the full absolute path).
  • Check the Args field for typos.
  • Use the error log (Cmd+Shift+E) to see the stderr output from the child process.
  • Note: the server cannot be respawned more than once every 5 seconds (rate limit).

Credential not found

If the upstream returns 401 errors:

  1. Go to Settings > Services & MCP > MCP Upstreams.
  2. Click the key icon for the server.
  3. Re-enter the Bearer token and save.

The credential lookup uses the server name as the keyring key. If you renamed the server, the old credential is no longer found — re-enter it under the new name.

Example: Connecting the MCP Filesystem Server

Install the server:

npm install -g @modelcontextprotocol/server-filesystem

Add it in Settings > Services & MCP > MCP Upstreams:

  • Name: filesystem
  • Type: Stdio
  • Command: npx
  • Args: -y @modelcontextprotocol/server-filesystem /path/to/allowed/dir

After saving, the tool filesystem__read_file (and others) will appear in your AI client’s tool list.

Example: Connecting a Remote HTTP MCP Server

  • Name: github
  • Type: HTTP
  • URL: https://api.example.com/mcp
  • Timeout: 30

Set the Bearer token via the key icon in settings. Tools appear as github__search_code, github__create_issue, etc.

Security Notes

  • Config files (mcp-upstreams.json) never contain credentials — only the upstream name is stored. Tokens live in the OS keyring only.
  • Stdio servers run with a sanitized environment. Your shell secrets (ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, etc.) are not inherited by spawned MCP processes. Only PATH, HOME, USER, LANG, LC_ALL, TMPDIR, TEMP, TMP, SHELL, and TERM are passed through. Add anything else explicitly in the Env field.
  • Self-referential HTTP URLs (pointing to TUIC’s own MCP port) are rejected to prevent circular proxying.
  • Only http:// and https:// URL schemes are accepted.

Remote Access

Access TUICommander from a browser on another device on your network.

Setup

  1. Open Settings (Cmd+,) → ServicesRemote Access
  2. Configure:
    • Port — Default 9876 (range 1024–65535)
    • Username — Basic Auth username
    • Password — Basic Auth password (stored as a bcrypt hash, never in plaintext)
  3. Enable remote access

Once enabled, the settings panel shows the access URL: http://<your-ip>:<port>

Connecting from Another Device

  1. Open a browser on any device on the same network
  2. Navigate to the URL shown in settings (e.g., http://192.168.1.42:9876)
  3. Enter the username and password you configured
  4. TUICommander loads in the browser with full terminal access

QR Code

The settings panel shows a QR code for the access URL — scan it from a phone or tablet to connect quickly. The QR code uses your actual local IP address.

What Works Remotely

The browser client provides the same UI as the desktop app:

  • Terminal sessions (via WebSocket streaming)
  • Sidebar with repositories and branches
  • Diff, Markdown, and File Browser panels
  • Keyboard shortcuts
  • Compose commands queued through the same agent idle gate as the desktop app; clearing the Compose queue leaves pending peer messages intact
  • Notification sounds, including the distinct G4→G4→E5 Attention callback, through the browser audio fallback

Security

  • Authentication — Basic Auth with bcrypt-hashed passwords
  • Secret storage — Session tokens, relay bearer tokens, and push VAPID private keys are stored in the OS keyring-backed credential vault; config files and /config responses expose only non-secret settings and existence flags
  • Local network only — The server binds to your machine’s IP; it’s not exposed to the internet unless you configure port forwarding (don’t do this without a VPN)
  • CORS — When remote access is enabled, any origin is allowed (necessary for browser access from different IPs)

MCP HTTP Server

Separate from remote access, TUICommander runs an HTTP API server for AI tool integration:

  • The server always listens on an IPC listener: Unix domain socket at <config_dir>/mcp.sock on macOS/Linux, or named pipe \\.\pipe\tuicommander-mcp on Windows
  • AI agents connect via the tuic-bridge sidecar binary, which translates MCP stdio transport to the IPC listener
  • Bridge configs are auto-installed on first launch for supported agents (Claude Code, Cursor, Windsurf, VS Code, Zed, Amp, Gemini, Codex, Grok, opencode, Droid, goose, pi) — and only for the ones present on the machine, so TUICommander never creates a config directory for a tool you do not have. On every subsequent launch, the bridge path is verified and updated if stale (from reinstalls, updates, or moves)
  • The mcp_server_enabled toggle in SettingsServices controls whether MCP protocol tools are exposed, not the server itself
  • Shows server status and active session count in settings

The Unix socket is accessible only to the current user (filesystem permissions) and requires no authentication — it’s designed for local tool integration, not remote access.

Mobile Companion

TUICommander includes a phone-optimized interface for monitoring agents from your phone.

Accessing the Mobile UI

  1. Enable remote access (see Setup above)
  2. Navigate to http://<your-ip>:<port>/mobile from your phone
  3. Log in with your credentials

Add to Home Screen

The mobile UI supports PWA (Progressive Web App) installation:

  • iOS Safari: Tap Share → “Add to Home Screen”
  • Android Chrome: Tap the three-dot menu → “Add to Home screen”

The app launches in standalone mode (no browser chrome) for a native-like experience.

Mobile Features

  • Sessions list — See all running agents with status (idle, busy, question, rate-limited, error)
  • Session detail — Live output streaming, quick-reply chips (Yes/No/Enter/Ctrl-C), text input
  • Question banner — Instant notification when any agent needs input, with quick-reply buttons
  • Activity feed — Chronological event feed grouped by time
  • Notification sounds — Audio alerts for questions, errors, completions, and rate limits

Tips

  • Pull down on the sessions list to refresh
  • The question banner appears on all screens — you don’t need to be on the sessions tab to respond
  • Sound notifications can be toggled in the mobile Settings tab

SSH Tunnel Management

TUICommander can manage persistent SSH tunnels with automatic reconnection, port forwarding, and audit logging.

Creating a Tunnel Profile

  1. Open Settings (Cmd+,) → ServicesSSH Tunnels
  2. Click Add Tunnel to open the editor
  3. Configure:
    • Name — A descriptive label (e.g., “prod-db-tunnel”)
    • Host — Remote SSH host
    • Port — SSH port (default 22)
    • User — SSH username
    • Identity File — Optional path to SSH private key (use the Browse button to select)
    • Port Forwards — Local or remote port forwarding rules (e.g., local 8080 → remote 80). Local forwards target remote_host/remote_port; Remote forwards target local_host/local_port. The remote host is pre-populated from the tunnel host when adding a Local forward
    • Options — ServerAliveInterval (default 15s), ServerAliveCountMax (default 3), StrictHostKeyChecking (Yes or AcceptNew)
  4. Save the profile

Tunnel profiles are stored as TOML files. Global profiles live in <config_dir>/tunnels/ and are available across all repos. Per-repo profiles are stored in <repo>/.tuic/tunnels/ and override global profiles with the same ID.

Auto-Connect

Enable Auto-Connect on a tunnel profile to have it start automatically when TUICommander launches. Useful for tunnels you always need (database access, internal services).

Toggle auto-connect in the tunnel editor — profiles marked with auto-connect are started during app hydration before you interact with the UI.

Statusbar Indicator

The status bar shows a shield icon for SSH tunnels:

  • Grey shield — You have tunnel profiles configured but none are currently connected
  • Green shield with badge — Shows the number of active tunnel connections

Click the shield to open the Tunnels Panel.

Command Palette

Open the command palette (Cmd+P / Ctrl+P) and type “tunnels” to toggle the Tunnels Panel without navigating to Settings.

Starting and Stopping Tunnels

  • In the Tunnels Panel, click the Start button next to a profile to launch the SSH tunnel
  • The TunnelStatusBadge shows the current state: Starting, Connected, Reconnecting, Stopped, or Error
  • Click Stop to gracefully terminate the SSH process (SIGTERM with 5s grace period, then SIGKILL)
  • On app exit, all active tunnels are automatically stopped — no orphaned SSH processes

SSH Agent Detection

TUICommander automatically detects your SSH agent and shows the agent type and loaded keys in the tunnel editor. Supported agents:

  • 1Password — Detected via the 1Password SSH agent socket
  • Secretive — Detected via the Secretive agent socket
  • GPG Agent — Detected via gpg-agent socket
  • Generic SSH Agent — Any other SSH_AUTH_SOCK value

The key listing shows fingerprint, comment, and key type for each loaded key, helping you verify that the correct identity is available before connecting.

Automatic Reconnection

When a tunnel disconnects due to a network issue or timeout, the supervisor automatically reconnects with exponential backoff:

  • Base delay: 1 second, doubling each attempt
  • Maximum delay: 30 seconds
  • Jitter: +/-25% to prevent thundering herd
  • Maximum retries: 10 before giving up
  • Backoff resets on successful connection

Non-retryable failures (authentication errors, host key mismatches) stop immediately without retry.

Audit Log

All tunnel events (start, connect, disconnect, error, retry, stop) are recorded in a SQLite database with WAL mode for performance. The audit log supports:

  • Querying events by tunnel ID
  • Querying events by time range
  • Automatic rotation of old events (configurable retention period)

Exit Classification

The supervisor classifies SSH process exits to determine whether retry is appropriate:

Exit ReasonRetryableDescription
AuthFailedNoPermission denied or authentication failure
HostKeyMismatchNoRemote host key changed
PortInUseNoLocal forwarding port already bound
ConnectionRefusedYesRemote host rejected the connection
NetworkDownYesNetwork unreachable
TimeoutYesConnection timed out
UserKilledNoProcess terminated by user signal

Remote Connection Manager

Remote connections let you manage tuic-remote daemons running on other machines. TUICommander routes API calls to the correct host based on which repo/session is active.

Adding an SSH Connection

  1. Open SettingsConnectionsAdd Connection
  2. Select SSH transport
  3. Configure host, port (default 22), user, and optional identity file
  4. Set the remote daemon port (default 9877)
  5. Save — an SSH tunnel is automatically created to forward the daemon port

Adding a Direct Connection

  1. Open SettingsConnectionsAdd Connection
  2. Select Direct transport
  3. Enter the URL of the remote daemon (e.g., http://10.0.0.5:9877)
  4. Set the auth username
  5. Save — health polling begins immediately

Remote Repositories and Terminals

Once a remote connection is configured:

  • Add remote repo — When adding a repository, select a connection. The repo appears in the sidebar with a remote badge
  • Open terminal — Terminals on remote repos connect via WebSocket to the remote daemon. I/O works identically to local terminals
  • Health monitoring — Connection health is polled periodically. Disconnected connections show a warning badge in the sidebar

Connections are stored in <config_dir>/connections.json with SSH and Direct transport types.

tuic-remote (Beta)

A standalone headless daemon for running TUICommander on a Linux server without a desktop environment. It exposes the same HTTP/WebSocket API as the desktop app’s remote access feature, but runs as an independent binary — no Tauri, no GUI.

Installation

Download the tuic-remote binary for your platform from the GitHub Releases page.

PlatformArtifact
Linux x64tuic-remote-x86_64-unknown-linux-gnu
Linux ARM64tuic-remote-aarch64-unknown-linux-gnu
macOS ARM (Apple Silicon)tuic-remote-aarch64-apple-darwin
Windows x64tuic-remote-x86_64-pc-windows-msvc.exe
# Example: Linux x64
curl -fsSL -o tuic-remote https://github.com/sstraus/tuicommander/releases/latest/download/tuic-remote-x86_64-unknown-linux-gnu
chmod +x tuic-remote

Setup

Set a password before first use:

./tuic-remote --set-password

This stores a bcrypt hash in the TUICommander config directory (~/.config/tuicommander/ on Linux).

Running

# Default port 9877
./tuic-remote

# Custom port
TUIC_PORT=8080 ./tuic-remote

The daemon binds to 0.0.0.0:<port> and serves:

  • The TUICommander web UI (PWA-capable)
  • WebSocket terminal streaming
  • MCP tool integration (for AI agents)

TLS

Configure TLS via the TUICommander config file (~/.config/tuicommander/config.toml):

[services.tls]
cert_path = "/path/to/cert.pem"
key_path = "/path/to/key.pem"

Differences from Desktop Remote Access

Desktop Remote Accesstuic-remote
Requires desktop appYesNo
Runs headlessNoYes
Tauri dependencyYesNo
Default port98769877
LAN auth bypassConfigurableAlways disabled
Signal handlingN/AGraceful SIGINT/SIGTERM

Status

Beta — the core HTTP/WebSocket API is stable, but the standalone daemon is new and may have rough edges. Report issues on GitHub.

Troubleshooting

ProblemFix
Can’t connect from another deviceCheck that both devices are on the same network. Try pinging the host IP.
Connection refusedVerify the port isn’t blocked by a firewall. The settings panel includes a reachability check.
Authentication failsRe-enter the password in settings — the stored bcrypt hash may be from a different password.
Terminals not respondingWebSocket connection may have dropped. Refresh the browser page.

SSH Tunnel Management

Architecture

TunnelManager
  └── TunnelSupervisor (per profile)
        ├── SSH child process (tokio::process::Command)
        ├── BackoffCalculator (reconnect timing)
        ├── ExitClassifier (stderr → ExitReason)
        └── AuditLog (SQLite WAL)

The TunnelManager orchestrates multiple TunnelSupervisor instances, one per active tunnel profile. Each supervisor owns an SSH child process and runs a supervision loop:

  1. Validate the profile (fields, port ranges, duplicate bind ports)
  2. Check local port availability for all -L forwards
  3. Spawn ssh with constructed arguments (including agent forwarding if SSH_AUTH_SOCK is found)
  4. Health check: if the process dies within 500ms, classify the exit immediately
  5. If the process survives 500ms, mark as Connected and reset the backoff counter
  6. On process exit, classify the exit reason from stderr patterns and exit code
  7. If retryable, wait the backoff delay and loop; otherwise, stop

Tunnel starts claim a tokenized reservation before the first await. The reservation is removed automatically if the start future fails or is cancelled, but only when its token still owns the slot; a later start with the same profile ID cannot be erased by an older cancelled future. Stop during startup removes the reservation, prevents late supervisor publication, and suppresses the Started audit record. Started is written only after the live handle is published successfully.

Shutdown

TunnelSupervisor::stop() sends a signal via a oneshot channel. The supervision loop catches this at any tokio::select! point and performs graceful shutdown:

  • Unix: SIGTERM to the SSH process, wait up to 5 seconds, then SIGKILL
  • Windows: child.kill() immediately

Profile Configuration

Profiles are TOML files with this structure:

id = "550e8400-e29b-41d4-a716-446655440000"
name = "prod-db-tunnel"
host = "bastion.example.com"
port = 2222
user = "deploy"
identity_file = "/home/deploy/.ssh/id_ed25519"
auto_connect = true

[[forwards]]
type = "Local"
bind_port = 5432
remote_host = "db.internal"
remote_port = 5432

[[forwards]]
type = "Remote"
bind_port = 9090
local_host = "127.0.0.1"
local_port = 9090

[options]
server_alive_interval = 15
server_alive_count_max = 3
strict_host_key_checking = "Yes"

Storage Scopes

ScopePathPrecedence
Global<config_dir>/tunnels/*.tomlBase
Per-repo<repo>/.tuic/tunnels/*.tomlOverrides global (same ID)

ProfileStore::load_all() merges both scopes, with per-repo profiles taking precedence.

Tunnel States

Starting ──────► Connected
    │                 │
    │                 ▼
    │           Reconnecting ──► Connected (backoff reset)
    │                 │
    │                 ▼
    ▼           Stopped (max retries)
Error
    │
    ▼
Stopped
StateMeaning
StartingSSH process is being spawned
ConnectedSSH process survived health check; tunnel is operational
Reconnecting { attempt, reason }Process exited with retryable reason; waiting backoff before retry
Stopped { reason }Terminal state: user requested stop, max retries exceeded, or non-retryable exit
Error { message }Validation failure or spawn error; no process was created

Exponential Backoff

BackoffCalculator computes retry delays:

  • Base: 1000ms
  • Formula: min(base * 2^attempt, 30000) + jitter
  • Jitter: +/-25% of computed delay (uniform random)
  • Floor: 100ms minimum delay
  • Max retries: 10 (returns None after exhaustion)
  • Reset: called on successful connection (attempt counter returns to 0)

Example sequence (base values, before jitter): 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s, 30s, 30s

Exit Classification

classify_exit() inspects SSH stderr output first, then falls back to exit code:

PatternExitReasonRetryable
“Permission denied” / “Authentication failed”AuthFailedNo
“Host key verification failed” / “REMOTE HOST IDENTIFICATION HAS CHANGED”HostKeyMismatchNo
“Address already in use” / “Could not request local forwarding”PortInUseNo
“Connection refused”ConnectionRefusedYes
“Network is unreachable” / “No route to host”NetworkDownYes
“Connection timed out”TimeoutYes
Exit code 130 (SIGINT) / 137 (SIGKILL)UserKilledNo

Audit Logging

AuditLog uses SQLite with WAL journal mode for safe concurrent access.

Schema

CREATE TABLE tunnel_events (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    tunnel_id TEXT    NOT NULL,
    kind      TEXT    NOT NULL,
    detail    TEXT    NOT NULL DEFAULT '{}'
);

Indexed on tunnel_id and timestamp.

Event Kinds

Started, Connected, Disconnected, Error, Retry, Stopped

Operations

  • insert(tunnel_id, kind, detail) — Record an event
  • query_by_tunnel(tunnel_id, limit) — Most recent N events for a tunnel
  • query_by_time_range(from, to) — Events within a time window
  • rotate(max_age_days) — Delete events older than N days

UI Components

TunnelsPanel

Main panel listing all tunnel profiles with:

  • Profile name and host
  • TunnelStatusBadge showing current state
  • Start/Stop toggle button
  • Edit button opening TunnelEditorModal

TunnelEditorModal

Form for creating and editing profiles:

  • Name, host, port, user, identity file fields
  • Port forwards list with add/remove and type-aware endpoint fields: Local forwards save remote_host/remote_port, Remote forwards save local_host/local_port
  • Options section (keepalive, host key checking)
  • Validation errors shown inline

TunnelStatusBadge

Color-coded status indicator:

  • Green: Connected
  • Blue (pulsing): Starting
  • Orange: Reconnecting (shows attempt number)
  • Red: Error
  • Grey: Stopped

Integration with Remote Connection Manager

When creating an SSH remote connection (RemoteConnection with RemoteTransport::Ssh), a tunnel profile is automatically created to forward the daemon port. The tunnel supervisor manages the SSH connection, and the remote connection routes API calls through the forwarded port.

Module Map

ModuleResponsibility
tunnels/profile.rsData model: TunnelProfile, ForwardSpec, ProfileOptions
tunnels/command.rsBuild SSH command-line arguments from a profile
tunnels/classifier.rsClassify SSH exit reasons from stderr/exit code
tunnels/agent.rsDiscover SSH_AUTH_SOCK for agent forwarding
tunnels/port.rsCheck if a local TCP port is available
tunnels/backoff.rsExponential backoff with jitter
tunnels/audit.rsSQLite audit log (WAL mode)
tunnels/supervisor.rsPer-tunnel supervision loop
tunnels/storage.rsTOML profile persistence (global + per-repo)
tunnels/manager.rsOrchestrate multiple supervisors
tunnels/tauri_commands.rsTauri IPC command handlers (desktop)
tunnels/commands.rsHTTP command handlers (browser mode)

Auto-Connect

Profiles with auto_connect: true are started automatically on app launch. The tunnel store’s hydrate() method loads all profiles, checks which are marked for auto-connect, and starts them if not already active. Hydration runs once and is guarded against duplicate calls.

The auto_connect field is persisted in the TOML profile:

auto_connect = true

SSH Agent Detection

detect_agent_type() inspects SSH_AUTH_SOCK to identify the running SSH agent:

Pattern in SSH_AUTH_SOCKDetected Agent
1password or 2BUA8C4S2C1Password
secretiveSecretive
gpg or gnupgGPG Agent
(1Password socket exists but not active)SSH Agent (1Password available)
(empty)Not available
(other)SSH Agent

list_ssh_agent_keys() runs ssh-add -l to enumerate loaded keys, returning fingerprint, comment, and key type for each.

Orphan SSH Process Cleanup

When check_local_port() reports AddrInUse, the supervisor calls kill_ssh_on_port() before retrying:

  1. lsof -ti tcp:<port> -sTCP:LISTEN finds PIDs listening on the port
  2. ps -p <pid> -o comm= verifies each PID is an ssh process
  3. Only confirmed SSH processes receive SIGTERM

This handles stale SSH tunnels left over from a previous app crash without killing unrelated processes.

check_local_port() also distinguishes PermissionDenied (ports below 1024 on macOS/Linux require root) from AddrInUse, providing specific error messages for each case.

Statusbar Shield

The status bar shows an SSH tunnel indicator:

  • Grey shield — Tunnel profiles exist but none are currently connected
  • Green shield with count badge — N tunnels are connected; the badge shows the count

Clicking the shield opens the Tunnels Panel.

Shutdown on Exit

When the app exits (RunEvent::Exit), TunnelManager::shutdown_all() iterates all active supervisors, sends stop signals, and clears the tunnel map. This ensures no orphaned SSH child processes survive after the app closes

Architecture Overview

Tech Stack

LayerTechnologyPurpose
FrontendSolidJS + TypeScriptReactive UI with fine-grained updates
BuildVite + LightningCSSFast dev server, optimized CSS
BackendTauri (Rust)Native APIs, PTY, git, system integration
Terminalalacritty_terminal + canvasNative VT engine with GPU-accelerated rendering
StateSolidJS reactive storesFrontend state management
PersistenceJSON files via RustPlatform-specific config directory
TestingVitest + SolidJS Testing LibraryUnit/integration tests (~830 tests)

Hexagonal Architecture

The project follows hexagonal architecture with clear separation between layers:

┌──────────────────────────────────────────────┐
│                  UI Layer                     │
│  SolidJS Components (render + user input)     │
│  ┌──────┐ ┌───────┐ ┌─────────┐ ┌────────┐  │
│  │Sidebar│ │TabBar │ │Terminal │ │Settings│  │
│  └──┬───┘ └──┬────┘ └──┬──────┘ └──┬─────┘  │
├─────┼────────┼─────────┼───────────┼─────────┤
│     │    Application Layer (Hooks)  │         │
│  ┌──┴────────┴─────────┴───────────┴──┐      │
│  │ useGitOps · usePty · useTerminals  │      │
│  │ useGitHub · useDictation · etc.    │      │
│  └──┬────────┬─────────┬─────────────┘      │
├─────┼────────┼─────────┼─────────────────────┤
│     │   State Layer (Stores)   │              │
│  ┌──┴────────┴─────────┴──────┐              │
│  │ terminals · repositories   │              │
│  │ settings · github · ui     │              │
│  └──┬─────────────────────────┘              │
├─────┼────────────────────────────────────────┤
│     │     IPC / Transport Layer              │
│  ┌──┴─────────────────────────────────┐      │
│  │ invoke.ts / transport.ts           │      │
│  │ Tauri IPC (native) | HTTP (browser)│      │
│  └──┬─────────────────────────────────┘      │
├─────┼────────────────────────────────────────┤
│     │     Backend (Rust/Tauri)               │
│  ┌──┴─────────────────────────────────┐      │
│  │ pty · git · github · config        │      │
│  │ agent · worktree · dictation       │      │
│  │ output_parser · error_classification│      │
│  └────────────────────────────────────┘      │
└──────────────────────────────────────────────┘

Design Principles

  • Logic in Rust: All business logic, data transformation, and parsing implemented in the Rust backend. The frontend handles rendering and user interaction only.
  • Cross-Platform: Targets macOS, Windows, and Linux. Uses Tauri cross-platform primitives.
  • KISS/YAGNI: Minimal complexity, no premature abstractions.
  • Dual Transport: Same app works as native Tauri desktop app or browser app via HTTP/WebSocket.

Directory Structure

src/
├── components/           # SolidJS UI components
│   ├── Terminal/         # Native terminal renderer (CanvasTerminal) with PTY integration
│   ├── Sidebar/          # Repository tree, branch list, CI rings
│   ├── TabBar/           # Terminal tabs with drag-to-reorder
│   ├── Toolbar/          # Window drag region, repo/branch display
│   ├── StatusBar/        # Status messages, zoom, dictation
│   ├── SettingsPanel/    # Tabbed settings (General, Agents, Services, etc.)
│   ├── GitPanel/         # Git panel (Changes, Log, Stashes)
│   ├── MarkdownPanel/    # Markdown file browser and renderer
│   ├── HelpPanel/        # Keyboard shortcuts documentation
│   ├── TaskQueuePanel/   # Agent task queue visualization
│   ├── PromptOverlay/    # Agent prompt interception UI
│   ├── PromptDrawer/     # Prompt library management
│   └── ui/               # Reusable UI primitives (CiRing, DiffViewer, etc.)
├── stores/               # Reactive state management
├── hooks/                # Business logic and side effects
├── utils/                # Pure utility functions
├── types/                # TypeScript type definitions
├── transport.ts          # IPC abstraction (Tauri vs HTTP)
└── invoke.ts             # Smart invoke wrapper

src-tauri/src/
├── lib.rs                # App setup, plugin init, command registration
├── main.rs               # Entry point
├── pty.rs                # PTY session lifecycle
├── git.rs                # Git operations
├── github.rs             # GitHub API integration
├── config.rs             # Configuration management
├── state.rs              # Global state (sessions, buffers, metrics)
├── agent.rs              # Agent binary detection and spawning
├── worktree.rs           # Git worktree management
├── output_parser.rs      # Terminal output parsing
├── prompt.rs             # Prompt template processing
├── error_classification.rs # Error classification and backoff
├── menu.rs               # Native menu bar
├── mcp_http.rs           # HTTP/WebSocket server
└── dictation/            # Voice dictation (Whisper)
    ├── mod.rs            # State management
    ├── audio.rs          # Audio capture (CPAL)
    ├── commands.rs       # Tauri commands
    ├── model.rs          # Whisper model management
    ├── transcribe.rs     # Whisper transcription
    └── corrections.rs    # Post-processing corrections

Application Startup Flow

  1. Rust (main.rs): Calls tui_commander_lib::run()
  2. Library (lib.rs): Creates AppState, loads config, spawns HTTP server if enabled, builds Tauri app with plugins, registers the Tauri command surface, and sets up the native menu
  3. Frontend (index.tsx): Mounts <App /> component
  4. App (App.tsx): Initializes all hooks, calls initApp() which hydrates stores from backend, detects binaries, sets up keyboard shortcuts, starts GitHub polling
  5. Render: Full UI hierarchy with terminals, panels, overlays, and dialogs

Module Dependencies

App.tsx
├── useAppInit       → hydrates all stores from Rust config
├── usePty           → PTY session management via invoke()
├── useGitOperations → branch switching, worktree creation
├── useTerminalLifecycle → tab management, zoom, copy/paste
├── useKeyboardShortcuts → global keyboard handler
├── useGitHub        → GitHub polling (uses githubStore)
├── useDictation     → push-to-talk (uses dictationStore)
├── useQuickSwitcher → branch quick-switch UI
└── useSplitPanes    → split terminal panes

Data Flow

IPC Communication

TUICommander supports two IPC modes through a unified transport abstraction:

Tauri Mode (Native Desktop)

Frontend (SolidJS) ──invoke()──> Tauri IPC ──> Rust Command
Frontend (SolidJS) <──listen()── Tauri Events <── Rust emit()
  • Zero-overhead RPC via @tauri-apps/api/core.invoke()
  • Event subscription via @tauri-apps/api/event.listen()
  • Used when running as native Tauri application

Browser Mode (HTTP/WebSocket)

Frontend (SolidJS) ──fetch()──> HTTP REST API ──> Rust Handler
Frontend (SolidJS) <──WebSocket── PTY Output Stream
  • HTTP REST for all commands (mapped from Tauri command names)
  • WebSocket for real-time PTY output streaming
  • SSE for MCP JSON-RPC transport
  • Used when running in browser via pnpm dev

Transport Abstraction

src/invoke.ts provides invoke<T>(cmd, args) that resolves to the correct transport at module initialization:

// Zero overhead in Tauri - resolved once at import
const invoke = isTauri() ? tauriInvoke : httpInvoke;

src/transport.ts maps Tauri command names to HTTP endpoints:

mapCommandToHttp("get_repo_info", { path }) → GET /repo/info?path=...
mapCommandToHttp("create_pty", config)       → POST /sessions

PTY Output Pipeline

Terminal output flows through multiple processing stages:

PTY Process (shell)
    │
    ▼
Raw Bytes ──> Utf8ReadBuffer
    │         (handles split multi-byte UTF-8 characters)
    ▼
UTF-8 String ──> EscapeAwareBuffer
    │             (prevents splitting ANSI escape sequences)
    ▼
Safe String
    ├──> Ring Buffer (64KB, for MCP access)
    ├──> WebSocket broadcast (for browser clients)
    └──> Tauri Event ("pty-output", {session_id, data})
              │
              ▼
         Frontend: CanvasTerminal renders grid frame
              │
              ▼
         OutputParser detects special events
         (rate limits, PR URLs, progress, prompts)

Each PTY session has a dedicated reader thread (spawned in pty.rs) that reads from the PTY master fd in a loop.

State Management

Frontend Stores

SolidJS reactive stores hold all frontend state. Each store follows the pattern:

const [state, setState] = createStore<StoreType>(initialState);

// Public API exposed as object with methods
export const myStore = {
  state,        // Read-only reactive state
  hydrate(),    // Load from Rust backend
  action(),     // Modify state + persist to Rust
};

Store Dependency Graph

repositoriesStore ──references──> terminalsStore (terminal IDs per branch)
githubStore ──provides data to──> Sidebar (CI rings, PR badges), StatusBar (PR/CI badges)
settingsStore ──configures──> Terminal (font, theme, shell)
uiStore ──controls──> panel visibility, sidebar state
promptLibraryStore ──used by──> PromptDrawer, PromptOverlay
dictationStore ──manages──> dictation state, model downloads
notificationsStore ──plays──> sound alerts on terminal events
errorHandlingStore ──retries──> failed operations with backoff
statusBarTicker ──feeds──> StatusBar (rotating priority-based messages)
notesStore ──provides──> NotesPanel (ideas/notes), StatusBar (badge count)
userActivityStore ──tracks──> StatusBar (merged PR grace period)

Persistence Flow

User Action
    │
    ▼
Store.action()
    ├──> setState() (immediate reactive update)
    └──> invoke("save_xxx_config", data) (async persist to Rust)
              │
              ▼
         Rust: save_json_config(filename, data)
              │
              ▼
         JSON file in platform config directory

Hydration Flow (App Startup)

useAppInit.initApp()
    │
    ├──> repositoriesStore.hydrate()    → load_repositories
    ├──> uiStore.hydrate()              → load_ui_prefs
    ├──> settingsStore.hydrate()        → load_app_config
    ├──> notificationsStore.hydrate()   → load_notification_config
    ├──> repoSettingsStore.hydrate()    → load_repo_settings
    ├──> repoDefaultsStore.hydrate()    → load_repo_defaults
    ├──> promptLibraryStore.hydrate()   → load_prompt_library
    ├──> notesStore.hydrate()           → load_notes
    ├──> keybindingsStore.hydrate()     → load_keybindings
    ├──> agentConfigsStore.hydrate()    → load_agents_config
    └──> agentDetection.detectAll()     → detect installed AI agents

Event System

Tauri Events (Backend → Frontend)

EventPayloadSource
pty-output-{session_id}{session_id, data}PTY reader thread
pty-exit-{session_id}{session_id}PTY child exit
dictation-progress{percent}Model download
menu-event{id}Native menu click

Frontend Event Handling

Menu events are handled in App.tsx:

listen("menu-event", (event) => {
  switch (event.payload.id) {
    case "new-tab": handleNewTab(); break;
    case "close-tab": closeTerminal(); break;
    case "toggle-sidebar": uiStore.toggleSidebar(); break;
    // ... 30+ menu actions
  }
});

GitHub Polling

githubStore.startPolling()
    │
    ▼
invoke("github_start_polling", {paths, issueFilter, prHideDrafts})
    │
    ▼
Rust: github_poller loop (60s base, 120s when tab hidden,
      300s max, exponential backoff on errors / rate limit)
    │
    ├──> GraphQL batch query to GitHub API per repo
    │
    ▼
Rust emits Tauri events (only when state changed since last poll):
    ├──> "github-pr-update"    {repo_path, statuses}
    ├──> "github-issues-update" {repo_path, issues}
    └──> "github-transition"   — PR notifications on state changes
         (merged, closed, blocked, ci_failed, changes_requested, ready)
    │
    ▼
githubStore listen() handlers update store state
    │
    ▼
Reactive updates to Sidebar CI rings, PR badges

PR State Filtering in StatusBar

The StatusBar applies lifecycle rules before displaying PR data:

githubStore.getBranchPrData(repoPath, branch)
    │
    ├── state = CLOSED → never show (filtered out)
    ├── state = MERGED → show for 5 min of accumulated user activity, then hide
    │                     (userActivityStore tracks click/keydown events)
    └── state = OPEN   → show as-is (PR badge + CI badge)

Per-Repo Immediate Polls

On repo-changed events (git index/refs/HEAD changes), githubStore.pollRepo(path) triggers an immediate re-poll for that repo, debounced to 2 seconds to coalesce rapid git events.

Claude Usage Polling

Claude Usage is a native feature (not a plugin) managed by src/features/claudeUsage.ts. It polls the Anthropic OAuth usage API and posts results to the status bar ticker.

initClaudeUsage()  (called from plugins/index.ts if not disabled)
    │
    every 5 min (API_POLL_MS)
    │
    ▼
invoke("get_claude_usage_api")
    │
    ▼
Rust: read ~/.claude/.credentials OAuth token
    → HTTP GET to Anthropic usage endpoint
    → parse UsageApiResponse (five_hour, seven_day, per-model buckets)
    │
    ▼
statusBarTicker.addMessage({
    id: "claude-usage:rate",
    pluginId: "claude-usage",
    text: "Claude: 5h: 42% · 7d: 18%",
    priority: 10–90 (based on utilization),
    onClick: openDashboard
})
    │
    ▼
StatusBar renders ticker message
    ├── Standalone ticker (when active agent is not claude)
    └── Absorbed into agent badge (when active agent is claude)

Status Bar Ticker

The statusBarTicker store provides a priority-based rotating message system used by the Claude Usage feature and available to plugins via the ui:ticker capability.

statusBarTicker
    │
    ├── Messages sorted by priority (descending)
    ├── Equal-priority messages rotate every 5s
    ├── Expired messages scavenged every 1s (TTL-based)
    │
    └── StatusBar rendering:
        ├── Agent badge absorbs claude-usage messages when active agent is claude
        └── Standalone ticker for all other messages

Keyboard Shortcut Flow

KeyDown Event
    │
    ▼
useKeyboardShortcuts (global listener)
    │
    ├── Platform modifier detection (Cmd on macOS, Ctrl on Win/Linux)
    ├── Shortcut matching against registered handlers
    │
    ▼
Handler execution (e.g., handleNewTab, toggleSidebar)
    │
    ▼
Store updates → Reactive UI updates

Quick Switcher (held-key UI):

Cmd+Ctrl pressed (macOS) / Ctrl+Alt pressed (Win/Linux)
    │
    ▼
Show branch overlay with numbered shortcuts
    │
    ▼
Press 1-9 while holding modifier
    │
    ▼
switchToBranchByIndex(index) → handleBranchSelect()
    │
    ▼
Release modifier → hide overlay

State Management

Overview

State is split between the Rust backend (source of truth for persistence) and SolidJS frontend stores (reactive UI state).

Backend State (src-tauri/src/state.rs)

AppState

The central backend state, shared across all Tauri commands via State<'_, Arc<AppState>>:

#![allow(unused)]
fn main() {
pub struct AppState {
    pub sessions: DashMap<String, Mutex<PtySession>>,       // Active PTY sessions
    pub worktrees_dir: PathBuf,                              // Worktree storage path
    pub metrics: SessionMetrics,                             // Atomic counters
    pub output_buffers: DashMap<String, Mutex<OutputRingBuffer>>, // MCP output access
    pub mcp_sse_sessions: DashMap<String, UnboundedSender<String>>, // SSE clients
    pub ws_clients: DashMap<String, Vec<UnboundedSender<String>>>,  // WebSocket clients
}
}

Concurrency model:

  • DashMap for lock-free concurrent read/write of session maps
  • Mutex for interior mutability of individual PTY writers and buffers
  • Arc<AtomicBool> for pause/resume signaling per session
  • AtomicUsize for zero-overhead metrics counters

PtySession

#![allow(unused)]
fn main() {
pub struct PtySession {
    pub writer: Box<dyn Write + Send>,          // Write to PTY
    pub master: Box<dyn MasterPty + Send>,      // PTY master handle
    pub(crate) _child: Box<dyn Child + Send>,   // Child process
    pub(crate) paused: Arc<AtomicBool>,         // Pause flag
    pub worktree: Option<WorktreeInfo>,         // Associated worktree
    pub cwd: Option<String>,                    // Working directory
}
}

SessionMetrics

Zero-overhead atomic counters:

#![allow(unused)]
fn main() {
pub(crate) struct SessionMetrics {
    pub(crate) total_spawned: AtomicUsize,
    pub(crate) failed_spawns: AtomicUsize,
    pub(crate) active_sessions: AtomicUsize,
    pub(crate) bytes_emitted: AtomicUsize,
    pub(crate) pauses_triggered: AtomicUsize,
}
}

Buffer Types

BufferPurposeCapacity
Utf8ReadBufferAccumulates bytes until valid UTF-8 boundaryVariable
EscapeAwareBufferHolds incomplete ANSI escape sequencesVariable
OutputRingBufferCircular buffer for MCP output access64 KB

Constants

#![allow(unused)]
fn main() {
pub(crate) const MAX_CONCURRENT_SESSIONS: usize = 50;
pub(crate) const OUTPUT_RING_BUFFER_CAPACITY: usize = 64 * 1024;
}

Frontend Stores

Store Pattern

All stores follow a consistent pattern:

// Internal reactive state
const [state, setState] = createStore<Type>(defaults);

// Exported as a module object
export const myStore = {
  get state() { return state; },  // Read-only access
  hydrate() { ... },              // Load from Rust
  action() { ... },               // Mutate + persist
};

Store Registry

StoreFilePurposePersisted
terminalsStoreterminals.tsTerminal instances, active tab, split layoutPartial (IDs in repos)
repositoriesStorerepositories.tsSaved repos, branches, terminal associations, repo groupsrepositories.json
settingsStoresettings.tsApp settings (font, shell, IDE, theme, update channel)config.json
repoSettingsStorerepoSettings.tsPer-repository settings (scripts, worktree)repo-settings.json
repoDefaultsStorerepoDefaults.tsDefault settings for new repositoriesrepo-defaults.json
uiStoreui.tsPanel visibility, sidebar widthui-prefs.json
githubStoregithub.tsPR/CI data per branch, remote tracking (ahead/behind), PR state transitionsNot persisted
promptLibraryStorepromptLibrary.tsPrompt templatesprompt-library.json
notificationsStorenotifications.tsNotification preferencesnotification-config.json
dictationStoredictation.tsDictation config and statedictation-config.json
errorHandlingStoreerrorHandling.tsError retry configui-prefs.json
rateLimitStoreratelimit.tsActive rate limitsNot persisted
tasksStoretasks.tsAgent task queueNot persisted
promptStoreprompt.tsActive prompt overlay stateNot persisted
diffTabsStorediffTabs.tsOpen diff tabsNot persisted
mdTabsStoremdTabs.tsOpen markdown tabs and plugin panelsNot persisted
notesStorenotes.tsIdeas/notes with repo tagging and used-at trackingnotes.json
statusBarTickerstatusBarTicker.tsPriority-based rotating status bar messagesNot persisted
userActivityStoreuserActivity.tsTracks last user click/keydown for activity-based timeoutsNot persisted
updaterStoreupdater.tsApp update state (check, download, install)Not persisted
keybindingsStorekeybindings.tsCustom keyboard shortcut bindingskeybindings.json
agentConfigsStoreagentConfigs.tsPer-agent run configs and togglesagents.json

Key Store Relationships

repositoriesStore
    │
    ├── BranchState.terminals: string[]  ──references──> terminalsStore IDs
    ├── BranchState.worktreePath         ──managed by──> worktree.rs
    └── BranchState.additions/deletions  ──from──> git.rs (get_diff_stats)

terminalsStore
    │
    ├── TerminalData.sessionId           ──maps to──> AppState.sessions key
    ├── TerminalData.agentType           ──read by──> StatusBar (agent badge)
    ├── TerminalData.usageLimit          ──read by──> StatusBar (usage display)
    └── TabLayout.panes                  ──indexes into──> TerminalData[]

githubStore
    │
    ├── Per-branch PR status             ──from──> github.rs (get_repo_pr_statuses)
    ├── Per-repo remote status           ──from──> github.rs (get_github_status)
    ├── CheckSummary                     ──drives──> CiRing component
    └── PR state transitions             ──emits to──> prNotificationsStore

statusBarTicker
    │
    ├── TickerMessage[]                  ──rendered by──> StatusBar
    ├── Claude Usage messages            ──from──> features/claudeUsage.ts (native)
    └── Plugin messages                  ──from──> pluginRegistry (ui:ticker capability)

notesStore
    │
    ├── Note.repoPath                    ──filters by──> active repo
    ├── Note.usedAt                      ──marks when──> sent to terminal
    └── filteredCount()                  ──drives──> StatusBar badge

settingsStore
    │
    ├── font/theme                       ──configures──> Terminal component
    ├── shell                            ──passed to──> create_pty
    ├── ide                              ──used by──> open_in_app
    └── updateChannel                    ──used by──> updaterStore

Debug Registry (MCP Introspection)

Stores self-register snapshot functions via src/stores/debugRegistry.ts. Snapshots are exposed on window.__TUIC__ and accessible through MCP debug(action=invoke_js).

APIReturns
__TUIC__.stores()List of registered store names
__TUIC__.store(name)Snapshot of the named store

Registered: github, globalWorkspace, keybindings, notes, paneLayout, repositories, settings, tasks, ui.

To register a new store, append at the bottom of the store file:

import { registerDebugSnapshot } from "./debugRegistry";
registerDebugSnapshot("name", () => ({ /* safe subset */ }));

Configuration Files

All config files are JSON, stored in the platform config directory:

PlatformPath
macOS~/Library/Application Support/tuicommander/
Linux~/.config/tuicommander/
Windows%APPDATA%/tuicommander/

Legacy path ~/.tuicommander/ is auto-migrated on first launch.

Config File Map

FileContentsRust Type
config.jsonShell, font, theme, MCP, remote access, update channelAppConfig
notification-config.jsonSound preferences, volumeNotificationConfig
ui-prefs.jsonSidebar, error handling settingsUIPrefsConfig
repo-settings.jsonPer-repo scripts, worktree optionsRepoSettingsMap
repo-defaults.jsonDefault settings for new repos (base branch, scripts)RepoDefaultsConfig
repositories.jsonSaved repos, branches, groupsserde_json::Value
prompt-library.jsonPrompt templatesPromptLibraryConfig
dictation-config.jsonDictation on/off, hotkey, language, modelDictationConfig
notes.jsonIdeas/notes with repo tags and used-at timestampsserde_json::Value
keybindings.jsonCustom keyboard shortcut overridesserde_json::Value
agents.jsonPer-agent run configs and togglesAgentsConfig
claude-usage-cache.jsonIncremental JSONL parse offsets for session statsSessionStatsCache

Terminal State Machine

Definitive reference for terminal activity states, notifications, and question detection.

State Variables

Each terminal has these reactive fields in terminalsStore:

FieldTypeDefaultSource of truth
shellState"busy" | "idle" | nullnullRust (emitted as parsed event)
awaitingInput"question" | "error" | nullnullFrontend (from parsed events)
awaitingInputConfidentbooleanfalseFrontend (from Question event)
activeSubTasksnumber0Rust (parsed + stored per session)
debouncedBusybooleanfalseFrontend (derived from shellState with 2s hold)
unseenbooleanfalseFrontend (set by fireCompletion, cleared on tab focus)
agentTypeAgentType | nullnullFrontend (from agent detection)
agentState"starting" | "working" | "awaiting_input" | "idle" | "completed" | nullnullRust (session lifecycle snapshot)
backgroundWorkbooleanfalseRust (session lifecycle snapshot)

Rust-side per-session state:

FieldLocationPurpose
SilenceState.last_output_atpty.rsTimestamp of last real output (not mode-line ticks)
SilenceState.last_chunk_atpty.rsTimestamp of last chunk of any kind (real or chrome-only). Used by backup idle timer to detect reader thread activity.
SilenceState.last_status_line_atpty.rsTimestamp of last spinner/status-line
SilenceState.pending_question_linepty.rsCandidate ?-ending line for silence detection
SilenceState.output_chunks_after_questionpty.rsStaleness counter: real-output chunks since last ? candidate
SilenceState.question_already_emittedpty.rsPrevents re-emission of the same question
SilenceState.suppress_echo_untilpty.rsDeadline to ignore PTY echo of user-typed ? lines
active_sub_tasksAppState.session_statesSub-agent count per session
shell_statesAppState.shell_statesDashMap<String, AtomicU8>: 0=null/unobserved, 1=busy, 2=idle. Null is omitted on the wire and produces agent lifecycle starting; transitions use compare_exchange to prevent duplicate events when reader thread and silence timer race.
last_output_msAppState.last_output_msEpoch ms of last real output (not chrome-only). Stamped only when !chrome_only.
SessionState.background_workAppState.session_statesMeaningful live agent descendant; persistent integration-helper subtrees are excluded. Keeps task lifecycle working without changing terminal readiness.

The Activity Dashboard uses an effective state rather than raw shellState: rate limit/error/input take precedence; a ready composer is shown as Idle even when the backend still tracks a long-lived background terminal. Otherwise lifecycle starting/working (including backgroundWork) precedes completed, live shell activity, and lifecycle or shell idle. Live shell busy intentionally overrides a lagging lifecycle idle snapshot. The periodic session snapshot updates both lifecycle and shell state, is serialized with a bounded native-IPC timeout, and records both the session identity and shell-event revision at request start so a PTY event or session replacement received while it is in flight wins. A successful snapshot that omits a session marks its terminal exited and clears its session and lifecycle fields; a transport failure leaves the existing state untouched. The raw busy debounce is not a dashboard working signal, so a fresh completed snapshot cannot remain working-styled or working-ordered; lifecycle idle does so only when there is no newer live shell activity.

1. Tab Indicator — Visual Priority

The tab dot reflects the terminal’s highest-priority active state:

Priority    State       Color       CSS var         Condition
────────    ─────       ─────       ───────         ─────────
   1        Error       red         --error         awaitingInput == "error"
   2        Question    orange      --attention     awaitingInput == "question"
   3        Busy        blue ●̣      --activity      debouncedBusy && !awaitingInput
   4        Unseen      purple      --unseen        unseen && !debouncedBusy && !awaitingInput
   5        Done        green       --success       shellState=="idle" && !unseen && !debouncedBusy && !awaitingInput
   6        Idle        gray        (default)       shellState==null or none of above

Error and Question have pulse animation. Busy has pulse animation. Unseen and Done are static.

Complete state combination matrix

Every valid combination of the 4 key fields and the resulting indicator:

awaitingInput  debouncedBusy  unseen  shellState   → Indicator
─────────────  ─────────────  ──────  ──────────   ──────────
"error"        true           any     any          → Error (red)
"error"        false          any     any          → Error (red)
"question"     true           any     any          → Question (orange)
"question"     false          any     any          → Question (orange)
null           true           any     any          → Busy (blue pulse)
null           false          true    "idle"       → Unseen (purple)
null           false          true    null         → Unseen (purple)
null           false          false   "idle"       → Done (green)
null           false          false   "busy"       → (transient: cooldown pending)
null           false          false   null         → Idle (gray)

Lifecycle of each indicator

                    ┌──────────────────────── Error (red) ◄─── API error / agent crash
                    │                              │
                    │   ┌──────────────────── Question (orange) ◄─── agent asks ?
                    │   │                          │
                    │   │    ┌─────────────── Busy (blue) ◄─── real output detected
                    │   │    │                     │
                    │   │    │    ┌────────── Unseen (purple) ◄─── completion fired,
                    │   │    │    │                │                user not watching
                    │   │    │    │    ┌───── Done (green) ◄─── user viewed unseen tab,
                    │   │    │    │    │            │              or short idle session
                    │   │    │    │    │    ┌─ Idle (gray) ◄─── no session / fresh
                    │   │    │    │    │    │
                    ▼   ▼    ▼    ▼    ▼    ▼
                 [ Higher priority wins when multiple states active ]

2. shellState — Derived in Rust

Rust is the single source of truth. The reader thread classifies every PTY chunk:

PTY chunk arrives in reader thread
         │
         ▼
    ┌─────────────────────────────┐
    │ Compute chrome_only:        │
    │  = no regex question found  │
    │  AND no ?-ending line       │
    │  AND changed_rows non-empty │
    │  AND ALL changed rows pass  │
    │    is_chrome_row() (contain │
    │    ⏵/›/✻/• markers)        │
    └─────────┬───────────────────┘
              │
         chrome_only?
        ╱            ╲
      YES             NO
       │               │
       ▼               ▼
  Mode-line tick    Real output
  (timer, spinner)  (agent working)
       │               │
       │               ├── last_output_at = now
       │               │
       │               └── if shell_state ≠ busy:
       │                      emit ShellState { "busy" }
       │                      shell_state = busy
       │
       └── if shell_state == busy
           AND last_output_at > threshold ago
               (500ms for shell, 5s for agent sessions)
           AND active_sub_tasks == 0
           AND not in resize grace:
              emit ShellState { "idle" }
              shell_state = idle

A backup timer (the existing silence timer, 1s interval) also checks:

Silence timer (every 1s)
         │
         ▼
    reader thread active?  ─── YES ──► skip
    (last_chunk_at < 2s)       (reader handles idle via !has_status_line guard)
         │ NO
         ▼
    shell_state == busy?  ─── NO ──► skip
         │ YES
         ▼
    last_output_at > threshold ago?  ─── NO ──► skip
    (500ms shell / 2.5s agent)
         │ YES
         ▼
    active_sub_tasks == 0?  ─── NO ──► skip
         │ YES
         ▼
    emit ShellState { "idle" }
    shell_state = idle

This catches the case where NO chunks arrive at all (agent truly silent — reader thread blocked on read()). When chrome-only chunks are arriving (mode-line timer ticks), the reader thread is active and handles idle transitions correctly via its own !has_status_line guard.

Session end

When the reader thread loop breaks (Ok(0)), Rust emits ShellState { "idle" } before stopping, ensuring the frontend sees the final transition. The frontend then receives the exit callback and sets sessionId = null.

Frontend consumption

pty-parsed event: ShellState { state }
         │
         ▼
terminalsStore.update(id, { shellState: state })
         │
         ▼
handleShellStateChange(prev, next)  ← existing debounced busy logic

The frontend does NOT derive shellState from raw PTY data. handlePtyData renders grid frames and updates lastDataAt — but never touches shellState.

Transition table

FromToTriggerCondition
nullbusyFirst real output chunk
busyidleChrome-only chunk or silence timerlast_output_at > threshold (500ms shell / 2.5s agent) AND active_sub_tasks == 0 AND not resize grace
idlebusyReal output chunk
busyidleSession ends (reader thread exit)Always (cleanup)
anynullTerminal removed from storecleanup

What does NOT cause transitions

EventWhy it’s ignored
Mode-line timer tick (✻ Cogitated 3m 47s)Classified as chrome_only
Status-line update (▶▶ ... 1 local agent)Classified as chrome_only
ActiveSubtasks eventUpdates counter, doesn’t produce real output
Resize redrawSuppressed by resize grace (1s)

3. debouncedBusy — Derived from shellState

Smoothed version with a 2-second hold to prevent flicker:

shellState events from Rust:

  busy ─────────── idle ──── busy ─────── idle ──────────────────
                     │         │            │
debouncedBusy:       │         │            │
  true ──────────────┼─────────┼── true ────┼── true ──┐ false ──
                     │         │            │          │
                     └── 2s ───┘            └── 2s ───┘
                     cooldown               cooldown
                     cancelled              expires
EventdebouncedBusy effect
shellState → busyImmediately true. Cancel any running cooldown. Record busySince (first time only).
shellState → idleStart 2s cooldown. If cooldown expires: set false, fire onBusyToIdle(id, duration).
shellState → busy during cooldownCancel cooldown. Stay true. Keep original busySince.

onBusyToIdle fires exactly once per busy→idle cycle, after the 2s cooldown fully expires.

4. awaitingInput — Question and Error Detection

State diagram

                          Question event
                          (passes all guards)
    ┌──────┐             ┌──────────┐
    │ null │────────────►│ question │
    └──┬───┘             └─────┬────┘
       │                       │
       │  ◄── clear triggers ──┘
       │      (see table below)
       │
       │  Error event
       │  (API error, agent crash)
       │                 ┌────────┐
       └────────────────►│ error  │
                         └───┬────┘
                             │
                    ◄── clear triggers ──┘
                        (see table below)

Clear triggers

TriggerClears “question”?Clears “error”?Why
StatusLine parsed eventYesYesAgent is working again (showing a task)
Progress parsed eventYesYesAgent is making progress
User keystroke (terminal.onData)YesYesUser typed something — prompt answered
shellState idle → busyYesNoAgent resumed real output (reliable post-refactor since mode-line ticks no longer cause idle→busy)
Process exitYesYesSession over

What does NOT clear awaitingInput

EventWhy it doesn’t clear
shellState idle → busyClears "question" but not "error". API errors are persistent and need explicit agent activity (status-line) or process exit to clear.
Mode-line tickChrome-only output, not agent activity
activeSubTasks changeSub-agent count changing doesn’t mean the main question was answered

Notification sounds

Sounds play on transitions into a state, never on repeated sets or clearing:

getAwaitingInputSound(prev, current):

  prev      current     sound
  ────      ───────     ─────
  null   →  question →  play "question"
  null   →  error    →  play "error"
  *      →  same     →  null (no sound)
  *      →  null     →  null (clearing, no sound)
  question→ error    →  play "error" (state changed)
  error  →  question →  play "question" (state changed)

5. unseen — Completion Visibility Tracking

unseen tracks whether the user has seen a completed task.

Lifecycle

                                          ┌─────────┐
  fireCompletion() ──────────────────────►│ unseen  │
  (background tab, agent done)            │ = true  │
                                          └────┬────┘
                                               │
  User clicks/switches to this tab ───────────►│
  (setActive clears unseen)                    │
                                               ▼
                                          ┌─────────┐
                                          │ unseen  │
                                          │ = false │
                                          └─────────┘

What sets unseen

Only ONE place: App.tsx fireCompletion() sets unseen = true (along with activity = true).

What clears unseen

Only ONE place: terminalsStore.setActive(id) sets unseen = false.

Tab color transitions for unseen

Agent working    Agent done     User switches    User switches
(background)     (background)   to other tab     to THIS tab
    │                │               │                │
    ▼                ▼               ▼                ▼
  Blue ●̣  ───►  Purple ● ────►  Purple ● ────►  Green ●
  (busy)       (unseen)        (stays unseen)   (done/idle)

6. activeSubTasks — Sub-agent Tracking

Parsed from the agent mode line by Rust OutputParser:

Mode line text                               Parsed count
──────────────────────────────────────────   ────────────
"▶▶ bypass permissions on · 1 local agent"  → 1
"▶▶ Reading files · 3 local agents"         → 3
"▶▶ bypass permissions on"                  → 0
(no mode line)                               → unchanged

Stored in both Rust (AppState.active_sub_tasks) and frontend (terminalsStore).

Effects on other states

                    activeSubTasks
                    ┌─────────────────────────────────────────────┐
                    │                                             │
                    ▼                                             ▼
              > 0 (agents running)                    == 0 (no agents)
              ┌────────────────────┐                  ┌──────────────────┐
              │ shellState:        │                  │ shellState:      │
              │   stays busy       │                  │   normal rules   │
              │   (idle blocked)   │                  │   (500ms timer)  │
              │                    │                  │                  │
              │ Question guard:    │                  │ Question guard:  │
              │   low-confidence   │                  │   passes through │
              │   IGNORED          │                  │                  │
              │                    │                  │                  │
              │ Completion:        │                  │ Completion:      │
              │   SUPPRESSED       │                  │   normal rules   │
              └────────────────────┘                  └──────────────────┘

Reset

EventEffect
ActiveSubtasks { count: N } parsed eventSet to N
UserInput parsed eventReset to 0 (new agent cycle)
Process exitReset to 0

7. Completion Notification

Fires when an agent was busy for ≥5s then truly goes idle.

Two signals share one per-busy-cycle completion latch:

Path 1: Session exit (Terminal.tsx)

Process exits → reader thread ends → exit callback fires
         │
         ├── terminal is active tab? → SKIP
         ├── cycle already notified? → SKIP
         │
         └── mark cycle notified → play("completion")
             (does NOT set unseen — user may switch soon)

Path 2: Busy-to-idle (App.tsx) — sets unseen

onBusyToIdle(id, durationMs)
         │
         ├── durationMs < 5s? ────────────────────── SKIP
         ├── terminal is active tab? ─────────────── SKIP
         │
         ├── agentType set? ── YES ─► defer 10s ──► fireCompletion()
         │                 NO ──────► fireCompletion()
         │
         ▼
    fireCompletion()
         │
	     ├── cycle already notified? ───── SKIP (exit won the race)
         ├── terminal is active tab? ──── SKIP (user switched to it)
         ├── debouncedBusy still true? ── SKIP (went busy again)
         ├── terminal removed? ────────── SKIP
         ├── activeSubTasks > 0? ──────── SKIP (agents still running)
         ├── awaitingInput set? ────────── SKIP (question/error active)
         │
         ▼
    mark cycle notified
    play("completion")
    set unseen = true
    → tab turns purple (Unseen)
    → when user views: tab turns green (Done)

Sound deduplication

The first path to handle a busy cycle marks completionNotified. The other path then exits without calling the notification manager. A subsequent idle→busy transition resets the latch. This deduplicates an immediate process exit and a delayed BUSY→IDLE callback even when they are more than the audio cooldown apart.

Timing under the new architecture

t=0       Agent starts working (real output) → shellState: busy
t=0..T    Agent works. Mode-line ticks arrive but don't affect shellState.
t=T       Agent stops real output. Mode-line may continue.
t=T+0.5   Shell session: Rust idle threshold (500ms) reached → shellState: idle
          Agent session: still within 2.5s threshold → stays busy
           (If sub_tasks > 0: stays busy regardless of threshold)
t=T+2.5   Shell: Cooldown expires → debouncedBusy: false → onBusyToIdle fires
          Agent: Rust idle threshold (2.5s) reached → shellState: idle
t=T+4.5   Agent: Cooldown expires → debouncedBusy: false → onBusyToIdle fires
t=*+0     duration = T seconds. If T ≥ 5s and agentType:
             → defer 10s → fireCompletion
t=*+10    fireCompletion checks all guards → play("completion"), unseen=true

8. Question Detection Pipeline

Two layers: Rust detectionFrontend notification.

Rust: Two parallel detection strategies

┌─────────────────────────────────────────────────────────────────┐
│                    READER THREAD (per chunk)                     │
│                                                                 │
│  PTY data → parse_clean_lines(changed_rows) → events[]          │
│                                                                 │
│  ┌─ Strategy A: Regex (instant) ──────────────────────────────┐ │
│  │ parse_question() matches "Enter to select"                 │ │
│  │ → Question { confident: true }                             │ │
│  │ → emitted immediately in the events list                   │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
│  ┌─ Strategy B: Silence (delayed) ────────────────────────────┐ │
│  │ extract_question_line(changed_rows)                        │ │
│  │ → finds last line ending with '?' that passes              │ │
│  │   is_plausible_question() filter                           │ │
│  │ → stored as pending_question_line in SilenceState          │ │
│  │ → NOT emitted yet — waits for silence timer                │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
│  on_chunk() updates SilenceState:                               │
│    - regex fired? → clear pending, mark emitted                 │
│    - echo suppress window? → ignore '?' line                    │
│    - same line already emitted? → ignore (repaint)              │
│    - new '?' line? → set as pending candidate                   │
│    - real output after '?'? → increment staleness               │
│    - mode-line tick? → do nothing                               │
│    - staleness > 10? → clear pending (agent kept working)       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                    SILENCE TIMER (every 1s)                      │
│                                                                 │
│  is_silent()?                                                   │
│    ├── question_already_emitted? → skip                         │
│    ├── is_spinner_active()? → skip (status-line < 10s ago)      │
│    └── last_output_at < 10s? → skip                             │
│                                                                 │
│  If silent (all three pass):                                    │
│                                                                 │
│  ┌─ Strategy 1: Screen-based ────────────────────────────────┐  │
│  │ Read VT screen → extract_last_chat_line()                 │  │
│  │ → find line above prompt (❯, ›, >)                        │  │
│  │ → ends with '?' AND is_plausible_question()?              │  │
│  │ → emit Question { confident: false }                      │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌─ Strategy 2: Chunk-based fallback ────────────────────────┐  │
│  │ check_silence() → pending_question_line exists?           │  │
│  │ AND not stale (≤ 10 real-output chunks after)?            │  │
│  │ → verify_question_on_screen() (bottom 5 rows)            │  │
│  │ → emit Question { confident: false }                      │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  If neither strategy finds a question: continue sleeping.       │
└─────────────────────────────────────────────────────────────────┘

SilenceState update rules

Chunk typelast_chunk_atlast_output_atlast_status_line_atstaleness counterpending_question_line
Real output, no ‘?’Reset to nowReset to now+1 (if pending exists)Cleared if >10
Real output with ‘?’Reset to nowReset to nowReset to 0Set to new line
Real output + status-lineReset to nowReset to nowReset to now(per above rules)(per above rules)
Mode-line tick onlyReset to nowNot resetNot resetNot incrementedNot affected
Regex question firedReset to nowReset to nowReset to 0Cleared (handled)

Frontend: event handler + notification

pty-parsed: Question { prompt_text, confident }
         │
         ▼
    ┌──────────────────────────────────────────────────┐
    │ Guard: low-confidence question while agent busy  │
    │                                                  │
    │ NOT confident                                    │
    │ AND (shellState == "busy"                        │
    │      OR activeSubTasks > 0)?                     │
    │                                                  │
    │ YES → IGNORE (likely false positive)             │
    │ NO  → continue                                   │
    └──────────────────┬───────────────────────────────┘
                       │
                       ▼
    setAwaitingInput(id, "question", confident)
                       │
                       ▼
    createEffect detects transition (null → "question")
                       │
                       ▼
    play("question")

9. Timing Constants

ConstantValueLocationPurpose
Shell idle threshold500mspty.rs (Rust)Real output silence before idle (plain shell)
Agent idle threshold2.5spty.rs (Rust)Real output silence before idle (agent sessions)
Debounce hold2sterminals.tsdebouncedBusy hold after idle
Silence question threshold10spty.rsSilence before ‘?’ line → question
Silence check interval1spty.rsTimer thread wake frequency
Backup idle chunk threshold2spty.rsSkip backup idle if any chunk arrived within this window
Stale question chunks10pty.rsReal-output chunks before discarding ‘?’ candidate
Resize grace1spty.rsSuppress all events after resize
Echo suppress window500mspty.rsIgnore PTY echo of user-typed ‘?’ lines
Screen verify rows5pty.rsBottom N rows checked for screen verification
Completion threshold5sApp.tsxMinimum busy duration for completion notification
Completion deferral10sApp.tsxExtra wait for agent processes (sub-agents may still run)

10. Scenarios

A: Agent asks “Procedo?” — no sub-agents

t=0       Agent outputs "Procedo?" (real output)
          → shellState: busy
          → pending_question_line = "Procedo?"
          → last_output_at = now

t=2.5     No more real output. Rust idle check:
          last_output_at > 2.5s (agent threshold), active_sub_tasks=0
          → shellState: idle  │  Tab: blue→(cooldown)
          → debouncedBusy cooldown starts (2s)

t=1-9     Mode-line ticks arrive (chrome_only=true)
          → shellState stays busy (agent threshold not reached)
          → pending_question_line preserved

t=4.5     Cooldown expires → debouncedBusy: false
          → onBusyToIdle fires (duration ~2.5s < 5s → no completion)
          │  Tab: blue→green (Done)

t=10      Silence timer: is_silent()? YES
          → Strategy 1 or 2 finds "Procedo?"
          → emit Question { confident: false }
          → Frontend: guard passes (idle, subTasks=0)
          → awaitingInput = "question"
          → play("question") ✓
          │  Tab: green→orange (Question)

t=???     User types response → UserInput event
          → clearAwaitingInput
          │  Tab: orange→green (Done)
          → agent resumes → status-line → clearAwaitingInput (redundant, safe)
          │  Tab: green→blue (Busy)

B: Agent asks “Procedo?” — sub-agents running

t=0       Agent outputs "Procedo?" while 2 sub-agents run
          → shellState: busy  │  Tab: blue
          → pending_question_line = "Procedo?"
          → active_sub_tasks = 2

t=0.5+    No more real output but active_sub_tasks > 0
          → shellState stays busy (Rust: idle blocked)

t=10      Silence timer: is_silent()? YES
          → emit Question { confident: false }
          → Frontend: activeSubTasks=2 > 0, NOT confident → IGNORED

t=60      Last sub-agent finishes → ActiveSubtasks { count: 0 }

t=62.5    Rust: last_output_at > 2.5s (agent threshold), sub_tasks=0
          → shellState: idle  │  Tab: blue→(cooldown)

t=64.5    Cooldown expires → onBusyToIdle(duration=60s)
          → ≥ 5s, agentType set → defer 10s

t=74.5    fireCompletion()
          → activeSubTasks=0, awaitingInput=null
          → play("completion") ✓, unseen=true
          │  Tab: purple (Unseen)

          User switches to tab → unseen cleared
          │  Tab: purple→green (Done)

C: Ink menu — “Enter to select”

t=0       Agent renders Ink menu with "Enter to select" footer
          → parse_question() regex match (INK_FOOTER_RE)
          → emit Question { confident: true } immediately
          → SilenceState: pending cleared, question_already_emitted = true

t=0       Frontend: confident=true → guard skipped (always passes)
          → awaitingInput = "question"
          → play("question") ✓
          │  Tab: orange (Question)

          No 10s wait needed — instant detection.

D: False positive — agent discusses code with ‘?’

t=0       Agent outputs "// Should we use HashMap?"
          → is_plausible_question → false (starts with //)
          → NO candidate set

t=0       Agent outputs "Does this look right?"
          → is_plausible_question → true
          → pending_question_line = "Does this look right?"

t=0.1+    Agent continues with more real output (non-'?')
          → staleness +1, +2, ... +11 (> STALE_QUESTION_CHUNKS=10)
          → pending_question_line cleared

t=10+     Silence timer: pending is None → nothing emitted ✓
          │  No false notification

E: Agent completes long task — no question

t=0       Agent starts working (real output)
          → shellState: busy  │  Tab: blue

t=120     Agent finishes, goes to prompt. No more real output.

t=122.5   Rust: last_output_at > 2.5s (agent threshold), sub_tasks=0
          → shellState: idle  │  Tab: blue→(cooldown)

t=124.5   Cooldown expires → onBusyToIdle(duration=120s)
          → ≥ 5s, agentType set → defer 10s

t=134.5   fireCompletion()
          → all guards pass
          → play("completion") ✓, unseen=true
          │  Tab: purple (Unseen)

          User switches to tab → unseen cleared
          │  Tab: purple→green (Done)

F: User watches terminal — active tab

t=0       Agent working in active tab (user watching)
          → shellState: busy  │  Tab: blue

t=60      Agent finishes → idle

t=62      onBusyToIdle fires
          → terminal IS active tab → SKIP
          → no sound, no unseen
          │  Tab: blue→green (Done) — user was watching

G: Short command — under 5s

t=0       User runs `ls` → shellState: busy  │  Tab: blue

t=0.1     Output finishes → idle

t=2.1     Cooldown expires → onBusyToIdle(duration=0.1s)
          → duration < 5s → SKIP
          → no sound, no unseen
          │  Tab: blue→green (Done)

H: Process exits in background tab

t=0       Agent working in background tab
          → shellState: busy  │  Tab: blue

t=60      Process exits → reader thread ends
          → Rust emits ShellState { "idle" }
          → Frontend exit callback:
            sessionId = null, clearAwaitingInput
            mark cycle notified, play("completion") [Path 1] ✓

t=62      Cooldown expires → onBusyToIdle(duration=60s)
          → fireCompletion [Path 2] sees cycle notified → SKIP
          │  Tab remains Done

          User switches to tab → unseen cleared
          │  Tab: purple→green (Done)

I: Rate-limit detected

t=0       Agent output matches rate-limit pattern
          → RateLimit parsed event emitted

t=0       Frontend handler:
          shellState == "busy"?
            YES → IGNORE (false positive from streaming code)
            NO  → agentType set, not recently detected?
              YES → play("warning") ✓, rateLimitStore updated
              NO  → SKIP (dedup)

J: Resize during question display

t=0       "Procedo?" visible on screen, awaitingInput = "question"
          │  Tab: orange (Question)

t=X       User resizes terminal pane
          → resize_pty called → SilenceState.on_resize()
          → Shell redraws visible output (real PTY output)
          → Rust: shellState → busy (real output)

t=X       Resize grace active (1s):
          → All notification events SUPPRESSED (Question, RateLimit, ApiError)
          → "Procedo?" in redraw doesn't re-trigger question

t=X+1     Grace expires. awaitingInput still "question" (never cleared).
          │  Tab stays orange ✓

K: Agent error (API error, stuck)

t=0       Agent output matches API error pattern
          → ApiError parsed event emitted

t=0       Frontend handler:
          → awaitingInput = "error"
          → play("error") ✓
          │  Tab: red (Error)

          User answers / agent retries → StatusLine event
          → clearAwaitingInput
          │  Tab: red→blue (Busy)

L: Question then error (priority override)

t=0       Agent asks question → awaitingInput = "question"
          │  Tab: orange (Question)

t=5       API error while question is pending
          → awaitingInput = "error" (overrides question)
          → play("error") ✓
          │  Tab: orange→red (Error)

          Agent recovers → StatusLine event
          → clearAwaitingInput
          │  Tab: red→blue (Busy)

11. File Reference

FileResponsibility
src-tauri/src/pty.rsSilenceState, spawn_silence_timer, shellState derivation, extract_question_line, verify_question_on_screen, extract_last_chat_line, spawn_reader_thread
src-tauri/src/output_parser.rsparse_question (INK_FOOTER_RE), parse_active_subtasks, ParsedEvent enum
src-tauri/src/state.rsAppState (includes shell_state, active_sub_tasks maps)
src/stores/terminals.tsshellState, awaitingInput, debouncedBusy, handleShellStateChange, onBusyToIdle
src/components/Terminal/Terminal.tsxhandlePtyData (grid frame render), pty-parsed event handler, process-exit completion fallback
src/components/Terminal/awaitingInputSound.tsgetAwaitingInputSound edge detection
src/hooks/useTerminalCompletionNotifications.tsonBusyToIdle → completion notification with deferral, guards, and per-cycle latch
src/stores/notifications.tsplay(), playQuestion(), playCompletion() etc.
src/components/TabBar/TabBar.tsxTab indicator class priority logic
src/components/TabBar/TabBar.module.cssIndicator colors and animations

Agent UI Analysis — General Reference

Cross-agent reference for parsing AI agent terminal UIs in TUICommander. Agent-specific layouts are documented in agents/<name>.md.

Scope

TUICommander supports multiple AI coding agents. Each has a unique terminal UI with different rendering approaches, chrome patterns, and interaction models. This document covers:

  1. Shared concepts and detection strategies
  2. Code architecture and known gaps
  3. Research methodology for ongoing verification

Agent-specific documents:

  • Claude Code — Ink-based, ANSI cursor positioning
  • Codex CLI — Ink-based, absolute positioning + scroll regions
  • Gemini CLI — Ink-like, relative positioning + prompt box
  • Aider — Sequential CLI, no TUI framework
  • OpenCode — Bubble Tea full-screen TUI

Detection Strategy Per Agent

Each agent class requires a different parsing strategy:

AgentUI TypeParsing Strategychrome.rs applies?
Claude CodeCLI inline (Ink)Changed-rows delta analysisYes
Codex CLICLI inline (Ink)Changed-rows delta analysisYes
OpenCodeFull-screen TUI (Bubble Tea)Screen snapshot analysisNo (all rows are “chrome”)
Gemini CLICLI inlineChanged-rows delta analysisYes
AiderCLI sequentialChanged-rows delta analysisYes

CLI inline agents (CC, Codex, Gemini, Aider) render output into the terminal sequentially, with chrome at specific positions. chrome.rs functions work for these — is_separator_line, is_prompt_line, is_chrome_row classify individual rows.

Full-screen TUI agents (OpenCode) take over the entire screen. Every row changes on every update, making delta analysis useless. These need screen-snapshot-based parsing: identify panels by position, extract text from known regions, detect state changes by content comparison.


Shared Concepts

Chrome Detection

“Chrome” = UI decoration rows that are NOT real agent output (separators, mode lines, status bars, spinners, menus). Correctly classifying chrome is critical for:

  • Silence-based question detection: chrome-only chunks should not reset the silence timer or invalidate pending questions
  • Shell state transitions: chrome-only output should not prevent BUSY → IDLE transitions
  • Log trimming: chrome should be stripped from mobile logs and REST API responses

Prompt Line

The row where the user types input. Each agent uses a different character:

AgentPrompt charUnicode
Claude CodeU+276F
Codex CLIU+203A
Gemini CLI>ASCII

Separator Lines

Horizontal rules that delineate sections. Detected by a run of 4+ box-drawing characters (─ ━ ═ — ╌ ╍). Not all agents use separators.

AgentUses separatorsStyle
Claude CodeYes──── around prompt box
Codex CLIPartially──── between tool output and summary only
Gemini CLINo
AiderNo

Interactive Menu Detection

All observed agent menus share the pattern Esc to in their footer:

Footer variantAgent / Context
Esc to cancel · Tab to amendCC permission prompt
Enter to select · Tab/Arrow keys to navigate · Esc to cancelCC custom Ink menu
↑↓ to navigate · Enter to confirm · Esc to cancelCC built-in (/mcp)
Esc to cancel · r to cycle dates · ctrl+s to copyCC built-in (/stats)
←/→ tab to switch · ↓ to return · Esc to closeCC built-in (/status)
Enter to select · ↑/↓ to navigate · Esc to cancelCC Ink select
esc again to edit previous messageCodex (after interrupt)

Esc to is the most reliable cross-agent signal for “interactive menu active.”

OSC Sequences

Terminal escape sequences that carry structured metadata:

SequencePurposeAgent
\033]777;notify;Claude Code;...\007User attention notificationCC
\033]0;...\007Window title (task name + spinner)CC, Codex
\033]8;;url\007HyperlinkCC
\033]9;4;N;\007Progress notificationCC
\033]10;?\033\\Query foreground colorCodex
\033]11;?\033\\Query background colorCodex

Code Architecture

Unified chrome.rs module

All chrome detection is centralized in src-tauri/src/chrome.rs. The three pipelines (pty.rs, session.rs, state.rs) all import from this single module:

src-tauri/src/chrome.rs
├── is_separator_line()    — run-of-4 box-drawing chars (─ ━ ═ — ╌ ╍)
├── is_prompt_line()       — all agent prompt chars: ❯ › >
├── is_agent_prompt_row()  — BARE prompt only (no echoed user message, no `> quote`)
├── is_chrome_row()        — 10 marker chars + dingbat range + Codex • disambiguation
├── CHROME_SCAN_ROWS       — single constant (15)
├── find_chrome_cutoff()   — screen trim (transient: over-trim self-corrects next repaint)
└── find_scrollback_chrome_cutoff() — history trim (permanent: prompt anchor only)
PipelineFileWhat it uses from chrome.rs
Changed-rows parserpty.rsis_chrome_row (for chrome_only), is_separator_line, is_prompt_line
Screen trim (REST)session.rsfind_chrome_cutoff (replaces local trim_screen_chrome body)
Log trim (mobile)state.rsfind_scrollback_chrome_cutoff via mark_agent_chrome

Screen trim vs scrollback trim

The two cutoffs are deliberately different because the cost of a false positive is different. The screen is re-rendered every frame, so an over-trim disappears on the next repaint — find_chrome_cutoff can afford a separator-only anchor and the loose is_prompt_line. Scrollback is permanent history, so find_scrollback_chrome_cutoff anchors only on a bare prompt row:

  • A standalone separator is not an anchor — markdown tables, progress bars and Codex’s └ ──── dividers all carry box-drawing runs mid-output.
  • A prompt row carrying text is the agent echoing the user’s submitted message (❯ rename is broken, › riprendiamo) — that is the conversation, not chrome.

Scrollback classification also marks rather than deletes (LogLine::chrome); readers skip flagged lines, so a misclassification hides text instead of destroying it.

Parsing Functions

Chrome Detection (is_chrome_row) — chrome.rs

Classifies changed terminal rows as “UI decoration” vs “real agent output”.

Detected markers:

  • (U+23F5) — CC mode-line prefix
  • (U+23F8) — CC plan mode prefix
  • (U+203A) — CC/Codex mode-line prefix
  • · (U+00B7) — CC middle-dot spinner prefix
  • (U+2580) — Gemini prompt box top border
  • (U+2584) — Gemini prompt box bottom border
  • (U+2591) — Aider Knight Rider spinner
  • (U+2588) — Aider Knight Rider spinner / CC context bar
  • (U+25A0) — Codex interrupt marker
  • (U+2022) — Codex spinner (disambiguated: • Working = chrome, • Created = output)
  • U+2720–U+273F — CC spinner dingbats (✶✻✳✢ etc.)

Used by chrome_only calculation (pty.rs) which also considers has_status_line from parse_status_line events (for Gemini braille/Aider spinners). Gates:

  • last_output_ms timestamp updates
  • SHELL_BUSYSHELL_IDLE transitions
  • SilenceState::on_chunk()

Gaps:

  • No positional awareness — cannot use “last row = always chrome” heuristic
  • A bare subprocess count (1 shell, no ⏵⏵) carries no chrome glyph, so is_chrome_row returns false for it — parse_active_subtasks still reads the count via its bare-count path
  • User-defined status lines are only partially covered: a row with block glyphs (Context █░░░ 8%) is chrome, a plain one ([Opus 4.6 | Max] │ repo) is not. This is a classification gap only — no detection logic reads status-line content, and is_spinner_row’s leading-glyph rule keeps a HUD progress bar from faking liveness (#446-596f). See Claude Code § Status Lines.

Subprocess Count (parse_active_subtasks) — output_parser.rs:706

Extracts subprocess count from the mode line. Must handle:

  1. Old format: ⏵⏵ <mode> · N <type> — markers first, count last
  2. New format: N <type> · ⏵⏵ <mode> — count first, markers last
  3. Count only: N <type> — no markers at all (e.g., 1 shell)
  4. Bare mode: ⏵⏵ <mode> — markers only, no count (count = 0)

All four formats are implemented: formats 1, 2 and 4 by the mode-marker path (⏵⏵/›› anywhere on the row, count extracted from either side of the ·), format 3 by the bare-count path, which is restricted to known subprocess types (agents, shells, bash, background tasks) so ordinary numbers in output cannot trigger it.

Question Detection (extract_last_chat_line) — pty.rs:207

Finds the last agent chat line above the prompt box:

  1. Scan from bottom, find prompt line (, , >)
  2. Walk up past separators and empty lines
  3. First non-empty, non-separator line = last chat line

Robust: Does not depend on mode line format.


Test Expectations

Tests that hardcode specific bottom-zone layouts. Update these when adding new format support.

output_parser.rs — parse_active_subtasks tests

All use format: ⏵⏵|›› <mode> · N <type> (old format only)

  • test_active_subtasks_local_agents›› bypass permissions on · 2 local agents
  • test_active_subtasks_single_bash›› reading config files · 1 bash
  • test_active_subtasks_background_tasks›› fixing tests · 3 background tasks
  • test_active_subtasks_single_local_agent›› writing code · 1 local agent
  • test_active_subtasks_bare_mode_line_resets_to_zero›› bypass permissions on
  • test_active_subtasks_explicit_zero_count›› finishing · 0 bash
  • test_active_subtasks_triangle_* — same patterns with ⏵⏵ prefix

pty.rs — extract_last_chat_line tests

  • test_extract_last_chat_line_standard_claude_code⏵⏵ bypass permissions on (shift+tab to cycle)
  • test_extract_last_chat_line_with_wiz_hud — 3 HUD lines + mode line
  • test_extract_last_chat_line_plan_mode⏸ plan mode on (shift+tab to cycle)

pty.rs — is_chrome_row / chrome_only tests

  • test_chrome_only_single_statusline_row_is_chrome⏵⏵ auto mode
  • test_chrome_only_wrapped_statusline_is_chrome⏵⏵ bypass permissions on + ✻ timer
  • test_chrome_only_subtasks_row_is_chrome›› bypass permissions on · 1 local agent

Verification Methodology

These documents must be re-verified weekly (or after agent version updates) to catch layout changes before they break parsing.

Procedure 1: Capture current layout from live sessions

Use session action=list to find active sessions, then for each:

session action=output session_id=<id> limit=4000               → clean text
session action=output session_id=<id> limit=8000 format=raw     → raw ANSI

Compare against documented layouts. Look for:

  • Changed cursor-up distances (\033[NA) → bottom zone height changed
  • New Unicode characters in mode/status lines → update is_chrome_row
  • Changed OSC sequences → update notification detection
  • New footer text → update question detection

Procedure 2: Trigger interactive menus

  1. Create a fresh session: session action=create
  2. Start agent in restricted mode (CC: --permission-mode default, Codex: -a untrusted)
  3. Request operations that trigger approval prompts
  4. Capture raw output — compare separators, colors, footers
  5. Cancel and clean up

Procedure 3: Audit parser compatibility

cd src-tauri && cargo test -- --test-threads=1 \
  chrome_only \
  active_subtasks \
  extract_last_chat_line \
  separator \
  prompt_line \
  question \
  2>&1 | head -100

Research Techniques

  1. Raw ANSI capture via MCP: session action=output format=raw reveals cursor positioning, colors, and OSC sequences invisible in clean output
  2. Cursor-up distance as height probe: \033[NA reveals bottom zone height
  3. OSC sequence interception: \033]777;notify;... and \033]0;... carry metadata
  4. Color as semantic signal: RGB colors distinguish interactive vs chrome elements
  5. Forced state transitions: specific CLI flags surface all UI variants
  6. Screen clear detection: \033[2J\033[3J\033[H distinguishes full-screen menus

Agent Detection Matrix

Standardized checklist for analyzing and onboarding new AI coding agent CLIs. Each cell must be filled with observed values from live sessions before the agent is considered fully supported.

Detection Matrix

1. Identity & Rendering

PropertyClaude CodeCodex CLIGemini CLIAiderOpenCode
Version testedv2.1.81v0.116.0+ (re-verified)v0.34.0v0.86.2v1.2.20
Date tested2026-03-212026-04-192026-03-222026-03-222026-03-22
Rendering engineInk (React)Ink (React)Ink-like (Node.js)Python rich + readlineBubble Tea (Go)
Cursor positioningRelative (\033[NA])Absolute (\033[r;cH)Relative (\033[1A])Sequential (no cursor)Absolute (\033[r;cH)
Scroll mechanism\r\n paddingScroll regions (\033[n;mr])\r\n paddingNormal scrollFull-screen redraw
Screen clear on menusSometimes (\033[2J)NoNoN/AFull-screen TUI
Parsing strategyChanged-rows deltaChanged-rows deltaChanged-rows deltaChanged-rows deltaScreen snapshot

2. Prompt Line

PropertyClaude CodeCodex CLIGemini CLIAiderOpenCode
Prompt char (U+276F) (U+203A, bold)> (purple, rgb 215,175,255)> (green, ANSI #40)None (framed box)
Prompt backgroundNoneDark gray (rgb 57,57,57)Dark gray (rgb 65,65,65)NoneDark (rgb 30,30,30)
Prompt box border──── separatorsBackground color only▀▀▀ top / ▄▄▄ bottomNone┃╹▀ vertical frame
Ghost text styledim cell attribute\033[2m dimGray (rgb 175,175,175)N/AGray placeholder
Multiline inputEnter = submitEnter = newlineEnter = submitEnter = submitUnknown

3. Separator Lines

PropertyClaude CodeCodex CLIGemini CLIAiderOpenCode
Uses separatorsYesPartiallyYesYes (green ─────)No (uses ┃╹▀)
Separator chars (U+2500) (U+2500) (U+2500) (U+2500) (vertical frame)
Separator colorGray (rgb 136,136,136)StandardDark gray (rgb 88,88,88)Green (rgb 0,204,0)N/A
Separator purposeFrame prompt boxBetween tool output & summaryAbove prompt areaBetween conversation turnsPrompt box border
Decorated separatorsYes (──── label ──)NoNoNoN/A
Min run length4+ charsFull widthFull widthFull widthN/A

4. Status / Chrome Lines

PropertyClaude CodeCodex CLIGemini CLIAiderOpenCode
Mode line⏵⏵ <mode> (last row)NoneNoneNoneMode in prompt box (Build/Plan)
Status line(s)0-N below separator1 line below prompt2-row status bar (4 columns)Token report after responseRight panel (context, cost, LSP)
Status indent2 spaces (\033[2C)2 spaces1 spaceNoneN/A (panel layout)
Info lineNoneNoneShift+Tab to accept edits + MCP/skills countNonetab agents · ctrl+p commands
Subprocess countIn mode lineNoneNoneNoneNone (progress bar instead)

5. Spinner / Working Indicators

PropertyClaude CodeCodex CLIGemini CLIAiderOpenCode
Spinner chars✶✻✳✢· (U+2720-273F) (U+2022)⠋⠙⠹⠸⠴⠦⠧⠇ (braille)░█ / █░ (Knight Rider)■⬝ (progress bar)
Spinner colorWhiteStandardBlue/green (varies)StandardStandard
Spinner positionAbove separatorInline with outputBelow output, above separatorInline (backspace overwrite)Footer row
Time display(1m 32s)(10s • esc to interrupt)(esc to cancel, Ns)NoneNone
Token display↓ 2.2k tokensNoneNoneTokens: Nk sent, N received. Cost: $X.XXNone
Tip textSpinner verb namesNoneItalic tips during spinnerNoneNone
Detected byis_chrome_rowis_chrome_rowparse_status_lineparse_status_linedetect_opencode_screen_activity ✓ (footer esc interrupt, not the bar glyphs)

6. Interactive Menus

PropertyClaude CodeCodex CLIGemini CLIAiderOpenCode
Permission promptMultiselect (❯ 1. Yes)Not observed (sandbox)None (model-level refusal)File add: Y/N/A/S/D△ Permission required inline
Selection char (blue)Not observedN/AN/A⇆ select
Footer patternEsc to cancel/closeesc to interruptesc to cancel (in spinner)Noneenter confirm
OSC 777 notifyYes — needs your permission (blocked, high-confidence); is waiting for your input (blocked picker OR 60s idle timer, low-confidence); needs your attention (ignored)NoNoNoNo
OSC 0 window titleYes (task + spinner)YesYes (◇ Ready (workspace))NoNo
Slash commands/mcp, /stats, /status/model, /mcp, /fast/help, /settings, /model, /stats/helpNone observed

7. System Messages

PropertyClaude CodeCodex CLIGemini CLIAiderOpenCode
Output prefix (white/green/red) (U+2022) (U+2726, purple)None (blue text)None (inline in panel)
Tool call display + verb• Ran <cmd> (shell), • Called + └ fn() (MCP)╭───╮ ✓ ToolName ╰───╯ boxNone read / write
Warning prefixN/A (U+26A0)N/AOrange textN/A
Error indicator (red) or • …hook (failed) + error textRed text┃ Error: inline
Interrupt markerN/ANot observed^Cesc interrupt hint
Tool result (U+23BF) (U+2514) tree connectorInside ╭───╮ boxInline completion marker
Truncation… +N lines… +N lines (ctrl + t to view transcript)Not observedNot observedNot observed

Trigger Procedures

How to force each UI state for analysis and testing.

Procedure A: Start agent in each permission mode

AgentRestricted modePermissive mode
Claude Codeclaude --permission-mode defaultclaude --permission-mode bypassPermissions
Codex CLIcodex -a untrustedcodex (suggest mode, default)
Gemini CLIgemini (default, workspace-restricted)gemini --sandbox=false (unconfirmed)
AiderN/A (no sandbox)N/A
OpenCodeUnknownUnknown

Procedure B: Trigger permission/approval prompt

AgentActionExpected result
Claude Code (default mode)“create a file /tmp/test.txt with hello”Multiselect: Yes/Yes+allow/No
Codex CLI (untrusted)SameNot observed — auto-approves in sandbox
Gemini CLI“create a file /tmp/test.txt with hello”Text refusal (workspace restriction)
AiderOpen file not in chatAdd file to the chat? (Y)es/(N)o/(A)ll/(S)kip all/(D)on't ask again
OpenCodeAccess external directory△ Permission required with Allow once / Allow always / Reject

Procedure C: Trigger interactive menus

AgentCommandExpected result
Claude Code/mcpServer list with selection
Claude Code/statsUsage heatmap with date cycling
Claude Code/statusSettings panel with search box
Codex CLI/modelModel selector
Codex CLI/mcpMCP server list
Gemini CLI/settingsSettings panel (unconfirmed)
Gemini CLI/statsUsage stats

Procedure D: Observe working state

AgentActionWhat to capture
AnySend a complex multi-tool taskSpinner animation, cursor-up distance
AnySend task during active subprocessSubprocess count display
AnyPress Escape during workInterrupt marker

Procedure E: Capture raw ANSI

For each state above:

session action=output session_id=<id> limit=8000 format=raw

Look for:

  • Cursor positioning: \033[NA] (relative up), \033[r;cH (absolute)
  • Colors: \033[38;2;R;G;Bm (RGB foreground)
  • Background: \033[48;2;R;G;Bm
  • Screen clear: \033[2J
  • Scroll regions: \033[n;mr
  • OSC sequences: \033]777;..., \033]0;..., \033]8;...

Onboarding a New Agent

  1. Fill the detection matrix columns by running procedures A-E
  2. Create docs/architecture/agents/<name>.md with observed layouts
  3. Update chrome.rs if new markers/chars are needed
  4. Add test cases from real captured text
  5. Run /agent-ui-audit skill to verify parser compatibility

Claude Code — UI Layout Reference

Agent-specific layout reference for Claude Code (Anthropic). See agent-ui-analysis.md for shared concepts.

Observed version: v2.1.81 (2026-03-21) Rendering engine: Ink (React for terminals) Rendering approach: ANSI relative cursor positioning (\033[NA, \033[1B)


Layout Anatomy (bottom → top)

[agent output / response text]
[empty line(s)]
✶ Undulating… (1m 32s · ↓ 2.2k tokens)     (spinner — above separator, while working)
[empty line]
──────────────────────────────────── (upper separator, may contain label)
❯ [user input]                       (prompt line)
──────────────────────────────────── (lower separator)
  [status line(s)]                   (0-N lines, indented 2 spaces)
  [mode line]                        (last row, indented 2 spaces)

Real-world Examples (live sessions, 2026-03-21)

Standard idle — no subprocess

❯
──────────────────────────────────────────────────────────────────────────
  [Opus 4.6 (1M context) | Max] │ tuicommander git:(main*)
  Context █░░░░░░░░░ 8% $0 (~$2.97) │ Usage ⚠ (429)
  ⏵⏵ bypass permissions on (shift+tab to cycle)

With subprocess — new format (count left)

───────────────────────────────────────────────────────── extractor ──
❯
──────────────────────────────────────────────────────────────────────────
  [Opus 4.6 (1M context) | Max] │ mdkb git:(feat/document-organizer*)
  Context ░░░░░░░░░░ 4% $0 (~$79.88) │ Usage ⚠ (429)
  1 shell · ⏵⏵ bypass permissions on

Subprocess only — no mode indicator

───────────────────────────────────────────────────────── extractor ──
❯
──────────────────────────────────────────────────────────────────────────
  [Opus 4.6 (1M context) | Max] │ mdkb git:(feat/document-organizer*)
  Context ░░░░░░░░░░ 4% $0 (~$79.81) │ Usage ⚠ (429)
  1 shell

Default mode (no bypass) — single status line, no mode line

❯
───────────────────────────────────────────────────────────────────────────────
  [Opus 4.6 (1M context) | Max] ░░░░░░░░░░ 3% | tuicommander git:(main*) |… ○ low · /ef…

Agent working — spinner above, no prompt box

  ⎿  $ ls -la /Users/stefano.straus/Documents/.mdkb/index.sqlite

✶ Undulating…


Separator Line

A row containing a run of 4+ box-drawing characters (─ ━ ═ — ╌ ╍). May contain embedded labels:

────────────────────────────────────────────────────────────────────────────────
──────────────────────────────────────────────────────────────── extractor ──
──────── ■■■ Medium /model ────────

Spinner / Timer Lines (above upper separator)

✶ Undulating…
✻ Sautéed for 1m 19s
✳ Ideating… (1m 32s · ↓ 2.2k tokens)
✻ Sautéed for 2m 9s · 1 local agent still running
· Proofing… (1m 14s · ↓ 1.6k tokens)

Markers: (U+2736), (U+273B), (U+2733), (U+2722), · (U+00B7). Detected by is_chrome_row (✻ check) and parse_status_line (dingbat range U+2720–U+273F).


Status Lines (between lower separator and mode line)

Zero or more lines. Content is arbitrary and agent-customizable. Indented with 2 spaces (via \033[2C).

  [Opus 4.6 (1M context) | Max] │ tuicommander git:(main*)
  Context █░░░░░░░░░ 5% $0 (~$0.64) │ Usage ⚠ (429)

Or Wiz HUD:

  [Opus 4.6 | Team] 54% | wiz-agents git:(main)
  5h: 42% (3h) | 7d: 27% (2d)
  ✓ Edit ×7 | ✓ Bash ×5

Configured via ~/.claude/settings.jsonstatusLine, but rendered through the PTY using ANSI cursor positioning. They appear as changed_rows and pass through is_chrome_row.

Custom status lines are never a detection signal. Their content is user-defined — the two blocks above are just two of countless possible layouts — so nothing in the state machine reads them: no model name, no context percentage, no cost, no usage counter is parsed from these rows. They matter in exactly one direction, getting classified as chrome so they do not reset the silence timer or stamp last_output_ms.

Coverage today (chrome.rs, asserted by status_line_not_chrome, cc_status_context_bar, cc_wiz_hud_line):

Rowis_chrome_rowWhy
Context █░░░░░░░░░ 8% $0 (~$2.97) │ Usage ⚠ (429)yesblock glyphs /
[Opus 4.6 (1M context) | Max] │ repo git:(main*)nocarries no chrome glyph — known gap
5h: 42% (3h) | 7d: 27% (2d)nosame gap

A status line is also barred from proving the agent is alive: is_spinner_row requires the spinner glyph to lead the row, so a HUD progress bar that ticks every second cannot be read as a working spinner. That rule is the fix for the regression that pinned Claude BUSY forever under a wiz status bar (#446-596f).


Mode Line (last row)

The final visible row. Always chrome. Indented with 2 spaces.

Observed variants

FormatExampleSource
Mode only ⏵⏵ bypass permissions ontest
Mode + hint ⏵⏵ bypass permissions on (shift+tab to cycle)live
Mode + subprocess (old) ⏵⏵ bypass permissions on · 1 shelltest
Mode + subprocess (old, plural) ⏵⏵ bypass permissions on · 2 local agentstest
Subprocess + mode (new) 1 shell · ⏵⏵ bypass permissions onlive
Subprocess only 1 shellscreenshot
Plan mode ⏸ plan mode on (shift+tab to cycle)test
Accept edits ⏵⏵ accept edits on (shift+tab to cycle)test
Auto mode ⏵⏵ auto modetest
Empty``test
Absent (default mode)N/A — no mode line at alllive

Key markers

  • ⏵⏵ (U+23F5 x2) — current mode-line prefix
  • ›› (U+203A x2) — older mode-line prefix
  • (U+23F8) — plan mode prefix
  • · (U+00B7) — separator between mode and subprocess count

Subprocess types observed

shell / shells, local agent / local agents, bash, background tasks


Interactive Menus

Permission Prompt

Replaces the entire bottom zone when CC requests tool approval.

⏺ Write(/tmp/test-permission-prompt.txt)
────────────────────────────────────────── (BLUE separator, rgb 177,185,249)
 Create file
 ../../../../../tmp/test-permission-prompt.txt
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ (dotted ╌ U+254C, rgb 80,80,80)
  1 hello
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
 Do you want to create test-permission-prompt.txt?
 ❯ 1. Yes                                (❯ colored BLUE, rgb 177,185,249)
   2. Yes, allow all edits in tmp/ during this session (shift+tab)
   3. No

 Esc to cancel · Tab to amend
FeatureNormalPermission prompt
Top separatorGray (rgb 136,136,136)Blue (rgb 177,185,249)
Content separatorNoneDotted (U+254C)
charGray promptBlue selection (rgb 177,185,249)
Mode line⏵⏵/⏸ on last rowNone
Last lineMode indicatorEsc to cancel · Tab to amend

Custom Ink Menu (e.g., /wiz:setup)

────────────────────────────────────── (dim gray separator, \033[2m)
← ☐ Essential  ☐ Workflow  ☐ Tools  ✔ Submit  →
Select essential components to install:
❯ 1. [ ] notifications              (checkbox, ❯ blue)
  ...
────────────────────────────────────── (dim gray separator)
  6. Chat about this
Enter to select · Tab/Arrow keys to navigate · Esc to cancel

May clear the entire screen (\033[2J\033[3J\033[H).

Built-in Menu (/mcp)

───────────────────────────────────── (blue separator, rgb 177,185,249)
  Manage MCP servers
  ❯ tuicommander · ✔ connected
    context7 · ✔ connected
    mac · ✘ failed
  ※ Run claude --debug to see error logs
  https://code.claude.com/docs/en/mcp for help   (OSC 8 hyperlink)
 ↑↓ to navigate · Enter to confirm · Esc to cancel   (italic, \033[3m)

Built-in (/stats)

───────────────────────────────────────────────────────────────────────
    Overview   Models
      Mar Apr May Jun ... Mar
      ··········░·▓██▓█·
  ...
  You've used ~29x more tokens than The Count of Monte Cristo
    Esc to cancel · r to cycle dates · ctrl+s to copy

Built-in (/status)

───────────────────────────────────────────────────────────────────────
   Status   Config   Usage
  ╭───────────────────────────────────────────────────────────────────╮
  │ ⌕ Search settings...                                             │
  ╰───────────────────────────────────────────────────────────────────╯
    Auto-compact                              true
    ...
  ↓ 3 more below
  ←/→ tab to switch · ↓ to return · Esc to close

Uses box-drawing ╭╮╰╯│ for search box (not matched by is_separator_line).


OSC 777 Notifications

CC emits terminal notifications for user attention events:

\033]777;notify;Claude Code;Claude needs your permission to use Write\007
\033]777;notify;Claude Code;Claude Code needs your attention\007

All share the prefix \033]777;notify;Claude Code;.


Ink Rendering Mechanics

Spinner animation (above separator)

\033[8A          ← cursor UP 8 rows (to spinner position)
✶               ← overwrite spinner character
\r\n × 7        ← 7 empty newlines back down to bottom

The cursor-up count equals the bottom zone height:

  • 8 = bypass mode (spinner + empty + sep + prompt + sep + 2 status + mode)
  • 6 = default mode (spinner + empty + sep + prompt + sep + 1 status, no mode)
  • 10 = with subprocess in bypass mode

Full bottom zone redraw

\r\033[1B  separator ────
\r\033[1B  ❯ [input]
\r\033[1B  separator ────
\r\n       \033[2C [status line 1]    ← 2C = cursor forward 2 = indent
\r\n       \033[2C [status line 2]
\r\n       \033[2C [mode line]

Key implications

  • All bottom-zone rows transit the PTY as ANSI sequences
  • The vt100 parser processes them into screen buffer rows
  • Spinner updates touch the spinner row AND all rows below it, causing the entire bottom zone to appear as changed rows
  • The 2-space indent on status/mode lines comes from \033[2C

Codex CLI — UI Layout Reference

Agent-specific layout reference for Codex CLI (OpenAI). See agent-ui-analysis.md for shared concepts.

Observed version: v0.116.0 (2026-04-19), re-audited on v0.146.0 (2026-08-02) Rendering engine: Ink (React for terminals) Rendering approach: ANSI absolute positioning (\033[row;colH) + scroll regions


Layout Anatomy

Codex uses a fundamentally different approach from Claude Code:

  • No separator-framed prompt box — uses background color instead
  • Absolute cursor positioning\033[12;2H (row 12, col 2)
  • Terminal scroll regions\033[12;41r to define scrollable content area
  • Reverse index\033M to scroll content upward
[agent output with • bullet prefix]
[empty line]
 ← dark background (rgb 57,57,57) starts here
› [user input]                       (prompt, bold ›, dark bg)
                                     (dark bg continues)
                                     (dark bg continues)
  gpt-5.4 high · 100% left · ~/project   (status line, dim, normal bg)

Real-world Examples (live session, 2026-03-21)

Startup banner

╭────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.116.0)                 │
│                                            │
│ model:     gpt-5.4 high   /model to change │
│ directory: ~/Gits/personal/tuicommander    │
╰────────────────────────────────────────────╯
  Tip: Use /mcp to list configured MCP tools.

Idle (waiting for input)

› Summarize recent commits           (ghost text, dim)
  gpt-5.4 high · 100% left · ~/Gits/personal/tuicommander

After tool execution (simple file create)

› create a file called /tmp/codex-test.txt with "hello"
• Creating /tmp/codex-test.txt with the requested contents.
• Added /tmp/codex-test.txt (+1 -0)
    1 +hello
───────────────────────────────────────────────────────────────────────────────
• Created /tmp/codex-test.txt with hello.
› Summarize recent commits
  gpt-5.4 high · 98% left · ~/Gits/personal/tuicommander

After tool execution (shell commands + MCP calls, 2026-04-19)

• PreToolUse hook (failed)
  error: hook exited with code 127
• Ran git status --short
  └ ?? .gitignore
    ?? .serena/
    … +2 lines (ctrl + t to view transcript)
    ?? StepsWidgetDemo/
    ?? project.yml
• PostToolUse hook (failed)
  error: hook exited with code 127
• Ran xcodegen generate
  └ ⚙️  Generating plists...
    ⚙️  Generating project...
    ⚙️  Writing project...
    Created project at /Users/.../StepsWidgetDemo.xcodeproj
• Ran xcodebuild -project StepsWidgetDemo.xcodeproj -scheme StepsWidgetDemo ...
  └ 2026-04-19 22:27:42.803 xcodebuild[67191:4346310]  DVTFilePathFSEvents: ...
    … +109 lines (ctrl + t to view transcript)
    ** BUILD SUCCEEDED **
• Waited for background terminal
──────────────────────────────────────────────────────────────────────────────
• Il progetto compila. Prima di chiudere salvo ...
• Called
  └ serena.write_memory({"memory_name":"project_overview","content":"..."})
    Memory project_overview written.
• Working (4m 55s • esc to interrupt)
› Improve documentation in @filename
  gpt-5.4 high · ~/Gits/personal/steps

Tool display patterns (v0.116.0+):

PatternMeaning
• Ran <command>Shell command execution
• Called + └ <fn>(...)MCP/function call with args on next line
• Added <path> (+N -M)File created/modified with diff stats
• Creating <path>File operation in progress
• Waited for background terminalBackground job completed
• PreToolUse hook (failed)Hook error (with error: detail below)
• PostToolUse hook (failed)Hook error (with error: detail below)
… +N lines (ctrl + t to view transcript)Truncated output (N lines hidden)
(U+2514)Tree connector for tool output/results

After interrupt (Escape)

■ Conversation interrupted - tell the model what to do differently.
› Summarize recent commits
  esc again to edit previous message

Key Differences from Claude Code

FeatureClaude CodeCodex CLI
Prompt char (U+276F) (U+203A, bold)
Prompt boxSeparator-framed (────)Background color (rgb 57,57,57)
Cursor positioningRelative (\033[8A)Absolute (\033[12;2H)
Scrolling\r\n paddingScroll regions (\033[12;41r) + reverse index (\033M)
Status lineMulti-line, indented 2spSingle line, indented 2sp, dim
Mode line⏵⏵ bypass permissions on etc.None observed
Separator usageAround prompt boxBetween tool output and summary
System messages prefix (white/green/red) prefix (bullet)
WarningsN/A prefix (yellow)
Interrupt markerN/A prefix
Ghost textVia dim cell attributeVia \033[2m dim
Submit keyEnterEnter (but multiline: Enter = newline in prompt)

Prompt Line

  • Character: (U+203A) — bold \033[1m
  • Background: dark gray rgb(57,57,57)\033[48;2;57;57;57m
  • The prompt area spans multiple rows with dark background
  • Ghost text (placeholder) shown in dim: \033[2m

Multiline input: Codex supports multiline prompts where Enter adds a newline. Submit is also Enter (single line). This makes programmatic input tricky — sending text + Enter may add a newline instead of submitting.


Status Line

Single line below the prompt area, always present.

v0.146.0 (audited live 2026-08-02) — five ·-separated fields:

  gpt-5.6-luna xhigh · ~/Gits/personal/tuicommander · main · Context 100% left · 260K window

Format: <model> <effort> · <directory> · <git branch> · Context <N>% left · <N>K window

Two changes since v0.116.0:

  • A git branch field was added after the directory. It is the checked-out branch of the session cwd, so its presence and value both vary.
  • The context gauge moved after the directory and gained the Context label plus a trailing <N>K window field: 100% left became Context 100% left · 260K window.

v0.116.0 (historical) <model> <effort> · [<quota>% left ·] <directory>:

  gpt-5.4 high · 100% left · ~/Gits/personal/tuicommander
  gpt-5.4 high · ~/Gits/personal/steps

Rendered in dim (\033[2m) with normal background (not dark bg).

Detection is unaffected by this drift. detect_codex_screen_activity (src-tauri/src/pty.rs) never reads the status line: it anchors on the lowest prompt row and looks for is_working_status_row in the six rows above it. Both anchors are branch- and gauge-independent, which is why the shape can keep moving without breaking busy/idle.


Separator Usage

Codex uses ──── separators differently from CC — they appear between tool output and the agent’s summary response, not as a prompt box frame:

• Added /tmp/codex-test.txt (+1 -0)
    1 +hello
───────────────────────────────────────────────────────────────────────────
• Created /tmp/codex-test.txt with hello.

Spinner

Codex uses (U+2022) as a spinner/working indicator:

• Working (10s • esc to interrupt)

The is already in is_chrome_row’s marker set.


OSC Sequences

\033]10;?\033\\    — query terminal foreground color
\033]11;?\033\\    — query terminal background color
\033]0;...\007     — window title updates

No \033]777;notify; observed — Codex does not emit terminal notifications for approval prompts.

Animated OSC 0 title (v0.146.0, audited 2026-08-02)

While a turn is running Codex repaints the window title on every spinner frame, prefixing the cwd basename with a braille glyph:

\033]0;⠋ tuicommander\007      — U+280B
\033]0;⠙ tuicommander\007      — U+2819
\033]0;⠹ tuicommander\007      — U+2839
… ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏                — U+2838 U+283C U+2834 U+2826 U+2827 U+2807 U+280F
\033]0;tuicommander\007        — plain title, emitted once when the turn ends

A single 900-word turn produced 152 braille-prefixed titles and 2 plain ones (one at startup, one at turn end).

TUIC deliberately does not consume this for activity. The busy/idle transition is owned solely by detect_codex_screen_activity. Reading the title as a second source of the same transition would be a race, not a safety net: two independent producers of one state edge cannot be ordered, so a late title frame could reopen a turn the screen already closed (or the reverse). The screen adapter stays the single writer; the title is documentation only.


Approval Modes

CLI flag: -a or --ask-for-approval <POLICY>

PolicyBehavior
untrustedSandbox commands (does NOT prompt for approval)
on-failureDEPRECATED — auto-run, ask only on failure
on-requestModel decides when to ask
neverNever ask

Note: In untrusted mode, Codex auto-approves tool use within the sandbox. No interactive approval prompt was observed. The approval UI may only appear in specific edge cases or with on-request mode.


Slash Commands Observed

• Unrecognized command '/mode'. Type "/" for a list of supported commands.

Available: /model, /mcp, /fast, /feedback, /help, and others. Not observed: /stats, /status (CC-specific).


Known Issues

Enter key handling

Codex likely uses the kitty keyboard protocol to distinguish Enter (submit) from Enter (newline in multiline prompt). The TUICommander session action=input special_key=enter sends \r which Codex may interpret as newline. Workaround: send text and Enter in separate calls, but this is unreliable for multiline content.

ask_user_question tool (proposed)

GitHub issue openai/codex#9926 proposes a tabbed questionnaire UI similar to Claude Code’s skill menus. Currently available via request_user_input tool with collaboration_modes = true in config, but only in plan mode (Shift+Tab).


Rendering Mechanics (raw ANSI)

Absolute positioning

\033[12;2H     — cursor to row 12, col 2 (absolute)
\033[K         — erase to end of line

Scroll regions

\033[12;41r    — set scroll region rows 12-41
\033M          — reverse index (scroll content up within region)
\033[r         — reset scroll region

Prompt area rendering

\033[48;2;57;57;57m  — dark background starts
\033[1m›\033[22m     — bold › then unbold
\033[2m...          — dim ghost text
\033[49m             — background reset for status line

Unlike CC which uses relative cursor movement (\033[8A), Codex uses absolute positioning. This means changed_rows detection works differently — Codex updates specific rows by address rather than painting top-down.

Aider — UI Layout Reference

Agent-specific layout reference for Aider. See agent-ui-analysis.md for shared concepts.

Observed version: v0.86.2 (2026-03-22) Rendering engine: Python readline + rich (no TUI framework) Rendering approach: Sequential CLI output with ANSI colors


Key Characteristics

Aider is the simplest of all supported agents — a sequential CLI tool with no TUI framework, no screen management, no cursor positioning. Output flows linearly top-to-bottom like a normal shell command.

  • No Ink, no Bubble Tea — just Python with rich text formatting
  • Prompt is simple > (green, ANSI 256 color 40)
  • Spinner uses ░█/█░ Knight Rider pattern with backspace overwrite
  • No mode line, no status bar, no panels
  • File approval uses inline Y/N/A/S/D prompts

Observed States

Startup Banner

─────────────────────────────────────────────────────────────────────
Aider v0.86.2
Main model: openrouter/anthropic/claude-sonnet-4.5 with diff edit format, infinite output
Weak model: openrouter/anthropic/claude-haiku-4-5
Git repo: .git with 855 files
Repo-map: using 4096 tokens, auto refresh
─────────────────────────────────────────────────────────────────────
>
  • Green separators ───── (rgb 0,204,0)
  • Model info, git repo stats, repo-map config
  • Bare > prompt (green)

Working State (spinner)

░█  Updating repo map: examples/plugins/repo-dashboard/main.js
█░  Waiting for openrouter/anthropic/claude-sonnet-4.5
  • Knight Rider scanner: ░█ and █░ alternate using backspace (\b) to overwrite
  • (U+2591, light shade) and (U+2588, full block)
  • Task description after the scanner chars
  • Already detected by parse_status_line via AIDER_SPINNER_RE

Agent Response

To find the version of this project, I need to check the version files.
 • package.json (for the frontend/Node.js part)
 • src-tauri/Cargo.toml (for the Rust/Tauri part)
Please add these files to the chat so I can tell you the version.
Tokens: 11k sent, 75 received. Cost: $0.03 message, $0.03 session.
  • Blue text (rgb 0,136,255) for agent output
  • Bold bullets for lists
  • File names with inverted background (rgb 0,0,0 on rgb 248,248,248)
  • Token report after every response: Tokens: Nk sent, N received. Cost: $X.XX
  • Already detected by parse_status_line via AIDER_TOKENS_RE

File Approval Prompt

package.json
Add file to the chat? (Y)es/(N)o/(A)ll/(S)kip all/(D)on't ask again [Yes]:
  • File name shown in reverse video (\033[7m)
  • Inline prompt with 5 options: Y/N/A/S/D
  • Green text (same as main prompt)
  • Default answer in brackets: [Yes]
  • This is a readline prompt — Enter submits, no special handling needed

After Response (idle)

Tokens: 8.0k sent, 106 received. Cost: $0.03 message, $0.06 session.
─────────────────────────────────────────────────────────────────────
package.json src-tauri/Cargo.toml
>
  • Green separator between conversation turns
  • Active file list shown before prompt (files in chat context)
  • Bare > prompt

Error State

litellm.AuthenticationError: AuthenticationError: OpenrouterException - {"error":{"message":"User not found.","code":401}}
The API provider is not able to authenticate you. Check your API key.
  • Orange warning text (rgb 255,165,0) for non-fatal warnings
  • Red error text (rgb 255,34,34) for fatal errors

Detection Signals

Agent Identification

  • Aider v in startup banner
  • ░█ / █░ Knight Rider spinner
  • Tokens: + Cost: report after responses
  • Add file to the chat? approval prompt

Chrome Detection (is_chrome_row)

  • ░█ / █░ — not in current marker set but detected by parse_status_line
  • Separator ───── — detected by is_separator_line
  • Prompt > — detected by is_prompt_line
  • Token report lines — not chrome markers, but not agent output either

Subtask / Subprocess Count

None. Aider does not have subprocess/subtask concepts.

Permission / Approval

  • No tool approval system — Aider auto-applies edits (or asks about file adds)
  • File add approval: Add file to the chat? (Y)es/(N)o/...
  • Edit confirmation: only with --auto-commits disabled, shows diff for review

Rendering Mechanics (raw ANSI)

Sequential output (no cursor positioning)

\r\n    — standard newlines, no cursor movement
\b      — backspace for spinner animation only

Color scheme

\033[0;38;5;40m        — green (ANSI 256 #40) for prompt and UI elements
\033[38;2;0;136;255m   — blue (rgb 0,136,255) for agent response text
\033[38;2;0;204;0m     — green (rgb 0,204,0) for separators
\033[38;2;255;165;0m   — orange for warnings
\033[38;2;255;34;34m   — red for errors
\033[7m                — reverse video for file names
\033[1m                — bold for list bullets

Spinner (Knight Rider)

░█\b\b       — write 2 chars, backspace 2
█░\b\b       — overwrite with swapped chars
  \b\b       — clear with spaces

Uses backspace (\b) to overwrite in place. No cursor positioning.

Readline integration

\033[?2004h    — enable bracketed paste
\033[6n        — request cursor position (readline)
\033[?25l/h    — hide/show cursor during drawing

Implications for TUICommander

Parsing Strategy

Aider is the ideal case for chrome.rs changed-rows detection:

  • Sequential output → each new line is a new changed row
  • No full-screen redraws → delta analysis works perfectly
  • Spinner overwrites in place → appears as single changed row

Already Supported

  • AIDER_SPINNER_RE in parse_status_line detects the Knight Rider scanner
  • AIDER_TOKENS_RE detects token reports
  • is_separator_line matches the green ───── separators
  • is_prompt_line matches the bare > prompt
  • (U+2591) and (U+2588) detected by is_chrome_row — Knight Rider spinner classified as chrome
  • has_status_line in chrome_only calculation — spinner-only chunks don’t reset silence timer
  • find_chrome_cutoff correctly trims Aider bottom zone (separator + file list + prompt)

Not Yet Supported

  • File approval prompt detection (Add file to the chat?)
  • Token/cost extraction from the report line
  • File context list (shown before prompt) — not classified as chrome

Gemini CLI — UI Layout Reference

Agent-specific layout reference for Gemini CLI (Google). See agent-ui-analysis.md for shared concepts.

Observed version: v0.34.0 (2026-03-22) Rendering engine: Ink-like (Node.js, ANSI relative positioning) Rendering approach: ANSI relative cursor positioning (\033[1A, \033[2K, \033[G)


Layout Anatomy (bottom → top)

[agent output with ✦ prefix]
[suggest line]
                                                          ? for shortcuts
───────────────────────────────── (separator, gray rgb 88,88,88)
 Shift+Tab to accept edits                    1 MCP server | 3 skills
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ (prompt box top, dark rgb 30,30,30 on bg 65,65,65)
 > [user input]                  (prompt, purple >, ghost text gray)
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ (prompt box bottom)
 workspace (/directory)          branch          sandbox              /model
 ~/path                          main            no sandbox           Auto (Gemini 3)

Bottom zone = 8 rows: shortcuts hint, separator, info line, prompt box (3 rows), status labels, status values.


Real-world Examples (live session, 2026-03-22)

Startup banner

  ▝▜▄     Gemini CLI v0.34.0
    ▝▜▄
   ▗▟▀    Signed in with Google: user@example.com /auth
  ▝▀      Plan: Gemini Code Assist for individuals /upgrade
╭───────────────────────────────────────────────────────────────────────╮
│ We're making changes to Gemini CLI that may impact your workflow.     │
│ What's Changing: ...                                                  │
│ Read more: https://goo.gle/geminicli-updates                          │
╰───────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
  • Geometric ASCII art logo: ▝▜▄ / ▗▟▀ / ▝▀
  • Auth + plan info inline
  • Notification box with ╭╮╰╯│ border (like CC /status search box)
  • Numbered tips list

Idle (waiting for input)

                                                                  ? for shortcuts
─────────────────────────────────────────────────────────────────────────────────
 Shift+Tab to accept edits                              1 MCP server | 3 skills
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 >   Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀
 workspace (/directory)          branch          sandbox              /model
 ~/Gits/personal/tuicommander   main            no sandbox           Auto (Gemini 3)

Working state (spinner)

✦ intent: read package.json version (package.json)
  I will read the package.json file to find the project version.
 ⠴ Check tool-specific usage stats with /stats tools… (esc to cancel, 14s)
─────────────────────────────────────────────────────────────────────────────────
 Shift+Tab to accept edits
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 >   Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀
 workspace (/directory)          branch          sandbox              /model
 ~/Gits/personal/tuicommander   main            no sandbox           Auto (Gemini 3)
  • Spinner line appears between agent output and separator
  • Braille spinner + italic tip text + (esc to cancel, Ns) timer
  • During work, ? for shortcuts disappears, info line loses right-side text

Tool call (in response)

╭─────────────────────────────────────────────────────────────────────────╮
│ ✓  ReadFile package.json                                                │
│                                                                         │
╰─────────────────────────────────────────────────────────────────────────╯
  • Bordered box with ╭╮╰╯│ (same chars as startup notification)
  • prefix for completed tool calls
  • Tool name + arguments inline

Agent response (completed)

✦ The version of this project is 0.9.5, as specified in the package.json file.
  suggest: [ View CHANGELOG.md | Check README.md | List active sessions ]
  • (U+2726, purple rgb 215,175,255) prefix for agent output
  • suggest: line follows response (TUICommander protocol)
  • No token/cost report

Out-of-workspace write rejection

✦ I am unable to create the file at /tmp/gemini-test.txt because it is outside
  the allowed workspace directories. I can, however, create it within the
  project directory or the project's temporary directory.
  Would you like me to create it at ~/.gemini/tmp/tuicommander/gemini-test.txt?
  • No interactive permission prompt — Gemini refuses with text explanation
  • Workspace restriction enforced at model level, not via UI prompt

Prompt Line

  • Character: > (ASCII, colored purple rgb 215,175,255)
  • Background: dark gray rgb(65,65,65)\033[48;2;65;65;65m
  • Prompt box bordered by ▀▀▀ (U+2580, upper half block) top and ▄▄▄ (U+2584, lower half block) bottom
  • Border colors: dark rgb(30,30,30) foreground on rgb(65,65,65) background
  • Ghost text: gray rgb(175,175,175)Type your message or @path/to/file
  • Cursor shown with reverse video \033[7m
  • Enter = submit (single line prompt)

Separator Line

Single horizontal rule above the prompt area:

─────────────────────────────────────────────────── (gray, rgb 88,88,88)
  • Always present in idle and working states
  • No decorated separators (no embedded labels)
  • Uses (U+2500) — same char as CC and Codex

Spinner / Working Indicator

 ⠴ Check tool-specific usage stats with /stats tools… (esc to cancel, 14s)
 ⠋ Exclude specific tools from being used (settings.json)… (esc to cancel, 33s)
  • Braille spinner: ⠋⠙⠹⠸⠴⠦⠧⠇ (U+2800 range)
  • Color varies: blue rgb(135,189,241), green rgb(224,255,206) — colors shift during animation
  • Format: ⠋ <italic tip text>… (esc to cancel, Ns)
  • Tip text: italic (\033[3m), shows contextual tips/suggestions during work
  • Timer: (esc to cancel, Ns) in gray rgb(175,175,175)
  • Position: between agent output and separator (above bottom zone)

Already detected by parse_status_line via GEMINI_SPINNER_RE (braille range check).


Status Bar (bottom 2 rows)

Always visible. 4 columns with label/value pairs:

 workspace (/directory)          branch          sandbox              /model
 ~/Gits/personal/tuicommander   main            no sandbox           Auto (Gemini 3)
  • Labels: gray rgb(175,175,175)workspace (/directory), branch, sandbox, /model
  • Values: white rgb(255,255,255) — path, branch name, sandbox status, model name
  • Sandbox warning: pink rgb(255,135,175) when no sandbox

Info Line (between separator and prompt box)

 Shift+Tab to accept edits                              1 MCP server | 3 skills
  • Left: Shift+Tab to accept edits (gray)
  • Right: N MCP server | N skills (gray) — MCP and skill counts
  • Above the prompt box, below the separator

A second info hint ? for shortcuts appears right-aligned above the separator when idle.


Window Title (OSC 0)

\033]0;◇  Ready (tuicommander)\007
  • (U+25C7, white diamond) — state indicator
  • Ready — current state
  • (tuicommander) — workspace name
  • Updates on state changes

Detection Signals

Agent Identification

  • Gemini CLI v in startup banner
  • Geometric ASCII logo ▝▜▄
  • (U+2726) output prefix
  • Braille spinner ⠋⠙⠹⠸⠴⠦⠧⠇
  • ? for shortcuts hint line
  • OSC 0 with diamond

Chrome Detection (is_chrome_row)

  • Braille spinner chars — detected by parse_status_line via GEMINI_SPINNER_RE
  • Separator ───── — detected by is_separator_line
  • Prompt > — detected by is_prompt_line
  • ▀▀▀ / ▄▄▄ prompt box borders — NOT in chrome marker set
  • Status bar labels/values — NOT chrome markers
  • (U+2726) — NOT in is_chrome_row marker set

Subtask / Subprocess Count

None. Gemini CLI does not expose subprocess/subtask counts. Tool calls shown inline in bordered boxes (╭───╮ ✓ ReadFile ╰───╯).

Permission / Approval

  • No interactive permission UI — workspace restriction enforced at model level
  • Out-of-scope writes rejected with text explanation
  • No Esc to cancel permission footer
  • No OSC 777 notifications observed

Rendering Mechanics (raw ANSI)

Relative cursor positioning

\033[1A     — cursor UP 1 row (repeated for multi-row updates)
\033[2K     — erase entire line
\033[G      — cursor to column 1
\033[4A     — cursor UP 4 (bottom zone jump)
\033[4G     — cursor to column 4
\033[4B     — cursor DOWN 4 (back to bottom)

Uses \033[1A] repeated (like CC) rather than absolute \033[r;cH (like Codex/OpenCode). Bottom zone updates use \033[4A...\033[4B pattern to jump up, redraw, jump back.

Prompt box rendering

\033[48;2;65;65;65m              — dark gray background
\033[38;2;30;30;30m▀▀▀▀...      — dark top border on gray bg
\033[38;2;215;175;255m>           — purple prompt char
\033[7m \033[27m                 — cursor (reverse video block)
\033[38;2;175;175;175m...        — gray ghost text
\033[38;2;30;30;30m▄▄▄▄...      — dark bottom border
\033[49m                         — reset background

Color scheme

\033[38;2;215;175;255m   — purple (prompt char >, output prefix ✦, file names)
\033[38;2;255;255;255m   — white (agent text, status values)
\033[38;2;175;175;175m   — gray (labels, hints, timer)
\033[38;2;88;88;88m      — dark gray (separator ─────)
\033[38;2;255;135;175m   — pink (sandbox warning)
\033[38;2;135;189;241m   — blue (spinner, varies)
\033[38;2;224;255;206m   — green (spinner, varies)

Spinner animation

\033[3m     — italic on (for tip text)
\033[23m    — italic off

Spinner updates use the same \033[1A]\033[2K] erase-and-redraw pattern as the bottom zone.


Implications for TUICommander

Parsing Strategy

Gemini CLI is a CLI inline agent — changed-rows delta analysis works. Similar to CC in rendering mechanics (relative cursor positioning).

Already Supported

  • Braille spinner detected by parse_status_line via GEMINI_SPINNER_RE
  • Separator ───── detected by is_separator_line
  • Prompt > detected by is_prompt_line

Not Yet Supported

  • (U+2726) is the Gemini agent output prefix (NOT chrome — do not add to is_chrome_row)
  • Tool call boxes (╭╮╰╯│) not classified as chrome
  • ? for shortcuts hint line not classified as chrome
  • Status bar labels (bottom 2 rows) have no chrome markers (but find_chrome_cutoff trims them via separator anchor)
  • No sandbox/permission prompt detection needed (Gemini handles this at model level)

Now Supported (as of chrome.rs multi-agent update)

  • ▀▀▀ / ▄▄▄ prompt box borders detected as chrome via is_chrome_row
  • Braille spinner classified as chrome via has_status_line in chrome_only calculation
  • find_chrome_cutoff correctly trims the full Gemini 8-row bottom zone
  • Separator ───── detected by is_separator_line
  • Prompt > detected by is_prompt_line

OpenCode — UI Layout Reference

Agent-specific layout reference for OpenCode. See agent-ui-analysis.md for shared concepts.

Observed version: v1.2.20 (2026-03-22), re-audited on v1.18.5 (2026-08-02) Rendering engine: Bubble Tea (Go TUI framework) Rendering approach: Full-screen TUI, ANSI absolute positioning, mouse tracking


Key Difference from Other Agents

OpenCode is a full-screen TUI application, not a CLI that renders inline in the terminal like Claude Code or Codex. It takes over the entire terminal screen with its own layout, panels, and navigation. This means:

  • The “bottom zone” concept from CC/Codex does not directly apply
  • OpenCode manages its own screen regions (panels, status bar, prompt)
  • Mouse tracking is enabled (\033[?1000h, \033[?1003h, \033[?1006h)
  • It uses bracketed paste (\033[?2004h) and focus events (\033[?1004h)

Observed States

Welcome Screen

Only shown on fresh start, before first message:

                                                     █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
                                                     █  █ █  █ █▀▀▀ █  █ █    █  █ █  █ █▀▀▀
                                                     ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
                                   ┃
                                   ┃  Ask anything... "What is the tech stack of this project?"
                                   ┃
                                   ┃  Build  Claude Sonnet 4.5 lansweeper.ai
                                   ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
                                                                          tab agents  ctrl+p commands
                                         ● Tip Set agent temperature from 0.0 (focused) to 1.0 (creative)
  ~/Gits/personal/tuicommander:main                                                          1.2.20

Conversation (after first message) — two-panel layout

  ┃  what version is this project                                           █  Project version inquiry
  ┃                                                                         █
                                                                            █  Context
     Leggo la versione del progetto...                                      █  20,192 tokens
                                                                            █  0% used
     → Read SPEC.md [limit=50]                                              █  $0.00 spent
     → Read package.json [limit=20]                                         █
     → Read src-tauri/tauri.conf.json [limit=30]                            █  LSP
                                                                            █  LSPs will activate...
     Questo progetto è alla versione 0.9.5...                               █
                                                                            █
     ▣  Build · anthropic/claude-sonnet-4.5 · 10.2s                         █
                                                                            █
  ┃
  ┃  Build  Claude Sonnet 4.5 lansweeper.ai          ~/path:main
  ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
                                          tab agents  ctrl+p commands    • OpenCode 1.2.20

Key elements:

  • Left panel: conversation (messages, tool calls, results)
  • Right panel: sidebar with border — title, context tokens, cost, LSP info
  • Prompt box: bottom, framed with ┃╹▀
  • Model info: inside prompt box Build Claude Sonnet 4.5 lansweeper.ai

Permission Prompt

  ┃  △ Permission required
  ┃    ← Access external directory /tmp
  ┃
  ┃  Patterns
  ┃
  ┃  - /tmp/*
  ┃
  ┃                                                              ~/path:main
  ┃   Allow once   Allow always   Reject   ctrl+f fullscreen  ⇆ select  enter confirm
  ┃                                                              • OpenCode 1.2.20

Working State (during tool execution)

Footer changes to show progress bar and interrupt hint:

   ■■⬝⬝⬝⬝⬝⬝  esc interrupt                            tab agents  ctrl+p commands    • OpenCode 1.2.20
  • (U+25A0) — completed steps
  • (U+2B1D) — remaining steps
  • Mode label may switch: BuildPlan in prompt box

Error State

  ┃  Error: Unable to connect. Is the computer able to access the url?

Errors displayed inline in the frame, same area as conversation.


Key elements:

  • (U+25B3): permission required marker
  • : tool call prefix (Write direction)
  • 3 inline options: Allow once Allow always Reject — not numbered, not multiselect
  • Footer: ctrl+f fullscreen ⇆ select enter confirm
  • (U+21C6): select/navigate hint
  • Pattern display: shows glob pattern (/tmp/*)
  • Entire dialog inside frame — prompt box expands to contain it

UI Element Reference

Prompt Frame

  • Left border: (U+2503, heavy vertical)
  • Bottom border: ╹▀▀▀▀... (U+2579 corner + U+2580 upper half blocks)
  • No , , or > prompt char
  • Model info inline: Build Claude Sonnet 4.5 lansweeper.ai

Right Panel Border

  • (U+2588, full block) — vertical border for sidebar
  • (U+2584, lower half block) — top corner of sidebar

Tool Call Prefixes

  • — Read operations (files read by the agent)
  • — Write operations (files written/modified by the agent)

Completion Marker

  • (U+25A3, white square with rounded corners) — marks completed tool calls
  • Format: ▣ Build · anthropic/claude-sonnet-4.5 · 10.2s

Tips

  • (U+25CF) — orange marker (rgb 245,167,66)
  • Format: ● Tip <highlighted_word> <gray description>

Status Bar

  • Left: ~/Gits/personal/tuicommander:main (path + branch)
  • Right: 1.2.20 or • OpenCode 1.2.20
  • tab agents — switch to agents panel
  • ctrl+p commands — command palette
  • ctrl+f fullscreen — toggle fullscreen (in permission dialog)
  • ⇆ select — select between options
  • enter confirm — confirm selection

Rendering Mechanics

Full-screen with background

\033[48;2;10;10;10m    — near-black background fills entire screen
\033[48;2;30;30;30m    — slightly lighter for input box

Absolute cursor positioning

\033[29;42H            — cursor to row 29, col 42

Mouse tracking (enabled on startup)

\033[?1000h   — normal mouse tracking
\033[?1002h   — button-event tracking
\033[?1003h   — all-motion tracking
\033[?1006h   — SGR mouse mode
\033[?1004h   — focus events

Kitty keyboard protocol

\033[?2026h / \033[?2026l   — toggled very frequently (polling pattern)

Cursor

\033[1 q      — blinking block cursor
\033[?25h     — show cursor
\033[?25l     — hide cursor (during redraws)

Color palette queries (on startup)

\033]4;0;?\007 through \033]4;15;?\007    — all 16 ANSI palette colors
\033]10;?\007 through \033]19;?\007       — foreground, background, etc.

Implications for TUICommander

Chrome Detection

OpenCode is a full-screen TUI — every row changes on every update. The changed_rows / is_chrome_row approach does not work. Needs:

  • Full-screen TUI detection mode (mouse tracking + full background = TUI)
  • Screen-snapshot-based parsing instead of changed-row delta analysis

Prompt Detection

No standard prompt char (, , >). Would need to detect frame or input box background color change.

Permission Detection

  • △ Permission required is a unique text signal
  • Allow once Allow always Reject footer is unique to OpenCode
  • No OSC 777 notifications observed

Subtask / Subprocess Count

None. OpenCode does not expose subprocess/subtask counts. Instead:

  • Tool calls shown inline in conversation panel (→ Read, ← Write)
  • Progress bar in footer: ⬝■■■■■■⬝ (filled/empty squares)
  • Completion marker: ▣ Build · model · time

Working State

  • Progress bar: ■■⬝⬝⬝⬝⬝⬝ in footer row — graphical, not numeric
  • Mode label: changes from Build to Plan in prompt box
  • Interrupt hint: esc interrupt in footer during work
  • No spinner chars — uses progress bar instead

Busy/idle detection (v1.18.5, 2026-08-02)

detect_opencode_screen_activity in src-tauri/src/pty.rs owns the transition. Neither generic signal applies: OpenCode paints no prompt glyph, and is_spinner_row does not recognise the / progress bar (deliberately — a leading run is not a spinner). The adapter therefore keys on the composer frame plus the status bar painted under it:

RowIdleWorking
frame rows closed by ╹▀▀▀…identical
status bar/abs/path 18.1K (9%) ctrl+p commands⬝⬝⬝⬝⬝■■■ esc interrupt 18.1K (9%) ctrl+p commands
  • Working = a row below the frame-close row contains esc interrupt. Verified live to hold for the whole turn, including a tool phase (⠼ sleep 20 && echo done) and at 62 columns, where the status bar drops fields but keeps both hints.
  • Ready = OpenCode frame present and no interrupt hint, and the status bar’s ctrl+p commands hint is on screen. That last condition is what stops a half-painted frame from reading Ready mid-turn.
  • Unknown otherwise — notably a plain shell after OpenCode exits.

Without this adapter OSC 133 marked the long-lived opencode foreground command busy once and nothing ever cleared it, so the session stayed busy for the whole process (#535-d4f5).

Status-bar drift since v1.2.20: the welcome screen still shows tab agents ctrl+p commands plus a ~path:branch … <version> row, but once the first turn has run the footer collapses into a single bottom row carrying the absolute cwd, a N.NK (N%) context gauge and ctrl+p commands. tab agents and the • OpenCode X.Y.Z version suffix are gone from that state — ctrl+p commands is the part present in every state, which is why the adapter anchors on it.

Error Display

Errors shown inline in the frame:

  ┃  Error: Unable to connect. Is the computer able to access the url?

Agent Identification

OpenCode can be detected by:

  • ASCII art banner with OPENCODE text on first screen
  • ┃╹▀ vertical frame chars
  • Mouse tracking enabled on startup
  • • OpenCode X.Y.Z in status bar

pi — UI layout and detection

Agent: pi (@earendil-works/pi-coding-agent). Version audited: 0.83.0 · Date: 2026-08-02 · Theme: rose-pine-moon.

All values below were captured live from a real PTY session (raw ANSI), not inferred.

Identity & rendering

PropertyValue
Binarypi (npm global; the executable on disk is the node interpreter)
Rendering engine@earendil-works/pi-tui (own TUI lib — not Ink, not Bubble Tea)
Repaint styleFull-frame, wrapped in synchronized updates (\033[?2026h\033[?2026l)
Screen clear\033[2J\033[H\033[3J on startup repaint
Cursor positioningRelative — \033[3A up / \033[3B down, then \033[1G, cursor hidden (\033[?25l)
Config dir~/.pi/agent/
Sessions~/.pi/agent/sessions/<encoded-cwd>/<ISO-ts>_<uuid7>.jsonl

Process identity gotcha. proc_pidpath() (macOS) and /proc/<pid>/comm (Linux) both return the node interpreter for a pi session, not pi. Foreground-process classification therefore falls back to argv[0] for known interpreters — see is_script_interpreter in pty.rs and read_process_argv0 in process_env.rs. Without that fallback pi is invisible as an agent.

Bottom zone

Four rows, in this order, present in every state:

────────────────────────────────────────────  separator, RGB(196,167,231)
<composer>                                    reverse-video cursor block + blanks
────────────────────────────────────────────  separator
~/Gits/personal/tuicommander (main)           cwd + branch, RGB(110,106,134)
↑1.3k ↓1.8k R15k W6.0k CH88.5% $0.104 3.4%/272k (auto)    (openai) gpt-5.6-sol • medium
  • No prompt glyph. The composer is a bare reverse-video cell (\033[7m \033[0m) followed by blanks. is_prompt_line matches nothing here — readiness cannot be prompt-based.
  • Status row is the reliable “this is a pi screen” marker: the context gauge N%/Nk plus the model separator. See is_pi_status_row in pty.rs.

Working state

The composer row is replaced in place by an animated status row:

 ⠏ Working...
  • Spinner: braille ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏, RGB(196,167,231); label RGB(144,140,170).
  • Already matched by chrome::is_spinner_row (braille range U+2800–U+28FF).
  • Separators, cwd row and status row stay on screen unchanged for the whole turn.

detect_pi_screen_activity therefore reads: spinner in the footer → Working; otherwise, status row present → Ready; neither → Unknown.

OSC sequences

SequenceNotes
\033]8;;\007Emitted after nearly every row (hyperlink reset)
\033]0;<title>\007Only with the terminal-status-title extension installed

The extension (~/.pi/agent/extensions/terminal-status-title.js) prefixes the title with a state glyph, repainted every 120ms while working:

GlyphStateSource event
(U+25CB)idlesession_start
⠋…⠏ brailleworkingagent_start
(U+2713)doneagent_end
(U+2717)error

TUIC does not derive activity from this title: the screen adapter already owns the busy→idle transition, and a second path for the same transition is a race, not a safety net. The title only feeds the tab name, and cleanOscTitle strips the glyph and the | separator it sits on so every spinner frame collapses to one stable name (π | tuicommander).

Input

PropertyValue
SubmitEnter (verified live: text + \r submits)
Newlineshift+enter / ctrl+j
Clear to line startctrl+u (tui.editor.deleteToLineStart)
Interruptctrl+c (app.clear)

sendCommand’s agent path (Ctrl-U prefix, 50ms gap, separate \r) is correct for pi as-is — ctrl+u is a real binding, so the prefix is consumed rather than echoed.

Not yet observed

Permission/approval prompts, interactive menus (pi config), and the session picker (--resume) were not triggered during this audit. They are unaudited, not “absent” — trigger them before relying on any assumption about their chrome.

Configuration

Module: src-tauri/src/config.rs

Manages all application configuration as JSON files in the platform config directory.

Config Directory

PlatformPath
macOS~/Library/Application Support/com.tuic.commander/
Linux~/.config/com.tuic.commander/
Windows%APPDATA%/com.tuic.commander/

Legacy paths {platform_config}/tuicommander/, {platform_config}/tui-commander/ and ~/.tuicommander/ are auto-migrated on first launch.

Debug and release builds share this one directoryconfig_dir() never branches on cfg!(debug_assertions). The single-instance lock is release-only (lib.rs, #[cfg(not(debug_assertions))]), so a make dev build runs happily alongside the installed app, and both read and write the exact same config.json, repositories.json, and every other file below. What makes that safe is the locking model in ConfigFile<T> (see Core Functions): a cross-process advisory file lock. Ordinary AppConfig writes and upstream MCP writes additionally apply caller deltas to the latest value while that lock is held, so independent edits from two processes compose instead of becoming ordered whole-document overwrites. repositories.json used to be the one exception, seeded into a separate ~/.tuicommander-dev/ directory on first debug run; that seeding path is gone and it now lives here like everything else (see below).

Core Functions

#![allow(unused)]
fn main() {
pub fn config_dir() -> PathBuf
pub fn load_json_config<T: DeserializeOwned + Default>(filename: &str) -> T
}

Config domains write through ConfigFile<T>:

#![allow(unused)]
fn main() {
impl<T: Serialize + DeserializeOwned + Default> ConfigFile<T> {
    pub fn load(&self) -> (T, Stamp)
    pub fn update<F: FnOnce(&mut T) -> bool>(&self, mutate: F) -> Result<(), String>
    pub fn update_with<R, F>(&self, mutate: F) -> Result<R, String>
    pub fn update_with_strict<R, F>(&self, mutate: F) -> Result<R, String>
    pub fn save_checked(&self, value: &T, stamp: Stamp) -> Result<(), ConfigWriteError>
    pub fn save(&self, value: &T) -> Result<(), String>
}
}

Two locks protect every write: an in-process CONFIG_WRITE_LOCK mutex, and a cross-process advisory file lock (std::fs::File::lock() on a sibling <file>.lock) that serializes writers across the debug/release instances that now share one config dir. save_checked additionally compares a Stamp (mtime+len, captured at load()) against the file’s current on-disk state and returns ConfigWriteError::Conflict instead of overwriting a change it never saw — used by most per-domain files (notifications.json, ui-prefs.json, repo-settings.json, repositories.json, etc.). Those callers capture the stamp immediately before saving, so this narrows only the backend write race; it is not a user-session conflict protocol. config.json (AppConfig) and mcp-upstreams.json use delta-under-lock instead. See 2026-08-08-config-deltas-under-lock.md.

Config Files and Commands

Application Config (config.json)

Type: AppConfig

Frontend surfaces that update this full-document configuration use the shared updateAppConfig() queue. It serializes each fresh load → owned-field mutation → save sequence so simultaneous General, Services, and plugin changes cannot overwrite one another with stale snapshots.

Ordinary saves merge under the cross-process lock; they do not replace the document. PUT /config and the MCP config tool (action: "save") accept a body that mentions only the fields being changed. IPC save_config retains its typed full-config shape, but the backend derives the cache-to-request delta. commit_config_change locks config.json, reloads and hydrates the latest disk value, applies only the requested delta, persists it, and refreshes state.config from the result. Objects merge key by key; arrays and scalars replace wholesale (so an empty array still clears a list, null clears an optional field, and "" still blanks a string). This is not cosmetic: every field carries #[serde(default)], so deserializing a partial body on its own reset the omitted ones — services.server.enabled defaults to false, which is how a partial save used to switch remote access off on disk while the already-bound listener kept serving, surfacing only at the next boot.

All three writers also share server_settings_changed and rebind the listener through restart_after_server_settings_change when services.server.{enabled,port, ipv6_enabled} or services.auth.{username,password_hash} move, so the running process can never serve a configuration the disk disagrees with.

FieldTypeDefaultDescription
shellOption<String>NoneShell override (platform default if None)
font_familyString"JetBrains Mono"Terminal font family
font_sizeu1614Terminal font size
themeString"vscode-dark"Terminal theme
ideString""IDE for “Open in…”
default_font_sizeu1613Default font size for reset
mcp_server_enabledbooltrueEnable MCP HTTP server
mcp_portu169876Fixed port for MCP server (0 = OS-assigned)
collapse_toolsboolfalseReplace the full MCP tool list with 3 lazy-discovery meta-tools (search_tools, get_tool_schema, call_tool). Grok sessions use this surface automatically without changing the stored value — see mcp-http.md
servicesServicesConfig{}Nested remote-access config: server, auth, tls, relay, push (replaces the former flat remote_access_*/push_enabled/relay_enabled fields)

Remote-access secrets under services are not persisted in plaintext config.json: auth.session_token, relay.token, and push.vapid_private_key live in the OS keyring-backed credential vault. The JSON file keeps only the non-secret settings plus session_token_exists, token_exists, and vapid_private_key_exists booleans for UI state.

A vault read failure is never treated as “the secret is absent”: on error hydrate_one_secret keeps the *_exists flag that config.json recorded, so a momentarily locked keychain cannot flip the flag to false and make the next save delete a live credential. Plaintext still found in config.json is moved into the vault at load time and the file is rewritten immediately, so the cleartext copy does not survive on disk.

| confirm_before_quit | bool | true | Show quit confirmation | | confirm_before_closing_tab | bool | true | Show tab close confirmation | | copy_on_select | bool | true | Auto-copy terminal selection to clipboard | | osc52_clipboard | bool | true | Honor OSC 52 clipboard-write sequences from terminal output (a notice shows on each write; disable to ignore them) | | bell_style | String | "visual" | Terminal bell: “none”, “visual”, “sound”, “both” | | disabled_agents | Vec<String> | [] | Agent IDs hidden from the Add menu | | global_hotkey | Option<String> | null | OS-level window toggle hotkey combo | | intent_tab_title | bool | true | Show agent intent as tab title | | language | String | "en" | UI language code | | max_tab_name_length | u32 | 25 | Max tab name display length | | tab_cycling_all_types | bool | false | When true, next/prev-tab shortcuts cycle file/diff/markdown/editor tabs too (default cycles terminals only) | | tab_tree_enabled | bool | false | When true, a branch with >1 terminal shows a collapsible nested list of its terminals under the branch row in the sidebar | | prevent_sleep_when_busy | bool | false | Prevent macOS sleep when terminal is busy | | suggest_followups | bool | true | Show suggest: follow-up actions | | issue_filter | Option<String> | "assigned" | GitHub Issues filter: “assigned”, “created”, “mentioned”, “all”, “disabled” | | experimental_features_enabled | bool | false | Master toggle for experimental features | | ai_chat_enabled | bool | false | Sub-flag: enable AI Chat panel and shortcuts (requires experimental_features_enabled) | | scroll_history_enabled | bool | false | Sub-flag: scrollback history overlay on scroll-up in agent mode (requires experimental_features_enabled) | | ai_terminal_mcp_enabled | bool | false | Expose ai_terminal_* tools to external MCP clients. Off by default — see mcp-http.md | | auto_show_pr_popover | bool | false | Auto-show PR popover when switching to a branch with a PR | | update_channel | String | "stable" | Update channel: “stable” or “nightly” | | inline_blame_enabled | bool | true | Show GitLens-style inline git blame on the code editor’s active line |

Commands: load_app_config(), save_app_config(config)

Every writer of config.json — IPC save_config, PUT /config, MCP config action=save, session-token rotation, set_global_hotkey, the disabled_mcp_agents toggle and the push auto-enable on first subscription — goes through config::commit_config_change, which holds one process-wide mutex across the whole cache-delta → file-lock → latest-disk-read → delta-merge → preserve-secrets → write → update-state.config sequence. The cross-process file lock spans the authoritative disk read and write. This distinction matters: locking whole-document saves merely orders lost updates, while applying the delta after the locked read preserves unrelated fields written by another debug or release process. Rotation (config::rotate_session_token, shared by the desktop command and POST /auth/rotate-session-token) goes through the same path so the vault, the file and state.config cannot disagree — previously the in-memory config kept the pre-rotation token and the next unrelated save wrote it back.

The vault and config.json are one logical commit. Before changing any of the three vault-backed fields, save_app_config snapshots their previous values. If either a later vault operation or the atomic file replacement fails, all three vault values are restored before the error returns; state.config and the live authentication token are updated only after success. A rollback failure is appended to the original persistence error instead of being hidden. Individual credential set and delete operations also publish their in-memory vault clone only after the OS keyring accepts it.

Routing every writer through it also guarantees the file is produced by config_for_disk. A writer that serialized the config itself (the disabled_mcp_agents toggle called save_json_config("config.json", ..)) skipped the stripping step and wrote the session token, relay token and VAPID private key to disk in cleartext.

MCP Bridge Auto-Install

On every launch agent_mcp::ensure_mcp_configs writes the tuicommander bridge entry into each supported agent’s own MCP config, and repairs the path when the sidecar moves. Each target is written in the format its tool reads:

AgentConfig fileShape
Claude Code~/.claude.jsonJSON mcpServers
Cursor~/.cursor/mcp.jsonJSON mcpServers
Windsurf~/.codeium/windsurf/mcp_config.jsonJSON mcpServers
VS Code<user dir>/mcp.jsonJSON servers
Zed~/.config/zed/settings.jsonJSON context_servers
Amp~/.config/amp/settings.jsonJSON amp.mcpServers
Gemini CLI~/.gemini/settings.jsonJSON mcpServers
Droid~/.factory/mcp.jsonJSON mcpServers
opencode~/.config/opencode/opencode.json[c]JSON mcp, {type:"local", command:[…]}
Codex~/.codex/config.tomlTOML [mcp_servers] + env_vars allowlist
Grok~/.grok/config.tomlTOML [mcp_servers]
goose~/.config/goose/config.yamlYAML extensions (ExtensionEntry)
pi~/.pi/agent/mcp.jsonJSON mcpServers (pi-mcp-adapter extension)

Aider is absent because it has no MCP client.

A target is written only when it is installed. The writer creates every missing parent directory, so an unconditional pass used to create ~/.cursor/, ~/.gemini/, ~/.config/amp/ and friends for tools the user never had — which makes other software report Cursor or Windsurf as installed. Presence is proven two ways, cheapest first:

  1. the config directory holds a file that is not the one we write (.DS_Store and stale *.tmp staging files do not count), or
  2. one of the target’s CLI binaries resolves via cli::has_cli.

Claude’s config sits in $HOME, so it uses ~/.claude as its presence directory instead of the config file’s parent. pi is stricter still: its MCP support comes from the optional pi-mcp-adapter extension, which owns ~/.pi/agent/mcp.json — with no such file there is no adapter, so an auto-written entry would configure nothing.

A target that already holds a tuicommander entry keeps getting path repairs even when presence no longer resolves, so a stale bridge path is never left behind. Both gates live in auto_install_allowed, which only the launch pass consults: Settings → Agents installs on demand through ensure_spec_entry directly, because pressing Install states that the target is there — that is an explicit request, not a guess.

Configs that exist but do not parse are never overwritten (JSON, TOML and YAML alike): VS Code’s mcp.json and opencode’s config both allow comments, which serde_json rejects, and treating a parse failure as an empty document would replace the user’s whole config with our single entry.

Upstream MCP Config (mcp-upstreams.json)

Type: UpstreamMcpConfig

Interactive saves carry both the configuration the caller loaded (base) and its desired config. The backend derives additions, intentional removals, order changes, and per-server field deltas keyed by stable server ID, then applies them to the latest document inside ConfigFile::update_with. A popup toggle therefore changes only enabled; an OAuth/DCR auth record written after the popup loaded is preserved. Removing a server or clearing an optional auth field remains explicit and is not mistaken for an omitted/unchanged field.

Validation and the runtime registry diff use the exact merged pre/post values from the locked transaction. The lock is released before asynchronous reconnect work starts.

Commands: load_mcp_upstreams(), save_mcp_upstreams(base, config)

Notification Config (notifications.json)

Type: NotificationConfig

FieldTypeDefaultDescription
enabledbooltrueGlobal enable
volumef640.5Volume (0.0-1.0)
sounds.questionbooltruePlay on agent question
sounds.errorbooltruePlay on error
sounds.completionbooltruePlay on completion
sounds.warningbooltruePlay on warning
silence_remote_completionsbooltrueSuppress the completion chime for HTTP/MCP-created sessions
toasts_in_bellbooltrueMirror every toast into the toolbar bell, under a MESSAGES section

Commands: load_notification_config(), save_notification_config(config)

AI Chat Config (ai-chat-config.json)

Type: AiChatConfig

FieldTypeDefaultDescription
providerString"ollama"AI provider: ollama, anthropic, openai, openrouter, custom
modelString""Model name
base_urlOption<String>per-providerEndpoint base URL
temperaturef320.7Sampling temperature
context_linesu32150VtLogBuffer rows injected per turn
experimental_ai_block_enrichmentboolfalseEnrich OSC 133 blocks with semantic intent
agent_model_overridesOption<HashMap<ToolPhase, String>>NonePer-phase model routing. Keys: plan, search, read, write

Commands: load_ai_chat_config(), save_ai_chat_config(config)

Cron Scheduler Config (ai-cron.json)

Type: SchedulerConfig

FieldTypeDefaultDescription
jobsVec<ScheduledJob>[]List of scheduled agent jobs

Each ScheduledJob:

FieldTypeDescription
idStringUnique job identifier
cron_exprStringCron expression (validated on save)
goalStringAgent goal to execute

Commands: load_scheduler_config(), save_scheduler_config(config)

UI Preferences (ui-prefs.json)

Type: UIPrefsConfig

FieldTypeDefaultDescription
sidebar_visiblebooltrueSidebar visibility
sidebar_widthu32280Sidebar width in pixels
error_handling.strategyString"retry"Error strategy
error_handling.max_retriesu323Max retry count

Commands: load_ui_prefs(), save_ui_prefs(config)

Repository Settings (repo-settings.json)

Type: RepoSettingsMap (HashMap of RepoSettingsEntry)

Per-repository fields:

FieldTypeDefaultDescription
pathStringRepository path
display_nameStringDisplay name
base_branchString"main"Base branch for worktrees
copy_ignored_filesboolfalseCopy .gitignored files to worktree
copy_untracked_filesboolfalseCopy untracked files to worktree
setup_scriptString""Script to run after worktree creation
run_scriptString""Default run command
auto_fetch_interval_minutesu320Auto-fetch interval in minutes (0 = disabled)
auto_delete_on_pr_closeAutoDeleteOnPrClose"off"Auto-delete branch when PR merged/closed (off/ask/auto)
archive_scriptString""Script to run before archive/delete (non-zero exit blocks)

Commands: load_repo_settings(), save_repo_settings(config), check_has_custom_settings(path)

Repository Defaults (repo-defaults.json)

Type: RepoDefaultsConfig

Default values applied to new repositories when no per-repo override exists.

FieldTypeDefaultDescription
base_branchString"automatic"Default base branch
copy_ignored_filesboolfalseCopy .gitignored files to worktree
copy_untracked_filesboolfalseCopy untracked files to worktree
setup_scriptString""Default setup script
run_scriptString""Default run command
archive_scriptString""Default archive script

Commands: load_repo_defaults(), save_repo_defaults(config)

Repositories (repositories.json)

Type: serde_json::Value (flexible JSON, shape defined by frontend)

Stored in the shared config directory like every other file (see Config Directory) — debug and release builds read and write the same repositories.json. Writes go through ConfigFile::save_checked (see Core Functions). Its stamp is captured inside the save command, so it protects the backend read-to-write interval only; unlike the delta-backed config.json and mcp-upstreams.json paths, it is not a cross-process UI-session merge protocol.

repositories.json used to be the one file exempt from the (then-real) debug/release split: it was seeded into a separate ~/.tuicommander-dev/ directory on first debug run so a dev instance wouldn’t start with an empty repo list. That seeding path is gone now that both builds share one directory for repositories.json and all other config domains covered by this document. (~/.tuicommander-dev/ itself still exists for an unrelated purpose — see credentials.rs’s debug-only credential store.)

Commands: load_repositories(), save_repositories(config)

Prompt Library (prompt-library.json)

Type: PromptLibraryConfig

#![allow(unused)]
fn main() {
struct PromptEntry {
    id: String,
    label: String,
    text: String,
    pinned: bool,
}
}

Commands: load_prompt_library(), save_prompt_library(config)

AI Prompts (ai-prompts.json)

Type: AiPromptsConfig

FieldTypeDefaultDescription
diff_triage_system_promptOption<String>NoneCustom system prompt for diff triage LLM classification. Falls back to built-in default when None or empty.

Commands: load_ai_prompts(), save_ai_prompts(config)

MCP actions: list_ai_prompts, load_ai_prompt (requires service), save_ai_prompt (requires service + prompt, localhost only)

Notes (notes.json)

Type: serde_json::Value (flexible JSON, shape defined by frontend)

Commands: load_notes(), save_notes(config)

Keybindings (keybindings.json)

Type: serde_json::Value (flexible JSON, shape defined by frontend)

Custom keyboard shortcut overrides.

Commands: load_keybindings(), save_keybindings(config)

Agents Config (agents.json)

Type: AgentsConfig

Per-agent run configurations (custom commands, arguments, environment variables).

#![allow(unused)]
fn main() {
struct AgentRunConfig {
    name: String,
    command: String,
    args: Vec<String>,
    env: HashMap<String, String>,
    is_default: bool,
}

struct AgentSettings {
    run_configs: Vec<AgentRunConfig>,
}

struct AgentsConfig {
    agents: HashMap<String, AgentSettings>,
}
}

Commands: load_agents_config(), save_agents_config(config)

AI Chat Config (ai-chat-config.json)

Type: AiChatConfig

FieldTypeDefaultDescription
providerString"ollama"Provider: "ollama", "anthropic", "openai", "openrouter", "custom"
modelStringprovider-specificModel name (free text; settings tab suggests per provider)
base_urlOption<String>provider-specificPre-filled per provider, editable. Ollama default: http://localhost:11434/v1/
temperaturef320.7Sampling temperature passed through to provider
context_linesu32150Maximum VtLogBuffer lines injected into each turn’s context

Commands: load_ai_chat_config(), save_ai_chat_config(config)

API keys are stored in the OS keyring — service tuicommander-ai-chat, user api-key — via save_ai_chat_api_key / delete_ai_chat_api_key. Saved conversations live in <config_dir>/ai-chat-conversations/<id>.json.

Dictation Config (dictation-config.json)

Type: DictationConfig

FieldTypeDefaultDescription
enabledboolfalseDictation enabled
hotkeyString"CommandOrControl+Shift+D"Push-to-talk hotkey
languageString"en"Transcription language
modelString"large-v3-turbo"Whisper model name
auto_sendboolfalseAuto-submit after transcription

Commands: get_dictation_config(), set_dictation_config(config)

Cache Files

Claude Usage Cache (claude-usage-cache.json)

Module: src-tauri/src/claude_usage.rs

Persistent cache for incremental JSONL parsing of Claude session transcripts. Stored in the config directory. The cache maps project_slug -> (filename -> CachedFileStats) and tracks per-file byte offsets so only newly appended data is parsed on subsequent scans.

This is an internal cache file, not user-editable. It is automatically pruned when projects or session files are deleted.

Repo-Local Config (.tuic.json)

Module: src-tauri/src/config.rs

A .tuic.json file in the repository root provides team-shareable settings. It is read-only from the app — teams edit it directly in their repo and commit it.

Precedence chain: .tuic.json > per-repo app settings (repo-settings.json) > global defaults (repo-defaults.json)

Type: RepoLocalConfig (all fields Option<T>, missing fields fall through to lower tiers)

FieldTypeDescription
base_branchStringBase branch for worktrees
copy_ignored_filesboolCopy .gitignored files to worktree
copy_untracked_filesboolCopy untracked files to worktree
setup_scriptStringScript to run after worktree creation
run_scriptStringDefault run command
archive_scriptStringScript to run before archive/delete
worktree_storageWorktreeStorageStorage strategy (sibling/app-dir/inside-repo)
delete_branch_on_removeboolDelete branch when removing worktree
auto_archive_mergedboolAuto-archive merged worktrees
orphan_cleanupOrphanCleanupOrphan worktree handling
pr_merge_strategyMergeStrategyPR merge method preference
after_mergeWorktreeAfterMergePost-merge worktree action
auto_delete_on_pr_closeAutoDeleteOnPrCloseAuto-delete on PR close

Command: load_repo_local_config(repo_path) — returns RepoLocalConfig or null if file is missing or malformed.

Additional Commands

CommandModuleDescription
hash_password(password)lib.rsBcrypt hash for remote access authentication
list_markdown_files(path)lib.rsList .md files in a directory
read_file(path, file)lib.rsRead a file’s contents
get_mcp_status()lib.rsGet MCP server status (enabled, port, connected clients)
clear_caches()lib.rsClear in-memory caches
get_local_ip()lib.rsGet primary local IP address
get_local_ips()lib.rsList all local network interfaces
get_claude_usage_api()claude_usage.rsFetch rate-limit usage from Anthropic OAuth API
get_claude_usage_timeline(scope, days?)claude_usage.rsGet hourly token usage timeline from session transcripts
get_claude_session_stats(scope)claude_usage.rsScan JSONL transcripts for aggregated token/session stats
get_claude_project_list()claude_usage.rsList Claude project slugs with session counts
fetch_plugin_registry()registry.rsFetch remote plugin registry index

PTY Management

Module: src-tauri/src/pty.rs

Manages pseudo-terminal sessions for all terminal tabs in the application.

Session Lifecycle

create_pty() / create_pty_with_worktree()
    │
    ├── Resolve shell (platform default or user override)
    ├── Build shell command via portable-pty CommandBuilder
    ├── Spawn PTY pair (master + child process)
    ├── Store PtySession in AppState.sessions (DashMap)
    ├── Create OutputRingBuffer for MCP access
    ├── Spawn reader thread (background, non-blocking)
    │
    ▼
Session Active: write_pty() / resize_pty() / pause_pty() / resume_pty()
    │
    ▼
close_pty(cleanup_worktree)
    ├── Remove session from DashMap
    ├── Kill child process
    ├── Remove output buffer
    └── Optionally remove associated git worktree

Each session may also carry an orchestrator-owned pty_description, separate from the last user prompt captured by input-line bookkeeping. MCP spawn and input actions update it through pty-description-changed; desktop and browser clients render it together with the last prompt above the terminal.

Tauri Commands

Session Creation

CommandDescription
create_pty(config: PtyConfig)Spawn a new PTY session. Returns session ID.
create_pty_with_worktree(pty_config, worktree_config)Create worktree + spawn PTY in it. Returns WorktreeResult.

Production spawn sites share pty::spawn_pty_pair_with_retry and its async wrapper. Only explicitly classified PTY allocation failures (for example OS resource exhaustion or an interrupted/would-block allocation) receive the bounded three-attempt, 100/200 ms backoff. Once a PTY pair exists, command spawn runs exactly once: invalid binaries, cwd, and permission failures return immediately. Async Tauri and HTTP entry points run allocation and backoff on Tokio’s blocking pool; synchronous internal callers retain the same bounded policy. Each site still owns its justified command/env/dimension assembly.

Session Control

CommandDescription
write_pty(session_id, data)Write data (user input) to the PTY.
resize_pty(session_id, rows, cols)Resize the PTY terminal dimensions.
pause_pty(session_id)Pause the reader thread (stops output emission).
resume_pty(session_id)Resume the reader thread.
close_pty(session_id, cleanup_worktree)Close PTY and optionally remove worktree.
update_session_cwd(session_id, cwd)Update session’s working directory (called from frontend on OSC 7).

Monitoring

CommandDescription
get_orchestrator_stats()Active/max/available session counts.
get_session_metrics()Total spawned, failed, bytes emitted, pauses.
can_spawn_session()Check if under MAX_CONCURRENT_SESSIONS (50).
list_active_sessions()List all sessions with cwd and worktree info.
list_worktrees()List all managed worktrees.
get_process_stats()CPU% and RSS for TUIC + all child process trees (desktop Tauri command).
collect_process_stats(state)Same logic, callable from HTTP routes and MCP tools.

Reader Thread

Each session spawns a dedicated reader thread that reads from the PTY master fd:

#![allow(unused)]
fn main() {
spawn_reader_thread(reader, paused, session_id, app, state)
}

Processing pipeline per read:

  1. Read raw bytes from PTY master (up to 64KB buffer for natural burst batching)
  2. Strip Kitty keyboard protocol sequences (non-printable noise for consumers)
  3. Push through Utf8ReadBuffer — accumulates bytes until valid UTF-8 boundary, returns safe string
  4. Push through EscapeAwareBuffer — holds incomplete ANSI escape sequences (CSI, OSC, etc.)
  5. Feed into VtLogBuffer for VT100-aware changed-row parsing and primary-screen log extraction (mobile/MCP consumers)
  6. Write to OutputRingBuffer (64KB circular buffer for MCP access)
  7. Serialize parsed events once with serde_json::to_value — reused for both Tauri IPC and event bus (avoids double serialization)
  8. Broadcast to WebSocket clients (if any connected)
  9. Emit Tauri event pty-output with {session_id, data}throttled to ~10/s (≥100ms between emits). The desktop canvas renders from grid frames and discards this text (it only drives the frontend activity dot / lastDataAt); emitting per-chunk flooded the WebView main thread under output storms (yes), starving keydown so Ctrl+C never reached write_pty. Dropping intermediate chunks is safe — only a periodic “output happened” pulse is needed.

Cursor-up clamping — The clamp_cursor_up() function limits ESC[nA (cursor up) and ESC[nF (cursor previous line) sequences to prevent them from moving the cursor beyond the visible viewport. This replaced the previous DiffRenderer approach for simpler escape sequence handling.

ANSI anomaly detection — The detect_anomalous_sequences() function scans PTY output for unusual escape sequences (screen clears, cursor home, alt-screen toggles, scrollback clears) and logs them at warn level. This is a diagnostic tool for investigating scroll-jump issues.

Pause behavior: When paused flag is set (AtomicBool), the reader thread sleeps for 50ms instead of reading. This prevents output flooding during background operations.

Exit detection: When the read returns 0 bytes or an error, the thread:

  1. Flushes remaining buffered data
  2. Emits pty-exit event with exit code
  3. Removes session from AppState.sessions
  4. Updates metrics (decrement active_sessions)

Frame Emission Pipeline

Frame emission is decoupled from PTY reading via a per-session frame ticker thread (same approach as iTerm2’s Metal display-link renderer):

  1. Reader thread: processes PTY data into the alacritty VT grid, sets a grid_frame_dirty AtomicBool flag
  2. Ticker thread: every 16ms, checks the dirty flag → if set, serializes dirty rows via serialize_dirty_rows() → sends frame via send_grid_frame() (respects grid_frame_in_flight backpressure)
  3. Frontend: coalesces paint triggers via requestAnimationFrame (~60fps)
  4. Ack handler: clears only the in-flight flag; the ticker sends any dirty rows accumulated while the prior frame was in flight

This coalesces rapid writes (e.g. spinner CR+erase+rewrite within 16ms) into a single frame, eliminating flicker from intermediate erase states. The ticker exits when the reader’s running flag clears, with a final flush to avoid losing the last frame.

Synchronized output (DEC mode 2026). TUIC advertises Sy in the spoofed TERM_FEATURES, so agents — Codex in particular — wrap each repaint in ESC[?2026hESC[?2026l. The vendored VTE buffers those bytes and applies them atomically on ESU, but its 150ms SYNC_UPDATE_TIMEOUT is passive: it records a deadline and never fires. The embedder must enforce it, and the ticker is the only wakeup that can — by definition no further PTY bytes are coming.

The ticker therefore checks the deadline before the non-dirty early return, via flush_sync_timeout_if_needed() on VtLogBuffer. Three details are load-bearing:

  • A per-session sync_update_active AtomicBool, published by the reader after each process(), keeps idle sessions from taking the vt lock every 16ms. It mirrors real parser state, so a nested BSU (which re-arms the deadline rather than closing the update) keeps it set.
  • A timeout flush bypasses grid_send_min_interval_ms(). A protocol deadline is not animation; throttling it back a tick defeats the purpose.
  • Teardown calls force_stop_sync_if_buffered() before the final serialize — session exit is the other “no more bytes arrive” case, and without it buffered output dies with the session.

Without this enforcement a single BSU whose ESU is delayed or lost freezes the tab indefinitely: content buffers invisibly and only a later ESU releases it. That was the cause of Codex streaming appearing to eat text and then dump it all at once, and it made any binary containing the BSU bytes a permanent tab wedge.

Headless Reader Thread

spawn_headless_reader_thread() — used for HTTP-created sessions (no Tauri app handle). Same pipeline but skips Tauri event emission; only writes to ring buffer and WebSocket. Includes extract_question_line() for silence-based question detection, session lifecycle events (session-created, session-closed), and full output parser integration.

Named agent sessions propagate their stable display_name through the session-created event. That launch label is a replaceable base title: OSC and structured intent titles may update it. An independent display_name_is_custom flag protects only an explicit user rename and survives frontend reconnection. Session snapshots also carry is_remote, so reconnecting an HTTP/MCP-created PTY does not lose orchestration-only notification muting.

Shell Resolution

#![allow(unused)]
fn main() {
pub(crate) fn resolve_shell(override_shell: Option<String>) -> String
}

Priority:

  1. User override from settings (override_shell)
  2. Platform default via default_shell()

Platform defaults:

  • macOS: /bin/zsh
  • Linux: $SHELL environment variable, fallback /bin/bash
  • Windows: powershell.exe

Buffer Types

Utf8ReadBuffer

Handles the case where a multi-byte UTF-8 character (e.g., emoji, CJK) is split across two reads:

#![allow(unused)]
fn main() {
impl Utf8ReadBuffer {
    fn push(&mut self, new_bytes: &[u8]) -> String  // Returns valid UTF-8, keeps remainder
    fn flush(&mut self) -> String                     // Force-flush (lossy conversion)
}
}

EscapeAwareBuffer

Prevents ANSI escape sequences from being split between two emissions. Detects incomplete CSI (\x1b[...), OSC (\x1b]...), and other escape sequences:

#![allow(unused)]
fn main() {
impl EscapeAwareBuffer {
    fn push(&mut self, input: &str) -> String  // Returns safe-to-emit portion
    fn flush(&mut self) -> String              // Force-flush buffered escapes
}
}

OutputRingBuffer

Fixed-capacity circular buffer (64KB) that stores recent output for MCP access:

#![allow(unused)]
fn main() {
impl OutputRingBuffer {
    fn write(&mut self, data: &[u8])                    // Append data
    fn read_last(&self, limit: usize) -> (Vec<u8>, u64) // Read last N bytes
}
}

VtLogBuffer

Module: src-tauri/src/state.rs

VT100-aware extractor that captures clean log lines from PTY output. Designed for mobile/browser clients that need readable text without ANSI noise or TUI screen garbage.

#![allow(unused)]
fn main() {
impl VtLogBuffer {
    fn new(rows: u16, cols: u16, capacity: usize) -> Self  // Create with terminal size
    fn process(&mut self, data: &[u8]) -> Vec<ChangedRow>   // Feed raw PTY bytes, return changed rows
    fn resize(&mut self, rows: u16, cols: u16)              // Update terminal dimensions
    fn screen_rows(&self) -> Vec<String>                    // Current VT100 screen content (for slash menu detection)
    fn screen_log_lines(&self) -> Vec<LogLine>              // Styled screen rows for mobile/REST (structural tokens stripped)
    fn lines_since_owned(&self, offset: usize, limit: usize) -> (Vec<LogLine>, usize) // Paginated reads (absolute offset, structural tokens stripped, chrome lines skipped)
    fn total_lines(&self) -> usize                          // Monotonic counter (never decreases on eviction)
    fn oldest_offset(&self) -> usize                        // Absolute offset of oldest retained line
}
}

ChangedRow — describes a row that changed between two process() calls:

#![allow(unused)]
fn main() {
struct ChangedRow {
    row_index: usize,   // 0-based row in the VT100 screen
    text: String,        // Clean text content (no ANSI)
}
}

How it works:

  1. Maintains a vt100::Parser — a full VT100 screen emulator (24 rows × 220 cols default)
  2. On each process() call, compares current screen rows against previous snapshot
  3. Lines that have scrolled off the top are emitted to the log (diff-based detection)
  4. Separate alternate-screen contracts: changed rows are still returned while a TUI app owns the alternate screen, so status/intent/question parsers keep working. Durable log extraction reads only primary-screen history, so fullscreen repaint noise never reaches mobile/MCP logs
  5. Bounded by VT_LOG_BUFFER_CAPACITY (10,000 lines); oldest lines are dropped when full
  6. Monotonic cursor: total_lines() returns a monotonically increasing count of all lines ever pushed (not the current buffer length). Clients use this as a stable cursor for paginated reads via lines_since_owned(offset, limit). If a client’s saved offset falls in the evicted range, it is clamped to oldest_offset()

Resize: When the PTY is resized, VtLogBuffer.resize() keeps the parser in sync and clears the previous-row snapshot (avoids false scroll detection after resize). If an alternate-screen app is active, the durable-log cursor is synchronized against the inactive primary grid, not the unrelated alternate history; normal shell capture therefore resumes on the first line after exit.

Each session gets its own VtLogBuffer stored in AppState.vt_log_buffers: DashMap<String, Mutex<VtLogBuffer>>.

OSC 7 CWD Tracking

Shells that emit OSC 7 (\x1b]7;file://hostname/path\x07) report the current working directory after each command. TUICommander uses this to keep the Rust-side PtySession.cwd in sync:

  1. Frontend handler: terminal.parser.registerOscHandler(7, ...) in Terminal.tsx parses the file:// URL via parseOsc7Url().
  2. Store update: The parsed path is written to terminalsStore so the UI reflects the current directory.
  3. IPC persist: The frontend calls update_session_cwd(sessionId, cwd) to update PtySession.cwd on the Rust side.
  4. Restart recovery: The persisted cwd is used during session restore so reopened terminals start in the correct directory.
  5. Worktree reassignment: When the cwd changes to a path inside a different worktree, the terminal tab is reassigned to the corresponding branch in the sidebar.

Shell Environment Variables

build_shell_command() sets these environment variables for spawned PTY sessions:

VariableValuePurpose
COLORTERMtruecolorAdvertise 24-bit color support
KITTY_WINDOW_ID1Signal kitty keyboard protocol support for heuristic detection by Ink-based agents
TERM_PROGRAMghosttySatisfy Claude Code’s terminal allow-list for kitty protocol; also prevents macOS /etc/zshrc from sourcing zshrc_Apple_Terminal
TERM_PROGRAM_VERSION3.0.0Passes Claude Code’s version gate (rejects ^[0-2]\.)

Additionally, CLAUDECODE is removed from the environment (env_remove) to prevent nested-session detection when TUICommander itself runs inside a Claude Code session. NO_COLOR is also removed from every PTY command immediately after construction because it may belong to a Codex parent that launched TUICommander, not to the independent child session. This does not force application color or override explicit command flags; a deliberate per-agent environment may restore NO_COLOR after sanitization.

Child Process Priority

Each spawned shell is given a lower scheduling priority right after spawn (lower_pty_child_priority()), so heavy workloads run inside a pane (cargo build, bundlers, test runners) yield CPU to TUIC’s own render loop and the rest of the system. A child inherits the parent’s priority at fork time, so every process the shell later spawns is deprioritized too. The effect only bites under contention — an idle machine still runs the build at full speed.

PlatformMechanismDefault
macOS / Linuxsetpriority(PRIO_PROCESS, …)nice +10, override via TUIC_PTY_NICE
WindowsSetPriorityClass(BELOW_NORMAL_PRIORITY_CLASS)fixed

Validated on an M4 Max under 14-core saturation: TUIC’s UI goes from frozen (nice 0) to responsive (nice +10). BELOW_NORMAL (not IDLE_PRIORITY_CLASS) is the Windows analog — IDLE only runs when the whole system is idle, the equivalent of macOS QoS-background, which would make builds crawl.

macOS Thread QoS Elevation

On macOS, the PTY reader thread, the frame ticker, and the keystroke-write thread are all raised to QOS_CLASS_USER_INTERACTIVE via pthread_set_qos_class_self_np (raise_thread_for_interactive_io() in src-tauri/src/pty.rs, thread_qos module). This is complementary to the child-process renice: on Apple Silicon the scheduler is QoS-band driven — nice only reorders threads within a band. Without this elevation, TUIC’s interactive-path threads ran in the default QoS band alongside compiler worker threads, causing input latency under heavy builds. Raising to USER_INTERACTIVE puts the interactive path in a higher scheduler band. macOS-only; a no-op on Linux/Windows.

Session Conflict Flag File

When an agent reports a session conflict (session already in use or not found), TUICommander handles it via a flag-file mechanism instead of writing directly to the PTY.

Flow:

  1. The output parser detects a session conflict message (ParsedEvent::AgentSessionConflict)
  2. ChunkProcessor calls mark_session_conflict(), which creates a flag file named no-session-inject.<TUIC_SESSION> in the app config directory
  3. Shell wrapper functions (zsh, bash, fish) check for this flag file before injecting --session-id $TUIC_SESSION
  4. If the flag file exists, the wrapper skips session-id injection, allowing the agent to start a fresh session

This replaced the previous maybe_reset_tuic_session approach, which wrote export TUIC_SESSION=... directly to the PTY. Direct PTY writes could corrupt TUI output (e.g., Ink-based agents in raw mode). The flag-file approach is safe because it uses the filesystem as a side-channel — no bytes are injected into the terminal stream.

A debounce (last_session_conflict_mark) prevents creating multiple flag files within a short window for the same session.

Ctrl-U Prefix Handling

Single-key PTY writes that should clear the current input line prepend \x15 (Ctrl-U) on POSIX shells. The selection is shell-family aware, not host-platform aware: the detected shell (bash/zsh/fish → POSIX, powershell/cmd → Windows) drives the choice. Mixing PowerShell on macOS or a POSIX shell via WSL/MSYS now behaves correctly. Native Windows shells skip the prefix entirely to avoid inserting a literal ^U.

Frontend input helpers route through src/utils/sendCommand.ts:

  • sendCommand(fn, text) — full command: Ctrl-U (family-gated) + text + \r. Handles Ink raw-mode split writes.
  • sendPtyKey(fn, key) — pass-through single key/escape sequence. No prefix, no trailing CR. Use for ChoicePrompt option keys, TUI app navigation, and any raw-stdin interaction.

Never write text + "\r" directly to a PTY — see AGENTS.md.

OSC 133 Semantic Prompts

When the shell emits OSC 133 markers (modern bash/zsh/fish with the integration enabled), the reader records clean command lifecycles into the per-session knowledge store:

MarkerMeaning
OSC 133;APrompt start — delimits a new prompt line
OSC 133;BCommand start — the user has pressed Enter, command is about to run
OSC 133;CCommand output start
OSC 133;D[;exit_code]Command completed with the given exit code

ChunkProcessor.record_osc133_outcomes consumes the markers and writes a CommandOutcome { command, cwd, exit_code, classification, duration_ms, output_snippet } into the session knowledge store. Classification is one of Success, Error { error_type }, TuiLaunched { app_name }, Timeout, UserCancelled, Inferred. error_type is inferred from the output snippet (e.g. rust-error-borrow, npm-missing-module, python-traceback).

Fallback: when OSC 133 is absent (plain shells, remote sessions), the silence timer still records an Inferred outcome so the AI agent loop has something to learn from. The has_osc133_integration flag on AppState tracks per-session whether real markers have been seen.

Persistence lives at <config_dir>/agent-knowledge/<session_id>.json. A 2 s debounced background task (spawn_persist_task) flushes knowledge_dirty sessions to disk. load_all rehydrates stores on app start.

TUI Application Detection

src-tauri/src/ai_agent/tui_detect.rs tracks alternate-screen enter (ESC[?1049h) and leave (ESC[?1049l) to classify the terminal as:

#![allow(unused)]
fn main() {
enum TerminalMode {
    Shell,
    FullscreenTui { app_hint: Option<String>, depth: u8 },
}
}

depth is a counter for nested alt-screen pushes (e.g. less invoked from inside vim). Known app hints — matched heuristically from nearby screen rows — include vim, nvim, htop, btop, lazygit, less, tmux, claude, and others. The mode is surfaced on SessionState.terminal_mode and used by:

  • ai_terminal_get_context — tells the model it’s in a TUI so it prefers send_key + wait_for over line-oriented send_input.
  • SessionKnowledgeBar — renders a TUI badge and accumulates tui_apps_seen.
  • The agent safety layer — blocks Ctrl-U prefix injection while a TUI app is in the foreground.

Silence-Based Question Detection

The reader thread tracks output silence to detect unanswered agent prompts. When the terminal stops producing output for 10 seconds after a line ending with ? is detected, the session is treated as waiting for input. This complements the instant pattern-based detection in the output parser and catches generic questions that would cause too many false positives if detected immediately (e.g., streaming fragments like “ad?”, “swap?”).

Question extraction: extract_question_line() scans changed rows for a candidate, but a visible input-box anchor makes chat order authoritative: only the latest chat content above the current prompt may become a question. The changed-row fallback is used only when no prompt anchor is available. This prevents scroll/repaint from resurrecting a question retained above a later answer or completion. Question events carry the input turn_epoch, and the state accumulator rejects an event produced by an older turn.

Echo suppression: When the user submits a line — including bare Enter — the shared desktop/HTTP bookkeeping advances the turn, clears the current wait, and activates a 500ms suppression window (suppress_user_input). During this window, matching PTY echo is ignored for question detection.

Single threshold: All silence-based questions use a uniform 10-second timeout regardless of whether new output has arrived since the question was detected.

Shell State (Busy/Idle) Detection

The backend combines explicit lifecycle markers, agent-specific screen evidence, real output, and silence to emit ShellState events (busy/idle). Rust is the single source of truth — the frontend does not derive activity from raw PTY data. Before the first lifecycle observation, shell state is absent and detected-agent state is starting; the internal null sentinel is never serialized as idle.

PTY lifecycle events update the authoritative SessionState through a lossless single-consumer lane. The global broadcast bus remains the live SSE/WS transport, where slow consumers may reconnect after lag, but a dropped broadcast copy cannot strand the sticky awaiting/idle state.

Transitions:

  • Explicit markers: OSC 133 shell markers and OSC 7770 agent hooks transition immediately. Output silence cannot override an observed hook busy; it ends on hook idle, a confirmed interruption, process exit, or a stable ready composer after the submitted turn produced real activity. The last path recovers safely when an idle hook is missed without letting the previous turn’s composer cancel a fresh submission.
  • → busy: A submitted agent prompt, real output, an animated spinner, or an agent-specific Working screen transitions via atomic CAS (try_shell_transition). Positive screen evidence is evaluated even while the stored state is idle, so false-idle is self-healing.
  • → idle: The 1s silence timer is the sole heuristic idle path. Plain shells use 500ms; agents use 2.5s and must have no active sub-tasks. Agents with ready-screen adapters require the ready prompt to remain stable for 1.5s.
  • Interrupts: Ctrl-C and bare Escape record interrupt pending but never force idle. Idle follows only after an interrupted/ready screen, explicit Stop, or process exit.

Movement is the default busy signal (#446-596f): “if the text above the input area moves, the agent is active.” Post-cutoff changed_rows are text-equality diffed (TerminalGrid::process), so a byte-identical repaint produces no ChangedRow. Static completed summaries, hints, HUD bars, and banner art are inert. Spinner rows among the changed rows additionally refresh last_output_ms while they animate. Claude and Codex have narrowly scoped semantic presence exceptions described below because current versions can freeze a valid active status while a child or blocking hook runs.

Agent screen adapters: Gemini and Aider remain prompt-based (Ready or Unknown). Gemini and Codex accept composers only in the current bottom chrome zone (or the final three rows when no input box can be identified), so a historical submitted prompt or markdown quote cannot report Ready. Codex detects Working/Ready/Interrupted from its semantic status near that current composer; both and the newer » composer glyph are accepted. Claude treats only a spinner-prefixed phase containing an ellipsis and parenthesized progress as Working; this outranks the empty composer that current Claude versions leave visible during long tools. Completed summaries such as ✻ Sautéed for 1m 25s remain Ready. If Claude emits a premature Stop/suggest before a blocking Stop hook, a live phase marker reopens that turn and clears the stale completion suggestions. Grok similarly keeps its composer visible during a turn: a leading Braille spinner in its bottom status row is Working, and the stable composer becomes Ready only after that row disappears. This repairs the shell’s long-lived OSC 133 busy marker even when native Grok hooks are disabled.

Signal precedence and confirmation: Explicit hook busy > current Claude/Codex/Grok semantic Working marker > movement (real output / animated spinner) > silence. A ready prompt visible from the previous turn cannot cancel a newly submitted prompt until real activity has been observed; after activity, a stable ready composer can repair a missed hook idle. A current-turn completion marker prevents a stale static Codex Working row from relatching BUSY; movement of that exact semantic row can reopen a Codex internal continuation that starts without PTY input. Claude’s current live phase marker can supersede a premature completion from a blocking Stop hook. A pending process probe or confirmed meaningful descendant still owns the task lifecycle. Hook-based question suppression activates only after an OSC 7770 state marker is actually received.

OSC 777 notification classification: OSC 777 notify is a desktop-notification transport, not an awaiting-state protocol. Raw-stream parsing promotes only response-required wording (needs your permission, approval required, or is waiting for your input) to a confident question. This preserves plan/skill picker detection for hook-instrumented Claude sessions while ignoring the observed generic Claude Code needs your attention notification, which can announce completion and otherwise latches awaiting indefinitely.

State-regression capture: Enable POST /diagnostics/capture before reproducing ({"enabled":true,"session_id":"<id>"}), stop it afterward, and copy the exact <config dir>/captures/<id>.tcap file into src-tauri/src/fixtures/agent_prompts/. GET /diagnostics/capture reports the directory and bytes written. Framed records preserve input/output ordering, original chunk boundaries, and monotonic timestamps; legacy .raw fixtures remain output-only. Do not build fixtures from /sessions/:id/output: the bounded ring can overwrite the signal and its JSON string is lossy UTF-8.

Safety consumers: For agents with a verified screen adapter, peer-message injection and Unix auto-standby require confirmed idle (explicit Stop/OSC or stable ready screen). A silence-only idle can update the cosmetic state but cannot type into or SIGSTOP a potentially working agent. Agents without an adapter retain the legacy heuristic behavior until their UI is characterized.

Task lifecycle is separate from shell activity: shell_state=idle means the PTY is quiet; it does not prove that the assigned task finished. An agent’s suggest: [ ... ] marker explicitly closes the current task epoch and produces agent_state=completed plus a state_change: completed parent notification. Likewise, a visible ready composer may coexist with an autonomous background command. While a meaningful descendant of the agent is alive, session state reports background_work=true and keeps agent_state=working; shell_state remains idle because terminal input readiness is a separate fact. Persistent integration helpers (mdkb, tuic-bridge, and node_repl) and Claude’s standalone timed caffeinate -i -t <seconds> assertion do not count as work; Unix classification checks both comm and the authoritative argv path from unlimited-width ps output. A caffeinate invocation that wraps a command remains meaningful background work. Parent idle lifecycle mail is deferred until the real descendant exits, while confirmed-ready message delivery keeps using the terminal-readiness gate. The first confirmed-ready observation and every explicit agent IDLE marker arm a generation boundary: idle/completed lifecycle output waits until a process snapshot newer than that observation or marker has been reconciled. Fresh working evidence starts a new readiness episode even within the same task epoch: it invalidates only the satisfied or pending snapshot boundary, so the next ready observation must reconcile a newer snapshot while preserving tracked background work and the snapshot generation. One app-wide process snapshot is collected at most once per second on Tokio’s blocking pool and shared by every session. The refresher runs only while a ready probe or tracked background process needs it, skips missed interval ticks, and stops scanning stable idle sessions. Enumeration or parse failures preserve the prior background_work value. On Windows, where Toolhelp does not provide command lines, generic node.exe processes are kept as meaningful work rather than guessed to be node_repl helpers. Submitting new user or PTY-injected peer input starts a new task epoch immediately, clearing the prior completion marker and its stale suggested actions before new output arrives. Claude channel and inbox delivery do not claim a submitted turn; the channel is used only inside an already working turn. Idle or completed managed composers take the PTY submission path, and lifecycle changes only after that input or normal activity evidence. Idle CAS and parent lifecycle notification share the same per-session lifecycle lock; submitted epoch mutation and its IDLE-to-BUSY transition hold that lock as one critical section, so a new turn cannot inherit a stale idle notification. The authoritative parent inbox enqueue occurs under the child lock; parent terminal wake/dispatch runs only after release, avoiding cross-session lock ordering. A queued BUSY-to-IDLE transition also carries the task epoch observed before it waited for the lock and is discarded if a new submitted turn won first. Without a fresh marker the new task epoch returns to idle, not completed.

Transactional peer injection: Reserving an idle composer creates an ownership token before the PTY write. A failure proven to occur before any byte was written rolls the synthetic BUSY state back to the prior confirmed IDLE state and keeps the message queued. Once any byte may have escaped, failure is delivery_uncertain: the session remains conservatively BUSY, the authoritative inbox remains readable, and TUIC does not automatically retry into the terminal. Real output, a Working screen, or an explicit state marker invalidates rollback ownership so a late error cannot erase genuine activity. session status exposes the additive delivery_uncertain flag.

User-composed commands share that gate: the Compose panel’s enqueue action (enqueue_agent_command / POST /sessions/:id/queue) appends to the same typed pending_injections FIFO as peer delivery rather than writing to the PTY, so a command typed by the user cannot steer a turn in progress. It is appended and then flushed, never handed straight to deliver_message_to_pty: injecting ahead of any accepted peer message or user command would reorder delivery. Each flush pops one entry and leaves the session BUSY, so the queue drains one item per idle window in global acceptance order. state.queued_commands, list_queued_agent_commands, remove_queued_agent_command and clear_queued_agent_commands select only user-command entries; clear retains all peer/orchestrator entries in their original relative order. Each user command carries a process-unique id so the Compose panel can delete a single entry — a queue position would shift under the caller as the FIFO drains.

Status line ticks: Animated spinner repaint evidence refreshes both shell activity and SilenceState, preventing low-confidence question/tool-error events from contradicting a busy tab. Static mode/footer rows remain chrome only and do not prove activity.

Status line dedup is per turn: ChunkProcessor.last_status_task keys its dedup on (turn_epoch, task_name), so a spinner rotation inside one turn stays suppressed while the first status line of a new turn always re-emits. The epoch must stay in the key because an agent may name every turn identically — Codex always reports Working. A session-lifetime dedup swallowed every turn after the first, and since the status-line event is the only thing that clears the previous turn’s suggested_actions (which session_state_with_shell reads as a completion marker), the session reported a busy agent as completed/idle permanently.

Agent detection: detectAgentForTerminal() fires on shell-state transitions (immediate on idle, 500ms debounce on busy). A 30s fallback poll catches cold starts. This replaces the previous 3s polling interval, reducing syscalls ~30x.

Amber Tab Styling

Sessions created via HTTP/MCP (remote sessions) are flagged with isRemote. The tab bar applies an amber gradient background and amber bottom border (rgba(251, 191, 36, ...)) to visually distinguish remote-created sessions from locally spawned ones.

Concurrency

  • Sessions stored in DashMap<String, Mutex<PtySession>> for lock-free concurrent access
  • Each session’s writer has its own shared Mutex, independent from the PtySession metadata lock. User input, HTTP/WebSocket input, agent injection, and terminal-generated protocol replies all serialize through that writer. A reader may therefore wait for an in-flight write without blocking PTY draining, and mandatory device/kitty query replies are never dropped merely because session metadata is contended.
  • Reader thread holds Arc<AtomicBool> for pause signaling
  • Metrics use AtomicUsize for zero-overhead counting

Output Parser

Module: src-tauri/src/output_parser.rs

Parses terminal output to detect structured events: rate limits, status lines, PR URLs, and progress indicators.

Usage

#![allow(unused)]
fn main() {
let parser = OutputParser::new();
let events: Vec<ParsedEvent> = parser.parse(terminal_output);
}

ParsedEvent Variants

RateLimit

Detected when terminal output matches known rate limit patterns from AI agents:

#![allow(unused)]
fn main() {
ParsedEvent::RateLimit {
    pattern_name: String,    // e.g., "claude_rate_limit"
    matched_text: String,    // The matched text
    retry_after_ms: Option<u64>, // Parsed retry delay
}
}

StatusLine

Agent status output (e.g., token usage, timing):

#![allow(unused)]
fn main() {
ParsedEvent::StatusLine {
    task_name: String,
    full_line: String,
    time_info: Option<String>,
    token_info: Option<String>,
}
}

PrUrl

Pull request URL detected in output:

#![allow(unused)]
fn main() {
ParsedEvent::PrUrl {
    number: u32,     // PR number
    url: String,     // Full URL
    platform: String, // "github", "gitlab", etc.
}
}

Progress

OSC 9;4 progress indicator:

#![allow(unused)]
fn main() {
ParsedEvent::Progress {
    state: u8,  // 0=remove, 1=set, 2=error, 3=indeterminate, 4=warning
    value: u8,  // 0-100 progress percentage
}
}

Question

Agent is waiting for user input (question, confirmation, menu choice):

#![allow(unused)]
fn main() {
ParsedEvent::Question {
    prompt_text: String,  // The detected prompt line
    confident: bool,      // Protocol-backed signals are high-confidence
}
}

The emitted JSON also carries the internal _turn_epoch. The authoritative session reducer ignores question and retraction events from an older input turn.

Question events have two sources:

  • Response-required OSC 777 notifications are parsed from the raw PTY byte stream before VT rendering consumes the escape sequence. Only explicit permission, approval, or waiting-for-input wording is accepted. A generic desktop notification such as Claude Code needs your attention does not prove that the composer awaits a response and is ignored for awaiting-state detection. The accepted bodies do not carry the same weight:

    Every qualifying notification in a raw chunk is retained in stream order; a later OSC sequence cannot overwrite an earlier approval request merely because both arrived in one OS read.

    BodyConfidenceWhy
    needs your permission, approval requiredhighA request with one reading. Cleared by the answer.
    is waiting for your inputlowClaude sends it for a blocked picker and on its 60s idle timer after a finished turn. Retractable, so question-cleared drops it when the screen shows no prompt.

    A high-confidence question is retracted by nothing but real user input, so a body that also means “idle” must never be high-confidence — it latched the badge on a session that had finished 17h earlier (observed 2026-08-11).

  • Screen-verified silence detection handles rendered questions (all instant regex patterns were removed due to false positives from Ink agent streaming):

  1. extract_question_line() scans changed terminal rows for ?-ending lines, applying content filters to reject code comments (//), markdown headers (#), diff context (+/-), and code syntax (->, =>, ::, )?)
  2. SilenceState stores the candidate and starts a 10s silence timer
  3. When the timer fires, a visible input box restricts detection to the latest chat content above that prompt; only an unanchored screen may use the bounded changed-row fallback
  4. If verified, emits ParsedEvent::Question { confident: false }

Guards against false positives:

  • Spinner suppression: If a status-line event was seen within the last 10s, detection is suppressed
  • Staleness counter: If >10 non-? output chunks arrived after the candidate, it’s considered stale
  • Screen verification: Candidate must still be among the last 5 visible lines at fire time
  • User echo suppression: 500ms window after user input ignores PTY echo of typed text
  • Resize grace: 1s suppression after terminal resize to avoid re-detection of redrawn content

Hook-instrumented agents normally report awaiting through OSC 7770 state=awaiting, but that hook covers only the agent’s explicit question-tool event. Plan and skill pickers can instead be represented only by a qualifying OSC 777 notification, so raw-stream events bypass the heuristic-question suppression used for hook-instrumented sessions.

QuestionCleared

The retraction of a low-confidence Question. It carries no payload:

#![allow(unused)]
fn main() {
ParsedEvent::QuestionCleared  // wire type: "question-cleared"
}

Emitted by the silence timer in pty.rs, never by a parser: it means “the screen is quiet and the tracked question is no longer the current chat prompt”. Retraction runs independently from the one-shot emission gate. Bare Enter now produces the same user-input clear and turn transition as every other input transport; retraction remains the backstop when output changes without input.

emit_question_cleared_if_stale() fires only when awaiting_input && !question_confident && choice_prompt.is_none(), and the state.rs arm re-checks question_confident before clearing. Confident questions stay sticky on purpose: grok repaints while it waits, so absence from the current screen is not proof that it was answered.

Raw Capture Regression Fixtures

Agent-state failures must be captured from the raw PTY stream before analysis. Enable POST /diagnostics/capture before reproducing, stop it afterward, then copy the reported <config dir>/captures/<session-id>.tcap file into src-tauri/src/fixtures/agent_prompts/. /sessions/:id/output is not a fixture source: it is a rendered, bounded ring snapshot and can lose one-shot escape sequences. Framed fixtures preserve input/output direction, original chunk boundaries, ordering, and monotonic timestamps; legacy .raw fixtures remain supported as output-only input. Replay also applies the production chrome cutoff before the rendered-row parser and hook suppression.

Output-only fixtures cannot express a missing input-side CLEAR. Framed .tcap fixtures can reconstruct submitted lines, while the Awaiting RETRACTION block still drives the real event-bus accumulator and asserts SessionState directly.

src-tauri/src/fixtures/agent_prompts/scenario-matrix.json is the coverage contract derived from the local Claude and Codex transcript corpora. Histories contribute semantic shapes (question tools, lifecycle start/complete/abort), not terminal bytes. Each scenario therefore identifies whether its evidence is a real raw fixture, a controlled capture template, a runtime regression, or a synthetic concurrency/state case. A test enforces unique IDs, Claude and Codex coverage, fixture existence, and the invariant that every state SET declares at least one CLEAR path.

ApiError

API errors from agents and providers (5xx server errors, auth failures):

#![allow(unused)]
fn main() {
ParsedEvent::ApiError {
    pattern_name: String,    // e.g., "claude-api-error", "openai-server-error"
    matched_text: String,    // The matched text
    error_kind: String,      // "server", "auth", or "unknown"
}
}

Detects errors from two tiers:

  • Agent-specific: Claude Code, Aider, Codex CLI, Gemini CLI, Copilot CLI (note: the generic “request failed unexpectedly” pattern was removed from Copilot detection due to false positives on Claude Code output)
  • Provider-level: OpenAI, Anthropic, Google, OpenRouter, MiniMax JSON error structures

Frontend plays an error notification sound and logs via appLogger.error().

Intent

Agent-declared intent — what the LLM is currently working on:

#![allow(unused)]
fn main() {
ParsedEvent::Intent {
    text: String,   // Short action description
    title: Option<String>,  // Optional tab title from (parenthesized) suffix
}
}

Detected as a single-line plain-prefix token at column 0: intent: <text> (<title>).

Agents receive this instruction automatically via MCP init. To use manually without MCP, add to CLAUDE.md or equivalent:

## Intent Declaration
At the start of each distinct work phase, emit on its own line:
intent: <action, present tense, <60 chars> (<tab title, max 3 words>)
Example: `intent: Reading auth module for token flow (Auth review)`

The activity dashboard shows intent (crosshair icon) when available, falling back to user prompt (speech bubble) otherwise.

Colorization: colorize_intent() wraps intent text in \x1b[2;33m (dim yellow) for the terminal output stream. The optional (title) suffix is stripped from the display. Colorization is agent-gated to prevent false positives.

PWA/REST stripping: LogLine::strip_structural_tokens() removes intent: / suggest: plain-prefix tokens from log line spans before serving to mobile/browser clients.

Active subtask detection: The output parser recognizes ⏵⏵ (U+23F5) and ›› (U+203A) mode-line prefixes as active subtask indicators. The active_sub_tasks count is tracked in SessionState and used to suppress premature completion notifications.

PlanFile

Plan file path detected in agent output:

#![allow(unused)]
fn main() {
ParsedEvent::PlanFile {
    path: String,  // Absolute path to the plan file
}
}

Suggest

Agent-proposed follow-up actions:

#![allow(unused)]
fn main() {
ParsedEvent::Suggest {
    items: Vec<String>,  // e.g., ["Run tests", "Review diff", "Deploy"]
}
}

Detected as a plain-prefix token at column 0: suggest: [ A | B | C ].

Keyword rejoin (narrow panes): in a narrow pane the wrap can fall inside the keyword itself, so dewrap_suggest_keyword rejoins every split position (s\nuggest:suggest\n:) before the regex runs. The tail is accepted behind the agent’s own hanging wrap indent — Codex soft-wraps its output with two leading spaces and emits • suggest / : [ … ], verified live at 9 columns — while the head must still start at column 0, optionally after whitespace or an agent bullet, so prose ending in a partial word is never rewritten. The rejoin buffer is allocated only when a match qualifies: the scan keys on prefix + newline, which any line ending in s hits, and ordinary chunks must stay on the Cow::Borrowed path.

Colon-alone rows: at the narrowest widths the bullet plus suggest fills the row on its own, so the continuation row carries only : and the bracket body starts a row later (• suggest / : / [ A, captured live at 9 columns). The keyword rejoin then produces a suggest: with nothing after it, so dewrap_suggest_content matches a trailing whitespace run of [\t ]*, not [\t ]+ — the pull to the next row must not require a space that this shape never has. The column-0 anchor, the bracket body and the 2–4 item count remain the guards that keep prose out.

One bounded logical line may soft-wrap across terminal rows and may begin with any parser-supported agent bullet (, , , or ), but the bracketed content may not contain a nested [/]. The closing bracket must be at or before the cursor; cells to the right of the cursor are ignored so stale content left by a carriage-return overwrite cannot complete a partial token. Reconstruction follows at most four soft-wrap transitions and 512 bytes. If those bounds or cursor metadata prevent reconstruction, the cursor-row structural candidate is rejected rather than parsed from rendered cells. Items are pipe-delimited (2–4 per the protocol). Parsing is agent-gated; the raw token is stripped from the log delivered to PWA/REST consumers by strip_structural_tokens, and concealed on the desktop canvas by the frontend overlay.

UsageLimit

Claude Code usage limit percentage:

#![allow(unused)]
fn main() {
ParsedEvent::UsageLimit {
    percentage: u8,      // 0-100
    limit_type: String,  // "weekly" or "session"
}
}

Detected via regex matching "You've used X% of your weekly/session limit". Supports both ASCII and Unicode smart-quote apostrophes (' and \u{2019}).

UsageExhausted

Claude Code usage fully exhausted (no remaining quota):

#![allow(unused)]
fn main() {
ParsedEvent::UsageExhausted {
    reset_time: Option<String>,  // Raw text, e.g. "8pm (Europe/Madrid)"
}
}

Detected via "out of (extra) usage" pattern. The optional reset_time is extracted from "· resets <text>" suffix. The raw string is passed to plugins for scheduling; no timezone parsing is done in Rust.

ActiveSubtasks

Agent sub-task indicator from ›› task · N local agents mode-line:

#![allow(unused)]
fn main() {
ParsedEvent::ActiveSubtasks {
    count: u32,       // Number of active sub-tasks (0 = all finished)
    task_type: String, // "local agents", "bash", "background tasks", etc.
}
}

ShellState

Shell activity state derived from PTY output timing:

#![allow(unused)]
fn main() {
ParsedEvent::ShellState {
    state: String, // "busy" | "idle"
}
}

Emitted by the reader thread on real-output→busy and idle transitions. The frontend consumes this instead of deriving busy/idle from raw PTY data. See docs/backend/pty.md for idle detection details.

AgentSessionConflict

Claude Code startup session-id failure:

#![allow(unused)]
fn main() {
ParsedEvent::AgentSessionConflict {
    matched_text: String,
    kind: String,  // "in-use" | "not-found"
}
}

Fired when the PTY emits either:

  • Session ID <uuid> is already in use. — the injected --session-id collides with a live process or stale lock.
  • No conversation found with session ID: <uuid> — a --resume <uuid> pointed at a missing session file (usually wrong config dir, e.g. running c where the session lives under c2’s ~/.claude-private).

Auto-reset behaviour: on this event the reader thread writes a fresh export TUIC_SESSION=<new-uuid> (or set -gx under fish) into the PTY so the shell wrapper’s --session-id auto-injection stops wedging the tab on the stale id. Guarded by a 3-second cooldown — Claude prints the error line several times as it exits, but only the first fires the reset. The frontend raises a warn toast so the user knows what happened.

ChoicePrompt

Numbered confirmation / multiple-choice menu rendered by Claude-Code-style footers (Esc to cancel · Tab to amend):

#![allow(unused)]
fn main() {
ParsedEvent::ChoicePrompt {
    title: String,                 // The question above the options
    options: Vec<ChoiceOption>,    // { index, label, destructive }
    dismiss_key: Option<String>,   // e.g. "cancel"
    amend_key: Option<String>,     // e.g. "amend"
}
}

Detection:

  • Footer match extracts dismiss_key / amend_key from Esc to <word> / Tab to <word> (or locale equivalents).
  • Option regex ^\s*(?:[❯›>]\s*)?(\d+)[.)]\s+(.+?)\s*$ — numbered items, optional cursor marker (, , >).
  • Title heuristics walk up past blank rows and require either a ? suffix or a verb prefix (do you want, proceed, continue, should i, confirm, apply, allow) to avoid matching Markdown numbered lists.
  • Minimum two options required to reduce false positives.

Destructive flag: labels matching "no", "cancel", "reject", "abort", "deny", or the prefixes "don't" / "do not" are flagged so the PWA overlay and plugins can style them as destructive.

Flow: the payload is stored on SessionState.choice_prompt and dispatched via pluginRegistry.dispatchStructuredEvent("choice-prompt", …). Animated status-line updates preserve the prompt and its awaiting_input lifecycle; resolution, disappearance, replacement, and PTY exit clear it. A disappearing or resolved dialog emits choice-cleared so frontend and plugin consumers do not retain stale state. Single-key replies should go through sendPtyKey() in src/utils/sendCommand.ts, never raw text + \r.

SlashMenu

Slash command menu detected from VT100 screen rows:

#![allow(unused)]
fn main() {
ParsedEvent::SlashMenu {
    items: Vec<SlashMenuItem>,  // { command, highlighted }
}
}

Detected by parse_slash_menu() when slash_mode is active — scans the bottom screen rows for 2+ consecutive /command patterns. The prefix marks the highlighted item.

VT100-Aware Parsing

parse_clean_lines(rows: &[ChangedRow]) -> Vec<ParsedEvent>

Primary entry point for VT100-aware parsing. Accepts ChangedRow vectors from VtLogBuffer.process() — each row contains clean text extracted from the VT100 screen emulator. This replaces the legacy ANSI-stripping pipeline for mobile/MCP consumers.

ChangedRow production remains active on both the primary and alternate screens. This is intentional: fullscreen agents still emit lifecycle, intent, suggestion, and question surfaces that the parser must observe. It is independent from VtLogBuffer’s durable log, which reads only primary-screen scrollback. Enabling alternate-screen history therefore makes the UI scrollable without feeding repeated fullscreen snapshots into persistent logs.

parse_slash_menu(screen_rows: &[String]) -> Option<ParsedEvent>

Scans screen bottom rows (from VtLogBuffer) for slash command menus. Only called when slash_mode is active (user typed /). Returns SlashMenu event with all detected commands.

Pattern Detection

The parser uses regex patterns to detect:

  • Rate limit messages from Claude, Aider, OpenCode, Gemini, Codex
  • Questions and interactive prompts (hardcoded, Y/N, inquirer, Ink menus, generic ? lines)
  • API errors from agents and API providers (5xx, auth failures)
  • GitHub/GitLab PR URLs in gh pr create output
  • OSC 9;4 terminal progress sequences
  • Agent status lines with timing/token info (see below)

Patterns are compiled once at OutputParser::new() and reused across calls.

False-Positive Guards

Two guard functions prevent false-positive detection when agents read or display source code, diffs, or documentation containing error-like or question-like patterns:

  • line_is_source_code(line) — Returns true for lines that look like source code rather than real errors. Detects: Rust raw string literals (r"...", r#"..."#), line comments (//, #), function/const/let declarations, indented code with string delimiters (4+ leading spaces), markdown fences (```), bullet points (- , * ), and markdown tables (| ... |).
  • line_is_diff_or_code_context(raw_line, trimmed) — Returns true for lines that look like diff output or code listings. Detects: unified diff lines (+, - prefixes), line-number prefixed code (462 -...), Claude Code diff summary blocks (⏺⎿), and diff summary lines (Added16lines).

Both guards are applied to rate limit, API error, and question pattern matches before emitting events.

ANSI Pre-Processing

The strip_ansi() function pre-processes CUF (Cursor Forward, \x1b[nC) escape sequences by replacing them with the equivalent number of spaces before stripping all ANSI escapes. Without this, strip-ansi-escapes silently drops cursor movement sequences and would concatenate surrounding text (e.g., "hello\x1b[3Cworld" would become "helloworld" instead of "hello world").

Status Line Detection by Agent

AgentPatternExample
Claude Code·//////* + ellipsis✢Reading files… (12s) or · Considering…
AiderKnight Rider scanner ░█ / █░ + task text░█ Waiting for claude-3-5-sonnet
AiderToken report Tokens: prefixTokens: 5.2k sent, 1.3k received.
Codex CLIBullet / + task + parenthesized time• Working (4m 55s • esc to interrupt)
Copilot CLI// + task + dots/ellipsis∴ Thinking… or ● Read file...
Gemini CLIBraille spinner ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ + phrase⠋ Analyzing your codebase
Amazon QBraille spinner + task + ASCII dots⠹ Thinking...
ClineBraille spinner + mode + optional timer⠙ Planning (45s · esc to interrupt)
Generic[Running] prefix[Running] npm test

Hook-Instrumented Session Suppression

When native hook instrumentation is configured, heuristic Question events are suppressed only after the session actually emits an OSC 7770 state= marker. The runtime handshake avoids trusting a stale flag when hook installation or an agent upgrade has broken delivery. Hook busy is authoritative over silence; hook idle, a confirmed interrupted screen, or process exit ends it. For known agents, explicit IDLE waits for a process snapshot newer than the marker before publishing parent idle/completed lifecycle mail, so background descendants remain working even while the shell is ready. Other parsed events continue unchanged. See suppress_heuristic_question() and the explicit-state fields in SilenceState (src-tauri/src/pty.rs).

Claude Stop hooks can block after Claude has already emitted a Stop/suggest marker. If the current screen still contains a semantic active phase (spinner prefix, active verb ending in an ellipsis, and parenthesized progress), the PTY lifecycle reopens the same turn and discards the premature suggestions. A plain empty composer or completed-duration summary does not provide this evidence.

Slash-menu parsing is gated by the input FSM’s slash mode and intentionally does not emit a per-chunk debug record. Sustained output with a stale slash flag previously produced thousands of identical application-log writes in seconds.

Error Classification

Module: src-tauri/src/error_classification.rs

Classifies terminal error messages and calculates exponential backoff delays for retry logic.

Tauri Commands

CommandSignatureDescription
classify_error_message(message: String) -> StringClassify error type
calculate_backoff_delay_cmd(retry_count, base_delay_ms, max_delay_ms, backoff_multiplier) -> f64Calculate backoff delay

Error Categories

classify_error(message) returns one of:

CategoryDescriptionExamples
"transient"Temporary, retry-safeNetwork timeout, connection reset, rate limit
"server"API server-side error5xx responses, service unavailable, auth failures from providers
"permanent"Will not resolve with retryAuth failure, not found, invalid input
"unknown"UnclassifiedDefault for unrecognized patterns

Backoff Calculation

#![allow(unused)]
fn main() {
pub fn calculate_backoff_delay(
    retry_count: u32,
    base_delay_ms: f64,
    max_delay_ms: f64,
    backoff_multiplier: f64,
) -> f64
}

Formula: min(base_delay_ms * backoff_multiplier^retry_count, max_delay_ms)

Typical usage:

  • base_delay_ms: 1000 (1 second)
  • max_delay_ms: 30000 (30 seconds)
  • backoff_multiplier: 2.0 (double each retry)

Result sequence: 1s → 2s → 4s → 8s → 16s → 30s → 30s → …

Frontend Integration

The errorHandlingStore calls these Rust functions to classify errors detected in terminal output and calculate retry delays. The store manages active retries and respects the user’s configured strategy (retry, ignore, or manual).

AI Watchers

Event-driven autonomous actions on terminal sessions. Watchers observe terminal state transitions and fire AI agent conversations when conditions are met.

Architecture

event_bus (AppEvent::PtyParsed)
    │
    ▼
WatcherEngine::run()          ← subscribes to broadcast channel
    │
    ├─ shell-state: idle  ──► on_idle()   ──► evaluate Idle / CommandDone / Pattern / Unseen triggers
    ├─ shell-state: busy  ──► on_event()  ──► evaluate Busy triggers
    ├─ question           ──► on_event()  ──► evaluate Question triggers
    ├─ api-error/rate-limit ► on_event()  ──► evaluate Error triggers
    ├─ user-input         ──► on_user_input() ──► auto-pause all active watchers for session
    └─ SessionClosed      ──► detach + pause all watchers for session

event_bus (AppEvent::GitHubTransition)   ← emitted by github_poller
    │
    ├─ PrTransition::Pushed ─► on_pr_pushed() ─► evaluate PrPushed triggers (dedup by head_ref_oid)
    └─ PrTransition::Opened ─► on_pr_opened() ─► evaluate PrOpened triggers (once per PR appearance)

Data Model

WatcherRule

Persisted in ai-watchers.json (app config dir).

FieldTypeDescription
idStringUUID, auto-generated on create
nameStringHuman-readable label
session_idOption<String>None = template (unattached), Some = active instance
template_idOption<String>Links instance back to its template
triggerWatcherTriggerWhen to fire (see below)
instructionsStringPrompt sent to the AI agent
max_firesu32Limit before auto-exhaustion (default: 50)
fire_countu32How many times this rule has fired
cooldown_secsu32Minimum seconds between fires (default: 10, min: 5)
burst_thresholdu32Max fires within burst window before auto-pause (default: 5)
burst_window_secsu32Burst detection window (default: 60)
statusWatcherStatusactive, paused, stopped, exhausted

WatcherTrigger

VariantEvaluated inDescription
Idleon_idleTerminal returns to idle (shell prompt)
Busyon_eventTerminal enters busy state (command running)
CommandDone { on_failure_only }on_idleCommand completed; optionally only on non-zero exit
Question { confident_only }on_eventQuestion detected in terminal output
Erroron_eventAPI error or rate limit detected
Unseenon_idleTerminal is idle AND its tab is not visible
Pattern { regex }on_idleRegex matches against last 50 screen lines
PrPushed { authored_by_others }on_pr_pushedNew commit pushed to an open PR (git-scoped via repo_path)
PrOpened { authored_by_others }on_pr_openedA brand-new PR was opened (git-scoped via repo_path)

Trigger Evaluation Paths

Triggers are evaluated in three distinct paths:

  • Idle path (on_idle): Idle, CommandDone, Pattern, and Unseen are evaluated when the terminal transitions to idle. Unseen additionally checks session_visibility (tab visible flag from the frontend).
  • Event path (on_event): Busy, Question, and Error fire immediately when their corresponding event arrives — they don’t wait for idle.
  • GitHub path (on_pr_pushed / on_pr_opened): PrPushed and PrOpened fire from AppEvent::GitHubTransition (emitted by github_poller), not the terminal paths. They are git-scoped to repo_path, apply the authored_by_others filter (skips PRs you authored, and skips when the GitHub viewer can’t be resolved), and provision/reuse a worktree session to review the PR. PrOpened fires at most once per PR appearance (the poller suppresses the first-poll seed so pre-existing PRs don’t fire); PrPushed dedups by head_ref_oid so it fires once per commit.

Template / Instance Model

Watchers use a template → instance pattern:

  1. Template: A rule with session_id = None. Created via the UI. Not active — serves as a blueprint.
  2. Instance: Created by “attaching” a template to a terminal session. Clones the template with a new UUID, sets session_id, and starts in active status.

Detaching an instance clears session_id, resets fire_count, and pauses it. On session close, all instances for that session are automatically detached.

On app restart, all rules are detached and paused (session IDs don’t survive restart).

What the Agent Receives

When a watcher fires, it calls start_conversation() with this message:

## Watcher instructions
<the rule's instructions field>

## Terminal context
Last command: `<cmd>` (exit <code>), cwd: <path>
Output:
<sanitized output snippet from session_knowledge>

Screen (last N lines):
<last 50 lines from VtLogBuffer>

## Watcher fire #<count>/<max>

The conversation runs with Autonomy::Autonomous and a 10-step limit.

Safety Guards

GuardBehavior
Active conversationSkips if a conversation is already running on the session
CooldownPer-rule minimum interval between fires (default 10s)
Burst detectionAuto-pauses if fires exceed burst_threshold within burst_window_secs
Max firesTransitions to exhausted status when fire_count >= max_fires
User inputAny user keystroke auto-pauses all active watchers for that session

Tauri Commands

CommandParametersDescription
watcher_createname, session_id, trigger, instructions, max_firesCreate template or instance
watcher_listList all rules (templates + instances)
watcher_deleteidDelete a rule
watcher_toggleid, enabledPause/resume a rule
watcher_attachtemplate_id, session_idClone template as active instance
watcher_detachidDetach instance, reset fire count
watcher_updateid, name?, trigger?, instructions?, max_fires?Edit a rule’s fields

Frontend Events

EventPayloadWhen
watcher-status{ id, status, fire_count, session_id }On any status transition (fire, pause, exhaust, burst)

Key Files

FileRole
src-tauri/src/ai_agent/watcher.rsWatcherRule model, WatcherEngine event loop, trigger evaluation, CRUD, persistence
src-tauri/src/ai_agent/commands.rsTauri command handlers for watcher_*
src-tauri/src/state.rswatcher_engine OnceLock in AppState, session_visibility DashMap
src/components/WatcherManager/WatcherManager.tsxTemplate CRUD UI, attach/detach, edit form
src/components/WatcherManager/WatcherManager.module.cssPopover styles
Config: ai-watchers.jsonPersisted rules (app config dir)

Git Operations

Modules: src-tauri/src/git.rs, src-tauri/src/git_cli.rs, src-tauri/src/git_reads.rs

Git writes are performed by shelling out to the git CLI via the unified git_cli module. Git reads go through the reversible GitReads port (see below), which serves some ops from in-process gix and the rest from the same CLI. The git_cli::git_cmd(path) builder provides consistent error handling, binary resolution, and credential prompt suppression across all callsites.

Async Execution & Caching

All Tauri git commands are async and run git subprocesses inside tokio::task::spawn_blocking. This prevents blocking Tokio worker threads during I/O-heavy operations like git diff, git log, or git fetch.

Git data is cached with a 60s TTL in GitCacheState (state.rs), one moka::sync::Cache<String, Arc<T>> per result type keyed by repo path. moka’s get_with/try_get_with coalesce concurrent identical loads to a single computation — replacing the previous hand-rolled DashMap<String,(T,Instant)> whose check-then-compute-then-set pattern had a TOCTOU race that let a repo-changed burst fan out N duplicate computes. sync::Cache is used (not future::Cache) because every loader is blocking git work run on the blocking pool; the sync *_cached helpers keep working without async (git.rs::cached_get/cached_try wrap the pattern). github_repo_cooldown stays a plain DashMap — it is a cooldown set, not a TTL value cache.

The repo_watcher (FSEvents on macOS, inotify on Linux) monitors the working tree with per-category debounce (Git/WorkTree/Config) and calls invalidate_repo_caches() on file system changes (which also clears the prompt var_cache for the repo), so git data refreshes immediately instead of waiting for TTL expiry. On macOS/Windows it registers a single recursive watch (near-zero cost at the OS level); on Linux it splits into pruned non-recursive watches over the working tree (skipping ALWAYS_EXCLUDED_DIRS and gitignored paths, adding watches for newly created dirs from the event callback) plus targeted .git watches (root non-recursive for HEAD/index/sentinels, refs and worktrees recursive — never objects/logs), because a recursive inotify watch would walk and watch every subtree (node_modules, target, .git/objects) and flood the callback (issue #82). Each linked worktree gets its own watch: its working tree usually lives outside the repo root (the Sibling/AppDir storage strategies), so the root’s watch never sees it, and the git-state fingerprint is computed from the main checkout’s index + porcelain status, so a worktree-local edit leaves it identical and the emit is suppressed. Without those watches an agent editing a worktree produced no event at all and the branch’s sidebar diff badge stayed stale until the user selected the branch. The roots come from .git/worktrees/*/gitdir (linked_worktree_roots) and are re-synced by sync_worktree_watches on every git-state change — worktree add/remove is part of the fingerprint, so it always rides an emit and needs no watcher restart. classify_path matches worktree roots before the repo root, so a worktree stored inside the repo (.worktrees/, .claude/worktrees/ — usually gitignored) is not dropped as noise. The watcher respects .gitignore rules and hot-reloads them when .gitignore is modified. The 60s TTL serves as a safety net for missed watcher events. Most IPC calls for git data hit the cache (~0.2ms) instead of spawning a git subprocess (~20-30ms).

Watcher-miss observability: each cache’s moka eviction listener increments a shared ttl_fallbacks counter only on RemovalCause::Expired (TTL aged out without the watcher invalidating first) — explicit invalidations do not count. A rising counter means the watcher likely missed events; it is surfaced in the cpu_watchdog HEALTH/CPU-SPIKE snapshots as git_cache_ttl_fallbacks.

Internal callers that need synchronous access use _impl suffixes (e.g. get_diff_stats_impl) to avoid double spawn_blocking nesting.

GitReads Port (gix migration)

Read operations go through a reversible GitReads port (src-tauri/src/git_reads.rs) so individual ops can be served by in-process gix (gitoxide 0.84) instead of shelling out, removing the process spawn + FD + stdout-parse cost on hot paths. CliGitReads delegates to the existing git_cmd-based functions; GixGitReads implements the same trait with a moka handle cache (ThreadSafeRepository per path → thread-local Repository per call). GitReadsRouter (the global git_reads()) dispatches each op to its backend via a per-op PerOpBackend.

An op is flipped to gix only behind a byte-for-byte parity (“shootout”) test comparing gix output to the CLI on a fixture repo. Where gix 0.84 cannot match git’s exact output, the op stays on the CLI.

OpBackendNotes
branches_detailgixreferences() → shorten / peel / committer ISO8601 / author / summary / upstream. ahead/behind via the ahead_behind backend.
ahead_behindgixrev_parse_single + two with_hidden revwalks (counts are order-independent; handles no-common-ancestor).
worktree_pathsgixworktrees() + main worktree; paths canonicalized to match git worktree list real paths.
blamegixblame_file(); renamed-history files fall back to CLI (gix blame lacks -C/-M rename following).
commit_log, graph_commitsgixgix has no built-in topo sort, so gix_topo_order reproduces git log --topo-order (Kahn seeded by commit-date) and gix_decorations reproduces %D byte-for-byte (reverse-refname order, tag: prefix, HEAD -> branch). author_date UTC is normalized to git’s Z.
status_countsgixrepo.status() items mapped to staged/changed counts (TreeIndex = staged; IndexWorktree Change/IntentToAdd/untracked/conflict = changed; NeedsUpdate skipped). sparse-checkout / submodule → CLI fallback.
diff_statsgix (worktree)unstaged worktree-vs-index --shortstat via per-blob imara (Myers + slider), binary excluded. Staged (--cached) and commit (hash^..hash) modes → CLI; sparse/submodule/error → CLI.

All 8 read ops are served by gix, each gated by a byte-for-byte shootout test; the gix adapters fall back to the CLI internally for their unsupported edge cases (sparse/submodule, renamed-history blame, staged/commit diff). Backend::Cli is retained in PerOpBackend as a per-op rollback lever.

The displayed unified diff/patch (get_git_diff), stash, reflog, and all writes/auth stay on the CLI permanently — they are not part of the port. The gix dependency uses default-features = false with only ["sha1","revision","status","blame","blob-diff","dirwalk","parallel"] (pure Rust, no C toolchain).

Monitoring Git Concurrency

Background repo-monitoring refreshes — get_repo_summary_impl, get_repo_structure_impl, and get_repo_diff_stats_impl — each fan out git subprocesses (worktree-list, branch --merged, per-worktree diffs). On a repo-changed burst across many registered repos this is unbounded and can spike concurrent git pipes past the OS file-descriptor limit (EMFILE) while flooding the main thread with IPC.

Each of these entry points acquires one permit from AppState.monitoring_git_sem (MONITORING_GIT_CONCURRENCY = 8) for the whole refresh, capping concurrent background refreshes to 8. Gating is per-function (not per-spawn) and deadlock-free because these entry points never call each other. Operational git (commit/push/stage/checkout/diff-on-click) is never gated — only monitoring work is throttled.

Subprocess Helper (git_cli.rs)

Every git subprocess invocation goes through git_cmd(cwd: &Path) -> GitCmd. The builder provides three execution modes:

MethodUse Case
run()Strict — returns Err(GitError) on non-zero exit
run_silent()Optional — returns None on any error
run_raw()Full control — returns raw Output regardless of exit code

GitError implements Into<String> for seamless use in Tauri command returns.

Tauri Commands

Repository Info

CommandSignatureDescription
get_repo_info(path: String) -> RepoInfoGet repo name, branch, status, initials
get_git_branches(path: String) -> Vec<Value>List all branches (sorted by rules below)
check_is_main_branch(branch: String) -> boolCheck if branch is main/master/develop/trunk
get_initials(name: String) -> StringGenerate 2-char initials from repo name

Diff Operations

CommandSignatureDescription
get_git_diff(path: String) -> StringFull git diff (staged + unstaged)
get_diff_stats(path: String) -> DiffStatsAddition/deletion counts
get_changed_files(path: String) -> Vec<ChangedFile>List changed files with per-file stats (single subprocess call)
get_file_diff(path: String, file: String) -> StringDiff for a single file

Repository Summary

CommandSignatureDescription
get_repo_summary(repo_path: String) -> RepoSummaryAggregate snapshot: worktree paths, merged branches, diff stats, timestamps
get_repo_structure(repo_path: String) -> RepoStructureFast: worktree paths + merged branches only
get_repo_diff_stats(repo_path: String) -> RepoDiffStatsSlow: per-worktree diff stats + last commit timestamps

The frontend uses get_repo_structure (Phase 1) and get_repo_diff_stats (Phase 2) for progressive loading — UI rows appear immediately, stats fill in later. Refresh is single-flight per repository: concurrent requests join the active run and coalesce into one trailing rerun. This guarantees that sustained filesystem events cannot repeatedly cancel Phase 1 and leave deleted worktrees in the persisted sidebar cache. get_repo_summary remains for backward compatibility.

Branch Operations

CommandSignatureDescription
rename_branch(path, old_name, new_name) -> ()Rename a branch
update_from_base(path, branch, strategy?) -> StringFetch base ref (if remote) and rebase or merge the branch onto it. On conflict, reports (aborted) only after git rebase/merge --abort succeeds; if abort fails, the error says the repo may still be conflicted and includes the manual abort command.
start_conflict_assist(repo_path, pr_number) -> ConflictAssistResultCreates a PR-head worktree and rebases it. A conflict-free result is clean only after a successful origin refresh; stale tracking or local fallback results are clean_unverified with base_source and base_warning.
get_branch_base(path, branch) -> Option<String>Read stored base ref from git config branch.<name>.tuicommander-base
git_apply_reverse_patch(path, patch) -> ()Apply a reverse patch for hunk/line-level restore

Data Types

RepoInfo

#![allow(unused)]
fn main() {
struct RepoInfo {
    path: String,        // Repository path
    name: String,        // Repository name (from directory)
    initials: String,    // 2-char initials (e.g., "TC" for tuicommander)
    branch: String,      // Current branch name
    status: String,      // "clean", "dirty", or "conflict"
    is_git_repo: bool,   // Whether path is a git repository
}
}

DiffStats

#![allow(unused)]
fn main() {
struct DiffStats {
    additions: i32,
    deletions: i32,
}
}

ChangedFile

#![allow(unused)]
fn main() {
struct ChangedFile {
    path: String,       // Relative file path
    status: String,     // "M" (modified), "A" (added), "D" (deleted), etc.
    additions: u32,     // Lines added
    deletions: u32,     // Lines deleted
}
}

RepoStructure

#![allow(unused)]
fn main() {
struct RepoStructure {
    worktree_paths: HashMap<String, String>,  // branch → worktree path
    merged_branches: Vec<String>,             // branches merged into default
}
}

RepoDiffStats

#![allow(unused)]
fn main() {
struct RepoDiffStats {
    diff_stats: HashMap<String, DiffStats>,       // worktree_path → additions/deletions
    last_commit_ts: HashMap<String, Option<i64>>,  // branch → unix timestamp (seconds)
}
}

Utility Functions

get_repo_initials(name: &str) -> String

Generates 2-character initials from a repository name:

  • Split on hyphens, underscores, dots, spaces
  • If multiple words: first letter of first two words (e.g., “tuicommander” → “TC”)
  • If single word: first two letters (e.g., “react” → “RE”)
  • Always uppercase

is_main_branch(branch_name: &str) -> bool

Returns true for: main, master, develop, trunk, dev.

sort_branches(branches: &mut [Value])

Sorts branches by priority:

  1. Currently active branch (always first)
  2. Main branches (main, master, develop)
  3. Open PR branches (alphabetical)
  4. Feature branches without PRs (alphabetical)
  5. Merged/closed PR branches (alphabetical, always last)

GitHub Integration

Modules: src-tauri/src/github.rs, src-tauri/src/github_auth.rs, src-tauri/src/github_account.rs, src-tauri/src/improvement_scan.rs

Integrates with GitHub via GraphQL API for PR status, CI checks, and batch queries. Supports OAuth Device Flow login as an alternative to gh CLI tokens, plus multiple accounts (additional github.com logins and GitHub Enterprise Server) with per-repo bindings.

Multi-Account Model (github_account.rs)

The integration is account-centric: the primary key is a stable GitHubAccountId, not the host. This keeps github.com behaving exactly as before behind an “ambient default” account while enabling additional accounts.

  • GitHubHost — canonical (lowercased, validated) host. is_cloud() → github.com; graphql_url() / rest_base() return api.github.com (+/graphql) for cloud and https://{host}/api/graphql / https://{host}/api/v3 for GHE. is_ambient_default() routes the global-vs-per-account branch points.
  • Account kindsGithubComOAuth / GithubComEnv / GithubComGhCli (the ambient default, existing auth chain), additional named github.com accounts, and GhePat (GitHub Enterprise Server via pasted PAT).
  • Credential storage — github.com keeps Credential::GithubOauthToken (github/oauth-token) unchanged; per-account PATs use Credential::GithubToken(account_id)github/account/{id}/token.
  • Repo bindings{repo_path → account_id, owner, repo, remote_name} persisted per canonical repo root (worktrees resolve to the main root). resolve_repo_account(repo_path) returns RepoResolution::{Bound | NeedsBind(candidates) | NeedsAccount | Unmonitored} — binding-first, single-candidate auto-confirm, ambiguity surfaces all candidates (never a silent origin pick).
  • Per-account isolation (hybrid) — github.com keeps the global breaker/viewer/rate/cooldown fields byte-for-byte; GHE accounts get isolated ghe_state: DashMap<AccountId, GheAccountState>. The poller groups repos by resolved account and runs one batch per account, so a fault on one never opens another’s breaker. Cooldown keys: owner/repo (cloud, unchanged) vs {account_id}:owner/repo (GHE).
  • Limitationfetch_ci_failure_logs (gh-CLI-assisted) is disabled with a clear message for non-github.com accounts; all REST + GraphQL paths route through github_rest_url(host, path) / account-scoped tokens (no hardcoded api.github.com outside GitHubHost + tests).

Multi-account commands

CommandSignatureDescription
github_list_accounts() -> Vec<GitHubAccount>Additional accounts beyond the ambient github.com default
github_add_account(host: String, pat: String) -> GitHubAccountValidate PAT against {rest_base}/user, store token + record (github.com rejected → device flow)
github_remove_account(id: String) -> ()Cascade-remove token + record + bindings + per-account caches
github_bind_repo(repo_path, account_id, remote_name) -> ()Persist a repo→account binding
github_unbind_repo(repo_path: String) -> ()Remove a repo binding
github_list_bindings() -> Vec<Binding>All persisted repo→account bindings
github_resolve_repo(repo_path: String) -> RepoResolutionDtobound / needs-bind / needs-account / unmonitored + candidates

Token Resolution

Priority order (first non-empty wins) for the ambient github.com account:

  1. GH_TOKEN environment variable
  2. GITHUB_TOKEN environment variable
  3. OAuth keyring token (github_auth.rs — stored in OS keyring via keyring crate)
  4. gh_token crate (reads ~/.config/gh/hosts.yml)
  5. gh auth token CLI subprocess

The active token source is tracked in AppState.github_token_source as a TokenSource enum (Env, OAuth, GhCli, Pat, None). resolve_token_for_account(&GitHubAccount) runs this exact chain for github.com and returns the vault PAT (TokenSource::Pat) for GHE accounts.

Tauri Commands — Authentication (github_auth.rs)

CommandSignatureDescription
github_start_login() -> DeviceCodeResponseStart OAuth Device Flow, returns user code
github_poll_login(device_code: String) -> PollResultPoll for token, saves to keyring on success
github_logout() -> ()Delete OAuth token from keyring, fall back to env/CLI
github_auth_status() -> AuthStatusCurrent auth status with login, avatar, source
github_disconnect() -> ()Disconnect GitHub — clear all tokens from keyring and env cache
github_diagnostics() -> ValueDiagnostics: token sources, scopes, API connectivity

Tauri Commands — GitHub Data (github.rs)

CommandSignatureDescription
get_github_status(path: String) -> GitHubStatusPR + CI status for current branch
get_ci_checks(path: String) -> Vec<Value>Detailed CI check list
get_repo_pr_statuses(path: String, include_merged: bool) -> Vec<BranchPrStatus>Batch PR status for all branches
approve_pr(repo_path: String, pr_number: i32) -> StringSubmit approving review via GitHub API
get_all_pr_statuses(path: String) -> Vec<BranchPrStatus>Batch PR status for all branches (includes merged)
get_pr_diff(repo_path: String, pr_number: i32) -> StringGet PR diff content; falls back to a local-clone git diff when GitHub rejects oversized diffs
merge_pr_via_github(repo_path: String, pr_number: i32, merge_method: String) -> StringMerge PR via GitHub API
fetch_ci_failure_logs(repo_path: String, run_id: i64) -> StringFetch failure logs from a GitHub Actions run for CI auto-heal
run_improvement_scan(repo_path: String, focus: ImprovementFocus) -> ImprovementScanResultHeadless-slot one-shot AI scan for refactor/testing/perf proposals; emits proposals-ready
create_issue_from_proposal(repo_path: String, proposal: ImprovementProposal) -> CreatedIssueExplicit issue creation from a proposal; scan never creates issues automatically
check_github_circuit(path: String) -> CircuitStateCheck GitHub API circuit breaker state

Circuit breaker coverage

Every call out to GitHub goes through the account’s circuit breaker: GraphQL via graphql_with_retry, gh api writes via run_gh_write, and direct REST via send_rest_with_breaker (close/reopen issue, merge PR, approve PR, fetch_github_json, PR diff, PR refs). The availability breaker counts transport errors and 5xx responses, not deterministic 4xx caller outcomes such as a missing issue, merge conflict, validation failure, or permission denial. Rate limits use their separate backoff: 429, primary-limit 403 headers, retry-after, or a secondary/abuse-limit message in an otherwise ambiguous 403 body. Non-rate-limit bodies remain available to caller-specific error formatting.

Cached viewer login

state.github_viewer_login backs author:@me in the viewer-PR search and the assignee/creator/mentioned issue filters. It is dropped by github::invalidate_viewer_login on logout, disconnect and a successful device-flow login — without that, switching accounts kept showing the previous account’s PRs and issues for the rest of the session. Named accounts cache their own login in ghe_state and are deliberately untouched by that invalidation.

Data Types

GitHubStatus

#![allow(unused)]
fn main() {
struct GitHubStatus {
    has_remote: bool,
    current_branch: String,
    pr_status: Option<PrStatus>,
    ci_status: Option<CiStatus>,
    ahead: i32,
    behind: i32,
}
}

PrStatus

#![allow(unused)]
fn main() {
struct PrStatus {
    number: i32,
    title: String,
    state: String,    // "OPEN", "CLOSED", "MERGED"
    url: String,
}
}

BranchPrStatus (Batch Endpoint)

Full PR data for a single branch, returned by get_repo_pr_statuses:

#![allow(unused)]
fn main() {
struct BranchPrStatus {
    branch: String,
    number: i32,
    title: String,
    state: String,
    url: String,
    additions: i32,
    deletions: i32,
    checks: CheckSummary,        // passed/failed/pending/total
    author: String,
    commits: i32,
    mergeable: String,           // "MERGEABLE", "CONFLICTING", "UNKNOWN"
    merge_state_status: String,  // "CLEAN", "DIRTY", "BEHIND", etc.
    review_decision: String,     // "APPROVED", "CHANGES_REQUESTED", etc.
    labels: Vec<PrLabel>,        // Labels with pre-computed colors
    is_draft: bool,
    base_ref_name: String,
    created_at: String,
    updated_at: String,
    merge_state_label: Option<StateLabel>,   // Pre-classified display label
    review_state_label: Option<StateLabel>,  // Pre-classified display label
}
}

PrLabel

#![allow(unused)]
fn main() {
struct PrLabel {
    name: String,
    color: String,            // Hex color from GitHub
    text_color: String,       // Computed: black or white based on luminance
    background_color: String, // Computed: hex_to_rgba with alpha
}
}

CheckSummary

#![allow(unused)]
fn main() {
struct CheckSummary {
    passed: u32,
    failed: u32,
    pending: u32,
    total: u32,
}
}

StateLabel

#![allow(unused)]
fn main() {
struct StateLabel {
    label: String,     // Human-readable text (e.g., "Approved", "Behind")
    css_class: String, // CSS class for styling
}
}

Utility Functions

parse_pr_list_json(json_str: &str) -> Vec<BranchPrStatus>

Parses the JSON output from gh pr list --json ... and enriches with computed fields (merge state classification, review state classification, label colors).

classify_merge_state(mergeable, merge_state_status) -> Option<StateLabel>

Maps GitHub merge state to display labels:

mergeablemerge_state_statusLabelCSS Class
MERGEABLECLEANReady to mergemerge-ready
MERGEABLEUNSTABLEChecks failingmerge-unstable
CONFLICTING*Has conflictsmerge-conflict
*BEHINDBehind basemerge-behind
*BLOCKEDBlockedmerge-blocked
*DRAFTDraftmerge-draft

classify_review_state(review_decision) -> Option<StateLabel>

review_decisionLabelCSS Class
APPROVEDApprovedreview-approved
CHANGES_REQUESTEDChanges requestedreview-changes
REVIEW_REQUIREDReview requiredreview-required

hex_to_rgba(hex: &str, alpha: f64) -> String

Converts hex color (e.g., “#ff0000”) to rgba string (e.g., “rgba(255, 0, 0, 0.5)”).

is_light_color(hex: &str) -> bool

Calculates relative luminance using the sRGB formula to determine if a color is light (for choosing black vs white text).

Tauri Commands — Issues

CommandSignatureDescription
poll_issues(repos: Vec<(String, String, String)>, login: String, filter: String) -> Vec<RepoIssues>Fetch issues for multiple repos using GitHub Search API
close_issue(repo_path: String, issue_number: i32) -> StringClose an issue via GitHub GraphQL mutation
reopen_issue(repo_path: String, issue_number: i32) -> StringReopen a closed issue via GitHub GraphQL mutation

GitHubIssue

#![allow(unused)]
fn main() {
struct GitHubIssue {
    number: i32,
    title: String,
    state: String,           // "OPEN", "CLOSED"
    url: String,
    created_at: String,
    updated_at: String,
    author: String,
    labels: Vec<PrLabel>,    // Reuses PrLabel with computed colors
    assignees: Vec<String>,
    milestone: Option<String>,
    comments_count: u32,
}
}

Issue Filter Modes

The filter parameter in poll_issues controls which issues are fetched:

FilterGitHub Search QualifierDescription
assignedassignee:{login}Issues assigned to the authenticated user (default)
createdauthor:{login}Issues created by the authenticated user
mentionedmentions:{login}Issues mentioning the authenticated user
all(no user qualifier)All open issues in the repo
disabled(no query)Issue fetching disabled

Issue Query Construction

build_multi_repo_issues_query constructs a GitHub Search API query per repo:

  • Format: repo:{owner}/{name} is:issue is:open {user_qualifier}
  • Results parsed via parse_issue_node which extracts labels with hex_to_rgba color computation (same opacity constant LABEL_BG_OPACITY = 0.7 as PRs)

GraphQL Batching

get_repo_pr_statuses uses gh pr list with extensive --json fields to fetch all open PRs in a single call. This is efficient: 1 API call returns all branches with PR data.

Polling budget: ~2 calls/min/repo = 1,200/hr for 10 repos, well within GitHub’s 5,000/hr rate limit.

PR Approval & Merge

approve_pr

Submits an approving review on a pull request via gh api. Used by the remote-only PR popover.

PR Diff Fetching

PR diff reads use the GitHub REST diff representation first. If GitHub returns the oversized-diff 406 Not Acceptable response, the backend fetches the PR refs into the local clone and returns a local git diff base...head unified diff instead, so AI Review can still run on PRs that exceed GitHub’s rendered diff file cap.

CI Auto-Heal (fetch_ci_failure_logs)

Lists workflow runs for the branch’s latest head commit, inspects their jobs, and downloads logs for every completed failed job through the GitHub Actions jobs API. Job-level retrieval works while sibling jobs are still running, before the containing workflow has a final failure conclusion. Used by the CI auto-heal hook (useCiHeal) to inject failure context into agent terminals for automatic fix cycles (up to 3 delivered attempts per cycle).

GitHub Actions only. The aggregated PR check summary (which triggers ci_failed) also counts external CI — CircleCI, Codacy, etc. — but this fetcher reads only GitHub Actions logs. When the red checks are all external, it returns a clear error naming them (… failing checks run on external CI (not supported): ci/circleci: lint-blades, on_pr …) instead of the misleading “no jobs found”. The auto-heal hook surfaces that message as a warn toast and does not consume an attempt (attempts increment only after a fix prompt is delivered). Provider is classified from the check’s detail link (is_github_actions_link: GHA links contain /actions/runs/).

Stale PR Filtering

When include_merged is true, get_repo_pr_statuses includes recently merged PRs. Stale merged PRs are filtered: if a branch has been recreated after a PR was merged (detected via branch creation timestamp vs PR merge timestamp), the old merged PR is excluded to prevent ghost badges.

MCP & HTTP Server

Module: src-tauri/src/mcp_http/mod.rs

Optional HTTP/WebSocket server that exposes all Tauri commands as REST endpoints. Enables browser-mode operation and MCP (Model Context Protocol) integration for external AI tools.

Activation

The server has two independent listeners:

  • IPC listener (always started): On macOS/Linux, listens at <config_dir>/mcp.sock (Unix domain socket). On Windows, listens on \\.\pipe\tuicommander-mcp (named pipe). No authentication — used by the local tuic-bridge sidecar.
  • TCP listener (opt-in): Only starts when remote access is enabled. Binds to 0.0.0.0:<port> (port from services.server) with Basic Auth.

The mcp_server_enabled config flag controls whether the /mcp protocol route is active (MCP tool discovery and invocation), not whether the server itself starts. The HTTP API endpoints (sessions, git, config, etc.) are always available on the IPC listener.

The local IPC listener is independent from the Remote Access TCP toggle. Turning remote access on or off only starts or stops the authenticated TCP listener; it does not disable mcp.sock or the local MCP route. Lifecycle logs state whether a transition affects TCP or the always-on IPC listener.

Configuration via Settings > Services & MCP, or config.json:

{
  "mcp_server_enabled": true,
  "mcp_port": 9876
}

On startup, the server:

  1. Binds the IPC listener: Unix socket at <config_dir>/mcp.sock (macOS/Linux) or named pipe \\.\pipe\tuicommander-mcp (Windows)
  2. If remote access is enabled, binds a TCP listener on the configured port
  3. Starts Axum HTTP server on a background tokio thread
  4. Enables CORS for browser mode
  5. Spawns MCP session reaper (evicts stale sessions after 1h TTL)
  6. Spawns upstream health checker for proxied MCP servers

Unix Socket Lifecycle (macOS/Linux)

The socket at <config_dir>/mcp.sock is managed with two safety layers to survive crashes and rapid restarts:

LayerMechanismPurpose
Retry bind3 attempts × 100 ms, each removes stale file before tryingA crashed previous run leaves a dead socket file that blocks bind(2) — retrying clears it
Real liveness checkUnixStream::connect() in get_mcp_statusfile.exists() returns true for stale sockets; only a real connect reveals whether the server is alive

Why this matters for AI tool integrations: The tuic-bridge sidecar connects via the Unix socket to expose TUICommander tools to Claude Code. If the socket is stale (app crashed, Tauri force-quit), the bridge cannot connect and returns tools: [], silently disabling all MCP tools in the agent session. The retry bind ensures the socket is always valid on restart; the real liveness check ensures the UI accurately reports the server state.

REST API Endpoints

Session Management

MethodPathDescription
GET/sessionsList active PTY sessions
POST/sessionsCreate new PTY session
POST/sessions/:id/writeWrite data to session
POST/sessions/:id/resizeResize session terminal
GET/sessions/:id/outputRead session output (ring buffer)
POST/sessions/:id/pausePause session output
POST/sessions/:id/resumeResume session output
DELETE/sessions/:idClose session

Monitoring

MethodPathDescription
GET/healthHealth check
GET/statsOrchestrator stats (active/max/available)
GET/metricsSession metrics (spawned, failed, bytes)
GET/process/statsCPU% and RSS memory for TUIC and all child process trees
GET/process/monitorSelf-contained HTML dashboard for process metrics (for remote/PWA/mobile)

Git Operations

MethodPathDescription
GET/repo/info?path=Get repository info
GET/repo/diff?path=Get git diff
GET/repo/diff-stats?path=Get diff stats
GET/repo/changed-files?path=List changed files
GET/repo/branches?path=List git branches
GET/repo/github-status?path=Get GitHub status
GET/repo/pr-statuses?path=Get batch PR statuses
GET/repo/ci-checks?path=Get CI check details
POST/ai/review/prAI review of a PR diff → line-level findings (Main slot)
POST/repo/create-prCreate a PR (gh wrapper, UI-gated)
POST/repo/create-issueCreate an issue (gh wrapper, UI-gated)
POST/repo/post-pr-reviewPost a PR review with inline comments
GET/repo/merged-prs?path=&sinceTag=Merged PRs via GraphQL (changelog source)
GET/repo/changelog?path=&sinceTag=AI changelog {markdown, json} (Headless slot)
POST/repo/conflict-assistWorktree + rebase; reports verified/unverified clean or conflicts, base source, warning, and agent prompt

Configuration

MethodPathDescription
GET/configGet app config
PUT/configSave app config
POST/auth/hash-passwordHash password for remote access

GET /config and MCP config action=get redact remote-access secrets: services.auth.password_hash, services.auth.session_token, services.relay.token, and services.push.vapid_private_key. The config shape exposes only the corresponding *_exists booleans for secret presence.

PUT /config and MCP config action=save both advertise “config fields to save” and both accept a partial body: it is deep-merged onto the live config by merge_partial_app_config, so an omitted field keeps its current value instead of falling back to its serde default. Both also share server_settings_changed with the IPC save_config and rebind the listener through restart_after_server_settings_change, so no transport can leave the process serving a configuration the disk disagrees with. See config.md for the merge semantics.

Agents

MethodPathDescription
GET/agents/detectDetect installed agents and IDEs

Plugins

MethodPathDescription
GET/plugins/docsPlugin development guide (AI-optimized reference)
GET/api/plugins/:plugin_id/data/*pathRead plugin data file (JSON or plain text)

Worktrees

MethodPathDescription
POST/worktreesCreate worktree
DELETE/worktreesRemove worktree
GET/worktrees/paths?path=Get worktree paths for repo

Streaming

WebSocket (/sessions/:id/stream)

Real-time PTY output streaming per session. Connects to the session’s broadcast channel.

Client ──WebSocket──> /sessions/{session_id}/stream
                      Server pushes PTY output as text frames

Session Lifecycle Events

When sessions are created or closed (via HTTP, MCP, or PTY exit), the server broadcasts events through the SSE event bus:

  • session-created — Emitted when a new PTY session is created (both local and MCP-spawned). Carries session_id, cwd, agent_type, and the optional stable display_name. Frontend uses this to auto-add remote tabs; a spawn-assigned name remains replaceable by OSC/intent titles, while session-list snapshots carry independent display_name_is_custom and is_remote flags for reconnect.
  • term-alias-assigned — Emitted when a session receives its human-friendly alias. Carries session_id and alias. Frontend uses this to update tab tooltips.
  • session-closed — Emitted when a session exits. Carries session_id. Frontend uses this for cleanup.

These events are available on the SSE /events stream used by the mobile PWA and any connected WebSocket clients.

Streamable HTTP (POST /mcp)

MCP Streamable HTTP transport (spec 2025-03-26):

Client ──POST──> /mcp   (JSON-RPC request, response in body)
Client ──GET───> /mcp   (SSE stream for server notifications, requires Mcp-Session-Id header)
Client ──DELETE─> /mcp  (end session, pass Mcp-Session-Id header)

tools/call does not require Mcp-Session-Id. Identity is resolved per call rather than demanded up front. A caller that sends the header gets its protocol session refreshed — a stale id (app restart, or a long-lived client like Claude Code that lost its session) auto-recovers instead of erroring. A caller that sends none is served anyway: most tools need no caller identity at all, so a plain curl against POST /mcp now works.

The identity-scoped actions (agent action=register|send|inbox|wait) still refuse without a protocol session — but each refuses on its own, with the concrete next step (initialize, or agent action=register) that the previous blanket -32600 never gave the caller. Making identity itself independent of the protocol session is a separate step (Phase E of the dual-era plan); an x-tuic-session header binds a TUIC identity to an existing mcp-session-id, it does not substitute for one.

GET /mcp and DELETE /mcp still require the header — both are bound to a specific protocol session, and GET answers 401 without it.

The GET /mcp SSE stream emits notifications/tools/list_changed whenever the available tool set changes (e.g., native tools are enabled/disabled via config, or upstream MCP servers connect/disconnect). The bridge sidecar subscribes to this stream and forwards the notification to the AI agent.

The bridge uses the standard MCP ping request for its three-second liveness check. This keeps health traffic constant-size as terminal count grows; it does not rebuild or serialize the complete tool catalog. If IPC is reachable but /mcp is unavailable, the bridge reports that the MCP endpoint is unavailable instead of incorrectly claiming the desktop process is not running.

The bridge retains the downstream client’s last initialize request and replays it internally after reconnecting to a restarted TUIC process. This restores session-scoped metadata such as Grok compatibility mode without sending a second initialize response to the client.

On reconnect, a peer may reclaim its stable TUIC identity after the prior MCP protocol session has no live SSE subscriber and has missed the bridge activity grace period. The takeover retires the old forward and reverse routing entries atomically.

A currently subscribed or recently active owner is never replaced — but it can be joined. One PTY may hold more than one bridge (Codex opens two), and both inherit the same $TUIC_SESSION, so both assert the same x-tuic-session. Only a process that inherited that PTY’s environment can assert it, so a second asserting bridge is a sibling, not a claimant: it is added to the identity’s routing (mcp_to_session plus the session_to_mcp list) while the live owner keeps delivery ownership. Ownership stays put on purpose — two live siblings that traded it on every request would flip the delivery channel back and forth. The inbox is keyed by the PTY identity, so both bridges read the same mail. agent action=register from a joined sibling is a rename, not a takeover; a protocol session with neither the header nor an existing route is still refused with “already registered to another active MCP session” (throttled to one WARN per claimant pair). Ending one co-owner’s protocol session drops only its own routes and promotes a survivor to delivery owner; the peer entry, inbox and orchestrator role are torn down only when the last co-owner goes.

Blocking tool actions run off the runtime worker. Dispatch is action-aware: session create/input/close/kill/process-stats, agent spawn/detect/send, and config writes use run_blocking_handler (tokio::task::spawn_blocking) because they may sleep, spawn processes, write PTYs, or touch disk. Common read-only actions such as session list/output/status and agent list-peers/inbox/stats/metrics execute inline without cloning their JSON payload or scheduling blocking-pool work. Async waits remain on their event-driven async handlers. A panicking blocking handler becomes a tool error rather than killing the request task; POST /mcp does not add another blanket wrapper.

Lazy Tool Discovery (collapse_tools)

When collapse_tools: true in config.json (or via Settings > Services & MCP > TUIC Tools > “Collapse tools”), the server replaces the full tool list in tools/list with exactly three meta-tools (the Speakeasy pattern):

Meta-toolPurpose
search_toolsBM25 search over the full native + upstream tool corpus; returns name/description pairs
get_tool_schemaReturns the full {name, description, inputSchema} for a specific tool
call_toolDispatches to the named tool — routes to the native handler or proxy_tool_call for {upstream}__{tool} names

Rationale: a cold tool list of 100+ tools costs ~35k tokens in every agent turn; the 3 meta-tools cost ~500 fixed tokens and the agent fetches schemas on demand. Toggling collapse_tools fires notifications/tools/list_changed so connected clients refresh their tool cache.

TUIC also selects this three-tool surface automatically for an individual Grok session when initialize.clientInfo.name starts with grok-shell-. Grok accepts only one __ namespace delimiter in a qualified MCP tool name, so it otherwise discards proxied names such as tuicommander__upstream__tool. The meta-tools keep the upstream identifier in the call_tool argument instead of the qualified MCP tool id. This compatibility mode is session-local: it does not change collapse_tools or the tool surface returned to other connected clients.

Filter enforcement. Both search_tools and call_tool re-apply the safety filters that the full listing would apply: disabled_native_tools is checked up-front in handle_call_tool, and upstream allow/deny filters are enforced at both enumeration time (aggregated_tools) and dispatch time (proxy_tool_call). This is critical under collapse mode: discovery no longer gates dispatch, so an agent that knows a filtered tool name cannot bypass the filter by calling call_tool directly. search_tools and get_tool_schema also reject meta-tool names, and call_tool refuses to recurse into itself.

The BM25 index lives in AppState::tool_search_index (parking_lot::RwLock<ToolSearchIndex>, backed by src-tauri/src/tool_search.rs). A background task subscribes to the mcp_tools_changed broadcast and rebuilds the index whenever the tool set changes (upstream connect/disconnect, disabled_native_tools edit, collapse_tools toggle).

The MCP instructions string returned by initialize (build_mcp_instructions) swaps to a “lazy discovery” guide when collapse_tools: true or the connecting Grok session requires compatibility mode, so agents know to call search_tools first rather than looking for a flat tool table. The TUIC connection acknowledgment in those instructions is emitted exactly once per MCP connection or reconnect, never once per conversational turn. TUIC protocol context remains in initialize instructions and native core-tool descriptions only; upstream tool descriptions are preserved instead of receiving a repeated TUIC preamble.

MCP Native Tools

Eight native tools, organized by domain. Two (config, debug) are hidden by default via disabled_native_tools — discoverable through search_tools/get_tool_schema/call_tool when collapse_tools is enabled.

ToolActionsDefault
sessionlist, create, input, output, status, wait, resize, close, kill, pause, resume, process_statsEnabled
agentspawn, wait, detect, stats, metrics, register, list_peers, send, inboxEnabled
taskget, cancelEnabled
repolist, active, prs, status, worktree_list, worktree_create, worktree_removeEnabled
uitab, toast, confirm, screenshotEnabled
plugin_dev_guide(no actions — returns guide text)Enabled
configget, saveDisabled
debugagent_detection, logs, sessions, invoke_jsDisabled

The disabled_native_tools config key accepts an array of tool names to hide from tools/list. Default: ["config", "debug"].

ui action=confirm blocks only its requesting tool call while the native dialog is open. The dialog runs on the blocking pool, so an unanswered confirmation cannot occupy the async MCP workers serving other agents and sessions.

ui action=toast sound. sound accepts true/false or a notification-sound name: question, completion, error, warning, info, attention. true resolves from level (info→info, warn→warning, error→error); a name overrides it. attention is a triangular G4→G4→E5 callback meant for an agent working unattended that is blocked on the user: two quick knocks followed by a longer rise. An unknown name is an error, never a silently silent toast. The backend resolves the name before emitting, so the toast event carries a concrete sound and every client plays it through the user’s notification settings (volume, output device, per-sound mutes) rather than inventing a tone. The event is dual-emitted — Tauri emit for the desktop WebView and the event bus for SSE clients; a bus-only send never reaches the desktop, which has no bus→window forwarder.

The toast also resolves the calling MCP session to its peer project, PTY cwd, or session repository metadata (in that order). The resulting origin_repo_path is dual-emitted with the toast so clients can show its source and scope retained bell history to the repository that raised it. Callers do not provide this field.

Native responses omit optional values when they are unavailable. In particular, session action=output includes exit_code only after an exit status is known, and blocking wait timeouts return only the condition state without a repeated follow-up hint. Native tool values are wrapped as compact JSON text in the MCP content envelope. A proxied upstream value that is already a valid MCP CallToolResult object (an object with a content array) instead becomes the JSON-RPC result directly, preserving its content, isError, structuredContent, and any extension fields without mutation. This applies both to direct {upstream}__{tool} calls and to the collapsed call_tool meta-path. A malformed upstream value falls back to the native compact JSON text envelope so the response remains protocol-valid and inspectable.

task tool — long-running orchestration past the 300s ceiling

agent action=wait and session action=wait are server-side blocking long-polls clamped to WAIT_MAX_MS (300 000 ms). An orchestrator supervising a peer that works longer than five minutes cannot hold one open, and a client that drops mid-wait loses the outcome entirely.

agent action=spawn therefore also returns a task handle:

{ "session_id": "…", "task_id": "…", "poll_interval_ms": 1000, "…": "…" }

The handle is polled with task action=get instead of holding a wait open. This is purely additive — every field a pre-task client already read keeps its name and type, and a client that ignores task_id behaves exactly as before.

Lifecycle. A task is created working once the PTY is live (a refused or failed spawn leaves none). mark_session_exited (pty.rs) drives it to completed with result = {session_id, exit_code}, or to failed with error when the exit code is non-zero — so the outcome is recorded whether or not anyone was listening.

StatusMeaning
workingThe agent is running
input_requiredWaiting on input; still live
completed / failed / cancelledTerminal and immutable — never change again

The vocabulary is the MCP 2026-07-28 Tasks vocabulary from day one, so the standard tasks/* front door stays a serialization change rather than a semantic remap.

task action=get returns {task_id, status, status_message?, result?, error_detail?, poll_interval_ms}, absent optional fields omitted. A failed task reports its reason in error_detail, not error — a top-level error always means the call itself failed, so reusing it would make a successful poll look like a broken one.

task action=cancel marks the task cancelled and does not kill the agent (session action=kill does that). Cancelling an already-finished task is not an error: it reports the state that stands with cancelled: false, so a cancel racing the agent’s exit never looks like a failure. Because terminal states are immutable, a cancel that lands first is never overwritten by the later exit.

Ownership. A task_id is a capability over a spawned agent, so ownership is checked before any state is returned or mutated: one agent cannot inspect or cancel another’s children. A caller that spawned before registering is stamped with its pending id and still reaches the handle after it auto-binds a TUIC identity. cancel additionally re-checks the same loopback guard as agent spawn; get is read-only monitoring and stays open to authenticated remote clients.

Retention. Tasks live in memory for 24 h and are reaped by the existing mcp_sessions reaper, not a separate timer. They are deliberately not persisted: the case this exists for is a client restart, which the TUIC process outlives. Disk would only cover a TUIC restart — and that tears down every PTY, so a recovered working task would describe an agent that no longer exists.

ui tool — tab URL schemes

The url param of action=tab supports three schemes:

SchemeBehaviour
http(s):// / file://Loaded in a sandboxed iframe
tuic://edit/<path>?line=NOpens a native code-editor tab at the given file and line. Absolute paths require a // prefix: tuic://edit//Users/x/file.rs?line=42. Relative paths resolve against the active repo root.
tuic://open/<path>Opens a native markdown/preview tab

Custom URL schemes (vscode://, x-devonthink://, etc.) do not work inside iframes and must not be used with action=tab.

MCP Tools: ai_terminal_* (external agent surface)

Thirteen tools exposed to external MCP clients (e.g. Claude Code, Cursor) that let a remote AI agent observe and interact with a TUICommander terminal, plus read/write/run files in the session’s sandboxed repo. All input and mutating operations (send_input, send_key, drive_agent, write_file, edit_file, run_command) require user confirmation and are rejected while an internal agent loop is active on the target session.

Session aliases — Every tool that accepts a session_id also accepts a human-friendly alias (e.g. tc-1). Aliases are auto-assigned from the repo directory name: first letter of each segment joined + per-repo counter. list_sessions includes the alias field. Aliases reset on app restart.

Gated by ai_terminal_mcp_enabled config flag (default false). When the flag is off, these tools are hidden from tools/list (via filtered_native_tools) and calls are rejected at dispatch time. Enable in config.json or Settings > Services & MCP. Note: no live-reload — a connected client may see a stale tools snapshot until it reconnects or notifications/tools/list_changed fires.

ToolParamsDescription
ai_terminal_read_screensession_id, lines? (default 50, max 500), since_cursor?Read terminal text. Returns {screen, cursor, shell_state, awaiting_input, agent_intent?, agent_type?}shell_state is busy while the agent works (a spinner means busy, not idle), idle once stopped; awaiting_input is true when blocked on a question. Pass since_cursor for delta mode. Output passes through secret redaction.
ai_terminal_send_inputsession_id, textSend a text command to the session. Always prompts for confirmation.
ai_terminal_send_keysession_id, key (enter/tab/ctrl+c/escape/up/down/…)Send a single special key. Always prompts for confirmation.
ai_terminal_wait_forsession_id, pattern?, timeout_ms? (10000), stability_ms? (500)Wait for a regex match or for the screen to stabilise.
ai_terminal_get_statesession_idReturn structured SessionState (shell_state, cwd, terminal_mode, agent_type, …).
ai_terminal_get_contextsession_idCheap orientation: {shell_state, cwd, git_branch, last_exit_code, agent_type, terminal_mode}. Git branch read from .git/HEAD (no subprocess, no index lock).
ai_terminal_drive_agentsession_id, command?, timeout_ms? (30000), wait_pattern?, lines? (80), since_cursor?Atomic send→wait→read. Sends command, waits for idle/pattern, returns {screen, cursor, shell_state, session_state}. Pass since_cursor for delta mode. Requires user confirmation.
ai_terminal_read_filesession_id, file_path, offset?, limit? (default 200, max 2000)Read a text file from the session’s sandboxed repo. Paginated; binary files and files >10MB rejected. Secrets redacted.
ai_terminal_write_filesession_id, file_path, contentCreate or overwrite a text file. Always prompts for confirmation. Atomic via tmp+rename.
ai_terminal_edit_filesession_id, file_path, old_string, new_string, replace_all?Surgical search-and-replace on a file. Always prompts for confirmation. old_string must be unique unless replace_all=true.
ai_terminal_list_filessession_id, pattern, path?List files matching a glob pattern inside the session’s sandbox. Max 500 entries.
ai_terminal_search_filessession_id, pattern, path?, glob?, context_lines?Regex search across files in the session’s sandbox. Honors .gitignore. Max 50 matches with context lines.
ai_terminal_run_commandsession_id, command, timeout_ms?, cwd?Run a shell command and capture stdout/stderr. Always prompts for confirmation. Destructive commands blocked. Default timeout 2min, max 10min.

MCP Tool: debuginvoke_js and the Debug Registry

invoke_js executes JavaScript in the WebView (localhost-only). Results are logged with source='eval_js' and read via debug(action='logs', source='eval_js', limit=1).

window.__TUIC__ bridge — runtime introspection API:

MethodDescription
stores()List all registered store snapshot names
store(name)Get a store snapshot by name
plugins()All plugin states (legacy)
plugin(id)Single plugin state with manifest (legacy)
pluginLogs(id, limit?)Plugin log entries (legacy)
terminals()All terminal states (legacy)
terminal(id)Single terminal state (legacy)
agentTypeForSession(sid)Agent type lookup (legacy)
activity()Activity center sections/items (legacy)
logs(limit?)App log entries (legacy)

Registered stores (via debug registry): github, globalWorkspace, keybindings, notes, paneLayout, repositories, settings, tasks, ui. New stores self-register — see src/stores/debugRegistry.ts.

Adding a new store snapshot — 2 lines at the end of the store file:

import { registerDebugSnapshot } from "./debugRegistry";
registerDebugSnapshot("storeName", () => ({ /* fields to expose */ }));

MCP Tool: session Output

The session tool’s action=output strips ANSI escape codes by default, returning clean text suitable for AI consumption. Pass format="raw" to preserve escape sequences (e.g. for terminal rendering). For managed peers, task results travel through agent action=send; raw session output is only the anomaly fallback when a child failed to send its result. The action=list response includes process details per session: child_pid, foreground_pgid, foreground_process, shell_state, agent_state, background_work, and is_caller. is_caller=true identifies the managed PTY that owns the current MCP connection so an orchestrator does not close itself. Optional values such as alias, display name, cwd, worktree data, process identity, and agent state are omitted when absent rather than serialized as null; status follows the same omission rule.

Global overview: session action=list — one call; no per-session status fan-out.

shell_state is observed PTY activity (busy or idle); it is omitted before the first lifecycle observation rather than treating a newly spawned agent as idle. It is not task completion. For detected agents, agent_state is starting, working, awaiting_input, idle, or completed. background_work=true keeps agent_state=working while a meaningful agent descendant is alive even when shell_state=idle and the composer is ready; persistent integration helpers are excluded. Completion requires the explicit end-of-task suggest: [ ... ] protocol marker. A quiet ready prompt without that marker remains idle. Spawned-agent lifecycle mail uses completed for the same marker and reserves idle for an unclassified ready state.

ParamDefaultDescription
limit8192Max bytes to read
format(text)"raw" preserves ANSI escape codes
since_cursor(none)Cursor from a previous response — returns only new scrollback lines since this position

session action=input and HTTP POST /sessions/:id/write share the same PTY bookkeeping: each write stamps last_input_ms and feeds the InputLineBuffer so slash-mode tracking stays identical for MCP and remote web clients. When a combined text + Enter request targets a prefill-only agent such as Codex or OpenCode, MCP uses the canonical agent-submit sequence (Ctrl-U, bracketed paste for multiline text, a flushed scheduling gap, then CR). Other text/key pairs, including Claude’s established input path, retain raw pair semantics.

Orchestrated PTYs accept an optional pty_description field on session action=input. It is independent from last_prompt: the former is a short description of the assigned work written by the orchestrator, while the latter is the last substantial user prompt submitted to the agent. Omit the field to keep the current description; pass a string to replace it, or null / an empty string to clear it. agent action=spawn accepts the same field for the initial task. Updates are emitted as pty-description-changed over both Tauri events and /events SSE.

Delta reads: The non-raw output path returns a cursor field (monotonic scrollback position). Pass since_cursor on subsequent calls to receive only new lines since that position, avoiding full re-reads. The total_written field is kept alongside cursor for backwards compatibility. When since_cursor is provided, screen rows are excluded — only scrollback log lines are returned.

MCP Tool: repo — Worktree Create (Claude Code Agent Hint)

MCP repo action=worktree_create uses the same creation path as HTTP POST /worktrees, including base_repo validation, stale-worktree recovery, cache invalidation, worktree-created SSE/Tauri events, and setup-script result reporting.

When the MCP client identifies as Claude Code (detected via clientInfo.name at initialize time), the repo action=worktree_create response includes an additional cc_agent_hint field:

{
  "worktree_path": "/path/to/repo__wt/feature-branch",
  "branch": "feature-branch",
  "cc_agent_hint": {
    "worktree_path": "/path/to/repo__wt/feature-branch",
    "suggested_prompt": "Work in the worktree at `/path/...`. Use absolute paths for ALL file operations..."
  }
}

This works around Claude Code’s inability to change its working directory mid-session. The hint tells CC to spawn a subagent that uses absolute paths for all file operations (Read, Edit, Glob, Grep) and cd <path> && ... for shell commands.

Non-Claude Code MCP clients do not receive this field.

MCP Tool: repo — Worktree Remove

MCP repo action=worktree_remove returns { "ok": true } on full success. When delete_branch=true and safe branch deletion fails after the worktree is removed, the action still succeeds with branch_delete_warning populated so clients can report that the worktree was removed but the branch was kept.

Upstream MCP Proxy

TUICommander can proxy upstream MCP servers (stdio or HTTP) and aggregate their tools into its own tools/list response. Configuration lives in mcp-upstreams.json.

Upstream configuration save contract

IPC save_mcp_upstreams and HTTP PUT /mcp/upstreams both accept { base, config }: the snapshot the caller loaded and its desired result. The backend indexes servers by stable id, derives the semantic base-to-desired delta, and applies that delta to the latest on-disk configuration inside the cross-process ConfigFile lock. It validates and atomically persists the merged result while still holding that lock instead of replacing the file with a stale UI snapshot.

Absence has explicit semantics relative to base: omitting a former server from config removes that ID, and omitting its former optional auth field clears the auth value. Unchanged fields are not part of the delta, so concurrent edits survive; in particular, a stale UI save cannot erase OAuth/DCR auth written concurrently for an otherwise unchanged upstream. Concurrently added servers also survive unless the caller independently adds the same ID, which is rejected as a conflict.

Persistence returns the exact configuration immediately before and after the locked mutation. Once the lock is released, apply_config_diff uses that exact pair to disconnect removed or changed upstreams and connect added or changed ones, so the live registry hot-reloads precisely what the atomic write changed.

The desktop boot thread owns the always-on Unix-socket/named-pipe listener and its one-time background tasks for the lifetime of the process. A configuration save may stop and replace the TCP listener, but the boot runtime remains parked after that shutdown so dropping it cannot silently kill local bridge IPC.

Stdio transport

StdioMcpClient spawns a child process and communicates via newline-delimited JSON-RPC over stdin/stdout. The handshake is: initializenotifications/initializedtools/list.

RPC id-matching. The rpc() method matches responses by JSON-RPC id, skipping any server notifications (messages without an id field) that arrive between request and response. This prevents silent “0 tools” when a server emits notifications/tools/list_changed or log messages during the handshake.

Tilde expansion. All user-supplied paths (command, args, cwd) are expanded via crate::cli::expand_tilde() before being passed to std::process::Command. This applies globally across the codebase — PTY, agent spawn, headless prompts, worktree scripts, plugin exec, and file validation all expand ~ to $HOME.

HTTP transport

HttpMcpClient communicates via Streamable HTTP (POST to the server URL, mcp-session-id header for session affinity).

Bearer credential generations. resolve_bearer() re-reads the credential on every request attempt so a completed re-authorization takes effect immediately; the credential vault already caches the decrypted value process-wide, so this does not repeat the OS keychain prompt. A 401 recovery passes the exact bearer rejected by the server into the serialized refresh check. If storage now holds a different valid generation, that credential is retried as-is; only the rejected or invalid generation is refreshed at the authorization server.

Health checker

A background task runs every 60s (HEALTH_CHECK_INTERVAL) and calls tools/list on every Ready upstream. Failures feed a circuit breaker (3 consecutive failures → backoff starting at 1s, capped at 60s, max 5 retries before permanent Failed). Recovery from CircuitOpen, Connecting, or Failed is attempted on each tick.

Diagnostics

Both transports log warn! when tools/list returns a response without result.tools — making “0 tools” diagnosable instead of silent.

OAuth 2.1 Upstream Authentication

When an upstream MCP server requires OAuth instead of a static Bearer token, TUICommander runs a full RFC 9728 (Protected Resource Metadata) + RFC 8414 (Authorization Server Discovery) flow with PKCE S256.

Configuration

UpstreamMcpServer.auth is an enum:

#![allow(unused)]
fn main() {
enum UpstreamAuth {
    Bearer  { token: String },
    OAuth2  {
        client_id: String,
        scopes: Vec<String>,
        authorization_endpoint: Option<String>,  // None → discover
        token_endpoint: Option<String>,          // None → discover
    },
}
}

Missing endpoints trigger metadata discovery: the proxy issues an unauthenticated probe, follows the WWW-Authenticate: Bearer resource_metadata=<url> challenge to fetch ProtectedResourceMetadata, then resolves the authorization server’s .well-known/oauth-authorization-server (falling back to OIDC .well-known/openid-configuration when required).

Error → flow transition

src-tauri/src/mcp_proxy/http_client.rs emits a typed error:

#![allow(unused)]
fn main() {
enum UpstreamError {
    NeedsOAuth { www_authenticate: String },
    AuthFailed,
    Other(String),
}
}

A NeedsOAuth on any request transitions the upstream registry to needs_auth. The Services tab in Settings surfaces an Authorize button that calls start_mcp_upstream_oauth. Auto-triggered OAuth is gated behind explicit user consent (the confirm dialog shows the AS origin so the user can refuse an Authorization Server mix-up attempt).

Off-domain authorization servers are never blocked. MCP gateways, corporate proxies and hosted IdP tenants routinely serve AS metadata whose issuer and endpoints point at a different registrable domain than the MCP server — RFC 8414 §3.3 says the issuer must match the discovery URL, but refusing on that basis makes legitimate servers unusable. Discovery logs a warning on an issuer mismatch and continues; start_mcp_upstream_oauth returns cross_domain_as: true when the AS is off-domain, and the consent dialog switches to a warning kind naming the origin. The decision belongs to the user, not to a hard-coded gate.

Flow

  1. Startstart_mcp_upstream_oauth(name) generates a PKCE verifier/challenge (S256), mints an opaque state, records the pending flow in a DashMap keyed by state, sets upstream status to authenticating, and returns the authorization URL + AS origin.
  2. Consent UI — The frontend opens the URL via tauri-plugin-opener after user approval. The status bar and Services tab show “Awaiting authorization…”.
  3. Callback — The AS redirects to tuic://oauth-callback?code=…&state=…. The OS routes the deep link to the desktop app (src-tauri/src/mcp_oauth/mod.rsDEEP_LINK_SCHEME = "tuic://oauth-callback"). The deep-link handler calls mcp_oauth_callback(code, oauth_state).
  4. ExchangeTokenManager posts code + PKCE verifier to the token endpoint, receives { access_token, refresh_token?, expires_in? }, serializes into OAuthTokenSet, persists to the OS keyring (mcp_upstream_credentials.rs — structured JSON format with "type": "oauth2"), and transitions upstream to connecting.
  5. RefreshTokenManager is shared across every HttpMcpClient refresh path (unified per upstream); a semaphore serializes concurrent refresh attempts to defeat thundering-herd. expires_at uses a 60 s margin; None means “no known expiry — do not treat as expired”. A 401 recovery carries the exact bearer rejected by the server into the serialized refresh check. If an authorization exchange wrote a different valid credential between the request and recovery, that generation is retried as-is instead of being immediately refreshed or rotated; only the still-rejected or an invalid generation reaches the token endpoint.

Cancel

cancel_mcp_upstream_oauth(name) drops the pending flow entry and resets the upstream status to whatever it was before the attempt (disconnected / failed / ready).

SchemePurpose
tuic://oauth-callback?code=…&state=…OAuth 2.1 authorization code return path for upstream MCP servers

Registered at boot via Tauri’s single-instance + deep-link plugins. The frontend listener routes callbacks to mcp_oauth_callback without exposing the code to the WebView console.

Threat model

OAuth callbacks arrive exclusively through the OS-level tuic:// deep link — not over the network. There is no adversary position from which a remote attacker can probe the pending-flow map, so state comparison uses a direct DashMap lookup (no constant-time compare). The localhost dev callback server (used only in development) binds 127.0.0.1 with a random port; it is never exposed in production builds.

Inter-Agent Messaging

The agent tool’s messaging actions (register, list_peers, send, inbox) enable coordination between multiple AI agents connected to TUICommander. There is no separate swarm action; orchestration composes the agent and session primitives.

For agent action=spawn, prompt is always delivered. Caller-supplied args that contain {prompt} remain authoritative and receive direct substitution. Flags-only args keep their order; normal CLIs receive the prompt as the final positional argument, while prefill-only interactive TUIs receive it through the deferred PTY-injection path after their ready prompt appears. Configured run-config argv retains its established authoritative behavior: {prompt} is substituted where authored, otherwise the prompt is appended as the final positional argument rather than converted to deferred PTY delivery. Structured model is composed with args; direct Codex commands include the approval-bypass default. Outside authoritative run-config argv, direct executable identity also selects Codex prompt deferral and parser state, even when agent_type is omitted or disagrees. That bypass-default step leaves canonical Codex wrapper run-config argv untouched and adds launch_warning because TUIC cannot validate the wrapper’s internal Codex flags. Structured parameters retain their established composition independently, including appending a caller-supplied model.

name optionally assigns a non-empty peer and PTY display name at spawn time. The parent-assigned name is stored before prompt delivery, returned in the spawn response, exposed as name by agent action=list_peers and as display_name by session action=list, and preserved when the child later auto-binds its MCP connection. This avoids making identity depend on the child successfully executing a registration instruction in its initial prompt. The session list’s alias remains a separate repo-derived short address and is not replaced by the display name.

The optional pty_description field on agent action=spawn populates the orchestrator-owned task description shown above the PTY. When the caller’s orchestration schema cannot supply that field, spawn derives display-only metadata from the normalized task prompt (capped at 160 characters); an explicit string still wins, while null or an empty string explicitly keeps the new PTY descriptionless. The inference never changes prompt delivery or agent-specific launch semantics. Later session action=input calls may update the same field without adding another command to the MCP surface.

Every managed child is registered server-side and receives an inbox immediately, even when the caller has no bound peer identity. A registered parent additionally creates the bidirectional relationship: the child prompt receives its parent ID and send instruction, while the spawn response returns communication_ready, send_to, and parent_session_id. An unregistered caller receives communication_ready=false plus a warning instead of a false two-way guarantee. The spawn still records the caller’s MCP session as a pending parent: a later register call links existing children to the stable parent UUID and migrates any lifecycle notifications emitted before registration.

Deferred initial prompts use a one-shot internal watchdog. Successful PTY submission removes the marker silently. A prompt still pending after 30 seconds emits one prompt_delivery_failed message to the parent; there is no success event, delivery polling, or public delivery-state machine.

Ordinary managed-agent PTY injection is allowed only when the recipient is idle, its composer buffer is empty, and no confident question or approval is active. Busy workers and recipients with partially typed input keep the message queued. Clearing or submitting the composer rechecks the queue.

An orchestrator role is declared explicitly with agent action=register orchestrator=true and removed with orchestrator=false; spawning a child never infers or permanently grants the role. The declaration is returned by register and list_peers. Its routing is intentionally stricter: every peer and child-lifecycle message remains in the authoritative inbox, and peer payloads never enter its channel, active turn, pending-injection queue, or composer. An active agent wait owns delivery and suppresses terminal wake. Without a waiter, only canonical idle or completed lifecycle may submit the payload-free notification [TUIC] message available — read it with: agent action=inbox.

One exception, and it is narrow: when every message in the reserved window is a server-authored lifecycle notification (tuic-auto-*), the notice types those events instead of pointing at them — [TUIC] child agent 8c261794 is now idle; child agent 8c261794 exited (exit 0) — and acknowledges its own window, so the orchestrator owes no inbox call for it (delivery_path lifecycle_summary_and_inbox). A lifecycle payload is a state name generated by TUICommander itself, so this exposes nothing a peer authored. A single peer message in the window disqualifies the whole group back to the generic notice: a partial summary would satisfy the reader and silently bury the rest. The summary also falls back when it would exceed 240 characters. Mail that coalesces while the summary is being typed falls outside the acknowledged window and earns its own notice. Working, awaiting-input, starting, missing, and unknown state fail closed to inbox-only. Mail received while working remains eligible and is re-evaluated at the next authoritative idle/completed transition. One pending wake covers later unread mail through its logical inbox cursor. Inbox and successful wait observations acknowledge the same cursor atomically with their snapshot, including an empty snapshot, so a read cannot race a delayed wake assignment. A generic notice never hides the underlying payload from a later wait. PTY I/O happens outside the delivery gate. An ambiguous payload-free notice expires after five seconds and may be retried once after the managed lifecycle reconfirms readiness. A second uncertain result exhausts that unread-mail group’s wake budget: later expiry or idle/completed reevaluations, including newly coalesced mail, remain inbox-only until a successful inbox or wait observation acknowledges the group. An attempt that writes no bytes remains NotStarted and does not enter an automatic retry loop.

register and list_peers also return mail_wake. Its only current non-none value is managed_pty_lifecycle, derived from a live TUIC-managed PTY rather than claimed by the caller. Headerless/external orchestrators have a mailbox and MCP/SSE transport but no authoritative model lifecycle or host wake adapter capable of starting a turn, so they honestly report mail_wake: "none" and must use agent wait/inbox. MCP activity and SSE presence are not treated as idle proof.

The final injection decision atomically claims idle -> busy, closing the race between observing a ready screen and writing to the PTY. Idle is published before the queued-message flush, and each idle transition submits at most one queued message; remaining messages wait for later turns. This keeps backend state and UI events ordered and prevents lifecycle reports from overwriting an active composer. Peer messages and Compose commands occupy one typed FIFO, so neither producer can overtake an earlier accepted entry. The Compose queue count and clear operations select only user-command entries; clearing them retains peer messages and their relative order.

Raw PTY Capture Diagnostics

POST /diagnostics/capture controls the off-by-default raw-stream tap used for agent-state regression evidence. { "enabled": true, "session_id": "<id>" } records one session; omitting session_id records all sessions, and { "enabled": false } stops it. GET /diagnostics/capture returns the active filter, output directory, and byte count per opened session. A new enable starts fresh files, and each <config dir>/captures/<session-id>.tcap file is capped at 512 KiB. Records preserve direction, original read/write boundaries and monotonic timestamps; legacy .raw fixtures remain readable as output-only captures.

Capture must be enabled before reproduction. /sessions/:id/output is not a fixture-acquisition fallback: its bounded ring can lose a one-shot marker and its JSON string is lossy UTF-8. Negative and positive captures belong in src-tauri/src/fixtures/agent_prompts/ and are replayed through the same raw plus rendered-row composition as the reader thread.

The stdio bridge reads each IPC HTTP response through its declared Content-Length rather than waiting for connection EOF. A single transport error does not discard the current MCP identity; subsequent authenticated calls refresh the session-to-terminal binding. Ordinary calls keep a ten-second read deadline; direct and collapsed wait calls derive it from the clamped requested timeout plus a five-second transport margin.

Focused ui action=tab requests using tuic://open or tuic://edit switch to the registered repository that owns an absolute target path before activating the native file tab. This keeps repo-scoped tabs visible in the tab bar instead of rendering their content under an unrelated active repository. Background requests (focus=false) do not change repository context.

Protocol

  1. Auto-identity (no call needed): TUICommander’s Codex MCP entry explicitly whitelists TUIC_SESSION through env_vars; other supported clients inherit it from the agent PTY. tuic-bridge sends the value as the x-tuic-session header on the initialize POST /mcp. The server validates the UUID and binds the MCP session to that tuic session (apply_initialize_identity → the shared locked live-owner policy), auto-registering the peer. agent action=register becomes an optional rename. The same MCP session may refresh its binding, and a fresh session may reclaim a stale owner; a subscribed or recently active owner is not replaced but is joined, so a second bridge in the same PTY becomes routable instead of being locked out. An existing peer’s display name is preserved. The bridge’s eager initialize and the downstream client’s proxied initialize reuse the same existing mcp-session-id; this prevents the bridge’s own live SSE stream from being mistaken for a competing identity owner. External bridges without $TUIC_SESSION are not auto-bound at initialize.

  2. Register: optional rename/project/role update for an auto-bound peer. Pass orchestrator=true to declare the role or false to remove it; omission preserves the current declaration. A headerless external caller may omit tuic_session; the server generates an MCP-scoped UUID that remains stable for that connection and does not create a PTY. Supplying an explicit UUID preserves identity across reconnects and retains the live-owner takeover guard, which now refuses only callers that hold no route to the identity — a bridge already joined to it is renaming, not taking over. When the announced UUID differs from the one already bound to the MCP session, the two are ranked by whether they resolve to a live PTY rather than merely compared: an identity backed by a terminal outranks one that is not. A caller that registered an invented UUID may therefore repair itself by announcing its real $TUIC_SESSION, while the reverse — wandering off a terminal-backed identity onto a fabricated one — is refused with an error naming the identity to use. Two identities that both lack a terminal keep the original “already bound to a different peer identity” rejection. A repaired identity carries over any mail buffered under the abandoned one and retires it, so list_peers stops advertising an address that can never be reached. The retire and the recipient check inside send share one identity lock, so a message aimed at the abandoned identity either arrives before the retire and is carried over with the rest of the inbox, or arrives after it and is refused with “is not registered” — it is never buffered under an address that is deleted a moment later.

    That carry-over only has an implicit trigger when the same protocol session rebinds. A caller that reconnects and registers a brand-new UUID arrives with no link to its old identity, so it must name it: register replaces=<old_uuid>. Identity is never inferred from a name or project — it decides who may read whose mail. The response reports the outcome instead of staying silent: superseded_identity plus mail_migrated, and — when the superseded identity still owns a live PTY — mail_stranded and an identity_warning. That last case deliberately moves nothing: an identity with a terminal is a reachable peer, and taking its inbox would strand a working agent.

  3. Discover: agent action=list_peers returns all registered peers (filterable by project).

  4. Send: agent action=send to=<tuic_session> message="..." buffers to the recipient’s inbox. accepted=true and buffered_in_inbox=true acknowledge success; delivery_path is the single source of truth for the route and distinguishes SSE, terminal-or-queued, waiter, generic/coalesced orchestrator wake, and inbox-only delivery. It replaced delivered_via_channel on this response, which reported only the SSE sub-route yet read as a delivery verdict — false next to a delivered:true and a confirming delivery_path was pure ambiguity. The field remains on the stored AgentMessage as in-memory forensics but is #[serde(skip)] — it reaches no MCP response at all, including inbox/wait. Emitting it to the recipient repeated the same trap: it is false precisely when a waiter or the terminal carried the message, and the recipient reading it is already holding the message it describes. The route is delivery_path for the sender and the agent_msg tracing line for the operator. When the recipient is a real managed PTY, recipient_state contains only its current shell_state and agent_state; external generated peers omit recipient_state.

  5. Receive — three layers, most-immediate first:

    • Channel push: real-time notifications/claude/channel only when an ordinary managed Claude Code recipient already has a working turn and holds an SSE stream (CC + channels flag). A managed non-Claude worker, or an idle/completed Claude worker, uses PTY delivery even if its MCP bridge has an SSE stream. Registered orchestrators never receive peer payloads through this channel.
    • PTY injection: for an ordinary idle or completed managed agent, the message is typed into its terminal (framed single line; split write, Ink-safe) so it submits a real next turn without polling. A busy ordinary recipient without active Claude channel support gets the message on its next BUSY→IDLE transition. Oversized (>2 KB) bodies inject a pointer to agent action=inbox instead. An idle/completed orchestrator receives only the generic inbox wake described above; a busy orchestrator is never queued or steered.
    • Inbox poll: agent action=inbox — always the authoritative store.
  6. Wait (prefer over polling): agent action=wait blocks until new mail; session action=wait session_id=<id> until=idle|exited blocks on a peer’s lifecycle. The default is 60 seconds and the advertised cap is 300000 ms — a request at or above that runs as 295000 ms. The 5-second margin exists because a client aborts its own tools/call on its own deadline, and at least one shipping client (Codex) uses exactly 300s: a wait running the full cap would answer right on that deadline and return a client-side error instead of {timed_out:true}, making the advertised maximum unusable. The clamp is deliberately client-agnostic — a per-client table would need maintaining against every client release, and ending 5s early is invisible to the caller. Agent-wait success preserves {met,timed_out,new_messages} and directly includes every retained fresh message (up to the 100-message inbox capacity) plus next_since, in chronological order. Per-recipient logical unix-millisecond cursors make equal-clock-millisecond bursts safe.

    The cursor is kept server-side. since is optional on both wait and inbox: omitting it resumes from the caller’s stored read position (agent_read_cursor), passing it overrides, and since=0 stays the deliberate “replay everything” escape hatch — a replay never rewinds the stored cursor. next_since is now returned on every response, timeout included, falling back to the stored position when the batch is empty. Previously it was omitted whenever there were no messages, which left a timed-out waiter with since=0 as its only recoverable value and made it reload the whole history on the next call. Wait never consumes the authoritative inbox; actual FIFO eviction is still reported by missed_count on inbox reads. Both wait actions subscribe before their initial state check and then sleep on inbox or per-session lifecycle events; they do not run an internal polling loop. session action=wait validates session_id against the live session registry first and returns {"error": "Unknown session …"} immediately for an id that is not a real session — subscribing creates the per-session broadcast channel for whatever id it is given, and teardown only reaps ids that were real sessions.

Low-risk response compaction also omits an absent peer project from list_peers and an absent parent_session_id from standalone spawn responses. Proxied upstream tool payloads are unchanged.

Blocking waits and terminal wake-up use a per-recipient delivery lease. Each message is atomically assigned to exactly one wake-up owner: an active waiter, or SSE/PTY delivery. The deadline path performs its final inbox check while releasing the lease, and cancellation hands unobserved waiter-owned messages back to terminal delivery. This removes both duplicate inbox+terminal turns and the missed-wake race at the wait timeout boundary; inbox visibility itself is unchanged and remains backward compatible.

The server never infers orchestrator role from child spawn, peer name, prompt, MCP activity, or SSE presence. Registration is the sole declaration seam. Wake capability remains server-derived: without a live managed PTY and its canonical lifecycle, an explicitly declared external orchestrator is inbox/wait-only.

Spawned peers additionally auto-post a state_change (idle / completed / exited) to the parent’s inbox. They use the same waiter-or-generic-wake orchestrator routing and never inject the state payload into the parent composer. These notifications carry state only, never task output. Each child must send its result or blocker with agent action=send; session action=output is reserved for diagnosing the anomaly where that result message never arrived.

Channel Push Delivery

When an already working ordinary Claude Code worker has an active SSE stream (GET /mcp), messages are pushed into that turn as notifications/claude/channel JSON-RPC notifications. Idle or completed ordinary managed recipients use PTY submission instead. Registered orchestrators never use this payload-bearing route:

A channel notification is transport delivery into an existing turn, not proof that the recipient submitted a new one. It does not mutate the recipient’s task epoch or lifecycle. Managed Codex and other non-Claude agents never receive this extension; an idle or completed Claude composer also takes the PTY split-write payload plus Enter path so delivery owns a real submitted turn.

{
    "jsonrpc": "2.0",
    "method": "notifications/claude/channel",
    "params": {
        "content": "Message from worker-1: done with auth module",
        "meta": { "from_tuic_session": "abc-123", "from_name": "worker-1", "message_id": "msg-uuid" }
    }
}

This requires the client to be launched with --dangerously-load-development-channels server:tuicommander. The server declares experimental.claude/channel in its capabilities. Spawned Claude Code agents get this flag automatically.

Limits

  • Max message size: 64 KB
  • Inbox capacity: 100 messages per agent (FIFO eviction)
  • Peer registrations cleaned up on MCP session delete and TTL reap

Authentication

When remote access is enabled:

  • Basic Auth with username/password
  • Password stored as bcrypt hash in config
  • Session token, relay token, and VAPID private key stored in the OS keyring-backed credential vault
  • Applied to all endpoints

When MCP-only (localhost):

  • No authentication required
  • Localhost binding only

Security Model

  • Default: Localhost-only, no authentication, opt-in
  • Remote access: Configurable port, Basic Auth required
  • CORS: Enabled for all origins (browser mode support)
  • Compression: Gzip and Brotli via CompressionLayer (responses >860 bytes, auto-negotiated). SSE and WebSocket excluded by DefaultPredicate
  • No TLS: Intended for local network use; use SSH tunnel for remote
  • Loopback-only session actions: session create, input, kill, close, pause, and resume are restricted to loopback connections — a non-loopback (remote/LAN) MCP client cannot pause/resume sessions, write to PTYs, or spawn/destroy sessions (those remain read-only: list, output, status)
  • Remote /fs/read-editor* cap: Remote clients receive the standard 10 MB file-read cap on /fs/read-editor and /fs/read-editor-external, not the 250 MB local cap (MAX_EDITOR_LARGE_FILE_SIZE). The local (loopback) router routes these paths to the large-cap handler; the remote router routes them to the standard-cap handler to avoid OOM/latency over metered links (see build_remote_router in src-tauri/src/mcp_http/mod.rs)
  • Traversal gate on absolute-path fs routes: /fs/read-external, /fs/read-editor-external, /fs/write-external, /fs/copy-abs, /fs/move-abs and /fs/transfer (its destDir) share deny_unless_in_roots, which rejects .., NUL and relative paths before the lexical Path::starts_with containment check. Without that first layer, /repo/../../etc/passwd passes containment by components while the OS resolves it outside the repo. These routes are in shared_routes(), so they are reachable from the remote router too. Paths are intentionally not canonicalized — symlinks placed inside a registered repo are an accepted design decision
  • Anti-hijack guard on agent register: A non-loopback caller cannot register as an existing live TUIC session — the register action (along with list_peers, send, inbox) is restricted to loopback connections, preventing a remote client from injecting messages into another agent’s context (see mcp_transport.rs)

Browser Mode Integration

The frontend’s transport.ts maps all Tauri commands to HTTP endpoints:

// In browser mode:
invoke("create_pty", { config }) → POST /sessions { config }
invoke("get_repo_info", { path }) → GET /repo/info?path=...

PTY output in browser mode uses WebSocket instead of Tauri events.

GitHub Ops AI Routes

Desktop/browser mode exposes the GitHub Ops AI helpers over HTTP; the remote daemon does not serve them because they depend on desktop provider credentials.

EndpointBodyResponseNotes
POST /ai/improvements/scan{ repoPath, focus }ImprovementScanResultOne-shot Headless-slot LLM scan over local repo context (focus: refactor, testing, perf). Dual-emits proposals-ready to the window and /events SSE.
POST /repo/create-issue-from-proposal{ repoPath, proposal }CreatedIssueExplicit user-gated issue creation from one proposal; scan itself never creates issues.

Mobile Transport

The mobile companion UI (/mobile) uses the same HTTP/WebSocket infrastructure as the desktop browser mode:

  • Session polling: GET /sessions every 3s, enriched with SessionState (question, rate-limit, busy, agent type)
  • Real-time events: SSE via GET /events for session create/close notifications
  • Live output: WebSocket to /sessions/{id}/stream with JSON framing (output, parsed, exit)
  • Input: POST /sessions/{id}/write sends text to PTY (used by quick-reply chips and command input)
  • History: GET /sessions/{id}/output?format=text fetches initial ANSI-stripped output buffer

The mobile entry point shares transport.ts and invoke.ts with the desktop — no mobile-specific transport code.

MCP Proxy Hub

Module: src-tauri/src/mcp_proxy/

The MCP Proxy Hub turns TUICommander into a universal MCP aggregator. TUIC acts simultaneously as an MCP server (serving downstream clients such as Claude Code or Cursor) and as an MCP client (connecting to upstream MCP servers). Tools from all connected upstreams are merged into the single /mcp endpoint that TUIC already exposes, with each upstream’s tools namespaced as {upstream_name}__{tool_name}.

Architecture

Claude Code ──┐
Cursor ───────┼──▶  POST /mcp  ──┬──▶ GitHub MCP   (HTTP)
VS Code ──────┘   (TUIC server)  ├──▶ Filesystem MCP (stdio)
                                  ├──▶ Database MCP  (HTTP)
                                  └──▶ Custom MCP     (HTTP/stdio)

The entry point for all MCP traffic is POST /mcp (Streamable HTTP transport, spec 2025-03-26). When a tools/call request arrives with a name containing __, the transport layer routes it to the upstream registry instead of the native tool handler.

Module Layout

FilePurpose
mcp_proxy/mod.rsModule declaration
mcp_proxy/registry.rsCentral registry — connection lifecycle, tool aggregation, routing, circuit breaker
mcp_proxy/http_client.rsMCP client over Streamable HTTP
mcp_proxy/stdio_client.rsMCP client over stdio (spawned process)
mcp_upstream_config.rsConfig schema, validation, persistence (mcp-upstreams.json)
mcp_upstream_credentials.rsOS keyring credential management
mcp_http/mcp_transport.rsRouting logic inside the /mcp handler

Tool Namespace

All proxied tools are exposed with the prefix {upstream_name}__{tool_name}. The double underscore (__) is the routing discriminator — native TUIC tools never contain it. The separator splits only on the first occurrence, so tool names with internal underscores work correctly (e.g. upstream__tool__with__underscores routes to upstream upstream, tool tool__with__underscores). The namespace identifies the origin; the upstream description is preserved byte-for-byte so TUIC instructions are not duplicated across every discovered tool.

UpstreamRegistry

UpstreamRegistry (registry.rs) is the central hub stored in AppState as Arc<UpstreamRegistry>. It is thread-safe — all internal maps use DashMap (lock-free concurrent HashMap) and per-entry state is protected by parking_lot RwLocks and Mutexes.

Entry Lifecycle

connect_upstream(config)
    │
    ├── Validate: no duplicate name, no circular URL
    ├── Build client (Http or Stdio)
    ├── Insert UpstreamEntry into DashMap
    │
    ├── If disabled → status = Disabled (done)
    │
    └── Spawn async task:
            initialize_entry()
                ├── Run MCP handshake
                ├── Fetch tools/list
                ├── On success → status = Ready, cache tools
                └── On failure → circuit breaker records failure
                                 → status = CircuitOpen or Failed

Statuses

StatusMeaning
ConnectingHandshake in progress (initial state for enabled entries)
ReadyHandshake complete, tools available
CircuitOpenToo many failures, backoff timer active
DisabledDisabled by user in config (enabled: false)
FailedPermanently failed after max retries exceeded
NeedsAuthUpstream returned 401/challenge (or its OAuth token was rejected and could not be refreshed) — awaiting the user to click “Authorize”. Tool calls are rejected with -32001 until a user-initiated OAuth flow succeeds
AuthenticatingOAuth flow in progress (user clicked “Authorize”) — tool calls rejected with -32001

Boot-Time Auto-Connect & tools/list Readiness

On startup, auto_connect_saved_upstreams() (mcp_upstream_config.rs) registers every saved upstream. It is spawned, not awaited, on both boot paths (desktop lib.rs and headless run_headless): mcp_http::start_server parks on the shutdown signal and never returns, so any auto-connect placed after it would be dead code — leaving every upstream unconnected until the user touches the UI. Registration is fast (the per-upstream async initialize is itself spawned), so it never delays IPC socket binding.

To avoid serving a stale tool list, the registry exposes a one-shot settle gate:

  • mark_initial_connect_complete() — set by auto_connect_saved_upstreams once every upstream is registered (at both exits, including the empty-config early return). Async initialize may still be in flight.
  • await_initial_settle(timeout) — the first tools/list calls this before merged_tool_definitions(). It blocks (≤ timeout, default 3s) until auto-connect is complete and no entry is still Connecting, then serves. A global initial_settle_done latch makes every later call a no-op, so steady-state tools/list never blocks. On timeout it logs a warning and serves a possibly-partial list rather than hanging.

This also protects clients and older client versions that fetch tools/list during their handshake but do not apply a later notifications/tools/list_changed. Compatible current clients can refresh live; clients that ignore the notification still receive the complete settled list at connection time.

Tool Aggregation

aggregated_tools() collects tools from all Ready upstreams, applies per-upstream tool filters, prefixes names, and annotates descriptions. Non-Ready upstreams are silently omitted. The merged list is returned as the tools array in tools/list responses alongside native TUIC tools.

Tool Routing

proxy_tool_call(prefixed_name, args) parses the __ separator, looks up the upstream by name, checks the circuit breaker, dispatches the call to the correct client, and records metrics and circuit breaker outcomes.

Circuit Breaker

Each upstream has an independent circuit breaker with the following thresholds:

ParameterValue
Failures before circuit opens3
Initial backoff on open1 second
Maximum backoff cap60 seconds
Backoff growthExponential (1000ms × 2^excess)
Maximum retries before permanent failure10

State transitions:

  • Closed → CircuitOpen: 3 consecutive failures trigger the circuit. Backoff starts at 1s and doubles with each additional failure, capped at 60s.
  • CircuitOpen → Ready: A successful tool call or health check resets the failure count and closes the circuit.
  • CircuitOpen → Failed: After 10 total circuit re-opens without recovery, the entry is marked Failed and requires manual reconnect (reconnect_mcp_upstream).

Health Checks

A background task (spawn_health_checker) runs every 60 seconds and probes all Ready upstreams via tools/list (HTTP) or is_alive() process check (stdio). CircuitOpen upstreams whose backoff has expired are also probed for recovery.

HTTP Client (http_client.rs)

Implements the MCP Streamable HTTP transport (spec 2025-03-26):

  1. initialize() — Reads Bearer token from OS keyring (if any), sends initialize request, caches mcp-session-id header, sends notifications/initialized (fire-and-forget), fetches tools/list.
  2. call_tool(name, args) — Sends tools/call with the cached session ID and auth token.
  3. call_tool_with_reconnect(name, args) — Calls call_tool, and on HTTP 400 (session expired) or connection error, re-initializes once and retries.
  4. health_check() — Pings via tools/list. Used by the background health checker.
  5. shutdown() — Sends DELETE /mcp with the session ID to cleanly terminate the upstream session.

The User-Agent header is set to tuicommander-mcp-proxy/{version}.

OAuth Refresh Failure → Re-Authorization

When a request needs a fresh token, refresh_token_if_needed() runs the refresh and routes any failure through classify_refresh_error():

  • Fatal (invalid_grant, no refresh token available, HTTP 400/401) → UpstreamError::AuthFailed. The refresh token is expired/revoked and cannot recover automatically.
  • Transient (network, 5xx) → UpstreamError::Other (retryable) so the circuit breaker + health checks keep trying.

In initialize_entry_with_oauth, an AuthFailed on an OAuth2-configured upstream deletes the dead keyring token and re-enters NeedsAuth (UI shows “Authorize”) instead of parking the upstream in a silent red Failed/CircuitOpen the user can’t act on. Non-OAuth upstreams with a bad static token still go red (the arm is guarded by matches!(auth, Some(OAuth2 { .. }))). The health checker skips NeedsAuth entries, so there is no delete/init loop.

Stdio Client (stdio_client.rs)

Spawns a local process and communicates via newline-delimited JSON-RPC on stdin/stdout.

Process Lifecycle

  1. spawn_and_initialize() — Rate-limited (minimum 5s between spawns), clears any existing process, spawns a new child with a sanitized environment, runs the MCP handshake.
  2. call_tool(name, args) — Sends tools/call JSON-RPC via stdin, reads response from stdout.
  3. is_alive() — Non-blocking try_wait() check on the child process.
  4. shutdown() — Closes stdin (signals EOF), waits up to 2s for voluntary exit, then kills.

Environment Sanitization

The parent environment is cleared before spawning to prevent credential leakage (ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, etc.) to potentially untrusted MCP server processes. A safe allowlist is re-applied:

PATH, HOME, USER, LANG, LC_ALL, TMPDIR, TEMP, TMP, SHELL, TERM

User-configured env overrides from the upstream config are then applied on top of the safe set.

Respawn Rate Limit

To prevent tight loops when an MCP server crashes, the client enforces a minimum 5-second interval between spawn attempts. A premature respawn call returns an error immediately without spawning.

Config Schema

Configuration is persisted to mcp-upstreams.json in the platform config directory, separate from the main AppConfig.

UpstreamMcpConfig (top-level)

{
  "servers": [ /* array of UpstreamMcpServer */ ]
}

UpstreamMcpServer

FieldTypeDefaultDescription
idStringrequiredUnique UUID for config diff tracking
nameStringrequiredHuman-readable name, also the namespace prefix. Must match [a-z0-9_-]+
transportUpstreamTransportrequiredConnection type (http or stdio)
enabledbooltrueIf false, the entry is registered but never connected
timeout_secsu3230Per-request timeout (0 = no timeout, HTTP only)
tool_filterToolFilter?nullOptional allow/deny filter

UpstreamTransport

HTTP variant:

{
  "type": "http",
  "url": "https://example.com/mcp"
}

Stdio variant:

{
  "type": "stdio",
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-filesystem"],
  "env": { "ALLOWED_PATHS": "/home/user/projects" }
}

ToolFilter

FieldTypeDescription
mode"allow" or "deny"Allow only matching tools, or deny matching tools
patternsVec<String>Exact names or glob patterns (trailing * = prefix match)

Filter examples:

  • Allow only read tools: { "mode": "allow", "patterns": ["read_*", "list_*"] }
  • Block dangerous tools: { "mode": "deny", "patterns": ["delete_*", "rm", "exec_*"] }

Validation

validate_upstream_config() runs before every save_mcp_upstreams call and collects all errors (not just the first):

ErrorCause
EmptyNameServer name field is empty
InvalidNameName contains characters outside [a-z0-9_-]
DuplicateNameTwo servers share the same name
EmptyUrlHTTP transport has an empty URL
InvalidUrlSchemeHTTP URL does not start with http:// or https://
SelfReferentialUrlHTTP URL points to TUIC’s own MCP port (circular proxy guard)
EmptyCommandStdio transport has an empty command

The self-referential check compares the URL’s host (localhost, 127.0.0.1, ::1, 0.0.0.0) and port against TUIC’s own running port.

Credential Management

Credentials (Bearer tokens for HTTP upstreams) are stored in the platform OS keyring, never in config files:

PlatformBackend
macOSKeychain
WindowsCredential Manager
Linuxkeyutils / Secret Service

The keyring service name is tuicommander-mcp. The account name is the upstream name. Credential names follow the same [a-z0-9_-]+ validation as upstream names.

read_upstream_credential(name) returns None (not an error) when no credential exists.

Hot-Reload

apply_config_diff(old, new) compares two configs using server id as the stable identifier:

  • Removed servers → disconnect_upstream(name) (stdio: graceful shutdown, HTTP: no-op)
  • Added servers → connect_upstream(config)
  • Changed servers (same id, any field changed) → disconnect + reconnect
  • Unchanged servers → left running, no interruption

This is called automatically by save_mcp_upstreams after writing the config file, so adding or reconfiguring upstreams takes effect immediately without restarting TUIC.

SSE Events

Status changes emit UpstreamStatusChanged events via the app event bus, which surfaces as Server-Sent Events on GET /events. The event payload is:

{
  "type": "upstream_status_changed",
  "name": "github-mcp",
  "status": "ready"
}

Valid status values: connecting, ready, circuit_open, disabled, failed, needs_auth, authenticating.

Metrics

Each upstream tracks lock-free atomic counters:

MetricTypeDescription
call_countAtomicU32Total tool calls routed
error_countAtomicU32Total failed tool calls
last_latency_msAtomicU32Last observed round-trip time

Available via status_snapshot() which returns a JSON snapshot of all upstreams including status, transport info, tool count, and metrics.

Integration with /mcp Transport

In mcp_transport.rs, the tools/call handler checks the tool name for __:

#![allow(unused)]
fn main() {
if tool_name.contains("__") {
    // Route to upstream registry (async)
    state.mcp_upstream_registry.proxy_tool_call(&tool_name, args).await
} else {
    // Handle natively (sync, via spawn_blocking)
    handle_mcp_tool_call(&state, addr, &tool_name, &args)
}
}

The tools/list response merges native tools with upstream tools via merged_tool_definitions().

The build_mcp_instructions() function supplies TUIC protocol and orchestration context once in the initialize response. Proxied upstream descriptions remain upstream-owned and do not repeat that preamble.

Successful proxied tools/call responses that are valid MCP CallToolResult objects (an object with a content array) pass through as the downstream JSON-RPC result. TUIC does not re-wrap or mutate their content, isError, structuredContent, or extension fields. The same passthrough is used when collapse_tools routes an upstream call through the call_tool meta-tool. If an upstream returns a malformed result, TUIC falls back to a compact JSON text content envelope; native TUIC tools always use that compact envelope.

Voice Dictation

Module: src-tauri/src/dictation/

Local voice-to-text using Whisper with Metal acceleration on macOS. Push-to-talk workflow with streaming partial results: hold hotkey to record, see partial transcriptions in real-time, release to finalize.

Module Structure

FilePurpose
mod.rsDictationState — shared state for all dictation operations
audio.rsAudio capture from microphone via CPAL (VecDeque ring buffer)
commands.rsTauri command handlers
model.rsWhisper model download and management
transcribe.rsTranscriber trait + WhisperTranscriber implementation via whisper-rs
streaming.rsStreaming transcription loop with adaptive windows and VAD
vad.rsVoice Activity Detection (energy-based, ported from whisper.cpp)
corrections.rsPost-processing text corrections

Tauri Commands

Recording

CommandDescription
start_dictation()Start recording + streaming transcription
stop_dictation_and_transcribe()Stop streaming, final pass on full captured audio, return TranscribeResponse { text, skip_reason, duration_s }
inject_text(text)Apply corrections to text (called after transcription)

Tauri Events

EventDirectionPayload
dictation-partialRust → FrontendString — partial transcription text
dictation-download-progressRust → Frontend{ downloaded, total, percent }

Model Management

CommandDescription
get_model_info()List available Whisper models with download status
download_whisper_model(model_name)Download model (emits progress events)
delete_whisper_model(model_name)Delete a downloaded model

Configuration

CommandDescription
get_dictation_status()Model status, recording/processing state, and normalized audio_level (0–1). The preview polls this shared IPC/HTTP response while recording.
get_dictation_config()Load dictation configuration
set_dictation_config(config)Save dictation configuration
get_correction_map()Load text correction dictionary
set_correction_map(map)Save text correction dictionary
list_audio_devices()List available audio input devices

DictationState

#![allow(unused)]
fn main() {
pub struct DictationState {
    pub audio: Mutex<Option<AudioCapture>>,
    pub active_model: Mutex<Option<String>>,
    pub corrections: Mutex<TextCorrector>,
    pub recording: AtomicBool,
    pub processing: AtomicBool,
    pub streaming: Mutex<Option<StreamingSession>>,
    pub transcriber_arc: Mutex<Option<Arc<dyn Transcriber>>>,
    pub accumulated_partials: Arc<Mutex<String>>,
}
}

Managed as Tauri state alongside AppState.

Transcriber Trait

#![allow(unused)]
fn main() {
pub trait Transcriber: Send + Sync {
    fn transcribe(&self, audio: &[f32], language: Option<&str>) -> Result<TranscribeResult, String>;
}
}

WhisperTranscriber implements this trait using whisper-rs. The trait abstraction enables mock implementations for testing without requiring a Whisper model.

Recording Guard (TOCTOU)

start_dictation() uses compare_exchange(false, true, AcqRel, Acquire) on the recording flag to prevent TOCTOU races from concurrent IPC calls. If two calls arrive simultaneously, only the first succeeds; the second returns "Already recording". A drop guard resets recording = false on any early error return.

Streaming Architecture

User holds hotkey
    │
    ▼
start_dictation()
    ├── Load/reuse WhisperTranscriber (Arc-wrapped)
    ├── Start CPAL AudioCapture → VecDeque<f32> buffer
    ├── Start StreamingSession (background thread)
    │       │
    │       ├── Poll audio buffer (50ms interval)
    │       ├── Accumulate in step_buf
    │       ├── When step_buf >= window size:
    │       │       ├── VAD check → skip if silence
    │       │       ├── Build window: [keep_tail | step_buf]
    │       │       ├── whisper_full(window)
    │       │       └── Send partial via mpsc::channel
    │       └── Adaptive growth: 1.5s → 2.0s → 2.5s → 3.0s (max)
    │
    ├── Spawn event forwarder thread
    │       └── mpsc::Receiver → emit("dictation-partial")
    │
    └── Set recording = true
    │
User releases hotkey
    │
    ▼
stop_dictation_and_transcribe()  [async]
    ├── Set recording=false, processing=true (synchronous, UI updates immediately)
    ├── Stop cpal stream (buffer preserved)
    ├── Signal StreamingSession stop → join thread
    ├── Collect ALL audio (processed + unprocessed + capture buffer remainder)
    ├── spawn_blocking: Final transcription on full captured audio (if >= 0.5s)
    │   ├── ProcessingGuard (drop guard) clears processing=false on completion/panic
    │   ├── Apply text corrections
    │   └── Return TranscribeResponse
    └── Return TranscribeResponse { text, skip_reason, duration_s }
    │
    ▼
Frontend injects text into focus target

VAD (Voice Activity Detection)

Ported from whisper.cpp common.cpp vad_simple():

  • Algorithm: Compare absolute energy of last last_ms (1000ms) vs entire buffer
  • High-pass filter: First-order RC at 100Hz removes ambient noise (HVAC, fans)
  • Threshold: vad_thold = 0.6 — if energy_last / energy_all < 0.6, silence detected
  • Relative: Microphone gain doesn’t affect detection (ratio-based)

Streaming Constants

ConstantValuePurpose
INITIAL_STEP_MS1500First window size (fast first partial)
MAX_STEP_MS3000Maximum window size
STEP_GROWTH_MS500Growth per iteration
KEEP_MS200Overlap from previous window
POLL_INTERVAL_MS50Audio buffer polling interval
MAX_BUFFER_S30Force flush on very long recordings
VAD_THRESHOLD0.6Energy ratio threshold
VAD_FREQ_THRESHOLD100.0High-pass cutoff Hz

Audio Pipeline

Microphone → CPAL callback → try_lock() → VecDeque<f32> ← drain_samples() ← StreamingSession
                                                                                    │
                                                                              whisper_full()
                                                                                    │
                                                                              mpsc::channel
                                                                                    │
                                                                         event forwarder thread
                                                                                    │
                                                                          "dictation-partial"
                                                                                    │
                                                                         DictationToast (UI)

Key design: try_lock() in the CPAL callback ensures the real-time audio thread never blocks. On contention, samples are silently dropped — acceptable for dictation at 16kHz mono (~64KB/s).

Audio Resampling

process_audio_chunk() converts raw microphone input to the 16kHz mono f32 PCM format required by Whisper:

  1. I16 → F32 conversion — If the audio device provides I16 samples, they are normalized to [-1.0, 1.0] by dividing by i16::MAX.
  2. Stereo → mono — Multi-channel frames are averaged (frame.sum() / channels).
  3. Nearest-neighbor resampling to 16kHz — For sample rates other than 16kHz (e.g., 48kHz), the output length is calculated as input_len * (16000 / sample_rate) and samples are picked by index mapping (src_idx = i / ratio).

Pre-allocated scratch buffers (mono_buf, resample_buf) are captured in the CPAL closure to avoid per-callback heap allocations. The buffer is capped at 30 seconds (480k samples) to prevent unbounded growth.

Model Storage

Models stored in: <config_dir>/models/

Available models (GGML format):

ModelSizeQuality
small~488 MBGood
small.en~488 MBGood (English-only)
large-v2~3.0 GBHighest accuracy (slow)
large-v3-turbo~1.6 GBBest (recommended, default)

Text Corrections

User-configurable dictionary for post-processing:

{
  "new line": "\n",
  "tab": "\t",
  "period": ".",
  "comma": ","
}

Stored in dictation config. Applied after transcription, before injecting into terminal.

Platform Notes

  • macOS: Metal acceleration via whisper-rs (GPU-accelerated, always)
  • Linux: CPU-only (optional cuda/vulkan build feature)
  • Windows: CPU-only — the whisper.cpp Vulkan backend’s vulkan-shaders-gen sub-build is chronically broken on the Windows CI runner (MAX_PATH/MSBuild), so we ship CPU; re-enable vulkan once stabilized
  • Microphone permissions deferred until first use (avoids startup permission popup)

Microphone Permission Detection

Module: src-tauri/src/dictation/permission.rs

On macOS, microphone access is gated by the TCC (Transparency, Consent, and Control) framework. The MicPermission enum tracks the current state:

StateMeaning
NotDeterminedUser hasn’t been asked yet — system will prompt on first access
AuthorizedUser granted access
DeniedUser denied access — must be changed in System Settings
RestrictedSystem policy prevents access (e.g., MDM)

API:

  • MicPermission::check() — queries AVCaptureDevice authorization status via Objective-C bridge (objc2, objc2-av-foundation)
  • MicPermission::open_settings() — opens macOS System Settings at the Privacy & Security > Microphone pane

Platform behavior:

  • macOS: Full TCC integration via AVFoundation
  • Linux/Windows: Always returns Authorized (no TCC framework)

Alacritty Terminal Integration

TUICommander uses alacritty_terminal 0.26.0 as its terminal emulation backend. We maintain a local patch at src-tauri/patches/alacritty_terminal/ referenced via [patch.crates-io] in Cargo.toml.

Why a local patch

alacritty_terminal is designed for the Alacritty GUI app. Several methods and fields needed by an embedded terminal backend are private. Rather than forking the entire repo, we patch the crate locally — minimal changes, easy to audit, easy to rebase on upstream updates.

Our patches

FileChangeWhy
src/term/mod.rspub fn resize_reflow(size, reflow: bool)Disable reflow on resize. Ink/Claude Code uses CUU cursor positioning that breaks when reflow merges/splits lines.
src/term/mod.rspub fn mark_fully_damaged() (was fn)Lets us force full-frame damage directly instead of maintaining a parallel flag.
src/term/mod.rsParse-side damage: TermParseDamage enum, TermDamageState.parse_lines/parse_full, pub fn parse_damage()/reset_parse_damage(), damage recorded in write_at_cursorA SECOND, independent damage view for TUIC’s PTY parse path (TerminalGrid::processChangedRow), read+reset separately from the render damage so the two consumers never steal each other’s damage. Lets process() diff only changed rows instead of rebuilding+diffing the whole screen per PTY chunk. write_at_cursor now damages the written cell (upstream reconstructs input damage lazily at damage() time from cursor deltas, which left the parse consumer blind to typed text); this is at worst a safe over-damage for the render consumer. Correctness pinned by the process_damage_matches_full_diff differential test.
src/term/mod.rsfn osc7770(&mut self, verb, payload)OSC 7770 TUIC protocol handler. Fires Event::Tuic { verb, payload } for in-band state/suggest/intent signalling.
src/term/color.rspub fn named_color_to_index(NamedColor) -> Option<u8>Maps named colors to xterm-256 indices. Eliminates 30-line match duplication in our serializer.
src/event.rsEvent::Tuic { verb, payload } variantCarries parsed OSC 7770 events from VTE to the application layer.
src/term/mod.rsConfig.alt_scrolling_history + alt-grid history in Term::new/set_options, era reset in swap_altUser-visible parity with iTerm2’s optional alternate-screen scrollback, implemented with Alacritty’s separate grids rather than iTerm2’s shared persistent line buffer. Upstream gives the alternate grid capacity 0 (XTerm semantics), so an app printing more than a screenful (gh run watch, less, man) loses whatever scrolls off. The field defaults to 0, preserving upstream behavior for consumers that do not opt in; TUICommander uses the primary cap. Each enter/exit starts a fresh alternate era, so sessions never inherit one another and no alternate lines remain logically retained after exit. Oversized repeated redraws remain repeated because the emulator is byte-faithful, not a semantic snapshot deduplicator.
src/term/mod.rspub fn primary_history_size()Returns primary-grid history even while the alternate grid is active. Durable-log resize synchronization must stay in this coordinate space; using active alternate history can suppress the first normal-shell lines after exit.
src/grid/mod.rspub fn reset_history_era()clear_history() keeps lines_scrolled monotonic because absolute row ids must be stable for the life of a physical line. The alternate screen is a separate content universe wiped on every enter/exit, so it gets a fresh era instead: history and counter reset. Frame-protocol keyboard_flags bit 5 marks the transition; the frontend then atomically invalidates row, scroll, selection, search, and link state.
src/grid/mod.rslines_scrolled field + pub fn total_scrolled()Monotonic count of lines ever scrolled into history (incremented in scroll_up). total_scrolled() - history_size() gives lines evicted from the top, the base for an eviction-stable absolute row coordinate. Excluded from PartialEq; serde(default) so old ref fixtures still load.

VTE patch (src-tauri/patches/vte/)

We also patch the vte crate (0.15.0) to extend the Handler trait:

MethodPurpose
fn osc133(&mut self, command: char, params: &str)Shell integration markers (A/B/C/D). Routes OSC 133;X from osc_dispatch.
fn osc7(&mut self, url: &str)Current working directory. Routes OSC 7;url from osc_dispatch.
fn osc7770(&mut self, verb: &str, payload: &str)TUIC protocol. Routes OSC 7770;verb=payload from osc_dispatch.

OSC 7770 — TUIC Protocol

In-band signalling via the PTY stream. Never written to the grid (consumed by VTE before rendering).

Format: ESC ] 7770 ; verb=payload BEL or ESC ] 7770 ; verb=payload ST

Verbs:

VerbPayloadEffect
stateidle, busy, or awaitingidle/busy: immediate shell state transition (bypasses silence timer). awaiting: emits a confident Question (sets awaiting_input); busy also clears a prior awaiting. Driven by native agent hooks (see AI Agents → Native Hook Instrumentation). Unknown payloads are ignored.
suggestA|B|C (pipe-separated)Emits ParsedEvent::Suggest — never hits the grid, no conceal needed.
intenttext or text (Title)Emits ParsedEvent::Intent with optional tab title.

Advantages over text-based detection:

  • Zero cross-chunk issues (OSC has delimiter-based framing in VTE)
  • Zero conceal (never written to grid cells)
  • Zero regex (structured parse in VTE dispatcher)
  • Zero stale rescan (not in visible buffer)

Upstream API we use directly (no patch needed)

APIUsage
Term::new(config, dimensions, event_proxy)Create terminal grid
Processor::advance(&mut term, data)Feed PTY bytes
term.grid() / term.grid_mut()Read cell grid, cursor, history
term.damage() / term.reset_damage()Dirty-row tracking for incremental serialization
term.scroll_display(Scroll::Delta)Viewport scrolling
term.mode()Check TermMode flags (ALT_SCREEN, SHOW_CURSOR, kitty keyboard)
term.cursor_style()Cursor shape (block/beam/underline)
term.colors()Dynamic color palette (OSC 4/10/11/12 overrides)
term.selection / term.selection_to_string()Native selection API
RegexSearch::new(query) + term.regex_search_right()Native DFA regex search across grid + scrollback
EventListener traitCapture bell, title, clipboard, PTY write-back events

Canonical HTTP text snapshots read a single absolute range from this grid. They must not rebuild a snapshot by appending a separately retained log to the screen: increasing terminal rows can move history back into the viewport and make the two representations overlap.

Notable forks and patches (external)

Zed Editor (zed-industries/alacritty)

Zed maintains branches on their fork with patches not yet upstream:

BranchWhatRelevance
osc-133Semantic cell tagging — cells get Osc133CellType (Prompt/Input/Output) from OSC 133 sequences. Fires Event::Osc133. Requires Zed’s VTE fork (osc-133-2 branch).High — would replace our regex-based extract_osc133() pre-parser. Enables prompt zone rendering. See story 1552.
v0.16-child-exit-patchUses exit_status.into_raw() for ChildExit. Removed from fork (confirmed 2026-05-04).Story 1553 needs re-evaluation — implement independently if needed.
use-zed-vtePins to Zed’s VTE fork with Serialize/Deserialize on parser state. Removed from fork (confirmed 2026-05-04).Was prerequisite for OSC 133; check if osc-133 branch still depends on it.
grid-mutMakes grid_mut() public (removes #[cfg(test)]).Low — we already expose grid access via our own patches.
click-linksURL detection + click-to-open in grid. Ancient branch (pre-0.26 API).None — we handle link detection in our Canvas renderer.
cursor-blinkCursor blink timer via mio::Timer. WIP with debug prints.None — we handle blink in Canvas/JS.
cursor-configRestructures cursor config into cursor.style/hide_when_typing/custom_colors.None — we don’t use alacritty’s config system.
scrollbackAdded scrollback buffer — already merged into upstream alacritty.None (already upstream).
scroll/fix-alt-grid-sizeAlt screen gets zero scrollback — already merged upstream.None (already upstream).

Other projects

  • Rio Terminal — built on alacritty_terminal but maintains its own fork with rendering changes (not relevant to us since we do our own Canvas2D rendering).
  • Ghostty — uses its own terminal emulation written in Zig, not alacritty_terminal.
  • Warp — uses vte + forked alacritty grid internally, tightly coupled to their warpui framework. Not extractable.

Update procedure

Checking for upstream updates

# Check latest version on crates.io
cargo search alacritty_terminal

# Compare with our pinned version
grep "alacritty_terminal" src-tauri/Cargo.toml

Rebasing our patch on a new upstream version

  1. Download the new version:

    cargo download alacritty_terminal@<new_version> -o /tmp/alacritty_new
    

    Or copy from ~/.cargo/registry/src/ after adding the new version to Cargo.toml.

  2. Diff our patches against the old upstream:

    diff -ru ~/.cargo/registry/src/*/alacritty_terminal-0.26.0/src/term/mod.rs \
             src-tauri/patches/alacritty_terminal/src/term/mod.rs
    
  3. Apply patches to the new version. Our changes are small and isolated:

    • resize_reflow in term/mod.rs — add method, modify resize() to call it
    • mark_fully_damaged visibility in term/mod.rsfnpub fn
    • named_color_to_index in term/color.rs — new function, no existing code modified
  4. Update Cargo.toml version and the patches/ directory.

  5. Run tests: cargo test terminal_grid && cargo test vt_log

Checking Zed’s fork for new patches

# List branches on Zed's fork
gh api repos/zed-industries/alacritty/branches --jq '.[].name'

# Compare a specific branch
# https://github.com/zed-industries/alacritty/compare/master...<branch>

Periodic review cadence

Driven by the alacritty-upstream entry in .claude/scheduled-checks.json (every 20 days). Each run:

  • Check crates.io for new alacritty_terminal releases (cargo search alacritty_terminal).
  • Review Zed fork branches for new patches relevant to our embedded backend.
  • On major issues: If we hit terminal emulation bugs, check if upstream or Zed has a fix before writing our own.

Planned patches (stories)

StoryPriorityDescriptionStatus
1552-02ffP2Port Zed OSC 133 semantic cell tagging (requires VTE fork)Done — cell_type tagging + VTE osc133/osc7 handlers implemented
1550-64b1P3Move OSC 133 extraction into VTE handler (blocked by 1552)Done — VTE routes OSC 133 directly to Handler::osc133()
P2OSC 7770 TUIC protocol (state/suggest/intent)Done — full pipeline from VTE→Event→PTY→ParsedEvent
P3Use cell_type for idle detection (OSC 133 shells)Pending — next step after TUIC protocol
1553-5e8cP3Port Zed child-exit raw waitpid statusPending

VT100-to-PWA Protocol

How TUICommander transforms raw PTY output into structured, styled terminal content for the mobile Progressive Web App.

Architecture Overview

PTY (raw bytes)
  │
  ▼
VtLogBuffer (vt100 parser)
  ├── scrollback → LogLine[] (styled spans)
  ├── screen_rows → String[] (plain text)
  └── prompt_input_text → String (user-typed, no ghost text)
        │
        ▼
  HTTP / WebSocket handlers
  ├── trim_screen_chrome() → remove agent UI footer
  ├── is_separator_line() → detect decorated separators
  └── SessionState → accumulated parsed events
        │
        ▼
  Frontend (SolidJS)
  ├── OutputView → log + screen rendering, auto-scroll
  ├── CommandInput → bidirectional PTY ↔ textarea sync
  └── SlashMenuOverlay → arrow navigation, prefill

1. VtLogBuffer — VT100 Parsing Engine

File: src-tauri/src/state.rs

Configuration

ParameterValueNotes
Scrollback10,000 lines (VT100_SCROLLBACK)Internal vt100 parser buffer
Log capacity10,000 lines (VT_LOG_BUFFER_CAPACITY)Ring buffer of finalized LogLines
Default size24 rows × 80 colsResizable via resize()
#![allow(unused)]
fn main() {
VtLogBuffer::new(rows, cols, capacity)
// Parser created with: vt100::Parser::new(rows, cols, VT100_SCROLLBACK)
}

process(data: &[u8]) — Core Pipeline

Called for every PTY read chunk. Steps:

  1. Feed bytes to vt100 parser
  2. Detect changed rows by diffing current screen against prev_rows cache
  3. Extract scrollback delta:
    total_sb = scrollback_count()        // query vt100 internal counter
    delta = total_sb - self.scrollback_read
    new_lines = read_scrollback_lines(delta)
    
  4. Trim agent chrome from new lines (remove prompt/separator lines)
  5. Push to log ring buffer
  6. Update prev_rows snapshot for next diff
  7. Return changed row indices (for output parser)

Scrollback Extraction

The vt100 parser maintains an internal scrollback buffer. Lines are “scrolled off” when a line feed occurs at the bottom of the screen (real scroll), but NOT when cursor-based TUI redraws happen.

#![allow(unused)]
fn main() {
fn scrollback_count(&mut self) -> usize {
    // Temporarily set max scrollback window to query total count
    self.parser.screen_mut().set_scrollback(usize::MAX);
    let count = self.parser.screen().scrollback();
    self.parser.screen_mut().set_scrollback(0);
    count
}

fn read_scrollback_lines(&mut self, count, screen_height) -> Vec<LogLine> {
    // Page through scrollback in screen_height-sized chunks
    // using set_scrollback(offset) to position the view
    // Extract each row via extract_log_line()
}
}

Key invariant: scrollback_read is monotonically increasing. Each process() call reads only the delta since the last call.

extract_log_line(screen, row) — Styled Cell Extraction

Converts a vt100 screen row into a LogLine with colored spans:

#![allow(unused)]
fn main() {
struct LogLine { spans: Vec<LogSpan> }
struct LogSpan {
    text: String,
    fg: Option<LogColor>,   // Idx(u8) or Rgb(r,g,b)
    bg: Option<LogColor>,
    bold: bool,
    italic: bool,
    underline: bool,
}
}

Algorithm:

  • Iterate columns left-to-right
  • Skip wide-char continuation cells
  • Group consecutive cells with identical attributes into one span
  • Flush span when attributes change
  • Trim trailing empty/whitespace-only spans with default styling

screen_rows() — Current Screen Content

Returns the visible terminal content as plain text strings.

  • Fast path: Returns cached prev_rows from last process() call
  • Fallback: Reads directly from parser (before first process or after resize)

prompt_input_text() — User Input Extraction

Extracts what the user has typed at the prompt, excluding ghost/suggestion text.

Algorithm:

  1. Scan rows bottom-to-top for prompt character (, >, > )
  2. Walk cells left-to-right after the prompt char
  3. Collect cell contents while !cell.dim()
  4. Stop at first dim cell — that’s ghost/autocomplete text
  5. Return trimmed result

This is critical: Claude Code shows inline suggestions in dim text (e.g., ❯ /wiz:status where /wiz:status is grey). Without the dim check, the suggestion text would appear in the PWA textarea.

mark_agent_chrome() — Log Line Filtering

Flags agent UI chrome (prompt box, status bar, separator) on captured log lines.

  • Uses find_scrollback_chrome_cutoff() — anchors only on a bare prompt row (is_agent_prompt_row), never on a standalone separator
  • Extends cutoff upward past separator, empty and task-list lines
  • Sets LogLine::chrome; the line stays in the buffer and lines_since_owned() skips it. Nothing is deleted from history, so a misclassification hides text rather than destroying it — the failure mode that made the mobile log show paragraphs starting mid-sentence.

2. HTTP/WS Session Handlers

File: src-tauri/src/mcp_http/session.rs

GET /sessions/:id/output?format=log

Initial data fetch before WebSocket connects.

Response:

{
  "lines": [LogLine, ...],
  "total_lines": 1234,
  "offset": 1134,
  "screen": ["row1", "row2", ...],
  "input_line": "user typed text"
}
  • total_lines serves as the offset cursor for WS catch-up
  • offset is the absolute start of the returned window. Clients must use it instead of total_lines - lines.length: chrome lines occupy offset slots without being returned, so the subtraction lands inside the window already held and replays those lines on scroll-up
  • screen has chrome trimmed via trim_screen_chrome()
  • input_line from prompt_input_text() (dim text excluded). Only agent prompts (, , >) are recognized — a plain shell prompt ($/#/%/) yields null, so the textarea gets no PTY-driven reconciliation for shells.

WS /sessions/:id/stream?format=log&offset=N

Real-time bidirectional stream. Two concurrent tasks:

Server → Client (polling task)

Runs in a tokio::select! loop with two branches:

Branch 1: 200ms timer — polls VtLogBuffer for changes:

#![allow(unused)]
fn main() {
let (lines, new_offset) = buf.lines_since_owned(offset);
let trim = trim_screen_chrome(buf.screen_rows());
let input_line = buf.prompt_input_text();
}

Change detection via hash:

#![allow(unused)]
fn main() {
let mut hasher = DefaultHasher::new();
screen.hash(&mut hasher);
input_line.hash(&mut hasher);
let screen_hash = hasher.finish();
let screen_changed = screen_hash != prev_screen_hash;
}

Frame sent only when !lines.is_empty() || screen_changed:

{
  "type": "log",
  "offset": 100,
  "total_lines": 142,
  "lines": [...],
  "screen": [...],
  "input_line": "text"
}
  • offset is the start position of lines (where the delta begins).
  • total_lines is the post-read monotonic cursor (== offset when no new lines). The client stores it and passes it back as ?offset= on reconnect, so catch-up resumes from the last consumed line instead of replaying the whole scrollback from the mount offset. The catch-up frame on connect carries total_lines too. (Without this, every WS reconnect — frequent on mobile: background/foreground, lock, network change — re-injected the entire session scrollback, duplicating it.)

Branch 2: event bus — forwards SessionState on parsed events:

{
  "type": "state",
  "state": { "awaiting_input": true, "agent_type": "claude-code", ... }
}

Client → Server (input passthrough)

WebSocket Text/Binary messages are written directly to the PTY.

Removes Claude Code’s TUI footer (separator, status bar, permissions line, prompt).

Scan window: Last 15 rows from content end (handles Claude Code’s ~12-row footer).

Two anchor strategies:

  1. Separator lineis_separator_line():

    #![allow(unused)]
    fn main() {
    fn is_separator_line(s: &str) -> bool {
        // 4+ consecutive: ─ ━ ═ — ╌ ╍
        // Tolerates decorated separators:
        // "──────── ■■■ Medium /model ─"
    }
    }
  2. Prompt line — starts with , >, >

Takes the higher anchor (closer to content), extends upward past empty/separator lines, truncates.

write_to_session() — Slash Mode Tracking

When users type / from the mobile PWA, the backend tracks this to enable slash menu detection in the output parser:

#![allow(unused)]
fn main() {
if data == "/" || data.starts_with('/') {
    slash_mode.store(true);
} else if data.contains('\r') || data.contains('\n') {
    slash_mode.store(false);
}
}

The output parser checks slash_mode before scanning screen rows for slash command menus.


3. Frontend Transport

File: src/transport.ts

subscribePty(sessionId, onData, onExit, options)

Auto-detects Tauri (native) vs browser (HTTP/WS) mode.

Browser mode connects a WebSocket:

ws(s)://host/sessions/{sessionId}/stream?format=log&offset={logOffset}

Options:

interface SubscribePtyOptions {
  format?: "log";
  logOffset?: number;
  onLogLines?: (lines: unknown[]) => void;
  onScreenRows?: (rows: string[]) => void;
  onInputLine?: (text: string | null) => void;
  onStateChange?: (state: Record<string, unknown>) => void;
}

Frame dispatch:

Frame typeCallbackData
"log"onLogLines, onScreenRows, onInputLineStyled lines, screen rows, prompt input. total_lines is tracked as the reconnect cursor.
"state"onStateChangeSessionState snapshot
"exit" / "closed"onExitSession ended

On reconnect the WebSocket reopens with ?offset=<last total_lines> (log mode) so the server’s catch-up only sends lines committed since the last one the client received. The raw (format omitted) path uses total_written byte offsets for the same purpose.

rpc("write_pty", { sessionId, data })

Maps to POST /sessions/{sessionId}/write with body { data: "..." }.

Used for all PTY input: typed characters, escape sequences (arrow keys), control characters (Ctrl-U, Ctrl-C).


4. Mobile Components

OutputView

File: src/mobile/components/OutputView.tsx

Renders combined log history + current screen with auto-scroll management.

Text wrapping strategy: Normal text uses pre-wrap so long lines wrap on narrow screens. Consecutive lines containing box-drawing characters (U+2500–U+257F) are grouped into scrollable tableBlock containers with white-space: pre and overflow-x: auto, preserving alignment for tables, tree views, and bordered output. Grouping is done by groupLineBlocks() and detection by hasBoxDrawing() in src/mobile/utils/logLine.ts.

Initialization:

  1. HTTP fetch: GET /sessions/{id}/output?format=log
  2. WebSocket connect with logOffset from step 1 (avoids duplicate lines)

Data model:

displayedLines = [...logLines, ...screenRows]  // combined
                 .filter(searchQuery)            // optional filter
  • logLines: Accumulated scrollback (max 500, ring buffer)
  • screenRows: Current terminal screen (replaced each update)

Auto-scroll suppression:

  • Track userScrolledUp via scroll event listener
  • “At bottom” = within 80px of scroll end
  • scrollToBottom(force?): skips if user scrolled up, unless force=true
  • Initial load and session exit force-scroll

CommandInput — Delta Sync (textarea is source of truth)

File: src/mobile/components/CommandInput.tsx Helpers: src/mobile/components/syncGuards.ts

The textarea is the source of truth; the PTY is a write-only sink fed character deltas as the user types. Echoes from the PTY are accepted back only under strict conditions, so a laggy link can’t clobber what the user sees.

Direction 2: Textarea → PTY (syncDelta)

On every input event, syncDelta(newText) streams a minimal end-anchored delta computed by computeInputDelta(syncedText, newText):

// keep longest common prefix, backspace the divergent tail from the end,
// then type the new tail
function computeInputDelta(oldText, newText) {
  let prefix = 0;
  const max = Math.min(oldText.length, newText.length);
  while (prefix < max && oldText[prefix] === newText[prefix]) prefix++;
  return "\x7f".repeat(oldText.length - prefix) + newText.slice(prefix);
}

Append (no backspaces) and truncate (no retype) fall out as special cases. A mid-line edit backspaces only the divergent tail instead of nuking and retyping the whole line. This is correct as long as the remote cursor is at end-of-line (true while the user only appends/backspaces and hasn’t moved the readline cursor via arrow keys).

Why minimal, not full-nuke: the old “complex edit” branch sent \x7f×oldLen + newText on any mid-line change. That keystroke storm flickered readline and corrupted the line when a write dropped/reordered over a laggy mobile link — the “typing fa casino” symptom. Minimal deltas send the fewest keystrokes and keep the textarea consistent with the screen.

Direction 1: PTY → Textarea (guarded echo)

Source: ptyInputLine prop (from WebSocket input_line). An echo is accepted into the textarea only when both gates pass (syncGuards.ts):

  1. Post-send guard — within POST_SEND_GUARD_MS (500ms) of Enter, every echo is ignored (suppresses the ghost flash of the just-sent command).
  2. Strict-extension ruleisSupersetEcho(echo, syncedText): accept only if the echo strictly extends what we’ve sent (tab completion / autocomplete). Prompt redraws, lagging echoes, and history-nav replacements are ignored so the textarea can’t be clobbered.

For a plain shell, ptyInputLine is null (see prompt_input_text — it only recognizes agent prompts), so there is no PTY-driven reconciliation; the authoritative display of the shell line is the screen rendered by OutputView.

Send (Enter)

The typed text is already in the PTY via live delta sync, so Enter just writes \r (and clears the textarea + arms the post-send guard).

Mid-line editing

Moving the readline cursor is done with the ← / → keys in TerminalKeybar (\x1b[D / \x1b[C), not by moving the textarea caret. When arrows move the remote cursor, the textarea’s end-anchored model can diverge from the screen — the screen stays authoritative.

SlashMenuOverlay

File: src/mobile/components/SlashMenuOverlay.tsx

Displays slash command menu items detected from the terminal.

Item source: sessionState.slash_menu_items — populated by the output parser when it detects a slash command menu on screen (gated by slash_mode).

Arrow navigation:

┌─────────────────────────┐
│ /help     Get help...   │
│ /compact  Compact...    │
│ /review   Review code   │
│ ⏫  ▲  ▼  ⏬           │  ← arrow bar
└─────────────────────────┘
  • Single arrow (▲/▼): sends one \x1b[A or \x1b[B
  • Page arrow (⏫/⏬): sends items.length arrows (scrolls one page)
  • Anti-zoom: touch-action: manipulation on all buttons

Selection flow:

  1. User taps item → onSelect(command) callback
  2. Parent sets inputPrefill signal with { text, seq } counter
  3. CommandInput receives prefill, sets textarea value, focuses
  4. Also sends Ctrl-U + text to PTY so terminal shows it
  5. User reviews, optionally edits, presses Enter to submit

SessionDetailScreen — Orchestration

File: src/mobile/screens/SessionDetailScreen.tsx

Wires everything together:

OutputView ──onStateChange──→ wsState signal
           ──onInputLine───→ ptyInputLine signal
                                    │
SlashMenuOverlay ──onSelect──→ inputPrefill signal
                                    │
CommandInput ←── prefillValue ──────┘
             ←── ptyInputLine ──────┘

State merging: WebSocket state is authoritative when present; 3s poll state fills gaps.


5. SessionState Accumulation

File: src-tauri/src/state.rsapply_event_to_session_state()

The output parser emits PtyParsed events. These accumulate into SessionState:

Event typeFields updated
questionawaiting_input = true, question_text
user-inputawaiting_input = false, clear slash menu, capture last_prompt
rate-limitrate_limited = true, retry_after_ms
usage-limitusage_limit_pct
api-errorlast_error
status-lineClear rate-limit/error/suggest/slash, set current_task
intentagent_intent
suggestsuggested_actions
slash-menuslash_menu_items
progressprogress (0-100, None on state=0)

Lifecycle:

  • Created on SessionCreated
  • Removed on SessionClosed
  • is_busy cleared on PtyExit

6. Key Escape Sequences

SequenceMeaningUsage
\x15Ctrl-UClear input line (readline)
\x7fDEL/BackspaceDelete char before cursor
\rCarriage ReturnSubmit input
\x1b[AArrow UpNavigate menu / history
\x1b[BArrow DownNavigate menu / history
\x1b[CArrow RightMove readline cursor right (mid-line edit)
\x1b[DArrow LeftMove readline cursor left (mid-line edit)
\x1b[3~DeleteDelete char after cursor
\x03Ctrl-CInterrupt
\x04Ctrl-DEOF / exit

7. Debugging

Check backend logs

GET /logs?source=terminal&limit=20

Inspect raw WS frames

In browser DevTools → Network → WS → filter by /stream:

  • "type":"log" frames show line count, screen rows, input_line
  • "type":"state" frames show accumulated session state

Verify input_line extraction

GET /sessions/{id}/output?format=log

Response includes "input_line" — should show only user-typed text (no ghost/dim suggestions).

Verify separator detection

If agent chrome leaks through, check trim_screen_chrome() scan window (15 rows) and is_separator_line() threshold (4+ consecutive box-drawing chars).

Components

All components are SolidJS functional components in src/components/.

Component Tree

App.tsx (central orchestrator)
├── Toolbar/                  # Window drag region, repo/branch display
├── Sidebar/                  # Repository tree with branches
│   ├── GroupSection          # Collapsible repo group with color + drag-reorder
│   ├── RepoSection           # Single repo entry with branches
│   ├── ParkedReposPopover    # Popover to recall parked (hidden) repos
│   ├── CiRing               # CI status ring per branch
│   ├── StatusBadge           # Git status badge (clean/dirty/conflict)
│   └── PrDetailPopover/      # PR details popup (CI, reviews, labels)
├── main
│   ├── TabBar/               # Terminal tabs with drag-to-reorder
│   ├── Terminal/             # Native terminal renderer (never unmounted)
│   ├── TerminalArea/         # Terminal + split pane layout (up to 6 panes)
│   ├── SuggestOverlay/       # suggest: follow-up action chips
│   ├── GitPanel/             # Git panel (6 tabs)
│   │   ├── ChangesTab        # Staged/unstaged file list with stage/unstage/discard
│   │   ├── LogTab            # Commit log with expandable diffs
│   │   ├── StashesTab        # Stash list with apply/pop/drop/show
│   │   ├── BranchesTab       # Branch CRUD, prefix folding, search, checkout
│   │   ├── BlameTab          # Line-by-line git blame viewer
│   │   ├── HistoryTab        # Per-file commit history
│   │   ├── CommitGraph       # Visual commit graph with lane assignments
│   │   └── SyncRow           # Push/pull/fetch action bar
│   ├── DiffTab/              # Individual file diff tab (with Cmd+F search)
│   │   └── BranchDiffScrollView  # All-files scroll view (scroll mode)
│   ├── PrDiffTab/            # PR diff viewer tab
│   ├── CodeEditorPanel/      # CodeMirror 6 code editor tab
│   ├── MarkdownPanel/        # Markdown file browser
│   │   └── ContentRenderer  # Markdown to HTML (DOMPurify), interactive checkboxes, tweak highlights
│   ├── HtmlPreviewTab/       # Multi-format preview tab (HTML, PDF, images, video, audio, text)
│   ├── MarkdownTab/          # Individual markdown file tab (checkbox toggle, tweak comments, search)
│   ├── NotesPanel/           # Ideas/notes panel with edit, send, delete
│   ├── FileBrowserPanel/     # File tree browser with content search
│   │   └── TreeNode          # Recursive tree node (lazy-loaded)
│   ├── PluginPanel/          # Plugin HTML panel (sandboxed iframe)
│   ├── ClaudeUsageDashboard/ # Claude API usage dashboard (SolidJS)
│   ├── ErrorLogPanel/        # Application error log viewer
│   └── StatusBar/            # Status messages, agent badge, toggles
│       └── ZoomIndicator     # Font size display
├── TabBar/                   # Ordering, overflow, drag/drop, and menus
│   └── TabViews              # Shared terminal, diff, Markdown, and editor tab views
├── SettingsPanel/            # Tabbed settings overlay
│   ├── tabs/GeneralTab       # Font, shell, IDE, theme
│   ├── tabs/AgentsTab        # Agent detection, run configs, Claude Usage toggle
│   ├── tabs/ServicesTab      # Local MCP and remote-access composition
│   │   └── services/         # Upstream MCP and remote-machine domain panels
│   ├── tabs/GitHubTab        # GitHub OAuth login, token management
│   ├── tabs/PluginsTab       # Plugin management, logs
│   ├── tabs/KeyboardShortcutsTab # Rebindable keyboard shortcuts
│   ├── tabs/AppearanceTab    # Visual customization
│   ├── tabs/NotificationsTab # Sound and notification prefs
│   ├── tabs/RepoScriptsTab   # Per-repo scripts
│   └── tabs/RepoWorktreeTab  # Per-repo worktree options
├── HelpPanel/                # Keyboard shortcuts documentation
├── TaskQueuePanel/           # Agent task queue
├── PromptOverlay/            # Agent prompt interception
├── PromptDrawer/             # Prompt library management
├── CommandPalette/           # Cmd+P command palette
├── ActivityDashboard/        # Activity center (bell dropdown)
├── BranchSwitcher/           # Quick branch switcher (held-key overlay)
├── BranchPopover/            # Branch selection popover
├── TipOfTheDay/              # Startup tip notification
├── DictationToast/           # Dictation recording/transcribing indicator
├── ConfirmDialog/            # Reusable in-app confirmation dialog
├── RenameBranchDialog/       # Branch rename dialog
├── CreateWorktreeDialog/     # Worktree creation dialog
├── PostMergeCleanupDialog/   # Post-merge cleanup (switch base, pull, delete)
├── PromptDialog/             # Text input prompt dialog
├── RunCommandDialog/         # Configure terminal commands
├── WorktreeManager/          # Overlay panel for worktree management
├── MergePostActionDialog/    # Dialog for post-merge actions (keep/delete branch)
├── ContextMenu/              # Shared right-click menu (all panels). `separator:true` on an item = trailing divider AFTER it; empty-label item = standalone divider; a trailing separator on the LAST item is suppressed
└── IdeLauncher/              # Open repository in IDE

Application Controllers

Application lifecycles live in focused hooks under src/hooks/; App.tsx composes them and owns the top-level layout. Git operations retain the useGitOperations facade for callers, with stateful domains implemented under src/hooks/git/:

  • repository refresh and stale-result suppression;
  • serialized branch selection;
  • terminal/worktree ownership and OSC 7 reassignment;
  • worktree creation, setup, recovery, and removal;
  • merge, autofix, and conflict-assistance workflows.

Each coordinator owns its timers, queues, generations, or locks. These are behavioral boundaries rather than generic service wrappers.

Core Components

Terminal (Terminal/)

Native terminal renderer with full PTY integration.

Responsibilities:

  • Creates and manages CanvasTerminal instance backed by alacritty_terminal
  • Renders grid frames to HTML canvas for GPU-accelerated display
  • Subscribes to PTY output events
  • Handles terminal resize (with debouncing)
  • Applies font, theme, and zoom settings
  • Link detection for clickable URLs
  • Selection management for copy operations

CanvasTerminal keeps frame decode, reconciliation, scheduling, and paint in one imperative hot path. Sibling controllers own selection/search state, link verification cancellation and caches, fractional scroll/cache handoff, and DOM input-listener cleanup. These controllers do not use reactive state.

Key behavior: Terminals are never unmounted — they stay in the DOM when switching tabs. Only visibility is toggled. This preserves terminal state (scroll position, content, active processes).

Repository tree with branch management.

Features:

  • Expandable/collapsible repository entries
  • Icon-only collapsed mode
  • Branch list with active branch highlight
  • CI ring indicator per branch (from githubStore)
  • PR status badge
  • Diff stats (additions/deletions) per branch
  • Context menu (right-click) for repo/branch operations
  • Resizable width via drag handle (200-500px)
  • Keyboard redirect to active terminal

Shared PR presentation (PrStateBadge) and merge eligibility are leaf modules below the sidebar views. RepoSection, GitHubPanel, PrSection, and RemoteOnlyPrPopover do not import back through one another.

TabBar (TabBar/)

Terminal tab management.

Features:

  • Tabs filtered to active branch only
  • Drag-to-reorder tabs
  • Tab rename (double-click)
  • Close button per tab
  • Activity indicator (dot) for background terminals
  • Awaiting input indicator (question/error icons)
  • Context menu: Close, Close Others, Close to Right

SettingsPanel (SettingsPanel/)

Tabbed settings overlay.

Tabs:

  • General — Font family, font size, shell, IDE, theme, confirmations
  • Agents — Agent detection, run configurations, Claude Usage toggle
  • Services — MCP server, remote access, dictation settings
  • GitHub — GitHub OAuth login (Device Flow), token management, diagnostics
  • Plugins — Plugin management, enable/disable, log viewer
  • Keyboard Shortcuts — Rebindable shortcuts (auto-populated from actionRegistry.ts)
  • Appearance — Visual customization
  • Notifications — Sound and notification preferences
  • Repo Scripts — Setup script, run command per repository
  • Repo Worktree — Base branch, copy ignored/untracked files

PrDetailPopover (PrDetailPopover/)

Rich PR detail popup shown on hover/click in sidebar.

Displays:

  • PR title, number, author
  • State (open, merged, closed, draft)
  • Merge readiness (ready, conflicts, behind, blocked)
  • Review decision (approved, changes requested, review required)
  • CI check summary (passed/failed/pending ring)
  • Individual CI check details
  • Labels with computed colors
  • Line change counts (+additions/-deletions)
  • Timestamps (created, updated)

StatusBar (StatusBar/)

Status messages, agent badge, CWD display, ticker, and panel toggles.

Layout (left to right):

  1. ZoomIndicator — font size display
  2. Status info — notification text with pendulum ticker for overflow
  3. CWD — current working directory (click to copy, shortened with ~/)
  4. Agent badge — unified agent + usage display (see below)
  5. Ticker — rotating plugin messages (hidden when absorbed by agent badge)
  6. GitHub badges — PR badge + CI badge with popover (center area)
  7. Toggle buttons — Notes (with badge count), File Browser, Markdown, Diff, Dictation mic

Agent Badge — display priority:

The agent badge appears when the active terminal has a recognized agent type. It shows a single integrated element with the agent icon and the most relevant info, following this priority cascade:

PriorityConditionDisplayExample
1 (highest)PTY rate limit detectedIcon + warning + countdown⚠ 3m 20s
2Usage API available (Claude only)Icon + usage percentages5h: 6% · 7d: 69%
3PTY usage limit parsedIcon + percentage + limit type82% daily
4 (lowest)No usage dataIcon + agent nameclaude

Data sources:

  • Rate limit (priority 1): Detected by Rust output parser via regex on PTY output (e.g. “429”, “rate limit”, “too many requests”). Stored in rateLimitStore. Applies to all agents.
  • Usage API (priority 2): Polled every 5 min from Claude’s API by claudeUsage.ts. Posted to statusBarTicker with pluginId "claude-usage". Claude Code only. When active, the separate ticker message is suppressed to avoid duplication.
  • PTY usage limit (priority 3): Parsed from terminal output by the output parser (e.g. Claude’s [C1 S30 K26] status line). Stored on the terminal entry as usageLimit. Applies to all agents that emit usage info.
  • Agent name (priority 4): Fallback — just shows the agent type name.

Ticker integration: When the active agent is claude and the Claude Usage ticker is active, the ticker message is absorbed into the agent badge (priority 2) and hidden from the separate ticker area. Other ticker messages (from plugins, etc.) display normally.

Pendulum ticker: When the status info text overflows its container, a CSS pendulum animation scrolls the text back and forth at ~50px/s. Clicking the text dismisses the notification until the message changes.

Notes badge: The Ideas toggle button shows a count badge (accent-colored) with the number of notes visible for the current repo filter. Uses notesStore.filteredCount().

PR lifecycle in StatusBar: CLOSED PRs are never shown. MERGED PRs are shown with a 5-minute activity-based grace period (accumulated user activity tracked by userActivityStore). OPEN PRs are shown as-is.

NotesPanel (NotesPanel/)

Ideas/notes panel with per-repo filtering and terminal integration.

Features:

  • Add, edit, delete notes
  • Send note text to active terminal (marks note as “used”)
  • Notes filtered by active repo (global notes always visible)
  • Reassign notes to different projects via dropdown
  • Count badge in panel header and in the StatusBar toggle button
  • Used notes shown with a checkmark and dimmed styling

ConfirmDialog (ConfirmDialog/)

Reusable in-app confirmation dialog that replaces native Tauri ask() dialogs (which render as light-mode macOS system sheets). Uses shared dialog.module.css for consistent dark-theme styling.

Props: visible, title, message, confirmLabel, cancelLabel, kind (warning/info/error), onClose, onConfirm.

Keyboard: Enter confirms, Escape cancels.

ClaudeUsageDashboard (ClaudeUsageDashboard/)

Native SolidJS component (not a plugin) showing Claude API usage data. Displayed as a tab in the markdown/editor area. Features rate bucket gauges, per-model token breakdown, daily usage chart, and project stats. Opened by clicking the Claude Usage ticker in the status bar.

UI Primitives (components/ui/)

ComponentDescription
AgentIconAgent type icon with consistent sizing and coloring
CiRingSVG circular CI status indicator with proportional segments
DiffViewerSyntax-highlighted unified diff renderer
DropdownReusable dropdown select component
ContentRendererSafe markdown-to-HTML rendering with DOMPurify sanitization, interactive checkboxes, tweak highlights
PanelResizeHandleDraggable resize handle for panel boundaries
PromptOptionAgent prompt multiple-choice option
StatusBadgeGit status badges (clean/dirty/conflict)
ZoomIndicatorTerminal font size indicator

Shared Components (components/shared/)

ComponentDescription
ColorPickerDialogColor selection dialog (used by repo groups)
ColorSwatchPickerPreset color swatch grid
KeyComboCaptureKeyboard shortcut capture input (for keybinding editor)
SearchBarReusable search bar with regex/case-sensitive toggles

Panel Toggle States

PanelToggle ShortcutStore
SidebarCmd+BuiStore.toggleSidebar()
Git PanelCmd+Shift+DuiStore.toggleGitPanel()
Markdown PanelCmd+Shift+MuiStore.toggleMarkdownPanel()
Notes/Ideas PanelCmd+Alt+NuiStore.toggleNotesPanel()
File BrowserCmd+EuiStore.toggleFileBrowserPanel()
SettingsCmd+,Local state in App.tsx
HelpCmd+?Local state in App.tsx
Prompt LibraryCmd+Shift+KpromptLibraryStore.toggleDrawer()
Task QueueLocal state in App.tsx
Command PaletteCmd+PcommandPaletteStore.toggle()
Activity DashboardactivityDashboardStore.toggle()
Worktree ManagerCmd+Shift+WworktreeManagerStore.toggle()

Stores Reference

All stores use SolidJS createStore for reactive state. Each store exposes a state getter and action methods.

terminalsStore

File: src/stores/terminals.ts

Manages terminal instances, active tab selection, split pane layout, and closed tab history.

State Shape

FieldTypeDescription
terminalsRecord<string, TerminalData>All terminals by ID
activeIdstring | nullCurrently active terminal
layoutTabLayoutSplit pane layout state

Key Types

interface TerminalData {
  id: string;
  sessionId: string | null;
  name: string;
  nameIsCustom: boolean;            // When true, OSC/status-line title changes are ignored
  fontSize: number;
  cwd: string | null;               // Current working directory (from OSC 7)
  awaitingInput: AwaitingInputType; // "question" | "error" | null
  awaitingInputConfident: boolean;  // High-confidence detection — don't clear on idle→busy
  shellState: ShellState;           // "busy" | "idle" | null
  activity: boolean;
  unseen: boolean;                  // Terminal completed work while user wasn't viewing it
  progress: number | null;          // OSC 9;4 progress (0-100), null when inactive
  agentType: AgentType | null;      // Detected foreground agent process (e.g. "claude")
  pendingResumeCommand: string | null; // Set at restore time, consumed on first shell idle
  pendingInitCommand: string | null;   // Setup/run script to auto-execute on first shell idle
  usageLimit: { percentage: number; limitType: string } | null;
  lastDataAt: number | null;        // Timestamp of last PTY output
  lastPrompt: string | null;        // Last relevant user prompt (>= 10 words), set by Rust
  agentIntent: string | null;       // LLM-declared intent via intent: token
  currentTask: string | null;       // Current agent task from status-line parsing
  activeSubTasks: number;           // Count of running sub-agents from ›› status line
  isRemote: boolean;                // Created via HTTP/MCP (not locally by the UI)
  agentSessionId: string | null;    // Agent session ID for session-specific resume
  tuicSession: string | null;       // Stable tab UUID — injected as TUIC_SESSION env var
  suggestedActions: string[] | null; // Follow-up suggestions from suggest: token
  suggestDismissed: boolean;        // true after user dismissed — prevents re-show
}

interface TabLayout {
  direction: SplitDirection;  // "none" | "vertical" | "horizontal"
  panes: string[];            // Terminal IDs (up to MAX_SPLIT_PANES = 6)
  ratios: number[];           // N fractions summing to 1.0 (length === panes.length)
  activePaneIndex: number;    // 0..N-1
}

Actions

MethodDescription
add(data)Add a terminal
remove(id)Remove a terminal
setActive(id)Set active terminal (clears activity flag)
update(id, data)Partial update terminal data
setSessionId(id, sessionId)Update session ID
setFontSize(id, fontSize)Update font size
setAwaitingInput(id, type)Set awaiting input indicator
clearAwaitingInput(id)Clear awaiting input
splitPane(direction)Split into two panes
closeSplitPane(index)Collapse back to single pane
setSplitRatio(ratio)Adjust split ratio
setActivePaneIndex(index)Switch active pane

Queries

MethodDescription
get(id)Get terminal by ID
getActive()Get active terminal
getIds()Get all terminal IDs
getCount()Get terminal count
hasAwaitingInput()Any terminal awaiting input?
getAwaitingInputIds()Get IDs of terminals awaiting input

repositoriesStore

File: src/stores/repositories.ts

Manages saved repositories, branches, terminal associations, and PR status cache.

State Shape

FieldTypeDescription
reposRecord<string, RepositoryState>Repositories by path
activePathstring | nullActive repository path

Key Types

interface RepositoryState {
  path: string;
  displayName: string;
  initials: string;
  isGitRepo?: boolean;    // false for plain directories
  expanded: boolean;      // Show branch list
  collapsed: boolean;     // Icon-only mode
  parked: boolean;        // Hidden from sidebar (recallable via popover)
  branches: Record<string, BranchState>;
  activeBranch: string | null;
}

interface BranchState {
  name: string;
  isMain: boolean;
  isShell?: boolean;               // true for non-git directory shell entries
  worktreePath: string | null;
  terminals: string[];             // Terminal IDs
  hadTerminals: boolean;           // Suppresses auto-spawn after close-all
  lastActiveTerminal: string | null;
  additions: number;
  deletions: number;
  isMerged: boolean;               // Fully merged into main branch
  lastCommitTs: number | null;     // Unix timestamp of last commit
  runCommand?: string;
  savedTerminals?: SavedTerminal[];
  ciAutoHeal?: { enabled: boolean; attempts: number; lastRunId?: number; healing?: boolean };
  layout?: TabLayout;              // Split layout persisted per-branch
}

Actions

MethodDescription
hydrate()Load from Rust backend
add(repo)Add repository
remove(path)Remove repository
setActive(path)Set active repository
toggleExpanded(path)Toggle branch list visibility
toggleCollapsed(path)Toggle icon-only mode
setBranch(repoPath, branchName, data)Add/update branch
setActiveBranch(repoPath, branchName)Set active branch
addTerminalToBranch(repoPath, branchName, terminalId)Link terminal
removeTerminalFromBranch(repoPath, branchName, terminalId)Unlink terminal
setRunCommand(repoPath, branchName, command)Save run command
updateBranchStats(repoPath, branchName, additions, deletions)Update diff stats
removeBranch(repoPath, branchName)Remove branch
renameBranch(repoPath, oldName, newName)Rename branch
reorderTerminals(repoPath, branchName, fromIndex, toIndex)Reorder tabs

Queries

MethodDescription
get(path)Get repository by path
getActive()Get active repository
getPaths()Get all repository paths
getActiveTerminals()Get terminal IDs for active branch
isEmpty()Check if no repositories

settingsStore

File: src/stores/settings.ts

Application settings: font, shell, IDE, theme, confirmations.

State Fields

FieldTypeDefaultDescription
ideIdeType"cursor"IDE for “Open in…”
fontFontType"JetBrains Mono"Terminal font
agentstring"claude"Primary agent
defaultFontSizenumber12Default font size
shellstring""Shell override
themestring"dark"Terminal theme
confirmBeforeQuitbooleantrueQuit confirmation
confirmBeforeClosingTabbooleantrueTab close confirmation
maxTabNameLengthnumber20Max tab name length

Constants

  • IDE_NAMES — Display names for IDEs
  • IDE_ICONS — Emoji icons
  • IDE_ICON_PATHS — SVG icon paths
  • IDE_CATEGORIES — IDE grouping (editors, terminals, git, utilities)
  • FONT_FAMILIES — CSS font-family strings

githubStore

File: src/stores/github.ts

GitHub PR and CI data with background polling.

Actions

MethodDescription
updateRepoData(repoPath, prStatuses)Update PR data for all branches (detects state transitions for notifications)
startPolling()Start background polling (30s base, 2m when hidden, 5m backoff on rate limit)
stopPolling()Stop polling
pollRepo(path)Immediately poll a single repo (debounced 2s to coalesce rapid git events)
setRemoteStatus(repoPath, remote)Set remote tracking status directly (used by simulator)

Queries

MethodDescription
getCheckSummary(repoPath, branch)Get CI check summary
getPrStatus(repoPath, branch)Get PR status
getCheckDetails(repoPath, branch)Get CI check details
getBranchPrData(repoPath, branch)Get full BranchPrStatus
getRemoteStatus(repoPath)Get remote tracking status (ahead/behind)

promptLibraryStore

File: src/stores/promptLibrary.ts

Prompt template management with variable substitution.

State Fields

FieldTypeDescription
promptsSavedPrompt[]All prompts
drawerOpenbooleanDrawer visibility
searchQuerystringSearch filter
selectedCategoryPromptCategoryCategory filter
recentIdsstring[]Recently used prompt IDs

Actions

MethodDescription
hydrate()Load from Rust
openDrawer() / closeDrawer() / toggleDrawer()Drawer visibility
createPrompt(data)Create new prompt
updatePrompt(id, data)Update prompt
deletePrompt(id)Delete prompt
toggleFavorite(id)Toggle pinned status
markAsUsed(id)Add to recent list
processContent(prompt, variables)Substitute variables (via Rust)
extractVariables(content)Parse {{variable}} placeholders (via Rust)

statusBarTicker

File: src/stores/statusBarTicker.ts

Rotating message ticker for the status bar. Plugins and native features post messages; the highest-priority message is displayed, with rotation among equal-priority messages.

TickerMessage Type

interface TickerMessage {
  id: string;           // Unique message ID (scoped to plugin)
  pluginId: string;     // Plugin that posted the message
  text: string;         // Display text (~40 chars max)
  icon?: string;        // Optional inline SVG icon
  priority: number;     // Higher = more visible. >=80 gets warning styling
  ttlMs: number;        // Time-to-live in ms (0 = persistent until removed)
  createdAt: number;    // Timestamp when added
  onClick?: () => void; // Optional click handler
}

Actions

MethodDescription
addMessage(msg)Add or replace a message (by id + pluginId). Resets TTL on replace.
removeMessage(id, pluginId)Remove a specific message
removeAllForPlugin(pluginId)Remove all messages from a plugin
clear()Clear all messages and stop timers

Queries

MethodDescription
getCurrentMessage()Get the highest-priority non-expired message (rotates among equal-priority)
getAll()Get all active (non-expired) messages

Internals

  • Rotation: Messages at the same priority level rotate every 5 seconds.
  • Scavenging: Expired messages (past TTL) are cleaned up every 1 second.
  • StatusBar integration: The claude-usage ticker message (pluginId "claude-usage") is absorbed into the agent badge when the active terminal runs Claude, and suppressed from the separate ticker area.

notesStore

File: src/stores/notes.ts

Persistent notes/ideas with per-repo tagging and usage tracking.

Note Type

interface Note {
  id: string;
  text: string;
  createdAt: number;
  repoPath: string | null;
  repoDisplayName: string | null;
  usedAt: number | null;      // Timestamp when sent to terminal
}

Actions

MethodDescription
hydrate()Load notes from Rust backend
addNote(text, repoPath?, repoDisplayName?)Add a new note, optionally tagged with a repo
removeNote(id)Remove a note by ID
reassignNote(id, repoPath, repoDisplayName)Reassign a note to a different project
markUsed(id)Mark a note as used (sets usedAt timestamp)

Queries

MethodDescription
getFilteredNotes(activeRepo)Get notes for repo (global + repo-specific). null = all notes.
filteredCount(activeRepo)Count of notes visible for the given repo filter
count()Total note count

Other Stores

repoSettingsStore (repoSettings.ts)

Per-repository settings (base branch, scripts, worktree options).

uiStore (ui.ts)

Panel visibility (sidebar, diff, markdown, notes, file browser), sidebar width, dropdown state, loading state.

notificationsStore (notifications.ts)

Notification sound preferences and playback. Remote orchestration muting uses the terminal’s backend-preserved isRemote origin; completion lifecycle code sets a per-busy-cycle latch before playback so idle and exit cannot both chime.

dictationStore (dictation.ts)

Whisper dictation config, model management, recording state.

errorHandlingStore (errorHandling.ts)

Error retry configuration and active retry tracking.

rateLimitStore (ratelimit.ts)

Active rate limit tracking per session.

tasksStore (tasks.ts)

Agent task queue management.

promptStore (prompt.ts)

Active prompt overlay state and agent stats buffer.

diffTabsStore (diffTabs.ts) / mdTabsStore (mdTabs.ts)

Open diff and markdown tab management (identical API patterns).

updaterStore (updater.ts)

App update check, download, and install. Supports stable (Tauri built-in), beta, and nightly channels.

keybindingsStore (keybindings.ts)

Rebindable keyboard shortcuts (persisted, auto-populated from action registry).

commandPaletteStore (commandPalette.ts)

Command palette visibility and search state.

activityDashboardStore (activityDashboard.ts)

Activity center (bell dropdown) visibility.

prNotificationsStore (prNotifications.ts)

PR state transition notifications (merged, closed, blocked, CI failed, etc.).

userActivityStore (userActivity.ts)

Tracks last user activity timestamp. Used for merged PR grace period calculations.

worktreeManagerStore (worktreeManager.ts)

Worktree Manager overlay state and selection.

State Shape:

FieldTypeDescription
isOpenbooleanOverlay visibility
selectedIdsSet<string>Multi-select worktree IDs
repoFilterstring | nullFilter by repo path
textFilterstringFree-text search filter

Actions: open(), close() (resets all state), toggle(), toggleSelect(id), selectAll(ids), clearSelection(), setRepoFilter(path), setTextFilter(text).

agentConfigsStore (agentConfigs.ts)

Per-agent configuration (spawn args, environment overrides).

editorTabsStore (editorTabs.ts)

Open code editor tabs (CodeEditorTab).

activityStore (activityStore.ts)

Session activity history and timeline data.

branchSwitcher (branchSwitcher.ts)

Branch switch state and loading indicators.

contextMenuActionsStore (contextMenuActionsStore.ts)

Dynamic context menu action registration.

errorLog (errorLog.ts)

Error ring buffer and error panel state.

pluginStore (pluginStore.ts)

Loaded plugin instances and lifecycle state.

registryStore (registryStore.ts)

Remote plugin registry cache and install state.

repoDefaults (repoDefaults.ts)

Default settings applied to newly added repositories.

tabManager (tabManager.ts)

Tab ordering, branch-key mapping, and tab persistence logic.

Exclusive pane activation. TerminalArea renders terminals, diffs, markdown and editors as four independent For lists, each marking its pane active from its OWN store’s activeId — so “only one pane shows” is a cross-store invariant. createTabManager registers a deactivator per store (registerPaneDeactivator); terminals.ts registers its own since it doesn’t use the factory. Activating a tab — setActive(id) with a non-null id, or _addTab — calls activatePaneExclusively(storeName), which clears every other store’s activeId. setActive(null) and the _addTabBackground variants are local: a background open must not yank the user out of the pane they’re in.

Call sites therefore must NOT hand-roll setActive(null) on the other stores. This replaced the useTabActivationSync hook, which enforced the same rule from deferred on(activeId) effects: an effect keyed on a change cannot enforce an invariant that has to hold on every activation request, so re-activating an already-active tab (Edit on a file whose editor tab was already the active one) wrote the same value, fired nothing, and left the other pane rendered underneath. Pinned by src/__tests__/stores/paneExclusivity.test.ts.

appLogger (appLogger.ts)

Centralized logging — replaces direct console.* calls. Writes to ring buffer, forwards to console, and surfaces in ErrorLogPanel.

debugRegistry (debugRegistry.ts)

Dynamic snapshot registry for MCP invoke_js introspection. Stores self-register a snapshot function at init time, exposed on window.__TUIC__ as stores() (list names) and store(name) (get snapshot).

Registered stores: github, globalWorkspace, keybindings, notes, paneLayout, repositories, settings, tasks, ui.

Adding a new store — append 2 lines at the end of the store file:

import { registerDebugSnapshot } from "./debugRegistry";
registerDebugSnapshot("storeName", () => ({ /* fields to expose */ }));

Each store decides what to expose — no need to modify debugGlobals.ts.

Hooks Reference

Hooks contain business logic and side effects, bridging stores and Tauri commands.

useAppInit

File: src/hooks/useAppInit.ts

Initializes the application on startup.

export function initApp(deps: AppInitDeps): Promise<void>

What it does:

  1. Hydrates all stores from Rust backend (settings, repos, UI, prompts, etc.)
  2. Detects installed binaries (Claude, Aider)
  3. Applies platform CSS class (platform-darwin, platform-win32, platform-linux)
  4. Sets up close handler (quit confirmation dialog)
  5. Starts GitHub polling
  6. Loads custom fonts from settings
  7. Refreshes dictation config

usePty

File: src/hooks/usePty.ts

Low-level PTY session management. Wraps Tauri PTY commands.

Return API

MethodDescription
canSpawn()Check if under session limit (50)
createSession(config)Create PTY session, returns session ID
createSessionWithWorktree(ptyConfig, wtConfig)Create worktree + PTY
write(sessionId, data)Write to PTY
resize(sessionId, rows, cols)Resize PTY
pause(sessionId)Pause reader thread
resume(sessionId)Resume reader thread
close(sessionId, cleanupWorktree)Close PTY session
getStats()Get orchestrator stats
getMetrics()Get session metrics
listWorktrees()List managed worktrees
getWorktreesDir()Get worktrees directory
listActiveSessions()List all active sessions

useGitOperations

File: src/hooks/useGitOperations.ts

High-level git workflows: branch switching, worktree creation, repo management.

Dependencies

interface GitOperationsDeps {
  createTerminal: (repoPath, branch, opts?) => Promise<void>;
  closeTerminal: (id) => void;
  // ... other callbacks from App.tsx
}

Return API

MethodDescription
handleBranchSelect(repoPath, branch)Switch to branch (creates worktree if needed)
handleAddTerminalToBranch(repoPath, branch)Add terminal to existing branch
handleRemoveRepo(repoPath)Remove repository from sidebar
handleRemoveBranch(repoPath, branch)Remove worktree and branch
handleRenameBranch(oldName, newName)Rename git branch
handleAddRepo()Open folder dialog, add repository
handleAddWorktree(repoPath)Create worktree with generated name
handleNewTab()Create new tab for active branch
handleRunCommand(forceDialog, openDialog)Execute or configure run command
handleRepoSettings(repoPath, openPanel)Open repo-specific settings
refreshAllBranchStats()Refresh diff stats for all branches
activeWorktreePath()Get active worktree path
activeRunCommand()Get active run command

Signals

SignalTypeDescription
currentRepoPath()string | nullActive repository path
currentBranch()string | nullActive branch name
repoStatus()stringRepository git status
branchToRename(){repoPath, branchName} | nullBranch rename state

useTerminalLifecycle

File: src/hooks/useTerminalLifecycle.ts

Terminal tab management: create, close, zoom, copy/paste, reopen.

Return API

MethodDescription
createNewTerminal()Create terminal for active branch
closeTerminal(id, skipConfirm?)Close terminal (with confirmation)
closeOtherTabs(keepId)Close all except one
closeTabsToRight(afterId)Close tabs after given ID
reopenClosedTab()Reopen last closed tab
navigateTab(direction)Switch to prev/next tab
clearTerminal()Clear active terminal
copyFromTerminal()Copy selection from terminal
pasteToTerminal()Paste to active terminal
zoomIn() / zoomOut() / zoomReset()Font size controls
activeFontSize()Get active terminal’s font size
handleTerminalFocus(id)Handle terminal focus event
handleTerminalSelect(id)Handle tab click
terminalIds()Memo: terminal IDs for active branch

useKeyboardShortcuts

File: src/hooks/useKeyboardShortcuts.ts

Registers global keyboard event listener with platform-aware modifiers.

interface ShortcutHandlers {
  newTab: () => void;
  closeTab: () => void;
  toggleSidebar: () => void;
  // ... 30+ handlers
}

Returns cleanup function to remove listener on unmount.


useNativeKeyCombo

File: src/hooks/useNativeKeyCombo.ts

Feeds natively-captured keys into a shortcut recorder. macOS never delivers F13F20 to WKWebView, so a keydown listener sees nothing at all and those keys look unbindable even though keyEventToCombo, validateGlobalHotkeyCombo and the global-hotkey crate all accept them. src-tauri/src/native_keys.rs catches them with an NSEvent monitor and re-emits native-key-down; this hook turns that back into the same combo string the DOM path produces.

useNativeKeyCombo(active: () => boolean, onCombo: (combo: string) => void): void
export function nativeKeyToCombo(payload: NativeKeyDown): string
  • The listener is attached only while active() is true, so these keys keep their normal behaviour everywhere else in the app.
  • nativeKeyToCombo mirrors keyEventToCombo’s modifier order (Cmd, Ctrl, Alt, Shift) — the two are compared against each other for conflicts and persisted to the same store, so a mismatch would make one physical chord compare as two different combos.
  • No-op outside Tauri (isTauri()); the event does not exist in browser mode.
  • Consumers: KeyComboCapture (Global Hotkey) and KeyboardShortcutsTab (per-action recording), both routing the result through the same conflict check as the DOM path.

useGitHub

File: src/hooks/useGitHub.ts

GitHub status for a single repository.

Return API

Signal/MethodDescription
status()Reactive GitHub status
loading()Loading state
error()Error message
refresh()Force refresh
startPolling() / stopPolling()Polling control

useRepository

File: src/hooks/useRepository.ts

Git repository operations (lower level than useGitOperations).

Return API

MethodDescription
getInfo(path)Get RepoInfo (name, branch, status)
getDiff(path)Get full git diff
getDiffStats(path)Get additions/deletions counts
getChangedFiles(path)List changed files with stats
getFileDiff(path, file)Get single file diff
openInApp(path, app)Open in IDE
renameBranch(repoPath, old, new)Rename branch
createWorktree(base, branch)Create worktree
removeWorktree(repo, branch)Remove worktree
getWorktreePaths(repo)Get worktree paths
listMarkdownFiles(path)List .md files
readFile(path, file)Read file contents
generateWorktreeName(existing)Generate unique worktree name

useQuickSwitcher

File: src/hooks/useQuickSwitcher.ts

Held-key branch quick-switcher (Cmd+Ctrl on macOS, Ctrl+Alt on Win/Linux).

switchToBranchByIndex(index: number): void

useSplitPanes

File: src/hooks/useSplitPanes.ts

Split terminal pane management.

handleSplit(direction: "vertical" | "horizontal"): void

useConfirmDialog

File: src/hooks/useConfirmDialog.ts

Confirmation and info dialogs using Tauri dialog plugin.

MethodDescription
confirm(options)Show Yes/No confirmation
info(title, message)Show info dialog
error(title, message)Show error dialog
confirmRemoveWorktree(branch)Confirm worktree removal
confirmCloseTerminal(name)Confirm terminal close
confirmRemoveRepo(name)Confirm repo removal

useDictation

File: src/hooks/useDictation.ts

Push-to-talk dictation integration.

MethodDescription
handleDictationStart()Start recording
handleDictationStop()Stop and transcribe, inject text

useAgentDetection

File: src/hooks/useAgentDetection.ts

Detect installed AI agents and IDEs.

MethodDescription
detectAll()Detect all known agents
detectAgent(type, binary)Detect specific agent
isAvailable(type)Check if agent is available
getAvailable()Get all available agents
getDetection(type)Get detection result (path, version)

useFileDrop

File: src/hooks/useFileDrop.ts

Handles external file drag & drop using Tauri’s native onDragDropEvent API (not the HTML5 File API, which provides no paths in Tauri webviews).

Signals

SignalTypeDescription
isDragging()booleantrue while files are being dragged over the window

Behaviour

  1. Active PTY session — dropped file paths are written to the terminal as space-separated quoted strings (enables Claude Code image drops)
  2. No active PTY.md/.mdx files open in the Markdown viewer; all other files open in the Code Editor

A global dragover/drop preventDefault on document prevents the Tauri webview from treating drops as browser navigation (which would replace the UI with a white screen).


useKeyboardRedirect

File: src/hooks/useKeyboardRedirect.ts

Redirects keyboard events from sidebar to active terminal (bypasses focus trap). Setup-only hook, no return value.


useAutoFetch

File: src/hooks/useAutoFetch.ts

Periodic git fetch for all repositories at a configurable interval.

MethodDescription
startAutoFetch()Start periodic fetching
stopAutoFetch()Stop periodic fetching

useAutoDeleteBranch

File: src/hooks/useAutoDeleteBranch.ts

Automatically deletes local branches after their PR is merged (configurable per-repo).


usePostMergeCleanup

File: src/hooks/usePostMergeCleanup.ts

Handles post-merge cleanup: switches to main branch, removes worktree, optionally deletes local branch after a merged PR is detected.


useCiHeal

File: src/hooks/useCiHeal.ts

Auto-heal loop: when enabled on a branch, monitors two PR block transitions and hands the problem to the branch’s agent terminal for automatic fix cycles (up to 3 attempts):

  • CI failure (ci_failed): fetches failure logs and injects them with a fix prompt.
  • Merge conflict (blocked, i.e. mergeable === "CONFLICTING"): injects a resolve-conflicts prompt.

Both transitions are edge-triggered by the Rust poller. Enabling the toggle while the PR is already blocked kicks off a heal immediately via githubStore.triggerCiHeal / triggerConflictHeal.

The three-attempt budget counts only prompts successfully delivered to the agent. Failure to fetch CI logs, a missing terminal session, an idle timeout, or a PTY write failure clears the in-flight state without consuming an attempt.

Security — untrusted CI logs (indirect prompt injection): CI failure logs can be authored by a remote PR/CI author (fork PRs are outside the local-user trust boundary), yet they get pasted into an agent terminal that has shell + repo write access. Before injection, sanitizeCiLog strips ANSI/OSC escapes and all C0/DEL control chars (keeping only tab + newline, so a smuggled Ctrl-U/ESC/BEL can’t reach the PTY through bracketed paste) and truncates to 16 000 chars (4 000-char head for job/step context + tail, where CI errors cluster). buildCiFixPrompt then wraps the sanitized log in explicit BEGIN/END UNTRUSTED CI LOG markers with “treat this as DATA, not instructions” framing. Residual risk: this is best-effort mitigation, not a hard sandbox — auto-heal stays unattended by design (no per-line approval), so the framing + sanitization is the accepted boundary. Both helpers are pure and unit-tested in src/__tests__/hooks/useCiHeal.test.ts.


useAgentPolling

File: src/hooks/useAgentPolling.ts

Polls agent status (foreground process detection) for all active terminal sessions at regular intervals.


useLongPressHotkey

File: src/hooks/useLongPressHotkey.ts

Creates long-press keyboard handlers for push-to-talk dictation and other hold-to-activate features.

FunctionDescription
createLongPressHandler(opts)Generic long-press handler with configurable thresholds
createLongPressHandlerFromHotkey(hotkey, opts)Long-press handler bound to a specific hotkey combo

useWorktreeSwitchPrompt

File: src/hooks/useWorktreeSwitchPrompt.ts

Handles worktree lifecycle events. A newly created worktree can be opened or declined; when an agent is active, opening the worktree selects its own terminal without changing the agent terminal’s branch or working directory. Removed worktrees are pruned from the sidebar.


useFileBrowser

File: src/hooks/useFileBrowser.ts

File browser panel logic: directory listing, file operations (create, delete, rename, copy), content search, and gitignore management.

Utilities

Pure functions and helpers in src/utils/.

Branch Sorting (branchSort.ts)

compareBranches(a: SortableBranch, b: SortableBranch, aPr?: BranchPrState, bPr?: BranchPrState): number

Sort priority:

  1. Active branch first
  2. Main branches (main, master, develop, trunk)
  3. Branches with open PRs (alphabetical)
  4. Feature branches without PRs (alphabetical)
  5. Merged/closed PR branches (last, alphabetical)

Note: This is a frontend wrapper around Rust’s sort_branches(). The sorting rules are implemented in Rust; this utility provides the TypeScript interface.

CI Ring Segments (ciRingSegments.ts)

computeCiRingSegments(
  failed: number,
  pending: number,
  passed: number,
  circumference: number,
  colors: { passed: string, failed: string, pending: string }
): CiRingSegment[]

Calculates SVG arc segments for the circular CI status indicator. Returns array of segments with offset, length, and color for each status category.

PR State Mapping (prStateMapping.ts)

classifyMergeState(mergeable?: string, mergeStateStatus?: string): StateLabel | null
classifyReviewState(reviewDecision?: string): StateLabel | null

Maps GitHub merge state and review decision to display labels with CSS classes. Frontend mirror of Rust’s classify_merge_state() / classify_review_state().

Terminal Utilities

terminalFilter.ts

filterValidTerminals(branchTerminals: string[], existingTerminalIds: string[]): string[]

Filters branch’s terminal list to only include IDs that exist in the terminals store. Handles cleanup of stale references.

terminalOrphans.ts

findOrphanTerminals(terminalIds: string[], branchTerminalMap: Record<string, string[]>): string[]

Finds terminals that exist in the store but aren’t associated with any branch. Used for cleanup.

Path Utilities (pathUtils.ts)

Cross-platform path helpers that handle both / (Unix) and \ (Windows) separators. All path comparison and construction in the frontend MUST use these instead of raw string operations.

FunctionDescription
isAbsolutePath(p)True for Unix /..., Windows C:\..., and UNC \\... paths
normalizeSep(p)Convert all backslashes to forward slashes
pathStartsWith(path, prefix)Directory-boundary-aware prefix check, separator-agnostic
pathStripPrefix(path, prefix)Strip prefix at directory boundary, returns relative portion
joinPath(base, ...parts)Join segments, stripping redundant separators
pathParts(p)Split by either separator
pathBasename(p)Last segment (filename or directory name)
pathDirname(p)Directory portion, preserving original separator
replaceBasename(p, newName)Replace last segment

Forbidden patterns (use the helpers instead):

  • startsWith("/")isAbsolutePath()
  • path + "/"joinPath()
  • .split("/").pop()pathBasename()
  • path.startsWith(prefix + "/")pathStartsWith()

File Preview (filePreview.ts)

classifyFile(filePath: string): FileOpenTarget

Routes a file path to one of three open targets based on extension:

  • "markdown".md, .mdx
  • "preview" — documents (PDF, HTML), images (PNG, JPG, GIF, WebP, SVG, AVIF, ICO, BMP), video (MP4, WebM, MOV, OGG), audio (MP3, WAV, FLAC, AAC, M4A), text/data (TXT, JSON, CSV, LOG, XML, YAML, TOML, INI, CFG, CONF)
  • "editor" — everything else

Used by App.tsx (clickable paths, Command Palette), useFileDrop.ts (drag & drop), and HtmlPreviewTab (preview routing).

Shell Utilities (shell.ts)

FunctionDescription
escapeShellArg(arg)Escape string for safe shell argument
isValidBranchName(name)Validate git branch name format
isValidPath(path)Validate file system path

Hotkey Utilities (hotkey.ts)

FunctionDescription
hotkeyToTauriShortcut(hotkey)Convert display format to Tauri format
tauriShortcutToHotkey(shortcut)Convert Tauri format to display format

Time Utilities (time.ts)

relativeTime(isoString: string): string

Formats ISO timestamp as relative time (e.g., “5 minutes ago”, “2 hours ago”, “yesterday”).

Main Index (utils/index.ts)

General utilities including:

  • Platform detection helpers
  • Path manipulation
  • Theme conversion utilities
  • Hotkey conversion helpers (display ↔ Tauri format)

Type Definitions (types/index.ts)

All shared TypeScript types are centralized in a single file. Key type groups:

Terminal Types

TerminalPane, TerminalRef, PtyOutput, PtyExit, PtyConfig, IPty, SessionState, SavedTerminal

Repository Types

RepoInfo, Repository

GitHub Types

GitHubStatus, PrStatus, CiStatus, CheckSummary, CheckDetail, BranchPrStatus, PrLabel

PR State Types

MergeableState, MergeStateStatus, ReviewDecision

UI Types

SplitNode, SplitDirection, AgentStats, DetectedPrompt, OrchestratorStats

Callback Types

PtyDataHandler, PtyExitHandler

Transport Layer

The transport layer provides a unified IPC abstraction so the same frontend code works in both Tauri (native desktop) and browser (HTTP) modes.

Files

FilePurpose
src/invoke.tsSmart invoke() wrapper — zero overhead in Tauri
src/transport.tsHTTP transport implementation and command-to-endpoint mapping

invoke.ts

export function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>
export function listen<T>(event: string, handler: (event: Event<T>) => void): Promise<Unsubscribe>

Resolution: At module import time, detects if running in Tauri webview:

  • Tauri mode: Delegates directly to @tauri-apps/api/core.invoke() (zero overhead)
  • Browser mode: Maps command to HTTP endpoint via transport.ts

In-flight dedup (Tauri mode)

Concurrent identical calls for read-only commands in DEDUP_COMMANDS share a single IPC round-trip — the second caller gets the same Promise as the first, cleared on settle. Prevents the repo-changed fan-out storm where ~20 mounted components each spawned parallel git processes for the same repo. Mutations (stage/commit/push) are never deduped. Browser mode has the equivalent via isIdempotentRpc in transport.ts.

export function isTauri(): boolean
// Checks window.__TAURI__ existence

transport.ts

Command Mapping

Maps every Tauri command name to an HTTP method + path via a declarative COMMAND_TABLE. Each entry is a CommandTableEntry — an object { map } whose map(args, p) returns { method, path, body?, transform? }.

// Table-driven: each command maps to an HTTP request
const COMMAND_TABLE: Record<string, CommandTableEntry> = {
  create_pty: { map: (args) => ({ method: "POST", path: "/sessions", body: args.config }) },
  get_repo_info: { map: (args) => ({ method: "GET", path: `/repo/info?path=${enc(args.path)}` }) },
  write_pty: { map: (args) => ({ method: "POST", path: `/sessions/${args.sessionId}/write`, body: { data: args.data } }) },
  // ... ~80 commands
};

This replaces the previous 370-line switch statement with a flat lookup table for easier maintenance and review.

PTY Subscription

export function subscribePty(
  sessionId: string,
  onData: PtyDataHandler,
  onExit: PtyExitHandler
): Unsubscribe
  • Tauri mode: Uses listen("pty-output") and listen("pty-exit") Tauri events
  • Browser mode: Opens WebSocket to /sessions/{id}/stream

URL Building

export function buildHttpUrl(path: string): string
// Reads MCP port from config, builds http://localhost:{port}{path}

Design

The transport abstraction enables:

  1. Development: Run frontend with pnpm dev against the Rust HTTP server
  2. Browser mode: Access TUICommander from a browser on another device
  3. Testing: Frontend tests can mock at the invoke level
  4. MCP integration: External tools use the same HTTP API

The abstraction is resolved once at module load — no per-call overhead in production Tauri mode.

Terminal Features & Keyboard Shortcuts

Consolidated reference for all terminal behaviors, keyboard shortcuts, and configurable features.

Keyboard Shortcuts

Terminal Management

ShortcutActionNotes
Cmd+TNew terminal tab
Cmd+WClose tab/paneCloses active split pane, or tab if no split
Cmd+Shift+TReopen closed tabRestores last 10 closed tabs
Cmd+1–9Switch to tab NFirst 9 tabs only
Ctrl+TabNext tabNSEvent monitor on macOS
Ctrl+Shift+TabPrevious tabNSEvent monitor on macOS

Terminal Content

ShortcutActionNotes
Cmd+LClear terminalSends Ctrl+L to shell (clear screen)
Cmd+KClear scrollbackClears entire scrollback buffer (iTerm2 convention)
Cmd+CCopy selection
Cmd+VPaste
Cmd+FFind in terminalSearch overlay with match highlighting
Cmd+GFind next match
Shift+Cmd+GFind previous match

Scrolling

ShortcutAction
Cmd+HomeScroll to top of scrollback
Cmd+EndScroll to bottom
Shift+PageUpScroll one page up
Shift+PageDownScroll one page down
Wheel / two-fingerScroll the scrollback (smooth)
Shift+WheelForce scrollback scroll, never sent to the app

Wheel vs. mouse-reporting apps. When an app enables mouse tracking, the wheel is forwarded to it as SGR mouse codes only in the alternate screen (vim, lazygit, htop — they own the viewport and have no scrollback). In the main screen the wheel always scrolls TUIC’s scrollback, even if the app enabled mouse mode — that history belongs to the terminal, not the app, so only the terminal can scroll it. This is why an inline app that turns on mouse reporting without switching to the alt screen (e.g. grok --no-alt-screen) still scrolls normally. Hold Shift to force scrollback scrolling regardless of mouse mode (consistent with Shift bypassing mouse reporting for clicks/selection).

Split Panes

ShortcutActionNotes
Cmd+\Split verticallySide-by-side, max 4 panes
Cmd+Alt+\Split horizontallyStacked
Cmd+Shift+EnterMaximize/restore paneToggle zoom on active pane
Alt+Arrow Left/RightNavigate vertical panes
Alt+Arrow Up/DownNavigate horizontal panes

Panels

ShortcutAction
Cmd+[Toggle sidebar
Cmd+,Settings
Cmd+EFile browser
Cmd+Shift+MMarkdown panel
Cmd+Alt+NNotes/ideas panel
Cmd+OOpen file picker
Cmd+NNew file (picker for name + location)
Cmd+JTask queue
Cmd+BQuick branch switch
Cmd+GBranches tab
Cmd+Shift+DGit operations panel
Cmd+Shift+EError log
Cmd+Shift+AActivity dashboard
Cmd+Shift+WWorktree manager
Cmd+Shift+MMCP servers popup
Cmd+Shift+GDiff scroll view
Cmd+?Help panel
ShortcutAction
Cmd+PCommand palette
Cmd+Shift+KPrompt library
Cmd+RRun saved command
Cmd+Shift+REdit saved command
Cmd+Shift+FSearch file contents
Cmd+Ctrl+1–9Quick branch switch (hold Cmd+Ctrl, press number)

Zoom

ShortcutAction
Cmd+= / Cmd++Zoom in
Cmd+-Zoom out
Cmd+0Reset zoom

Terminal Behaviors

Copy on Select

Auto-copies selected text to clipboard when text is selected in terminal. Configurable in settings (copy_on_select, default: on).

URL Click

Cmd+Click on URLs in terminal output opens them in the system browser. URL detection handled by the native terminal link parser.

File Path Click

Clickable file paths in terminal output (absolute and relative paths with known extensions). Opens in IDE or markdown viewer.

Tab Features

  • Middle-click closes tab
  • Right-click context menu: Close, Close Other, Close Right, Rename, Detach to Window, Move to Worktree, Pin/Unpin, Copy Path
  • Drag-and-drop to reorder tabs
  • Double-click tab title to rename
  • Unseen activity badge when tab has new output

Split Panes

  • Max 4 panes per branch
  • Drag divider to resize
  • Flexible ratios preserved across layout changes
  • Modes: “separate” (independent tab bars) or “unified” (shared tab bar)

Configurable Settings

SettingDefaultDescription
copy_on_selecttrueAuto-copy terminal selection to clipboard
confirm_before_quittrueShow dialog when quitting with active terminals
confirm_before_closing_tabtrueShow dialog when closing tab with running process
split_tab_mode"separate"Tab bar mode for split panes
tab_ordering_mode"grouped-by-type"Tab ordering: “grouped-by-type”, “terminals-first”, “free”
intent_tab_titletrueShow agent intent as tab title
suggest_followupstrueShow suggested follow-up actions from agents
bell_style"visual"Terminal bell: “none”, “visual”, “sound”, “both”
prevent_sleep_when_busyfalsePrevent system sleep while terminal is busy

Custom Glyph Rendering

CanvasTerminal renders certain Unicode character ranges as geometric primitives instead of delegating to font glyphs. This matches the approach used by Alacritty, kitty, WezTerm, and Ghostty — ensuring pixel-perfect alignment regardless of which font is installed.

Why Custom Rendering

Font-based rendering of structural terminal characters has three problems:

  1. Cell mismatch — glyph metrics from the fallback font may not match the primary font’s cell dimensions, causing gaps or overlap
  2. Height/width fill — powerline arrows and block elements must fill the entire cell edge-to-edge; fillText() renders at the font’s natural metrics
  3. Font dependency — users would need specific “Nerd Font” or “Powerline” font variants installed

Custom rendering eliminates all three: shapes are drawn to exact cell boundaries using Canvas 2D primitives.

Rendered Ranges

RangeCountDescriptionDrawing Method
U+2500–U+257F128Box drawing (lines, corners, T-junctions, crosses)Line segments with light/heavy weights
U+2580–U+259F32Block elements (halves, shades, quadrants)fillRect with opacity for shades
U+E0B0–U+E0BF16Powerline arrows (triangles, semicircles, diagonals)beginPath/fill with fg/bg color handling
U+2800–U+28FF256Braille patterns (2×4 dot grid)Circles via arc()
U+1FB00–U+1FB3B60Sextant blocks (2×3 grid)fillRect per active cell
U+1FB3C–U+1FB6F52Smooth mosaic wedges/trianglesFilled polygons
U+1FB70–U+1FB8B281/8th block elementsfillRect at precise eighths

Characters outside these ranges fall through to fillText() using the configured font.

Specifications

Box Drawing (U+2500–U+257F)

Line segments from cell center to edges. Two weights: light (cellWidth/8) and heavy (cellWidth/4). Includes:

  • Single/double lines and corners
  • T-junctions and crosses (all light/heavy combinations)
  • Rounded corners (╭╮╰╯) — rendered as straight segments (same as light corners)
  • Diagonals (╱╲╳)
  • Dashed lines — see below

Dashed Lines

All dashes use 2:1 dash-to-gap ratio (matching WezTerm spec):

CodepointsTypeSegmentsFormula
U+2504/05, U+2506/07Triple dash (H/V)9 units: 3×(2+1)dash=2/9, gap=1/9
U+2508/09, U+250A/0BQuadruple dash (H/V)12 units: 4×(2+1)dash=2/12, gap=1/12
U+254C/4D, U+254E/4FDouble dash (H/V)6 units: 2×(2+1)dash=2/6, gap=1/6

Drawn as filled rectangles (not setLineDash) for pixel precision.

Block Elements (U+2580–U+259F)

  • Half blocks: fillRect covering half the cell
  • Shades (░▒▓): full-cell fillRect with globalAlpha at 0.25, 0.5, 0.75
  • Quadrants (▖▗▘…▟): fillRect for each active quadrant (cell/2 × cell/2)

Powerline (U+E0B0–U+E0BF)

These handle their own background: first fill the cell with bg color, then draw the shape in fg color. This is necessary because powerline arrows create a visual transition between two differently-colored segments.

CodepointShape
U+E0B0Right-pointing filled triangle
U+E0B1Right-pointing line triangle
U+E0B2Left-pointing filled triangle
U+E0B3Left-pointing line triangle
U+E0B4/B5Right semicircle (filled/line)
U+E0B6/B7Left semicircle (filled/line)
U+E0B8–U+E0BFDiagonal triangles (8 variants)

Braille (U+2800–U+28FF)

2 columns × 4 rows = 8 dots. The low byte of the codepoint IS the dot bitmask (ISO 11548):

bit 0 → col 0, row 0  (dot 1)    bit 3 → col 1, row 0  (dot 4)
bit 1 → col 0, row 1  (dot 2)    bit 4 → col 1, row 1  (dot 5)
bit 2 → col 0, row 2  (dot 3)    bit 5 → col 1, row 2  (dot 6)
bit 6 → col 0, row 3  (dot 7)    bit 7 → col 1, row 3  (dot 8)

Each dot is a circle with radius cellWidth/8, centered within its (cellWidth/2 × cellHeight/4) sub-area.

Sextant Blocks (U+1FB00–U+1FB3B)

2 columns × 3 rows = 6 segments. Each segment is cellWidth/2 × cellHeight/3.

Bit-to-position mapping:

bit 0 = top-left      bit 1 = top-right
bit 2 = middle-left   bit 3 = middle-right
bit 4 = bottom-left   bit 5 = bottom-right

The 60 codepoints cover all 6-bit combinations except: empty (0), left-half (0b010101 = U+258C), right-half (0b101010 = U+2590), and full (0b111111 = U+2588). A lookup table maps each codepoint offset to its bitmask.

Smooth Mosaic Wedges (U+1FB3C–U+1FB6F)

52 filled polygons using normalized coordinates (0–1) mapped to cell dimensions. Grid points: X ∈ {0, 1/2, 1}, Y ∈ {0, 1/3, 2/3, 1}. All shapes are straight-edged (no curves).

Includes:

  • Lower-left/right diagonal families
  • Upper-left/right diagonal families
  • Three-quarter blocks (3 of 4 center-corner triangles filled)
  • One-quarter blocks (single center-corner triangle)

1/8th Block Elements (U+1FB70–U+1FB8B)

RangeDescription
U+1FB70–U+1FB75Vertical 1/8 strips at column positions 2–7
U+1FB76–U+1FB7BHorizontal 1/8 strips at row positions 2–7
U+1FB7C–U+1FB81Combined corner/edge 1/8 blocks + stripe patterns
U+1FB82–U+1FB86Upper fractional blocks: 2/8, 3/8, 5/8, 6/8, 7/8
U+1FB87–U+1FB8BRight fractional blocks: 2/8, 3/8, 5/8, 6/8, 7/8

Positions 1/8 and 8/8 are not included because they already exist as U+258F (left 1/8), U+2595 (right 1/8), U+2594 (upper 1/8), U+2581 (lower 1/8), U+258C (left half), U+2590 (right half), U+2580 (upper half).

Implementation

All custom rendering happens in CanvasTerminal.tsx’s paintRow() function (Pass 2: text). Before the generic fillText() fallback, codepoints are checked against each range in order:

  1. Box drawing → drawBoxDrawingChar()
  2. Block elements → drawBlockChar()
  3. Powerline → drawPowerlineChar() (handles own fg/bg)
  4. Braille → drawBrailleChar()
  5. Legacy computing → drawLegacyComputingChar()
  6. Everything else → fillText() with configured font

References

TUICommander — Visual Style Guide

Reference for all UI/CSS/layout work. Every visual change MUST follow this guide.

Design Philosophy

VS Code Dark theme adapted for a terminal-first, developer-focused interface. The UI is a frame for terminal content — chrome recedes, content dominates. No bright whites. Muted UI elements, vivid status colors. Everything monospace except UI labels.

Application Layout

┌─────────────────────────────────────────────────────────────────────┐
│ #toolbar (38px macOS / 32px Win+Linux, --bg-primary, drag region)  │
│ ┌──────────────┬────────────────────────────────────┬────────────┐ │
│ │ toolbar-left │ toolbar-center (tab bar)            │toolbar-right│ │
│ │ (sidebar w)  │ [Tab1] [Tab2] [Tab3] [+]           │ [IDE btns] │ │
│ └──────────────┴────────────────────────────────────┴────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ #app-body (flex: 1, flex-direction: row)                           │
│ ┌──────────┬─────────────────────────────────┬───────────────────┐ │
│ │ #sidebar │ #main                            │ Side panels      │ │
│ │ 300px    │ (flex: 1)                        │ (400px, optional)│ │
│ │ --bg-    │                                  │                  │ │
│ │ secondary│ ┌──────────────────────────────┐ │ ┌──────────────┐ │ │
│ │          │ │ #terminal-container          │ │ │ Diff or      │ │ │
│ │ REPOS    │ │ (flex: 1, --bg-primary)      │ │ │ Markdown or  │ │ │
│ │  section │ │                              │ │ │ Notes/Ideas  │ │ │
│ │  title   │ │  terminal fills this          │ │ │ panel        │ │ │
│ │  repo    │ │  entire area                 │ │ │              │ │ │
│ │   header │ │                              │ │ │ panel-header │ │ │
│ │   branch │ │                              │ │ │ panel-content│ │ │
│ │   branch │ │                              │ │ │              │ │ │
│ │          │ │                              │ │ └──────────────┘ │ │
│ │ FOOTER   │ │                              │ │                  │ │
│ │ [+ Add]  │ └──────────────────────────────┘ │                  │ │
│ │ [icons]  │                                  │                  │ │
│ └──────────┴─────────────────────────────────┴───────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ #status-bar (28px, --bg-secondary, border-top)                     │
│ [zoom][sessions]  [branch ↑2][PR #42][CI ✓]  [toggles][💡][⚙][?] │
└─────────────────────────────────────────────────────────────────────┘

Key structural rules:

  • #app is flex-direction: column, fills 100vh × 100vw.
  • #app-body is flex-direction: row, flex: 1, min-height: 0.
  • Sidebar is fixed-width (resizable 200–500px), main area fills remaining space.
  • Side panels (Diff, Markdown, Notes) appear right of #main, width 400px, max 50vw.
  • All sections have overflow: hidden — scrolling is on inner content areas only.
  • Status bar is always at the bottom, never scrolls.

Color Palette

Values shown are the vscode-dark theme defaults (defined in :root of global.css). The app supports 11 themes — all core colors are CSS custom properties overridden at runtime by applyAppTheme() in themes.ts. When writing CSS, always use variables, never hardcode core palette values.

CSS Variables (:root in global.css)

VariableDefault (vscode-dark)Usage
--bg-primary#1e1e1eMain canvas — terminals, panel bodies
--bg-secondary#252526Sidebar, tab bar, status bar
--bg-tertiary#2d2d30Inputs, settings rows, button defaults
--bg-highlight#37373dHover states, active branch bg
--fg-primary#ccccccPrimary text (max brightness for text)
--fg-secondary#a0a0a0Labels, secondary text
--fg-muted#9aa1a9Section titles, tertiary text
--accent#59a8ddPrimary actions, active indicators, links (theme-dependent)
--accent-hover#7abde5Hover on accent elements (theme-dependent)
--activity#59a8ddBusy/activity pulse indicators (fixed in global.css, not overridden by themes)
--success#4ec9b0Positive states, open PRs (teal)
--warning#dcdcaaCaution, pending, main branch icon (yellow)
--attention#e8984cActionable alerts, confirmation prompts (orange)
--error#f48771Errors, failures, closed PRs (coral)
--merged#a371f7PR merged badge (purple)
--unseen#c084fcTerminal completed while user wasn’t viewing (purple, clears on view)
--border#3e3e42All borders and dividers
--text-on-accent#000000Black text on colored badge backgrounds
--text-on-error#000000Black text on error backgrounds
--text-on-success#000000Black text on success backgrounds

Extended Palette (hardcoded, contextual only)

ColorContext
#d29922Changes requested / review required (orange)
#e3b341CI pending (golden)
#ffd700Rate limit, question icon (gold)
rgba(122, 162, 247, *)Branch ahead/behind tint, pulse glow
rgba(158, 206, 106, *)Diff additions bg, CI success tint
rgba(247, 118, 142, *)Diff deletions bg, CI failure tint

Background Stacking Order (darkest → lightest)

#1e1e1e  --bg-primary    Terminal canvas, main area
#252526  --bg-secondary   Sidebar, tab bar, status bar, modals
#2d2d30  --bg-tertiary    Buttons, inputs, settings rows, panel headers
#37373d  --bg-highlight   Hover, active branch, selected items

Every surface uses exactly one of these four levels. Elevation = lighter.

Typography

VariableStackUsage
--font-monoJetBrains Mono, Fira Code, Hack, Cascadia Code, Source Code Pro, DejaVu Sans Mono, monospaceTerminals, branch names, stats badges, PR badges, code
--font-ui-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Noto Sans, Liberation Sans, sans-serifUI labels, buttons, headings, descriptions, settings

Size Scale

VariableSizeWhere
--font-3xs8pxMicro labels, pixel-level detail
--font-2xs10pxSmallest visible labels
--font-xs11pxBadge text, hotkey hints, metadata
--font-sm12pxSection titles (REPOS), secondary labels
--font-md13pxBranch names, tab names, settings labels — default for UI
--font-base14pxBody text, document default
--font-lg15pxPanel headings, chevrons
--font-xl17pxDialog titles
--font-2xl20pxLarge headings
--font-3xl24pxHero text, splash screens

Font weight: 400 normal, 500 medium (branch names), 600 semibold (repo names, badges), 700 bold (headings only).

Spacing

Fixed Dimensions

VariableValue
--sidebar-width300px (resizable: min 200px, max 500px)
--toolbar-height38px macOS / 32px Win+Linux
--tab-bar-height32px
--status-height28px

Spacing Scale

SizeUsage
1–2pxBranch item vertical margin, micro separation
4pxSidebar content top padding, compact flex gaps, micro padding
6pxIcon-to-text gaps, sidebar footer gaps, repo header padding
8pxButton padding, form gaps, sidebar footer padding, standard gap
12pxBranch item horizontal padding, panel header padding, medium padding
16pxSidebar section margin, branch list left indent, modal padding
20pxDialog content padding, sidebar empty state padding

Use gap on flex containers, not margins between children.

Border Radius

VariableValueUsage
--radius-xs2pxMinimal — focus rings
--radius-sm3pxSmall interactive elements
--radius-md4pxStandard — buttons, badges, inputs, branch items
--radius-lg6pxLarger controls — dropdowns, add-repo button, form inputs
--radius-xl8pxModals, panels, dialogs
--radius-pill12pxPR badges, status pills
--radius-full50%Circles — toggle thumbs, repo initials avatar

Shadows

VariableValueUsage
--shadow-popup0 8px 32px rgba(0,0,0,0.4)Modals, dialogs
--shadow-dropdown0 4px 16px rgba(0,0,0,0.3)Menus, popovers, context menus
--shadow-bottom-anchor0 -4px 20px rgba(0,0,0,0.4)Bottom-anchored panels

Three levels only. Never invent new shadow values.

Transitions & Animation

Durations

DurationUsage
0.1sHover backgrounds, active states — instant feedback
0.15sStandard — opacity, color, transform, border changes
0.2sLayout — sidebar collapse, toggle switches, chevron rotation

Keyframe Animations

pulse-opacity: Opacity 0.4 → 1.0 → 0.4, infinite. Duration 1.5s or 2s. Defined in each CSS Module file that uses it (not in global.css — CSS Modules scope animation names). Used for: active branch icon, CI pending badge, rate limit indicator.

pulse-question: Box-shadow 0 → 0 0 12px 4px rgba(122,162,247,0.4) → 0. Variants exist with red (error) and orange (confirm) colors. Used for: terminal tab glow when agent awaits input.

pendulum: Translates text from 0 to -overflow-px and back. Duration computed dynamically from overflow width (~50px/s, minimum 4s cycle). Uses CSS custom properties --overflow-px and --ticker-duration. Used for: status bar notification text that overflows its container.

Tab status dot color scheme (single indicator left of tab name):

  • Grey (opacity 0.3): idle — no session or command never ran
  • Blue (--activity, pulse infinite): busy — producing output now
  • Green (--success): done — command completed
  • Purple (--unseen, static): completed while user wasn’t viewing (clears on view)
  • Orange (--attention, pulse infinite): agent needs user input (question)
  • Red (--error, pulse infinite): API error or agent stuck

Tab type color scheme (gradient background + colored border-bottom):

  • Red (#ef4444): diff tabs
  • Blue (--accent / #7aa2f7): editor tabs
  • Teal (#2dd4bf): markdown tabs
  • Purple (#a78bfa): panel tabs
  • Amber (#fbbf24): remote PTY sessions (created via HTTP/MCP)

Always use ease timing. Respect prefers-reduced-motion. Never transition: all.

Component Reference

#sidebar {
  width: var(--sidebar-width);    /* 300px */
  min-width: 200px;
  max-width: 500px;
  background: var(--bg-secondary);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
}

Section title (e.g. “REPOS”):

  • font-size: --font-sm, text-transform: uppercase, color: --fg-muted
  • letter-spacing: 0.05em, padding: 4px 16px

Repo header:

  • Flex row, gap: 6px, padding: 6px 12px 3px
  • Repo initials: 28×28px circle, --accent bg, --text-on-accent text, --font-xs, semibold
  • Repo name: --font-sm, semibold, uppercase, --fg-secondary, truncated with ellipsis
  • Chevron: --font-lg, --fg-muted, rotates 0→90° on expand (150ms ease)
  • Actions (⋯, +): hidden by default (opacity: 0), shown on repo-header hover

Branch item (the most complex sidebar element):

.branch-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 12px;
  border-radius: var(--radius-md);
  margin: 1px 0;
  transition: background 0.1s;
}
.branch-item:hover { background: var(--bg-highlight); }
.branch-item.active {
  background: var(--bg-highlight);
  border-left: 2px solid var(--accent);
  padding-left: 10px;  /* compensate for border */
}

Branch item anatomy (left to right):

[icon 18px] [name flex:1] [stats badge?] [PR badge?] [actions on hover]
  • Icon (18px wide, centered): yellow for main, Y muted for feature, Y accent+pulse when agent active, Y green when shell idle, ? warning (orange)+pulse when awaiting input
  • Name: --font-md, weight 500, --fg-primary, ellipsis on overflow
  • Stats badge (optional): --font-xs, monospace, --bg-tertiary bg, --border border, --radius-lg, shows +N -N in green/red
  • PR badge (optional): --font-xs, monospace, semibold, --radius-pill, colored by state (see Status Badges below)
  • Actions (on hover only): max-width: 0 → 44px, two 20×20px buttons (+, ×)

Tab Bar

Located inside #toolbar, center section. Background matches toolbar (--bg-primary).

.tab {
  height: var(--tab-bar-height);  /* 32px */
  padding: 0 12px;
  font-size: var(--font-md);
  font-family: var(--font-mono);
  color: var(--fg-secondary);
  background: transparent;
  border: none;
  border-top: 2px solid transparent;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 80px;
  max-width: 200px;
}
.tab.active {
  color: var(--fg-primary);
  border-top-color: var(--accent);
  background: var(--bg-secondary);
}
.tab:hover:not(.active) {
  color: var(--fg-primary);
  background: var(--bg-tertiary);
}

Tab anatomy: [agent badge?] [name, truncated] [close × on hover]

  • Agent badge: small colored prefix (e.g. C claude, G gemini)
  • Close button: invisible by default, opacity: 1 on tab hover
  • New tab button [+]: 28px circle, --accent color

Status Bar

.bar {
  height: var(--status-height);   /* 28px */
  min-height: var(--status-height);
  background: var(--bg-secondary);
  border-top: 1px solid var(--border);
  display: flex;
  align-items: center;
  padding: 0 12px;
  font-size: var(--font-sm);
  gap: 8px;
  overflow: hidden;
}

Three sections: left (zoom, status info, CWD, agent badge, ticker), center (PR + CI badges), right (toggle buttons).

Agent badge (.agentBadge): --font-mono, --font-sm, weight 500, 1px 6px padding, --radius-sm, --bg-tertiary bg. Usage-dependent color classes:

  • .agentUsage--fg-secondary text (normal usage)
  • .agentUsageWarning#dcdcaa text (usage >=70%)
  • .agentUsageCritical#f48771 text + pulse-opacity animation (usage >=90%)
  • .agentRateLimited#f44747 text + pulse-opacity animation

Ticker message (.tickerMessage): --font-mono, --font-sm, --fg-muted, max-width 300px, --radius-sm. Warning priority (>=80): #ffd700 text, gold bg at 0.1 alpha. .tickerClickable adds cursor pointer and hover effect.

Pendulum overflow (.infoTickerActive): When the status info text is wider than its container, a CSS pendulum keyframe animation scrolls the text left then back. Duration is computed dynamically from overflow width (~50px/s). Click dismisses.

Notes toggle badge (.toggleBadge): Small accent-colored pill positioned over the toggle button, showing the filtered note count.

PR badges (center section): PrBadge + CiBadge components in .githubStatus, separated by a left border. CLOSED PRs are hidden; MERGED PRs have a 5-minute activity-based grace period.

Toggle buttons (right section): --bg-tertiary bg, --border border, --font-xs, 2px 8px padding. Active: --accent bg, white text. Each has a hotkey hint overlay positioned below.

Side Panels (Diff, Markdown, Notes/Ideas)

All follow the same structure:

.panel {
  width: 400px;
  min-width: 300px;
  max-width: 50vw;
  border-left: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  background: var(--bg-primary);
}
.panel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 12px;
  background: var(--bg-tertiary);
  border-bottom: 1px solid var(--border);
}
.panel-title { font-size: var(--font-lg); font-weight: bold; }
.file-count-badge {
  background: var(--accent);
  color: var(--text-on-accent);
  border-radius: var(--radius-pill);
  padding: 1px 6px;
  font-size: var(--font-xs);
}
.panel-content { flex: 1; overflow-y: auto; }

Dialog / Modal

.overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.65);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}
.dialog {
  width: 480px;
  max-width: 90vw;
  max-height: 80vh;
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-popup);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.dialog-header {
  padding: 16px;
  border-bottom: 1px solid var(--border);
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.dialog-header h2 { font-size: var(--font-xl); }
.dialog-content { padding: 16px; overflow-y: auto; flex: 1; }
.dialog-actions {
  padding: 12px 16px;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  border-top: 1px solid var(--border);
}

Primary button: background: var(--accent); color: var(--text-on-accent); padding: 8px 16px; border-radius: var(--radius-lg); Secondary button: background: var(--bg-tertiary); color: var(--fg-secondary); same padding/radius. Danger button: background: var(--error); color: var(--text-on-error);

Form Controls

input, select, textarea {
  height: 36px;                        /* standard height */
  background: var(--bg-tertiary);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  color: var(--fg-primary);
  font-size: var(--font-md);
  padding: 0 8px;
}
input:focus, select:focus, textarea:focus {
  border-color: var(--accent);
  outline: none;
}

Toggle switch: 36×20px track, --radius-full (10px), off=--bg-tertiary, on=--accent. White thumb slides with 0.2s transition.

Range slider: 4px track height, 16px circular thumb in --accent.

Settings Panel

Full-screen overlay. Inner panel: --bg-secondary, 700px max-width, 80vh max-height.

  • Left sidebar with tabs (General, Notifications, Dictation, Terminal, Agents)
  • Right content area with sections
  • Section heading: <h3>, --font-lg, bold, margin-bottom: 12px
  • Settings row: flex space-between, label left, control right, padding: 8px 0

Status Badges Reference

PR State (sidebar .branch-pr-badge)

StateBackgroundBorderText ColorExtra
Open--successnone--text-on-success
Drafttransparent1px dashed --fg-muted--fg-muted
Merged#a371f7none--text-on-accent
Closed--errornone--text-on-error
Conflict--errornone--text-on-errorpulse-opacity 1.5s
CI Failed--errornone--text-on-errorbold
CI Pendingtransparent1px solid #e3b341#e3b341pulse-opacity 2s
Changes Req.#d29922none--text-on-accent
Review Req.transparent1px solid #d29922#d29922

All badges: font-size: --font-xs, font-family: --font-mono, font-weight: 600, border-radius: --radius-pill, padding: 1px 6px.

CI State (status bar)

StateBackgroundText
Successrgba(158,206,106,0.2)#9ece6a
Failurergba(247,118,142,0.2)#f7768e
Pendingrgba(224,175,104,0.2)#e0af68

Agent/Usage (tab + status bar)

StateCSS ClassStyle
Agent runningTab colored agent prefixTab has colored agent prefix badge
Usage normal.agentUsage--fg-secondary text
Usage ≥70%.agentUsageWarning#dcdcaa text (warning yellow)
Usage ≥90%.agentUsageCritical#f48771 text + pulse-opacity 2s
Rate limited.agentRateLimited#f44747 text + pulse-opacity 2s
Ticker warning (≥80 priority).tickerWarning#ffd700 text, gold bg at 0.1 alpha
Update available.updateBadge#4ec9b0 text, teal bg at 0.15 alpha

Icons

No icon library. Text symbols and Unicode only. Emoji sparingly, always with filter: grayscale(1) brightness(1.5) to match the monochrome UI.

SymbolMeaningWhere
Main/primary branchSidebar branch icon
YFeature branchSidebar branch icon
?Awaiting inputBranch icon (warning/orange, pulsing)
+Add/createButtons
×Close/removeTab close, panel close, dialog close
Context menuRepo header
Edit/renameBranch double-click
Send/executeNotes panel send button
>Chevron (expand/collapse)Repo sections
Tab status dotTab bar (grey=running, green=idle, purple=unseen, blue-pulse=activity, orange-pulse=awaiting, red-pulse=error)
Git branch symbolStatus bar
💡Ideas panelStatus bar, panel header

Icon dimensions: 18px wide container for branch icons. --font-md or --font-lg size. Always left of text with gap: 6–8px.

Scrollbars

::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--bg-highlight);
  border-radius: var(--radius-md);
}
::-webkit-scrollbar-thumb:hover { background: var(--fg-muted); }

Terminal scrollbar overridden to 8px with !important.

Interactive States

Hover

  • Background: one level up (--bg-secondary--bg-tertiary, or --bg-tertiary--bg-highlight)
  • Text: --fg-secondary--fg-primary
  • Border: transparent → --accent (for add-repo button)
  • Duration: 0.1s

Active / Selected

  • Active branch: --bg-highlight bg + 2px solid var(--accent) left border
  • Active tab: --bg-secondary bg + 2px solid var(--accent) top border + --fg-primary text
  • Active toggle: --accent bg + white text
  • No hover animation on already-active items

Focus

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

Excluded on toggle buttons and mic button.

Disabled

  • opacity: 0.3 (strong) or 0.5 (mild)
  • cursor: not-allowed
  • No hover, no transitions

Hidden-until-hover

Pattern used for actions that clutter the UI when always visible:

  • Repo actions: opacity: 0; pointer-events: none;opacity: 1; pointer-events: auto; on .repo-header:hover
  • Branch actions: max-width: 0; overflow: hidden;max-width: 44px; on .branch-item:hover
  • Tab close button: opacity: 0opacity: 1 on .tab:hover

Platform Differences

PropertymacOSWindows/Linux
Toolbar height38px32px
Traffic light offset.platform-macos .toolbar-left { padding-left: 78px; }None
System font-apple-system firstSegoe UI (Win) / Roboto (Linux) first
Quit menuApp menuFile menu
Check for UpdatesApp menuHelp menu

CSS classes on <html>: .platform-macos, .platform-windows, .platform-linux.

Accessibility

  • Primary text (#cccccc on #1e1e1e): 10:1+ contrast ratio (exceeds WCAG AAA).
  • Secondary text (#a0a0a0 on #252526): 6:1+ (exceeds WCAG AA).
  • Status communicated by color + shape + icon — never color alone.
  • :focus-visible outlines for keyboard navigation.
  • prefers-reduced-motion query disables all animations.
  • Custom scrollbars maintain 8px touch target.

PWA / Mobile (src/mobile/mobile.css)

The mobile PWA has its own standalone stylesheet at src/mobile/mobile.css, completely independent from the desktop global.css. This allows the mobile UI to evolve separately while sharing the same design language.

What’s shared

  • Core color palette (same --bg-*, --fg-*, --accent, --success, --warning, --attention, --error variables with identical default values)
  • Border radius scale (--radius-sm through --radius-full, excluding --radius-xs)
  • Shadow tokens (--shadow-popup, --shadow-dropdown)
  • ANSI terminal palette

What differs

PropertyDesktop (global.css)Mobile (mobile.css)
Font mono stackJetBrains Mono, Fira Code, Hack, Cascadia, …SF Mono, Menlo, Consolas, DejaVu, …
Font size scale--font-3xs through --font-3xl (8–24px)Not defined — components use explicit px values
Layout variables--sidebar-width, --toolbar-height, --tab-bar-height, --status-heightNot used — mobile uses flex-based layout
User selectDisabled (none)Enabled (text)
Theme variables--activity, --merged, --unseenNot present (mobile has no terminal activity tracking yet)
Safe areasNot usedenv(safe-area-inset-top/bottom) on #mobile-app
Input font-sizeFrom scaleFixed 16px minimum to prevent iOS auto-zoom

Keeping in sync

When updating core palette colors in global.css, also update mobile.css — the :root blocks must stay aligned for the shared variables. Theme-specific variables (--activity, --merged, --unseen) are desktop-only and do not need mobile equivalents until that functionality ships in PWA.

Anti-Patterns (DO NOT)

  • No bright whites — max text brightness is --fg-primary (#cccccc).
  • No new shadows — only the three defined levels exist.
  • No transition: all — always list specific properties.
  • No hardcoded core colors — use CSS variables for the four bg levels, three fg levels, and status colors.
  • No icon libraries — text/unicode/emoji only.
  • No off-scale radius — only --radius-xs through --radius-full.
  • No !important — except terminal scrollbar overrides.
  • No pixel values outside the spacing scale unless component-specific dimension (like 28px repo initials).
  • No inline styles for theming — all colors and spacing via CSS variables (desktop: global.css, mobile: mobile.css).

Plugin Dashboard Style Guide

This document is the source of truth for plugin dashboard visuals. Every plugin that renders a dashboard (analytics, status, reports) MUST follow it so dashboards feel like a coherent part of TUICommander rather than a patchwork of third-party panels.

The reference implementation is the built-in Claude Usage dashboard (src/components/ClaudeUsageDashboard/). New plugin dashboards should visually match it at a glance.

Rules

  1. Use the shared classes. Do not hand-roll CSS for layout, cards, tables, stats, or typography. The classes below are injected into every plugin panel iframe via PLUGIN_BASE_CSS (src/components/PluginPanel/pluginBaseStyles.ts).
  2. Never hardcode colors. Use CSS variables (var(--accent), var(--fg-primary), var(--bg-secondary), …). These follow the active theme.
  3. Never hardcode pixel fonts for common text. Headings/body are sized by the base stylesheet.
  4. Register via host.registerDashboard(...) so the Settings → Plugins row shows a one-click Dashboard button. Do not rely on context menus as the only entry point.

Layout skeleton

<div class="dashboard">
  <div class="dash-header">
    <h1 class="dash-title">My Plugin</h1>
    <button class="primary" id="refresh">Refresh</button>
  </div>

  <div class="dash-section">
    <h2 class="dash-section-title">
      Overview
      <span class="dash-section-hint">last 7 days</span>
    </h2>
    <div class="dash-stat-grid">
      <div class="dash-stat">
        <div class="dash-stat-label">Commands</div>
        <div class="dash-stat-value">1.2k</div>
        <div class="dash-stat-sub">+8% vs prev</div>
      </div>
      <!-- more .dash-stat cards … -->
    </div>
  </div>

  <div class="dash-section">
    <h2 class="dash-section-title">Details</h2>
    <table>
      <thead><tr><th>Name</th><th class="num">Count</th></tr></thead>
      <tbody>…</tbody>
    </table>
  </div>
</div>

Class reference

ClassPurpose
.dashboardOuter flex container. Provides padding, gap, vertical stacking.
.dash-headerTop row with title + optional controls (refresh, selectors).
.dash-title18px / 600 title.
.dash-subtitleSmall muted subtitle (breadcrumb, repo name).
.dash-sectionLogical group. Inside .dashboard they auto-space via gap: 16px.
.dash-section-titleUppercase muted section label (12px / 600).
.dash-section-hintInline secondary hint next to a section title.
.dash-stat-gridAuto-fill grid for headline numbers (minmax(160px, 1fr)).
.dash-statSingle stat card.
.dash-stat-labelUppercase 10px label.
.dash-stat-value22px tabular value.
.dash-stat-subSecondary caption under a value.
.dash-meter / .dash-meter-fillHorizontal progress bar. Add .ok, .warn, or .critical for color.
.numRight-aligned tabular cell (use inside table <th>/<td>).

Generic classes from PLUGIN_BASE_CSS also apply inside dashboards: .card, .badge, .empty-state, button.primary, .hint, etc. See pluginBaseStyles.ts.

Do

  • Keep the <style> block in buildPanelHtml() empty unless you need plugin-specific visual tweaks that cannot be expressed via the standard classes.
  • Place the refresh button inside .dash-header, right-aligned via justify-content: space-between.
  • Use <table> + .num for tabular data. The base stylesheet already themes it correctly.
  • Use .dash-stat cards for headline numbers, not custom grids.
  • Use .empty-state for “no data yet” screens.

Don’t

  • Don’t redefine .card, .stat-card, .stat-grid, h1/h2/h3 sizes. These are global.
  • Don’t introduce custom color tokens — pick one of: --accent, --success, --warning, --error, --fg-primary, --fg-secondary, --fg-muted, --bg-primary, --bg-secondary, --bg-tertiary, --border.
  • Don’t set explicit background: #... anywhere.
  • Don’t wrap the dashboard in max-width containers narrower than the panel — let it fill the iframe.
  • Don’t use emoji icons. Use monochrome inline SVGs with fill="currentColor" (TUICommander convention).

Registering the dashboard

export default {
  id: "my-plugin",
  async onload(host) {
    // ... existing capability setup ...
    host.registerDashboard({
      label: "My Plugin",      // optional — defaults to "Dashboard"
      icon: MY_PLUGIN_ICON,    // optional inline SVG
      open: () => openDashboard(host),
    });
  },
};

The registered entry powers the Dashboard button in Settings → Plugins next to the plugin’s enable toggle. The host also closes the Settings panel automatically on click so the dashboard becomes visible.

Checklist before shipping

  • Dashboard uses .dashboard + .dash-* classes — no duplicated layout CSS
  • No hardcoded colors or font sizes for common elements
  • Registered via host.registerDashboard(...)
  • Empty/error states use .empty-state + generic .card.error-card if applicable
  • Verified visually against ClaudeUsageDashboard side-by-side

HTTP API Reference

REST API served by the Axum HTTP server when MCP server is enabled. All Tauri commands are accessible as HTTP endpoints.

Base URL

  • Local (Unix socket): <config_dir>/mcp.sock — always started on macOS/Linux. No auth, MCP always enabled. Used by the local MCP bridge binary.
  • Remote (TCP): http://<host>:{remote_access_port} — only started when remote access is enabled in settings. HTTP Basic Auth required.

Authentication

  • MCP mode (localhost): No authentication
  • Remote access mode: HTTP Basic Auth with configured username/password

Session Endpoints

List Sessions

GET /sessions

Returns array of active session info (ID, cwd, worktree path, branch, display_name, display_name_is_custom, is_remote, optional pty_description, and nested state). The origin fields let browser and desktop clients preserve manual-title protection and remote-completion muting across reconnects. For detected agents, state.agent_state distinguishes PTY silence (idle) from explicit protocol completion (completed); the latter requires a parsed suggest: [ ... ] marker. Other values are starting, working, and awaiting_input. state.background_work is true when meaningful non-helper descendants keep autonomous work alive despite an input-ready terminal (state.shell_state == "idle").

Create Session

POST /sessions
Content-Type: application/json

{
  "rows": 24,
  "cols": 80,
  "shell": "/bin/zsh",    // optional
  "cwd": "/path/to/dir"   // optional
}

Returns { "session_id": "..." }.

Create Session with Worktree

POST /sessions/worktree
Content-Type: application/json

{ "pty_config": { ... }, "worktree_config": { ... } }

Creates a git worktree and a PTY session in one call.

Spawn Agent Session

POST /sessions/agent
Content-Type: application/json

{ "pty_config": { ... }, "agent_config": { ... } }

Spawns an AI agent (Claude, etc.) in a PTY session.

Write to Session

POST /sessions/:id/write
Content-Type: application/json

{ "data": "ls -la\n" }

Queue a Command for the Next Idle Window

POST /sessions/:id/queue
Content-Type: application/json

{ "text": "run the tests" }        -> { "typed": false, "queued": 2 }

GET /sessions/:id/queue            -> [ { "id": 7, "text": "run the tests" } ]

DELETE /sessions/:id/queue         -> 2   (commands dropped)

DELETE /sessions/:id/queue/:cmdId  -> true  (false when it already drained)

Hands the text to the same idle gate peer messages use instead of typing it now: submitted immediately when the agent is idle (typed: true, queued: 0), otherwise parked until the agent’s next busy→idle transition, so a running turn is never steered. User commands and peer messages share one typed FIFO and are submitted one per idle window in backend acceptance order. queued, state.queued_commands, and DELETE count or remove only user commands; clearing Compose commands never deletes pending peer/orchestrator delivery.

Agent sessions only — 400 for a plain shell ("Session is not running an agent") or empty text, 404 when the PTY is gone. The current depth is also on every session snapshot as state.queued_commands (omitted when zero).

Resize Session

POST /sessions/:id/resize
Content-Type: application/json

{ "rows": 30, "cols": 120 }

Read Output

GET /sessions/:id/output?limit=4096&format=text

Returns recent output. Format controls what is returned:

formatResponse shapeDescription
(omit){ "data": "<string>", "data_length": N, "total_written": N }Raw PTY output as a lossy-UTF-8 string (not base64), read from the ring buffer
text{ "data": "<string>", "data_length": N, "total_written": N }One canonical terminal-grid snapshot, joined by \n (not from the ring buffer)
log{ "lines": [...], "total_lines": N, "screen": [...], "input_line"? }VT100-extracted clean lines (no ANSI, no TUI garbage) plus current screen rows and optional input line
ParamDefaultDescription
limitraw: 8192 bytes; text/log: allraw: max bytes; text/log: max lines to return
offset(tail)text/log: absolute start row/line offset. When omitted, returns the newest limit rows/lines. When provided, returns data starting from that offset
format(raw)See table above

format=log reads from VtLogBuffer — a VT100-aware buffer that extracts only scrolled-off lines, suppressing alternate-screen TUI apps (vim, htop, claude). Ideal for mobile clients.

format=text is a point-in-time canonical grid view. It does not concatenate the finalized log cursor with the visible screen, because growing a viewport can move history rows back onto the screen and make that concatenation overlap. total_written is the snapshot’s total grid-row count for this format.

total_lines in the response is a monotonically increasing counter — it never decreases when old lines are evicted from the buffer. Use it as a stable cursor for paginated reads. The offset parameter operates in the same coordinate space.

Kitty Protocol Flags

GET /sessions/:id/kitty-flags

Returns the current Kitty keyboard protocol flags (integer) for a session.

Foreground Process

GET /sessions/:id/foreground

Returns the foreground process info for a session.

PTY / Terminal Read State

GET  /sessions/:id/shell-state                         -> { "state": "busy"|"idle"|null }
GET  /sessions/:id/last-prompt                         -> { "prompt": string|null }
GET  /sessions/:id/input-buffer                        -> { "content": string }
GET  /sessions/:id/leaf-pid                            -> { "pid": number|null }
GET  /sessions/:id/has-foreground                      -> { "process": string|null }
POST /sessions/:id/visible              { "visible": bool }   -> { "ok": true }
GET  /sessions/:id/terminal/selection-text?startRow=&startCol=&endRow=&endCol=  -> { "text": string }
GET  /sessions/:id/terminal/logical-line?row=N         -> [logicalStartRow, text]
GET  /sessions/:id/terminal/hyperlink-span?row=R&col=C -> [startCol, endCol, url] | null
GET  /process/stats                                    -> ProcessStats[]

Read-only PTY/terminal state mirroring the desktop Tauri commands (story 062). The {field}-wrapped responses are unwrapped by the frontend transport to match the command’s bare return (e.g. Option<String>null). The desktop-only commands themselves are absent from the remote binary, so these handlers read AppState directly.

Pause/Resume

POST /sessions/:id/pause
POST /sessions/:id/resume

Rename Session

PUT /sessions/:id/name
Content-Type: application/json

{ "name": "my-session", "isCustom": true }

Sets a display name and its origin. isCustom: true protects an explicit user rename from subsequent OSC/intent titles; spawn-assigned and dynamic titles use false. Omitting the field preserves the legacy custom-rename behavior.

Close Session

DELETE /sessions/:id?cleanup_worktree=false

Streaming Endpoints

WebSocket PTY Stream

WS /sessions/:id/stream

Receives real-time PTY output as text frames. One WebSocket per session.

WebSocket JSON Framing (Mobile/Browser)

WebSocket connections to /sessions/:id/stream receive JSON-framed messages:

{"type": "output", "data": "raw terminal output text"}
{"type": "parsed", "event": {"type": "question", "text": "Allow?"}}
{"type": "exit"}
{"type": "closed"}

Frame types:

  • output — Raw PTY output (ANSI-stripped when ?format=text)
  • log — VT100-extracted clean lines batch (when ?format=log): {"type":"log","lines":[...],"offset":N}
  • parsed — Structured events (questions, rate limits, errors) from the output parser
  • exit — Session process exited
  • closed — Session was closed

WebSocket format=log

WS /sessions/:id/stream?format=log

When ?format=log is specified, the connection streams VT100-extracted log lines instead of raw PTY chunks:

  • On connect: sends all accumulated lines as a single catch-up frame
  • While running: polls every 200ms and sends new lines batched by offset
  • PTY input passthrough is still available (write text/binary frames to send to PTY)

Server-Sent Events (SSE)

GET /events?types=repo-changed,pty-parsed

Broadcasts server-side events to all browser/mobile clients. Supports optional ?types= query parameter for comma-separated event name filtering. Uses monotonic event IDs and 15-second keep-alive pings.

EventPayloadDescription
session-created{session_id, cwd, agent_type, display_name}New session started; display_name is the optional stable assigned name
pty-description-changed{session_id, description}Orchestrator updates the short task description shown above a PTY
session-closed{session_id}Session ended
repo-changed{repo_path}Git repository state changed
head-changed{repo_path, branch}Git HEAD changed (branch switch)
pty-parsed{session_id, parsed}Structured output event from PTY parser
pty-exit{session_id}PTY process exited
plugin-changed{plugin_ids}Plugin(s) installed/removed/updated
upstream-status-changed{name, status}MCP upstream server status change
mcp-toast{title, message, level, sound, origin_repo_path?}Toast notification from MCP layer, including the caller repository/cwd when known
triage-progress{repo_path, summary, files, phase, done, llm_used, llm_model}Diff-triage classification progress (browser parity for the desktop window event)
lagged{missed}Client fell behind; N events were dropped

MCP Streamable HTTP

POST /mcp
Content-Type: application/json

{ JSON-RPC message }

Single endpoint for all MCP JSON-RPC requests (initialize, tools/list, tools/call). Returns JSON-RPC responses directly in the HTTP response body. Session ID returned via Mcp-Session-Id header on initialize.

GET /mcp          → 405 Method Not Allowed
DELETE /mcp       → Ends MCP session (pass Mcp-Session-Id header)

Git Endpoints

Repository Info

GET /repo/info?path=/path/to/repo

Returns RepoInfo (name, branch, status, initials).

Git Diff

GET /repo/diff?path=/path/to/repo

Returns unified diff string.

Diff Stats

GET /repo/diff-stats?path=/path/to/repo

Returns { "additions": N, "deletions": N }.

Changed Files

GET /repo/files?path=/path/to/repo

Returns array of ChangedFile (path, status, additions, deletions).

Single File Diff

GET /repo/file-diff?path=/path/to/repo&file=src/main.rs

Returns diff for a single file.

Read File

GET /repo/file?path=/path/to/repo&file=src/main.rs

Returns file contents as text.

Branches

GET /repo/branches?path=/path/to/repo

Returns sorted branch list.

Repo Summary

GET /repo/summary?path=/path/to/repo

Aggregate snapshot: worktree paths, merged branches, and per-path diff stats in one round-trip. Replaces 3+ separate IPC calls.

Repo Structure (Progressive Phase 1)

GET /repo/structure?path=/path/to/repo

Returns { "worktree_paths": { "branch": "/path", ... }, "merged_branches": ["branch", ...] }. Fast path — no diff stats computation.

Repo Diff Stats (Progressive Phase 2)

GET /repo/diff-stats/batch?path=/path/to/repo

Returns { "diff_stats": { "/path": { "additions": N, "deletions": N }, ... }, "last_commit_ts": { "branch": N, ... } }. Slow path — computes per-worktree diff stats and last commit timestamps.

Local Branches

GET /repo/local-branches?path=/path/to/repo

Returns local branch list.

Checkout Remote Branch

POST /repo/checkout-remote
Content-Type: application/json

{ "repoPath": "/path/to/repo", "branchName": "feat-remote" }

Creates a local tracking branch from origin/<branchName>.

Rename Branch

POST /repo/branch/rename
Content-Type: application/json

{ "path": "/path/to/repo", "old_name": "old", "new_name": "new" }

Check Main Branch

GET /repo/is-main-branch?branch=main

Returns true if the branch is main/master/develop.

Initials

GET /repo/initials?name=my-repo

Returns 2-char repo initials.

Markdown Files

GET /repo/markdown-files?path=/path/to/repo

Returns list of .md files in a directory.

Recent Commits

GET /repo/recent-commits?path=/path/to/repo

Returns recent git commits.

GitHub Status

GET /repo/github?path=/path/to/repo

Returns PR status, CI status, ahead/behind for current branch.

PR Statuses (Batch)

GET /repo/prs?path=/path/to/repo

Returns BranchPrStatus[] for all branches with open PRs.

PR Statuses (Multi-Repo Batch)

POST /repo/prs/batch
Content-Type: application/json

{ "paths": ["/repo1", "/repo2"], "include_merged": false }

Returns aggregated PR statuses across multiple repositories.

Issues

GET /repo/issues?path=/path/to/repo

Returns GitHubIssue[] for the repo, filtered by the user’s configured issue filter.

Close Issue

POST /repo/issues/close
Content-Type: application/json

{ "repo_path": "/path/to/repo", "issue_number": 42 }

Closes the specified issue via GitHub GraphQL API.

Reopen Issue

POST /repo/issues/reopen
Content-Type: application/json

{ "repo_path": "/path/to/repo", "issue_number": 42 }

Reopens a closed issue via GitHub GraphQL API.

GitHub Auth & Diagnostics

Browser/PWA parity for the GitHub settings panel. Registered on the loopback router only (the headless tuic-remote daemon does not expose GitHub).

GET  /github/viewer-login                       -> string (login)
GET  /repo/ci-failure-logs?repoPath=&branch=    -> string (logs)
POST /github/pr-hide-drafts   { hide }          -> null
POST /github/auth/start                         -> DeviceCodeResponse
POST /github/auth/poll        { deviceCode }    -> PollResult
POST /github/auth/logout                        -> null
POST /github/auth/disconnect                    -> null
GET  /github/auth/status                        -> AuthStatus
GET  /github/diagnostics                        -> GitHubDiagnostics

Auth commands share the desktop *_impl (device-code flow + OS-keyring token via crate::credentials). get_all_issues is intentionally unmapped — it has no frontend invoke() caller (the /repo/issues route already serves browser issue lists).

Merged Branches

GET /repo/branches/merged?path=/path/to/repo

Returns list of branch names merged into the default branch.

Orphan Worktrees

GET /repo/orphan-worktrees?repoPath=/path/to/repo

Returns list of worktree directory paths that are in detached HEAD state (their branch was deleted).

Remove Orphan Worktree

POST /repo/remove-orphan
Content-Type: application/json

{ "repoPath": "/path/to/repo", "worktreePath": "/path/to/worktree" }

Removes an orphan worktree by filesystem path. The worktree path is validated against the repo’s actual worktree list.

Merge PR via GitHub

POST /repo/merge-pr
Content-Type: application/json

{ "repoPath": "/path/to/repo", "prNumber": 42, "mergeMethod": "squash" }

Merges a PR via the GitHub API. mergeMethod must be "merge", "squash", or "rebase". Returns {"sha": "..."} on success.

Approve PR

POST /repo/approve-pr
Content-Type: application/json

{ "repoPath": "/path/to/repo", "prNumber": 42 }

Submits an approving review on a PR via the GitHub API.

CI Checks

GET /repo/ci?path=/path/to/repo

Returns detailed CI check list.

PR Diff

GET /repo/pr-diff?path=/path/to/repo

Returns diff for the current branch’s open PR.

AI Review / Changelog / Conflict Assist

POST /ai/review/pr           { repoPath, prNumber }        -> PrReviewResult
GET  /repo/merged-prs?path=&sinceTag=                      -> MergedPr[]
GET  /repo/changelog?path=&sinceTag=                       -> { markdown, json }
POST /repo/conflict-assist   { repoPath, prNumber }        -> ConflictAssistResult

/ai/review/pr runs the multi-turn review engine (Main slot) over a PR diff and returns line-level findings. /repo/changelog summarizes merged PRs (Headless slot) into markdown + a structured JSON breakdown; sinceTag filters to PRs merged at/after that tag’s date. /repo/conflict-assist creates a worktree on the PR head and rebases it onto the base. status is clean only when the base was refreshed from origin, clean_unverified when a conflict-free result used an existing tracking ref or local fallback, and conflicts when manual resolution is needed. The response includes base_source, an optional base_warning, the conflicted-file list, and an agent prompt; it never pushes or merges.

Remote URL

GET /repo/remote-url?path=/path/to/repo

Returns the remote origin URL.

Git Panel Endpoints

Working Tree Status

GET /repo/working-tree-status?path=/path/to/repo

Returns porcelain v2 working tree status.

Panel Context

GET /repo/panel-context?path=/path/to/repo

Returns aggregated context for the Git Panel (status, branch, merge state).

Stage Files

POST /repo/stage
Content-Type: application/json

{ "repoPath": "/path/to/repo", "files": ["src/main.rs"] }

Unstage Files

POST /repo/unstage
Content-Type: application/json

{ "repoPath": "/path/to/repo", "files": ["src/main.rs"] }

Discard Files

POST /repo/discard
Content-Type: application/json

{ "repoPath": "/path/to/repo", "files": ["src/main.rs"] }

Commit

POST /repo/commit
Content-Type: application/json

{ "repoPath": "/path/to/repo", "message": "feat: add feature" }

Run Git Command

POST /repo/run-git
Content-Type: application/json

{ "repoPath": "/path/to/repo", "args": ["log", "--oneline", "-5"] }

Runs an arbitrary git command in the repo directory.

Commit Log

GET /repo/commit-log?path=/path/to/repo

Returns commit log entries.

File History

GET /repo/file-history?path=/path/to/repo&file=src/main.rs

Returns git log for a specific file.

File Blame

GET /repo/file-blame?path=/path/to/repo&file=src/main.rs

Returns line-by-line blame annotations.

Git Panel (Branches / Graph / Gutter)

GET  /repo/gutter-changes?path=&file=&scope=      -> GutterChange[]
GET  /repo/branches-detail?path=                  -> BranchDetail[] (cached)
GET  /repo/recent-branches?path=&limit=           -> string[]
GET  /repo/branch-base?path=&branchName=          -> string | null
GET  /repo/worktree-dirty?repoPath=&branchName=   -> bool
GET  /repo/base-ref-options?repoPath=             -> BaseRefOption[]
GET  /repo/commit-graph?path=&count=              -> GraphNode[]
POST /repo/clone-branch-name   { sourceBranch, existingNames }   -> string
POST /repo/create-branch       { path, name, startPoint?, checkout }       -> { ok: true }
POST /repo/delete-branch       { path, name, force }                        -> DeleteBranchResult
POST /repo/delete-local-branch { repoPath, branchName, keepWorktree? }      -> { ok: true }
POST /repo/update-from-base    { path, branchName, strategy? }              -> string
POST /repo/switch-branch       { repoPath, branchName, force, stash }       -> SwitchBranchResult
POST /repo/merge-archive-worktree { repoPath, branchName, targetBranch, afterMerge, force? } -> MergeArchiveResult

Powers the Git panel’s Branches tab, commit graph, and editor gutter in browser/PWA/remote. Mutations call the shared *_impl + invalidate_repo_caches. run_diff_triage (event-emitting, LLM progress) is not yet mapped — it belongs with the agent/chat/watcher event-bridge work; see todo.md.

Stash Endpoints

List Stashes

GET /repo/stash?path=/path/to/repo

Returns stash list.

Apply Stash

POST /repo/stash/apply
Content-Type: application/json

{ "repoPath": "/path/to/repo", "index": 0 }

Pop Stash

POST /repo/stash/pop
Content-Type: application/json

{ "repoPath": "/path/to/repo", "index": 0 }

Drop Stash

POST /repo/stash/drop
Content-Type: application/json

{ "repoPath": "/path/to/repo", "index": 0 }

Show Stash

GET /repo/stash/show?path=/path/to/repo&index=0

Returns diff of a stash entry.

Log Endpoints

Get Logs

GET /logs?limit=50&level=error&source=terminal

Retrieve log entries from the ring buffer (1000 entries max). All query params optional:

  • limit — max entries to return (0 = all, default: 0)
  • level — filter by level: debug, info, warn, error
  • source — filter by source: app, plugin, git, network, terminal, github, dictation, store, config

Push Log

POST /logs
{ "level": "warn", "source": "git", "message": "...", "data_json": "{...}" }

Clear Logs

DELETE /logs

Capture Raw PTY Streams

Start capture before reproducing an agent-state detection failure:

POST /diagnostics/capture
Content-Type: application/json

{ "enabled": true, "session_id": "<session-id>" }

Omit session_id to capture every session. Starting capture creates a fresh set of files rather than appending to an earlier run. GET /diagnostics/capture returns enabled, the optional session_filter, the capture dir, and each recorded session’s byte count. Stop with:

POST /diagnostics/capture
Content-Type: application/json

{ "enabled": false }

Files are written as framed PTY timelines to <app config dir>/captures/<session-id>.tcap, capped at 512 KiB per session. Each record preserves input/output direction, original chunk boundaries and a monotonic timestamp. Legacy .raw fixtures remain readable as one output record. Copy the relevant file into src-tauri/src/fixtures/agent_prompts/ and replay it through the production parser composition. Do not acquire state-detection fixtures from GET /sessions/:id/output: its ring is bounded, may already have overwritten the one-shot signal, and its string response is lossy UTF-8 rather than a byte-preserving fixture.

Execute JS in WebView (debug)

POST /debug/invoke_js
{ "script": "return window.__TUIC__.terminals().length;" }

Executes JavaScript in the main WebView. Loopback-only (rejected with 403 from non-localhost peers) — this is an RCE surface and is exposed on the local router only, never the remote router. Fire-and-forget: the return value (return expr) and any captured console.log/warn/error/info output are pushed to the ring buffer with source="eval_js". Read the result back via GET /logs?source=eval_js&limit=1.

The only injected global is window.__TUIC__ (stores, terminals, plugins, …). Mirrors the MCP debug action=invoke_js tool — both share log_routes::eval_debug_script. The HTTP route is what makes the tauri dev build (which has no MCP stdio transport) scriptable for diagnostics.

Configuration Endpoints

App Config

GET /config
PUT /config

Load/save AppConfig.

PUT /config merges its body onto the live config rather than replacing it, so a caller may send only the fields it wants changed. Objects merge key by key; arrays and scalars replace wholesale (an empty array still clears a list, "" still blanks a string). A wrongly-typed field is a 400, never a silent default. When the body moves services.server.{enabled,port,ipv6_enabled} or services.auth.{username,password_hash}, the HTTP listener is rebound just as the IPC save_config does, so the running process cannot keep serving a configuration the disk no longer agrees with.

GET /config redacts remote-access secrets (services.auth.password_hash, services.auth.session_token, services.relay.token, and services.push.vapid_private_key). Secret presence is exposed only through session_token_exists, token_exists, and vapid_private_key_exists.

Config / themes / notes / misc parity (story 066)

Browser/PWA parity for assorted stateless commands. Loopback router only. Mutating/action routes carry the require_local_or_auth guard; reads do not.

GET  /config/ai-prompts                      -> AiPromptsConfig
PUT  /config/ai-prompts        (AiPromptsConfig)            -> { ok }
POST /config/repo-local-config { repoPath }                -> { ok }   (GET = read)
POST /config/branch-label      { repoPath, branchName, label? } -> { ok }
POST /config/note-image        { noteId, dataBase64, extension } -> string (path)
POST /config/note-assets/delete       { noteId }           -> { ok }
POST /config/note-assets/delete-batch { noteIds }          -> { ok }
GET  /config/themes                          -> ThemeEntry[]
POST /config/project-mcp-upstreams { repoPath, upstreamNames? } -> { ok }
POST /exec/shell-script        { scriptContent, timeoutMs, repoPath } -> string  [guarded]
GET  /audio/output-devices                   -> AudioOutputDevice[] (empty on remote)
POST /agent/discover-session   { agentType, cwd, claimedIds, agentPid?, envOverrides } -> string|null
POST /agent/claude-project-dir { cwd, claudeConfigDir? }   -> string
POST /agent/open-in-custom     { executable, args, ctx }   -> { ok }   [guarded]
POST /generators/generate      { request }                 -> GeneratorResult  [guarded]
GET  /registry/plugins                       -> RegistryEntry[]

Intentionally NOT mapped (no frontend invoke() caller — YAGNI): load_app_config, save_app_config, get_note_images_dir, process_prompt_content_shell_safe, detect_claude_binary, mdkb_code_find. Skipped as integration/stateful (separate follow-up): set_ansi_colors (PTY ring-buffer state), the mdkb_* daemon commands, install_agent_mcp/remove_agent_mcp (config-file writes, also no caller).

Provider keyring + slot/ollama checks (story 072)

Browser/PWA parity for provider API-key storage (the OS keyring is proxied through the server so remote clients never touch it directly) plus slot/Ollama connectivity checks. Loopback router only; mutating routes carry the require_local_or_auth guard.

GET    /config/provider-key/exists?providerId=<id>   -> bool
POST   /config/provider-key    { providerId, key }   -> { ok }    [guarded]
DELETE /config/provider-key    { providerId }        -> { ok }    [guarded]
POST   /config/slot-test       { slot }              -> string    (connection test result)
POST   /config/ollama-models   { providerId }        -> string[]  (discovered model ids)

The OAuth upstream flow (start_mcp_upstream_oauth / cancel_mcp_upstream_oauth) is not mapped: start binds a loopback callback server and opens the OS browser, so the redirect can’t return to a remote/PWA client. Desktop drives it over IPC; browser clients get a clean host-only error until the redirect UX is redesigned.

Hash Password

POST /config/hash-password
Content-Type: application/json

{ "password": "..." }

Returns bcrypt hash string.

Notification Config

GET /config/notifications
PUT /config/notifications

Load/save NotificationConfig.

UI Preferences

GET /config/ui-prefs
PUT /config/ui-prefs

Load/save UIPrefsConfig.

Repository Settings

GET /config/repo-settings
PUT /config/repo-settings

Load/save per-repository settings.

Repository Defaults

GET /config/repo-defaults
PUT /config/repo-defaults

Load/save default settings applied to new repositories.

Check Custom Settings

GET /config/repo-settings/has-custom?path=/path/to/repo

Returns true if the repo has non-default settings.

Repositories

GET /config/repositories
PUT /config/repositories

Load/save the repositories list.

Prompt Library

GET /config/prompt-library
PUT /config/prompt-library

Load/save prompt entries.

Notes

GET /config/notes
PUT /config/notes

Load/save notes (opaque JSON, shape defined by frontend).

MCP Status

GET /mcp/status

Returns MCP server status (enabled, port, connected clients).

MCP Upstream Status

PUT /mcp/upstreams
Content-Type: application/json

{
  "base": { "servers": [...] },
  "config": { "servers": [...] }
}

base is the configuration previously loaded by the caller and config is its desired result. The backend derives an ID-keyed three-way delta, then applies it to the latest mcp-upstreams.json under the cross-process file lock. Removing a server from config explicitly deletes that ID; removing an optional auth field explicitly clears it. Fields and servers unchanged from base preserve concurrent updates, including OAuth/DCR auth written by another process. After the atomic write, the live registry hot-reloads the exact locked pre/post configurations. Returns 200 with an empty body, 400 for invalid config or duplicate IDs, and 500 for persistence or conflicting-add failures.

GET /mcp/upstream-status

Returns status and metrics for all upstream MCP servers (connecting, ready, circuit_open, disabled, failed).

MCP Instructions

GET /mcp/instructions

Returns dynamic server instructions for the MCP bridge binary as {"instructions": "..."}.

Filesystem Endpoints

GET  /fs/list?repoPath=/path/to/repo&subdir=src
GET  /fs/search?repoPath=/path/to/repo&query=main&limit=50
GET  /fs/search-content?repoPath=/path/to/repo&query=foo&caseSensitive=false&useRegex=false&wholeWord=false&limit=200
GET  /fs/read?repoPath=/path/to/repo&file=src/main.rs
GET  /fs/read-external?path=/absolute/path/to/file
POST /fs/write         { "repoPath": "...", "file": "...", "content": "..." }
POST /fs/mkdir         { "repoPath": "...", "dir": "..." }
POST /fs/delete        { "repoPath": "...", "path": "..." }
POST /fs/rename        { "repoPath": "...", "from": "...", "to": "..." }
POST /fs/copy          { "repoPath": "...", "from": "...", "to": "..." }
POST /fs/gitignore     { "repoPath": "...", "pattern": "..." }
GET  /fs/resolve-terminal-path?cwd=/repo&candidate=src/x.ts   -> ResolvedFilePath | null
GET  /fs/stat?path=/absolute/path                              -> PathStat (exists/is_dir/size/modified_at)
POST /fs/warm-index    { "repoPath": "..." }                   -> { "ok": true } (fire-and-forget BM25 build)
POST /fs/write-external { "path": "/abs", "content": "..." }   -> { "ok": true }
POST /fs/copy-abs      { "from": "/abs", "to": "/abs" }        -> { "ok": true }
POST /fs/move-abs      { "from": "/abs", "to": "/abs" }        -> { "ok": true }
POST /fs/transfer      { "destDir": "/abs", "paths": [...], "mode": "move"|"copy", "allowRecursive": bool } -> TransferResult

Sandboxed filesystem operations for the file manager panel. /fs/read-external reads an arbitrary absolute path (not sandboxed to a repo).

Claude Usage Endpoints

GET /claude/usage                              -> UsageApiResponse (rate-limit usage, 5-min cached)
GET /claude/projects                           -> ProjectEntry[]
GET /claude/timeline?scope=all&days=7          -> TimelinePoint[] (hourly token aggregation)
GET /claude/session-stats?scope=current        -> SessionStats

Powers the Claude Usage dashboard in browser/PWA/remote. scope is "all", "current", or a project slug. timeline/session-stats are desktop-only Tauri commands; the handlers call non-gated *_impl siblings so they also serve the remote daemon.

Absolute-path write boundary. /fs/write-external, /fs/copy-abs, and /fs/move-abs are gated to registered repository roots for the HTTP boundary (a 403 otherwise), mirroring /fs/read-external. The gate rejects traversal syntax (..), NUL bytes, and relative paths before the containment check: containment is Path::starts_with, which is purely lexical, so /repo/../../etc/passwd is “inside” /repo by components while the OS resolves it far outside. Paths are deliberately not canonicalized — a symlink inside a registered repo that points outside it is an accepted design decision in this project. /fs/transfer gates only its destDir — sources are commonly external (a file dragged in from the desktop). /fs/stat and /fs/resolve-terminal-path return only metadata (no content) so they are not repo-gated; both also refuse macOS TCC-protected directories. /fs/resolve-terminal-path returns JSON null on a miss (Option<ResolvedFilePath>).

Monitoring Endpoints

Health Check

GET /health

Returns { "status": "ok" }.

Orchestrator Stats

GET /stats

Returns { "active_sessions": N, "max_sessions": 50, "available_slots": N }.

Session Metrics

GET /metrics

Returns { "total_spawned": N, "failed_spawns": N, "bytes_emitted": N, "pauses_triggered": N }.

Local IPs

GET /system/local-ips

Returns list of local network interfaces and addresses.

Local IP (Primary)

GET /system/local-ip

Returns the preferred local IP address (single value).

Watcher Endpoints

Head Watcher

POST   /watchers/head?path=/path/to/repo
DELETE /watchers/head?path=/path/to/repo

Start/stop watching .git/HEAD for branch changes. Browser-only mode.

Repo Watcher

POST   /watchers/repo?path=/path/to/repo
DELETE /watchers/repo?path=/path/to/repo

Start/stop watching .git/ for repository state changes. Browser-only mode.

Directory Watcher

POST   /watchers/dir?path=/path/to/directory
DELETE /watchers/dir?path=/path/to/directory

Start/stop watching a directory (non-recursive) for file changes (create/delete/rename). Emits dir-changed SSE event. Used by File Browser panel for auto-refresh.

Hot Repos

PUT /watchers/hot-repos

Body: {"paths": ["/path/to/repo", ...]}

Updates the set of “hot” repository paths (repos with active terminals). Cold repos (not in this set) get throttled watcher debounce (15s vs 1.5s) and reduced GitHub polling frequency (~10min vs ~1min). Browser-only mode equivalent of the set_hot_repos Tauri command.

AI Watchers (agent rules — story 070)

GET  /ai/watchers                                            -> WatcherRule[]
POST /ai/watchers          { name, sessionId?, trigger, instructions?, promptId?, repoPath?, maxFires?, cooldownSecs? } -> id
POST /ai/watchers/update   { id, name?, trigger?, instructions?, promptId?, repoPath?, maxFires?, cooldownSecs? } -> { ok }
POST /ai/watchers/delete   { id }                            -> { ok }
POST /ai/watchers/toggle   { id, enabled }                   -> { ok }
POST /ai/watchers/attach   { templateId, sessionId }         -> id
POST /ai/watchers/detach   { id }                            -> { ok }

CRUD for the agent watcher rules (WatcherManager). Watcher fires surface as the existing session-created SSE event (a fired watcher spawns an agent session), so no dedicated watcher-fire stream is needed. Config mutations are client-initiated → the UI refetches GET /ai/watchers; no push event for state changes. The mutation logic is the shared ai_agent::watcher::*_rule core; watcher_create/watcher_update reuse the extracted *_impl.

AI Chat (config + conversation CRUD — story 069 RPC slice)

GET  /ai/chat/config                         -> AiChatConfig
PUT  /ai/chat/config          (AiChatConfig)  -> { ok }
GET  /ai/chat/conversations                  -> ConversationMeta[]
GET  /ai/chat/conversation?id=               -> Conversation
POST /ai/chat/conversation    (Conversation)  -> { ok }   (save)
POST /ai/chat/conversation/delete  { id }     -> { ok }
POST /ai/chat/new-id                         -> string (new conversation id)

File-backed conversation persistence + chat config.

GET (WS) /ai/chat/{chat_id}/stream

Chat registry live stream (event-bridge plan Step 4). WebSocket upgrade: the first frame is a ChatEvent::Snapshot ({"kind":"snapshot",...}), then live ChatEvent frames (chunk/error/cleared/snapshot) as they are fanned out. Closing the socket unsubscribes (no explicit chat_unsubscribe call). Browser parity for the desktop chat_subscribe Tauri Channel — frames are byte-identical so the same applyRegistryEvent handler consumes both. Dedicated per-chat WS, NOT the global /events bus (high-frequency token stream).

AI Agent Loop control + knowledge + scheduler (story 068 RPC slice)

POST /ai/conversation/cancel   { sessionId }            -> string
POST /ai/conversation/pause    { sessionId }            -> string
POST /ai/conversation/resume   { sessionId }            -> string
POST /ai/conversation/approve  { sessionId, approved }  -> { ok }
GET  /ai/session-knowledge?sessionId=                   -> SessionKnowledgeSummary
POST /ai/suggestions/toggle    { sessionId }            -> bool (new state)
POST /ai/knowledge/sessions    { filter?, limit? }      -> SessionListEntry[]
GET  /ai/knowledge/session?sessionId=                   -> SessionDetail | null
GET  /ai/scheduler/config                               -> SchedulerConfig
PUT  /ai/scheduler/config      (SchedulerConfig)        -> { ok }
POST /ai/triage/run            { repoPath, refresh? }   -> TriageResult   (desktop only)
POST /ai/improvements/scan     { repoPath, focus }      -> ImprovementScanResult (desktop only)
POST /repo/create-issue-from-proposal { repoPath, proposal } -> CreatedIssue (desktop only)
GET (WS) /ai/conversation/{session_id}/stream

Agent-loop control (cancel/pause/resume/approve), session-knowledge reads, and the scheduler config. State-taking commands reuse extracted *_impls (get_session_knowledge_impl, toggle_ai_suggestions_impl, get_knowledge_session_detail_impl).

Conversation token stream (event-bridge plan Step 3): the WebSocket /ai/conversation/{session_id}/stream is the browser parity for the desktop start_conversation Tauri Channel. The client sends the start params as the first text frame — { message, autonomy?, maxSteps?, temperature?, modelOverride?, bypassedTools?, reasoningEffort? } — then receives ConversationEvent frames ({"type":"text_chunk",...} etc.) with the same 50ms batching as desktop. Dedicated per-session WS, NOT the global /events bus (high-frequency token stream). A client disconnect stops forwarding but leaves the conversation running — cancel explicitly via /ai/conversation/cancel.

Diff triage (POST /ai/triage/run, event-bridge plan Step 2): triggers run_diff_triage; progress frames stream over the global /events SSE bus as triage-progress (low-frequency, safe on the bus). Desktop-only — the triage LLM pipeline needs the desktop providers, so the remote daemon does not serve it.

Improvement proposals (POST /ai/improvements/scan) run a one-shot Headless-slot LLM pass over deterministic local repo context (working-tree status + recent commits) and emit proposals-ready on the same GitHub Ops event shape. The scan never creates GitHub issues. A user action calls POST /repo/create-issue-from-proposal, which wraps the existing create_issue_impl path and returns { number, url, title }.

Agent Endpoints

Detect All Agents

GET /agents

Returns detected agent binaries and installed IDEs.

Detect Specific Agent

GET /agents/detect?binary=claude

Returns detection result for a specific agent binary.

Detect Installed IDEs

GET /agents/ides

Returns list of installed IDEs.

Prompt Endpoints

Process Prompt

POST /prompt/process
Content-Type: application/json

{ "content": "...", "variables": { ... } }

Substitutes {{var}} placeholders in prompt text.

Extract Variables

POST /prompt/extract-variables
Content-Type: application/json

{ "content": "..." }

Returns list of {{var}} placeholder names found in content.

Plugin Endpoints

List Plugins

GET /plugins/list

Returns array of valid plugin manifests.

Plugin Development Guide

GET /plugins/docs

Returns the complete plugin development reference as {"content": "..."}. AI-optimized documentation covering manifest format, PluginHost API, structured event types, and example plugins.

Plugin Data

GET /api/plugins/:plugin_id/data/*path

Reads a plugin’s stored data file. Returns application/json if content starts with { or [, otherwise text/plain. Returns 404 if the file doesn’t exist. Goes through the same auth middleware as all other routes.

Note: write_plugin_data maps to POST /api/plugins/:plugin_id/data/*path; delete_plugin_data has no HTTP route (no frontend caller). Data is sandboxed to ~/.config/tuicommander/plugins/{plugin_id}/data/.

Plugin RPC (host capabilities, story 071)

Browser/PWA parity for the plugin host RPC surface. Every route is :plugin_id-scoped and reuses the same per-plugin sandboxing as the Tauri commands (plugin_fs.rs path jail, plugin_http.rs allowed-URL check, plugin_exec.rs binary whitelist).

GET  /api/plugins/:plugin_id/fs/read?path=<p>                     -> string        (plugin_read_file)
GET  /api/plugins/:plugin_id/fs/read-base64?path=<p>              -> string        (plugin_read_file_base64)
GET  /api/plugins/:plugin_id/fs/tail?path=<p>&maxBytes=<n>        -> string        (plugin_read_file_tail)
GET  /api/plugins/:plugin_id/fs/list?path=<p>&pattern=&sortBy=    -> string[]      (plugin_list_directory)
POST /api/plugins/:plugin_id/fs/write    { path, content }        -> { ok }        (plugin_write_file)
POST /api/plugins/:plugin_id/fs/rename   { from, to }             -> { ok }        (plugin_rename_path)
POST /api/plugins/:plugin_id/build-artifacts/scan   { repoPaths, forceRefresh? } -> BuildArtifact[]
POST /api/plugins/:plugin_id/build-artifacts/delete { path, repoPaths } -> { ok }
POST /api/plugins/:plugin_id/exec        { binary, args, cwd? }   -> string        (plugin_exec_cli)
POST /api/plugins/:plugin_id/http        { url, method?, headers?, body?, allowedUrls } -> HttpResponse
GET  /api/plugins/:plugin_id/pty/output?sessionId=<id>&maxLines=  -> string        (plugin_read_session_output)
POST /api/plugins/:plugin_id/register    { capabilities }         -> { ok }
POST /api/plugins/:plugin_id/unregister                           -> { ok }
GET  /api/plugins/:plugin_id/readme                               -> string | null

Build-artifact scans normalize the root set, share an in-flight scan across callers, and reuse completed results for 30 seconds. Set forceRefresh: true to bypass a completed cached result; a scan already running for the same roots remains shared.

Intentionally not mapped (native/host-only, stay Tauri-only): plugin_watch_path / plugin_unwatch (change events need AppHandle/WS delivery), plugin_read_credential (OS keychain), and user-plugin install/uninstall (install_plugin_from_*, uninstall_plugin — local-FS install + AppHandle emit). delete_plugin_data is unmapped for lack of a frontend caller (YAGNI).

Worktree Endpoints

List Worktrees

GET /worktrees

Returns list of managed worktrees.

Create Worktree

POST /worktrees
Content-Type: application/json

{ "base_repo": "/path", "branch_name": "feature-x" }

base_repo must be an absolute, normalized path. The route rejects invalid paths before invoking git, matching MCP repo action=worktree_create validation.

Worktrees Base Directory

GET /worktrees/dir

Returns the base directory where worktrees are created.

Get Worktree Paths

GET /worktrees/paths?path=/path/to/repo

Returns { "branch-name": "/worktree/path", ... }.

Generate Worktree Name

POST /worktrees/generate-name
Content-Type: application/json

{ "existing_names": ["name1", "name2"] }

Returns a unique worktree name.

Finalize Merged Worktree

POST /worktrees/finalize
Content-Type: application/json

{ "repoPath": "/path/to/repo", "branchName": "feature-x", "action": "archive", "force": false }

Finalizes a merged worktree branch. action must be "archive" (moves to archive directory) or "delete" (removes worktree and branch). For action: "delete", the response includes branch_delete_warning when the worktree was removed but safe branch deletion failed, for example because the branch has unmerged commits.

force (optional, default false) skips the dirty-worktree gate. Both actions end in git worktree remove --force, so a worktree that is not known to be clean comes back as { "action": "needs_confirmation", "merged": true } without touching anything — ask the user, then re-send with "force": true. A dirty check that fails to run blocks the same way (worktree_dirty stays false, because git never reported “dirty”). This route shares finalize_merged_worktree_impl with the Tauri command, so both transports pass the identical gate.

Remove Worktree

DELETE /worktrees/:branch?repoPath=/path&deleteBranch=true

Query parameters:

  • repoPath (required) – base repository path
  • deleteBranch (optional, default true) – when true, also deletes the local git branch
  • force (optional, default false) – when true, uses forced worktree removal and forced branch deletion

Returns { "ok": true, "branch_delete_warning": null } on full success. When deleteBranch=true and git branch -d refuses to delete the branch after the worktree is removed, the request still succeeds with branch_delete_warning set so clients can report the partial outcome.

Push Notification Endpoints

Get VAPID Public Key

GET /api/push/vapid-key

Returns the VAPID public key for PushManager.subscribe(). No authentication required.

Response: { "publicKey": "<base64url>" }

Returns 404 if push is not enabled.

Subscribe

POST /api/push/subscribe
Content-Type: application/json

{ "endpoint": "https://...", "keys": { "p256dh": "...", "auth": "..." } }

Register a push subscription. Idempotent (same endpoint updates keys).

Push delivery is gated by desktop window focus: notifications for question and session completion events are sent whenever the desktop window is not focused (including when the app is minimized or the user is on another workspace). This avoids duplicate alerts while the user is actively at the desktop, and still wakes the PWA service worker when the phone is locked.

Unsubscribe

DELETE /api/push/subscribe
Content-Type: application/json

{ "endpoint": "https://..." }

Remove a push subscription by endpoint.

Tauri-Only Commands (No HTTP Route)

The following commands are accessible only via the Tauri invoke() bridge in the desktop app. They have no HTTP endpoint.

CommandModuleDescription
get_claude_usage_apiclaude_usage.rsFetch rate-limit usage from Anthropic OAuth API
get_claude_usage_timelineclaude_usage.rsGet hourly token usage timeline from session transcripts
get_claude_session_statsclaude_usage.rsScan session transcripts for aggregated token/session stats
get_claude_project_listclaude_usage.rsList Claude project slugs with session counts
plugin_watch_pathplugin_fs.rsStart watching path for changes (change events need AppHandle/WS)
plugin_unwatchplugin_fs.rsStop watching a path
plugin_read_credentialplugin_credentials.rsRead credential from system store
fetch_plugin_registryregistry.rsFetch remote plugin registry index
install_plugin_from_zipplugins.rsInstall plugin from local ZIP file
install_plugin_from_urlplugins.rsInstall plugin from HTTPS URL
uninstall_pluginplugins.rsRemove a plugin and all its files
get_agent_mcp_statusagent_mcp.rsCheck MCP config status for an agent
install_agent_mcpagent_mcp.rsInstall TUICommander MCP entry in agent config
remove_agent_mcpagent_mcp.rsRemove TUICommander MCP entry from agent config

Tauri Commands Reference

All commands are invoked from the frontend via invoke(command, args). In browser mode, these map to HTTP endpoints (see HTTP API).

PTY Session Management (pty.rs)

CommandArgsReturnsDescription
create_ptyconfig: PtyConfigString (session ID)Create PTY session
create_pty_with_worktreepty_config, worktree_configWorktreeResultCreate worktree + PTY
write_ptysession_id, data()Write to PTY
enqueue_agent_commandsession_id, text{ typed, queued }Queue a command for the agent’s next idle window (typed at once when already idle); errors for non-agent sessions
clear_queued_agent_commandssession_idusizeDrop every queued command; returns how many
list_queued_agent_commandssession_id[{ id, text }]The queued user commands in delivery order; peer messages excluded
remove_queued_agent_commandsession_id, command_idboolDrop one queued command by id; false when it already drained
resize_ptysession_id, rows, cols()Resize PTY; alternate-screen resizes preserve primary-log continuity
pause_ptysession_id()Pause reader thread
resume_ptysession_id()Resume reader thread
close_ptysession_id, cleanup_worktree()Close PTY session
can_spawn_sessionboolCheck session limit
get_orchestrator_statsOrchestratorStatsActive/max/available
get_session_metricsJSONSpawn/fail/byte counts
list_active_sessionsVec<ActiveSessionInfo>List all sessions with display_name_is_custom, is_remote, and the same optional lifecycle state (shell_state, agent_state, background_work, queued_commands) returned by GET /sessions
list_worktreesVec<JSON>List managed worktrees
update_session_cwdsession_id, cwd()Update session working directory (from OSC 7)
get_session_foreground_processsession_idJSONGet foreground process info
get_kitty_flagssession_idu32Get Kitty keyboard protocol flags for session
get_last_promptsession_idOption<String>Get last user-typed prompt from input line buffer
get_shell_statesession_idOption<String>Get current shell state (“busy”, “idle”, or null); agent-specific semantic Working markers can repair a transient false-idle state
has_foreground_processsession_id: StringboolChecks if a non-shell foreground process is running
debug_agent_detectionsession_id: StringAgentDiagnosticsReturns diagnostic breakdown of agent detection pipeline
set_session_namesession_id, name, is_custom?()Set a session display name and whether it represents an explicit user rename
get_input_buffer_contentsession_idStringGet the current content of the input line buffer (what the user is typing). Used by plugins with pty:read capability.
get_process_statsVec<ProcessStat>CPU% and RSS memory for TUIC and all child process trees

Generators (generators.rs)

CommandArgsReturnsDescription
generate_valuegenerator_id, optionsGeneratedValueGenerate a secure random value (password, uuid_v4, uuid_v7, ulid, cuid2, jwt_secret, totp_secret, nano_id, slug, ed25519_keypair)

Git Operations (git.rs)

CommandArgsReturnsDescription
get_repo_infopathRepoInfoRepo name, branch, status
get_git_diffpathStringFull git diff
get_diff_statspathDiffStatsAddition/deletion counts
get_changed_filespathVec<ChangedFile>Changed files with stats
get_file_diffpath, fileStringSingle file diff
get_gutter_changespath, file, scope?Vec<GutterChange>Per-line editor gutter/scrollbar change markers (diff parsed in Rust)
get_git_branchespathVec<JSON>All branches (sorted)
get_recent_commitspathVec<JSON>Recent git commits
rename_branchpath, old_name, new_name()Rename branch
check_is_main_branchbranchboolIs main/master/develop
get_initialsnameString2-char repo initials
get_merged_branchesrepo_pathVec<String>Branches merged into default branch
get_repo_summaryrepo_pathRepoSummaryAggregate snapshot: worktree paths + merged branches + per-path diff stats in one IPC
get_repo_structurerepo_pathRepoStructureFast phase: worktree paths + merged branches only (Phase 1 of progressive loading)
get_repo_diff_statsrepo_pathRepoDiffStatsSlow phase: per-worktree diff stats + last commit timestamps (Phase 2 of progressive loading)
run_git_commandpath, argsGitCommandResultRun arbitrary git command (success, stdout, stderr, exit_code)
get_git_panel_contextpathGitPanelContextRich context for Git Panel (branch, ahead/behind, staged/changed/stash counts, last commit, rebase/cherry-pick state). Cached 5s TTL.
get_working_tree_statuspathWorkingTreeStatusFull porcelain v2 status: branch, upstream, ahead/behind, stash count, staged/unstaged entries, untracked files
update_from_basepath, branch_name, strategy?StringFetch configured base ref and rebase or merge the branch onto it. Conflict cleanup reports (aborted) only after abort succeeds; abort failure includes manual recovery guidance.
git_stage_filespath, files()Stage files (git add). Path-traversal validated
git_unstage_filespath, files()Unstage files (git restore --staged). Path-traversal validated
git_discard_filespath, files()Discard working tree changes (git restore). Destructive. Path-traversal validated
git_commitpath, message, amend?String (commit hash)Commit staged changes; optional --amend. Returns new HEAD hash
get_commit_logpath, count?, after?Vec<CommitLogEntry>Paginated commit log (default 50, max 500). after is a commit hash for cursor-based pagination
get_stash_listpathVec<StashEntry>List stash entries (index, ref_name, message, hash)
git_stash_applypath, index()Apply stash entry by index
git_stash_poppath, index()Pop stash entry by index
git_stash_droppath, index()Drop stash entry by index
git_stash_showpath, indexStringShow diff of stash entry
git_apply_reverse_patchpath, patch, scope?()Apply a unified diff patch in reverse (git apply --reverse). Used for hunk/line restore. scope="staged" adds --cached. Patch passed via stdin (no temp files). Path-traversal validated
get_file_historypath, file, count?, after?Vec<CommitLogEntry>Per-file commit log following renames (default 50, max 500)
get_file_blamepath, fileVec<BlameLine>Per-line blame: hash, author, author_time (unix), line_number, content
get_branches_detailpathVec<BranchDetail>Rich branch listing: name, ahead/behind, last commit date, tracking upstream, merged status
delete_branchpath, name, force()Delete a local branch. force=false uses safe -d; force=true uses -D. Refuses to delete the current branch or default branch
create_branchpath, name, start_point, checkout()Create a new branch from start_point (defaults to HEAD). checkout=true switches to it immediately
get_recent_branchespath, limitVec<String>Recently checked-out branches from reflog, ordered by recency

Commit Graph (git_graph.rs)

CommandArgsReturnsDescription
get_commit_graphpath, count?Vec<GraphNode>Lane-assigned commit graph for visual rendering. Default 200, max 1000. Returns hash, column, row, color_index (0–7), parents, refs, and connection metadata (from/to col/row) for Bezier curve drawing

GitHub Authentication (github_auth.rs)

CommandArgsReturnsDescription
github_start_loginDeviceCodeResponseStart OAuth Device Flow, returns user/device code
github_poll_logindevice_codePollResultPoll for token; saves to keyring on success
github_logout()Delete OAuth token from keyring, fall back to env/CLI
github_auth_statusAuthStatusCurrent auth: login, avatar, source, scopes
github_disconnect()Disconnect GitHub (clear all tokens from keyring and env cache)
github_diagnosticsJSONDiagnostics: token sources, scopes, API connectivity

GitHub Integration (github.rs)

CommandArgsReturnsDescription
get_github_statuspathGitHubStatusPR + CI for current branch
get_ci_checkspathVec<JSON>CI check details
get_repo_pr_statusespath, include_mergedVec<BranchPrStatus>Batch PR status (all branches)
approve_prrepo_path, pr_numberStringSubmit approving review via GitHub API
merge_pr_via_githubrepo_path, pr_number, merge_methodStringMerge PR via GitHub API
get_all_pr_statusespathVec<BranchPrStatus>Batch PR status for all branches (includes merged)
get_pr_diffrepo_path, pr_numberStringGet PR diff content
run_pr_reviewrepo_path, pr_numberPrReviewResultAI review of a PR diff (multi-turn engine, Main slot) → line-level findings
get_merged_prsrepo_path, since_tag?Vec<MergedPr>Merged PRs via GraphQL, optionally since a tag’s date (AI changelog source)
generate_changelogrepo_path, since_tag?{markdown, json}AI changelog from merged PRs (headless slot, one-shot)
start_conflict_assistrepo_path, pr_numberConflictAssistResultWorktree on PR head + rebase onto base; reports verified/unverified clean or conflicts, base provenance/warning, and agent prompt (push gated, never auto-merge)
run_improvement_scanrepo_path, focusImprovementScanResultOne-shot Headless-slot scan of local repo context for improvement proposals (focus: refactor, testing, perf); emits proposals-ready
create_issue_from_proposalrepo_path, proposalCreatedIssueHuman-gated issue creation from an improvement proposal
fetch_ci_failure_logsrepo_path, branchStringFetch failed-job logs for the branch’s latest GitHub Actions head, including partially completed workflow runs
check_github_circuitpathCircuitStateCheck GitHub API circuit breaker state

Worktree Management (worktree.rs)

CommandArgsReturnsDescription
create_worktreebase_repo, branch_nameJSONCreate git worktree
remove_worktreerepo_path, branch_name, delete_branch?, force?{ branch_delete_warning?: string }Remove worktree; delete_branch (default true) controls whether the local branch is also deleted. If safe branch deletion fails after the worktree is removed, returns branch_delete_warning so the UI can report that the branch was kept. Archive script resolved from config (not IPC).
delete_local_branchrepo_path, branch_name()Delete a local branch (and its worktree if linked). Refuses to delete the default branch. Uses safe git branch -d
check_worktree_dirtyrepo_path, branch_nameboolCheck if a branch’s worktree has uncommitted changes. Returns false if no worktree exists. When git cannot answer (the worktree list or status call fails) it returns an error, never false — callers that gate a destructive action must see the failure
get_worktree_pathsrepo_pathHashMap<String,String>Worktree paths for repo
get_worktrees_dirStringWorktrees base directory
generate_worktree_name_cmdexisting_namesStringGenerate unique name
list_local_branchespathVec<String>List local branches
checkout_remote_branchrepo_path, branch_name()Check out a remote-only branch as a new local tracking branch
detect_orphan_worktreesrepo_pathVec<String>Detect worktrees in detached HEAD state (branch deleted)
remove_orphan_worktreerepo_path, worktree_path()Remove an orphan worktree by filesystem path (validated against repo)
switch_branchrepo_path, branch_name()Switch main worktree to a different branch (with dirty-state and process checks)
merge_and_archive_worktreerepo_path, branch_name, target_branch, after_merge, force?MergeArchiveResultMerge worktree branch into base and archive. A pre-flight counts the commits the target is missing and checks whether the worktree is dirty; both are returned so the caller can say what the merge actually carried. When after_merge is archive or delete and the worktree is not known to be clean, it returns action: "needs_confirmation" without touching anything — re-call with force: true to proceed. The commit count does not enter that decision: both cleanups end in git worktree remove --force, which destroys uncommitted work whether or not the branch carries commits. A dirty check that fails also blocks (worktree_dirty stays false because git never said “dirty”). If conflict cleanup abort fails, the error reports the repo may still be conflicted and includes the manual abort command.
finalize_merged_worktreerepo_path, branch_name, action, force?MergeArchiveResultClean up a merged worktree. Passes the same dirty-worktree gate as merge_and_archive_worktree: without force a worktree that is not known to be clean comes back as action: "needs_confirmation" instead of being wiped (merged: true — only the cleanup stopped, the merge already landed). Delete action may include branch_delete_warning if the worktree was removed but safe branch deletion kept the branch.
list_base_ref_optionsrepo_pathVec<String>List valid base refs for worktree creation
run_setup_scriptrepo_path, worktree_path()Run post-creation setup script in new worktree
generate_clone_branch_name_cmdbase_name, existing_namesStringGenerate hybrid branch name for clone worktree

Configuration (config.rs)

CommandArgsReturnsDescription
load_app_configAppConfigLoad app settings
save_app_configconfig()Save app settings
load_notification_configNotificationConfigLoad notifications
save_notification_configconfig()Save notifications
load_ui_prefsUIPrefsConfigLoad UI preferences
save_ui_prefsconfig()Save UI preferences
load_repo_settingsRepoSettingsMapLoad per-repo settings
save_repo_settingsconfig()Save per-repo settings
check_has_custom_settingspathboolHas non-default settings
load_repo_defaultsRepoDefaultsConfigLoad repo defaults
save_repo_defaultsconfig()Save repo defaults
load_repositoriesJSONLoad saved repositories
save_repositoriesconfig()Save repositories
load_prompt_libraryPromptLibraryConfigLoad prompts
save_prompt_libraryconfig()Save prompts
load_notesJSONLoad notes
save_notesconfig()Save notes
save_note_imagenote_id, data_base64, extensionString (absolute path)Decode base64 image, validate ≤10 MB, write to config_dir()/note-images/<note_id>/<timestamp>.<ext>
delete_note_assetsnote_id()Remove note-images/<note_id>/ directory recursively (no-op if missing)
get_note_images_dirStringReturn config_dir()/note-images/ absolute path
load_keybindingsJSONLoad keybinding overrides
save_keybindingsconfig()Save keybinding overrides
load_agents_configAgentsConfigLoad per-agent run configs
save_agents_configconfig()Save per-agent run configs
load_activityActivityConfigLoad activity dashboard state
save_activityconfig()Save activity dashboard state
load_repo_local_configrepo_pathRepoLocalConfig?Read .tuic.json from repo root; returns null if absent or malformed
save_repo_local_configrepo_path()Write the repo’s effective resolved worktree/branch settings (global defaults + per-repo overrides) to .tuic.json at its root (committable, team-shareable). Preserves fields already in the file (e.g. mcp_upstreams); never writes script fields

SSH Tunnels (tunnels/tauri_commands.rs)

CommandArgsReturnsDescription
list_tunnel_profilesVec<TunnelProfile>Load all tunnel profiles (global + per-repo merged)
save_tunnel_profileprofile: JSONString (profile ID)Create or update a tunnel profile. Auto-generates UUID if id is empty. Validates before saving
delete_tunnel_profileidboolDelete a tunnel profile by ID. Stops the tunnel if running
start_tunnelidStringStart a tunnel by profile ID. Loads the profile, validates, and spawns the SSH process
stop_tunnelid()Stop a running tunnel by profile ID
list_active_tunnelsVec<JSON>List all active tunnels with ID, status, and started_at
get_tunnel_statusidJSONGet the current status of a specific tunnel (starting, connected, reconnecting, stopped, error)
list_ssh_config_hostsVec<String>Parse ~/.ssh/config and return all non-negated, non-wildcard Host entries
get_tunnel_auditid, limit?Vec<JSON>Query audit log events for a tunnel (default limit 20). Returns timestamp, kind, and extracted message
list_ssh_agent_keysSshAgentInfoDetect SSH agent type (1Password, Secretive, GPG, generic) and list loaded keys via ssh-add -l

Agent Detection (agent.rs)

CommandArgsReturnsDescription
detect_agent_binarybinaryAgentBinaryDetectionCheck binary in PATH
detect_all_agent_binariesVec<AgentBinaryDetection>Detect all known agents
detect_claude_binaryStringDetect Claude binary
detect_installed_idesVec<String>Detect installed IDEs
open_in_apppath, app()Open path in application
spawn_agentpty_config, agent_configString (session ID)Spawn agent in PTY

Agent Session Discovery (agent_session.rs)

CommandArgsReturnsDescription
discover_agent_sessionsession_id, agent_type, cwdOption<String>Discover agent session UUID from filesystem for session-aware resume
verify_agent_sessionagent_type, session_id, cwdboolVerify if a specific agent session file exists on disk (for TUIC_SESSION resume)

AI Chat (ai_chat.rs)

Conversational AI companion with terminal context injection. See docs/user-guide/ai-chat.md for the feature overview.

CommandArgsReturnsDescription
load_ai_chat_configAiChatConfigLoad provider / model / base URL / temperature / context_lines from ai-chat-config.json
save_ai_chat_configconfig()Persist chat config
has_ai_chat_api_keyboolWhether an API key is stored in the OS keyring for the current provider
save_ai_chat_api_keykey: String()Store API key in OS keyring (service tuicommander-ai-chat, user api-key)
delete_ai_chat_api_key()Remove stored API key
check_ollama_statusOllamaStatusProbe GET /api/tags on the configured base URL (default http://localhost:11434/v1/); returns reachable + model list
test_ai_chat_connectionStringValidate API key + base URL with a minimal completion request
list_conversationsVec<ConversationMeta>List persisted conversations (id, title, updated_at, message count)
load_conversationid: StringConversationLoad a saved conversation body
save_conversationconversation: Conversation()Persist a conversation to ai-chat-conversations/<id>.json
delete_conversationid: String()Remove a saved conversation (idempotent)
new_conversation_idStringMint a fresh conversation UUID
stream_ai_chatsession_id, messages, chat_id, on_event: Channel<ChatStreamEvent>()Stream a turn. Events: chunk { text }, end, error { message }, tool_call / tool_result (agent mode). Context assembly pulls VtLogBuffer (capped at context_lines), SessionState, recent ParsedEvents, git context
cancel_ai_chatchat_id: String()Cancel an in-flight stream (idempotent)

Chat Registry (ai_chat_registry.rs)

Cross-window state synchronization for the AI Chat panel. The registry is the Rust-side source of truth; frontends subscribe via Channel<ChatEvent> for real-time projection.

CommandArgsReturnsDescription
chat_subscribechat_id, on_event: Channel<ChatEvent>{ subscriptionId, snapshot }Subscribe to a chat’s state changes. Returns current snapshot + subscription ID. Events: snapshot, chunk { delta }, error { message }, cleared
chat_unsubscribechat_id, subscription_id()Remove a subscriber (normal cleanup path)
chat_get_statechat_idConversationStateSnapshotRead-only snapshot of a chat’s current state
chat_push_messagechat_id, role, content()Push a message to the registry and fan-out to subscribers
chat_clearchat_id()Clear conversation state and notify subscribers
chat_set_pinnedchat_id, pinned()Set the pinned flag on a chat
chat_attach_terminalchat_id, terminal_id()Attach a terminal session to a chat
chat_detach_terminalchat_id()Detach the terminal from a chat
open_panel_windowpanel_id, title?, params?, width?, height?()Open (or focus) a detached panel window. panel_id becomes the window label prefix (panel-{id}). URL: /?mode=panel&panel={id}&{params}. Emits panel-window-closed { panelId } on destroy
close_panel_windowpanel_id()Close a detached panel window by ID
focus_main_window()Bring the main window to foreground (used by detached panels after cross-window actions)

AI Agent Loop (ai_agent/commands.rs)

ReAct-style agent loop driving a terminal session with ai_terminal_* tools, plus a Tauri-side query for the per-session knowledge store.

CommandArgsReturnsDescription
start_agent_loopsession_id, goal, unrestricted?: boolString (status message)Start a ReAct loop on the given terminal session with the given goal. When unrestricted=true, sets TrustLevel::Unrestricted — bypasses sandbox and approval prompts. Errors if an agent is already active for the session.
cancel_agent_loopsession_idStringCancel the active agent loop. Errors if no loop is active.
pause_agent_loopsession_idStringPause the active agent loop between iterations.
resume_agent_loopsession_idStringResume a paused agent loop.
agent_loop_statussession_id{ active: bool, state: AgentState?, session_id }Query whether an agent is active and its current state (running/paused/pending_approval).
approve_agent_actionsession_id, approvedStringApprove or reject the pending destructive command the agent wants to run. Errors if no agent is active.
get_session_knowledgesession_idSessionKnowledgeSummaryLightweight summary for the SessionKnowledgeBar UI: commands count, last 5 outcomes with kind badges, recent errors with error_type, TUI mode indicator, TUI apps seen. Returns an empty summary when the session has no recorded knowledge yet.
list_knowledge_sessionsfilter?: { text?, hasErrors?, since? }, limit?SessionListEntry[]Scan persisted ai-sessions/ and list sessions sorted by most recent activity. Filter by text (matches command/output/intent/error_type), errors-only, or UNIX-seconds since lower bound. limit clamps at 500 (default 100).
get_knowledge_session_detailsession_idSessionDetail?Full command history for one session — reads the in-memory store when active, falls back to disk otherwise. HistoryCommand rows include pre-extracted kind/error_type and the opt-in semantic_intent.
load_scheduler_configSchedulerConfigLoad cron scheduler config from ai-cron.json. Returns { jobs: ScheduledJob[] } where each job has id, cron_expr, goal.
save_scheduler_configconfig: SchedulerConfig()Validate cron expressions and persist scheduler config. Errors if any expression is invalid.

Agent Tools (ai_agent/tools.rs)

13 tools available to the ReAct agent loop and exposed via MCP as ai_terminal_*:

Terminal tools (require session_id):

ToolArgsDescription
read_screensession_id, lines?Read visible terminal text (default 50 lines). Secrets redacted.
send_inputsession_id, commandSend a text command to the PTY (Ctrl-U prefix + \r).
send_keysession_id, keySend a special key (enter, tab, ctrl+c, escape, arrows).
wait_forsession_id, pattern?, timeout_ms?, stability_ms?Wait for regex match or screen stability.
get_statesession_idStructured session metadata (shell_state, cwd, terminal_mode).
get_contextsession_idCheap orientation: {shell_state, cwd, git_branch, last_exit_code, agent_type}. Branch from .git/HEAD (no subprocess).

Filesystem tools (sandboxed per session via FileSandbox):

ToolArgsDescription
read_filefile_path, offset?, limit?Paginated file read (default 200, max 2000 lines). Binary/10MB rejected. Secrets redacted.
write_filefile_path, contentAtomic create/overwrite (tmp+rename). Sensitive paths flagged.
edit_filefile_path, old_string, new_string, replace_all?Search-and-replace. Must be unique unless replace_all=true.
list_filespattern, path?Glob match (e.g. src/**/*.rs). Max 500 entries.
search_filespattern, path?, glob?, context_lines?Regex search, .gitignore-aware. Max 50 matches with context.
search_codequery, path?, limit?BM25 semantic search over repo files via AppState::content_index. Returns ranked file paths with relevance scores.
run_commandcommand, timeout_ms?, cwd?Shell command with captured stdout/stderr. Safety-checked. Env sanitized.

MCP OAuth 2.1 (mcp_oauth/commands.rs)

OAuth 2.1 authorization for upstream MCP servers. Full RFC 9728 (Protected Resource Metadata) + RFC 8414 (Authorization Server Discovery) flow with PKCE S256. Completion via the tuic://oauth-callback deep link.

CommandArgsReturnsDescription
start_mcp_upstream_oauthname: StringStartOAuthResponseBegin an OAuth flow for the named upstream. Transitions status to authenticating, returns the authorization URL + AS origin for the consent dialog. PKCE challenge is generated and stored per pending flow
mcp_oauth_callbackcode: String, oauth_state: String()Consume the tuic://oauth-callback?code=…&state=… deep link. Exchanges the code for tokens, persists OAuthTokenSet to the OS keyring, transitions upstream to connecting
cancel_mcp_upstream_oauthname: String()Abort an in-flight OAuth flow. Drops the pending entry and resets upstream status

MCP Upstream Proxy (mcp_upstream_config.rs, mcp_upstream_credentials.rs)

Commands for managing upstream MCP servers proxied through TUICommander’s /mcp endpoint.

CommandArgsReturnsDescription
load_mcp_upstreamsUpstreamMcpConfigLoad upstream config from mcp-upstreams.json
save_mcp_upstreamsbase: UpstreamMcpConfig, config: UpstreamMcpConfig()Apply the caller’s ID-keyed base-to-config delta to the latest locked mcp-upstreams.json, validate it, and hot-reload the exact persisted change. Removing a server or its optional auth field is an explicit deletion; unrelated concurrent changes are preserved
reconnect_mcp_upstreamname: String()Disconnect and reconnect a single upstream by name. Useful after credential changes or transient failures
get_mcp_upstream_statusVec<UpstreamStatus>Get live status of all upstream MCP servers. Status values: connecting, ready, circuit_open, disabled, failed, authenticating, needs_auth
save_mcp_upstream_credentialname: String, token: String()Store a Bearer token for an upstream in the OS keyring
delete_mcp_upstream_credentialname: String()Remove a Bearer token from the OS keyring (idempotent)

UpstreamMcpConfig schema

interface UpstreamMcpConfig {
  servers: UpstreamMcpServer[];
}

interface UpstreamMcpServer {
  id: string;              // Unique UUID, used for config diff tracking
  name: string;            // Namespace prefix — must match [a-z0-9_-]+
  transport: UpstreamTransport;
  enabled: boolean;        // Default: true
  timeout_secs: number;    // Default: 30 (0 = no timeout, HTTP only)
  tool_filter?: ToolFilter; // Optional allow/deny filter
}

type UpstreamTransport =
  | { type: "http"; url: string }
  | { type: "stdio"; command: string; args: string[]; env: Record<string, string> };

interface ToolFilter {
  mode: "allow" | "deny";
  patterns: string[];  // Exact names or trailing-* glob prefix patterns
}

Upstream status values

The live registry exposes status via SSE events (upstream_status_changed). Valid status strings:

ValueMeaning
connectingHandshake in progress
readyTools available
circuit_openCircuit breaker open, backoff active
disabledDisabled in config
failedPermanently failed, manual reconnect required

Agent MCP Configuration (agent_mcp.rs)

CommandArgsReturnsDescription
get_agent_mcp_statusagentAgentMcpStatusCheck MCP config for an agent
install_agent_mcpagentStringInstall TUICommander MCP entry
remove_agent_mcpagentStringRemove TUICommander MCP entry
get_agent_config_pathagentStringGet agent’s MCP config file path
get_mcp_bridge_infoMcpBridgeInfoBridge path + ready-to-paste JSON config snippet

Prompt Processing (prompt.rs)

CommandArgsReturnsDescription
extract_prompt_variablescontentVec<String>Parse {var} placeholders
process_prompt_contentcontent, variablesStringSubstitute variables
resolve_context_variablesrepo_path: StringHashMap<String, String>Resolve git context variables (branch, diff, changed_files, commit_log, etc.) for smart prompt substitution. Best-effort: variables that fail are omitted

Smart Prompt Execution (smart_prompt.rs)

CommandArgsReturnsDescription
execute_headless_promptcommand: String, args: Vec<String>, stdin_content: Option<String>, timeout_ms: u64, repo_path: String, env: Option<HashMap<String,String>>Result<String, String>Spawn a one-shot agent process in argv form (no shell — metacharacters in args are literal). Prompt content piped via stdin. Timeout capped at 5 minutes
execute_shell_scriptscript_content: String, timeout_ms: u64, repo_path: StringResult<String, String>Execute shell script content directly via platform shell (sh/cmd). No agent involved — runs the content as-is. Captures stdout. Timeout capped at 60 seconds

Claude Usage (claude_usage.rs)

CommandArgsReturnsDescription
get_claude_usage_apiUsageApiResponseFetch rate-limit usage from Anthropic OAuth API
get_claude_usage_timelinescope, days?Vec<TimelinePoint>Hourly token usage from session transcripts
get_claude_session_statsscopeSessionStatsAggregated token/session stats from JSONL transcripts
get_claude_project_listVec<ProjectEntry>List project slugs with session counts

scope values: "all" (all projects) or a specific project slug. days defaults to 7.

Uses incremental parsing with a file-size-based cache (claude-usage-cache.json) so only newly appended JSONL data is processed on each call. The cache is persisted across app restarts.

Voice Dictation (dictation/)

CommandArgsReturnsDescription
start_dictation()Start recording
stop_dictation_and_transcribeTranscribeResponseStop + transcribe. Returns {text, skip_reason?, duration_s}
inject_texttextStringApply corrections
get_dictation_statusDictationStatusModel/recording status plus normalized audio_level (0–1)
get_model_infoVec<ModelInfo>Available models
download_whisper_modelmodel_nameStringDownload model
delete_whisper_modelmodel_nameStringDelete model
get_correction_mapHashMap<String,String>Load corrections
set_correction_mapmap()Save corrections
list_audio_devicesVec<AudioDevice>List input devices
get_dictation_configDictationConfigLoad config
set_dictation_configconfig()Save config
check_microphone_permissionStringCheck macOS microphone TCC permission status
open_microphone_settings()Open macOS System Settings > Privacy > Microphone

Filesystem (fs.rs)

CommandArgsReturnsDescription
resolve_terminal_pathpathStringResolve terminal path
list_directorypathVec<DirEntry>List directory contents
fs_read_filepathStringRead file contents
write_filepath, content()Write file
create_directorypath()Create directory
delete_pathpath()Delete file or directory
rename_pathsrc, dest()Rename/move path
copy_pathsrc, dest()Copy file or directory
copy_path_absfrom, to()Copy a file by absolute paths (cross-repo paste). Rejects directories.
move_path_absfrom, to()Move a file by absolute paths (cross-repo cut+paste); copy+remove fallback across filesystems.
fs_transfer_pathsdestDir, paths, mode ("move"|"copy"), allowRecursiveTransferResult { moved, skipped, errors, needs_confirm }Move/copy OS paths into a destination directory. Skips silently on name conflicts; returns needs_confirm=true (no-op) when a source is a directory and allowRecursive=false. Used by the drag-drop handler when dropping files onto a folder in the file browser.
add_to_gitignorepath, pattern()Add pattern to .gitignore
search_filespath, queryVec<SearchResult>Search files by name in directory
search_contentrepoPath, query, caseSensitive?, useRegex?, wholeWord?, limit?()Full-text content search; streams results progressively via content-search-batch events. Binary files and files >1 MB are skipped. Supports cancellation.
search_content_allquery, caseSensitive?, limit?()Cross-repo BM25 content search over every ready index; streams via the same content-search-batch events with each match tagged repo_path. Only repos whose index is built participate (depends on Content Indexing strategy). Shares the cancellation slot with search_content.

Plugin Management (plugins.rs)

CommandArgsReturnsDescription
list_user_pluginsVec<PluginManifest>List valid plugin manifests
get_plugin_readme_pathidOption<String>Get plugin README.md path
read_plugin_dataplugin_id, pathOption<String>Read plugin data file
write_plugin_dataplugin_id, path, content()Write plugin data file
delete_plugin_dataplugin_id, path()Delete plugin data file
install_plugin_from_zippathPluginManifestInstall from local ZIP
install_plugin_from_urlurlPluginManifestInstall from HTTPS URL
uninstall_pluginid()Remove plugin and all files
install_plugin_from_folderpathPluginManifestInstall from local folder
register_loaded_pluginplugin_id()Register a plugin as loaded (for lifecycle tracking)
unregister_loaded_pluginplugin_id()Unregister a plugin (on unload/disable)

Plugin Filesystem (plugin_fs.rs)

CommandArgsReturnsDescription
plugin_read_filepath, plugin_idStringRead file as UTF-8 (within $HOME, 10 MB limit)
plugin_read_file_base64path, plugin_idStringRead file bytes as base64 (within $HOME, 10 MB limit)
plugin_read_file_tailpath, max_bytes, plugin_idStringRead last N bytes of file, skip partial first line
plugin_list_directorypath, pattern?, plugin_idVec<String>List filenames in directory (optional glob filter)
plugin_watch_pathpath, plugin_id, recursive?, debounce_ms?String (watch ID)Start watching path for changes
plugin_unwatchwatch_id, plugin_id()Stop watching a path
plugin_write_filepath, content, plugin_id()Write file within $HOME (path-traversal validated)
plugin_rename_pathsrc, dest, plugin_id()Rename/move path within $HOME (path-traversal validated)

Plugin HTTP (plugin_http.rs)

CommandArgsReturnsDescription
plugin_http_fetchurl, method?, headers?, body?, allowed_urls, plugin_idHttpResponseMake HTTP request (validated against allowed_urls)

Code Intelligence / MDKB (mdkb_commands.rs)

CommandArgsReturnsDescription
mdkb_statusMdkbStatusCheck if mdkb binary is available and daemon connected
mdkb_outlinerepo_path, file_pathVec<OutlineSymbol>Get symbol outline (functions, types) for a file
mdkb_goto_definitionrepo_path, file_path, line, col?DefinitionLocation?Find definition of symbol at position
mdkb_referencesrepo_path, symbol_nameVec<ReferenceLocation>Find all callers of a symbol via code_graph
install_mdkbStringDownload and install mdkb binary
uninstall_mdkb()Remove mdkb binary (errors for homebrew/cargo installs)

Plugin CLI Execution (plugin_exec.rs)

CommandArgsReturnsDescription
plugin_exec_clibinary, args, cwd?, plugin_idStringExecute whitelisted CLI binary, return stdout. Allowed: mdkb. 30s timeout, 5 MB limit.

Plugin Credentials (plugin_credentials.rs)

CommandArgsReturnsDescription
plugin_read_credentialservice_name, plugin_idString?Read credential from system store (Keychain/file)

Plugin Registry (registry.rs)

CommandArgsReturnsDescription
fetch_plugin_registryVec<RegistryEntry>Fetch remote plugin registry index

Watchers

CommandArgsReturnsDescription
start_head_watcherpath()Watch .git/HEAD for branch changes
stop_head_watcherpath()Stop watching .git/HEAD
start_repo_watcherpath()Watch .git/ for repo changes
stop_repo_watcherpath()Stop watching .git/
start_dir_watcherpath()Watch directory for file changes (non-recursive)
stop_dir_watcherpath()Stop watching directory
set_hot_repospaths: Vec<String>()Set repos with active terminals (cold repos get throttled watchers/polling)

System (lib.rs)

CommandArgsReturnsDescription
load_configAppConfigAlias for load_app_config
save_configconfig()Alias for save_app_config
hash_passwordpasswordStringBcrypt hash
list_markdown_filespathVec<MarkdownFileEntry>List .md files in dir
read_filepath, fileStringRead file contents
get_mcp_statusJSONMCP server status (no token — use get_connect_url for QR)
get_connect_urlipStringBuild QR connect URL server-side (token stays in backend)
check_update_channelchannelUpdateCheckResultCheck beta/nightly channel for updates (hardcoded URLs, SSRF-safe)
clear_caches()Clear in-memory caches
get_local_ipOption<String>Get primary local IP
get_local_ipsVec<LocalIpEntry>List local network interfaces
regenerate_session_token()Regenerate MCP session token (invalidates all remote sessions)
fetch_update_manifesturlJSONFetch update manifest via Rust HTTP (bypasses WebView CSP)
read_external_filepathStringRead file outside repo (standalone file open)
get_relay_statusJSONCloud relay connection status
get_tailscale_statusTailscaleStateTailscale daemon status (NotInstalled/NotRunning/Running with fqdn, https_enabled)

Global Hotkey

CommandArgsReturnsDescription
set_global_hotkeycombo: Option<String>()Set or clear the OS-level global hotkey
get_global_hotkeyOption<String>Get the currently configured global hotkey

App Logger (app_logger.rs)

CommandArgsReturnsDescription
push_loglevel, source, message()Push entry to ring buffer (survives webview reloads)
get_logslevel?, source?, limit?Vec<LogEntry>Query ring buffer with optional filters
clear_logs()Flush all log entries

Notification Sound (notification_sound.rs)

CommandArgsReturnsDescription
play_notification_soundsound()Play a Rust rodio notification sound (question, completion, error, warning, info, or attention)
block_sleep()Prevent system sleep
unblock_sleep()Allow system sleep

LLM API (llm_api.rs)

Smart Prompts “API” execution mode — direct LLM calls for prompt-based automation (distinct from AI Chat keyring).

CommandArgsReturnsDescription
load_llm_api_configLlmApiConfigLoad llm-api.json (provider, model, base_url)
save_llm_api_configconfig: LlmApiConfig()Persist LLM API config
has_llm_api_keyboolCheck if an API key exists in the keyring for Credential::LlmApiKey
save_llm_api_keykey: String()Store the LLM API key in the OS keyring
delete_llm_api_key()Remove the LLM API key from the OS keyring
execute_api_promptsystem_prompt, content, timeout_ms?StringExecute a direct LLM call using the configured provider/model. Returns the model’s response text.
test_llm_apiStringValidate connection to the configured LLM endpoint (sends a test prompt)

TUIC SDK

The TUIC SDK provides window.tuic inside iframes hosted by TUICommander, enabling plugins and external pages to interact with the host app: open files, read content, launch terminals, copy to clipboard, receive theme updates, and more.

Two Injection Modes

1. Inline HTML Tabs (Plugins)

Plugins use html tabs — TUIC injects the SDK <script> directly into the iframe content. window.tuic is available immediately on load.

// Plugin panel — window.tuic is injected automatically
tuic.open("README.md");                         // relative to active repo
tuic.open("/absolute/path/file.txt", { pinned: true });
tuic.edit("src/App.tsx", { line: 42 });
tuic.terminal(tuic.activeRepo());

See Plugin Authoring Guide for full plugin details.

2. URL Tabs (External Pages)

Tabs created with url load content from a remote server in an iframe. The parent cannot inject scripts into cross-origin iframes, so the page must opt in to the SDK via a postMessage handshake.

Handshake Protocol

┌─────────────┐                            ┌─────────────┐
│  TUIC Host   │                            │  iframe URL  │
│  (parent)    │                            │  (child)     │
└──────┬──────┘                            └──────┬──────┘
       │  iframe onload                            │
       │── tuic:sdk-init ────────────────────────>│
       │── tuic:repo-changed ────────────────────>│
       │── tuic:theme-changed ───────────────────>│
       │                                           │ creates window.tuic
       │                                           │ dispatches "tuic:ready"
       │                                           │
       │  (fallback path — async listeners)        │
       │<── tuic:sdk-request ──────────────────────│
       │── tuic:sdk-init + repo + theme ─────────>│
       │                                           │
       │<── tuic:open, tuic:edit, ... ─────────────│ (on user action)
       │── tuic:get-file-result ─────────────────>│ (async response)
       │── tuic:host-message ────────────────────>│ (push from host)

Both paths are implemented in src/components/PluginPanel/PluginPanel.tsx. The version field carries TUIC_SDK_VERSION so the child can feature-detect.

Step 1: Child Page — Bootstrap Listener

Add this <script> in the <head> of your page, before any framework initialization. It must be synchronous so the listener is registered before the parent’s onload fires.

<script>
(function () {
  window.addEventListener("message", function (e) {
    if (!e.data || e.data.type !== "tuic:sdk-init") return;

    window.tuic = {
      version: "1.0",
      open: function (path, opts) {
        parent.postMessage({ type: "tuic:open", path: path, pinned: !!(opts && opts.pinned) }, "*");
      },
      edit: function (path, opts) {
        parent.postMessage({ type: "tuic:edit", path: path, line: (opts && opts.line) || 0 }, "*");
      },
      terminal: function (repoPath) {
        parent.postMessage({ type: "tuic:terminal", repoPath: repoPath }, "*");
      }
    };

    window.dispatchEvent(new Event("tuic:ready"));
  });
})();
</script>

Note: For URL-mode pages, only the basic methods (open, edit, terminal) are shown above. To use the full SDK (activeRepo, getFile, theme, etc.), copy the complete SDK from src/components/PluginPanel/tuicSdk.ts or use the inline HTML mode.

Step 2: Child Page — React to SDK Availability

Use the tuic:ready event to update your UI (e.g., show an “Open in TUIC” button):

// Alpine.js example
Alpine.data("myApp", () => ({
  _tuicReady: false,
  get hasTuic() { return this._tuicReady; },

  init() {
    window.addEventListener("tuic:ready", () => { this._tuicReady = true; });
    // If SDK was already initialized before Alpine mounted
    if (window.tuic) this._tuicReady = true;
  },

  tuicOpen(filePath) {
    if (window.tuic) window.tuic.open(filePath, { pinned: true });
  }
}));

API Reference

Files

tuic.open(path, opts?)

Open a file in a TUIC tab.

ParamTypeDescription
pathstringFile path — relative (resolved against active repo) or absolute
opts.pinnedbooleanPin the tab (default: false)

tuic.edit(path, opts?)

Open a file in the external editor.

ParamTypeDescription
pathstringFile path — relative or absolute
opts.linenumberLine number to jump to (default: 0)

tuic.getFile(path): Promise<string>

Read a file’s text content from the active repo.

ParamTypeDescription
pathstringFile path — relative or absolute

Returns a Promise that resolves with the file content string, or rejects with an Error if the file is not found, the path escapes the repo root, or no active repo is set.

tuic.getFile("package.json")
  .then(content => JSON.parse(content))
  .catch(err => console.error("Cannot read:", err.message));

Path Resolution

All file methods (open, edit, getFile) accept both relative and absolute paths:

  • Relative paths (e.g., "README.md", "src/App.tsx") are resolved against the active repository root.
  • Absolute paths (e.g., "/Users/me/code/repo/file.ts") are matched against known repositories (longest prefix wins).
  • Path traversal (../) that escapes the repo root is blocked and returns an error.
  • ./ prefixes are supported and normalized.
tuic.open("README.md");                    // → /active/repo/README.md
tuic.open("src/../README.md");             // → /active/repo/README.md
tuic.open("/Users/me/repo/file.ts");       // → absolute, matched to repo
tuic.getFile("../../../etc/passwd");       // → rejected (traversal)

HTML <a> tags with tuic:// href are automatically intercepted:

<a href="tuic://open/README.md">View README</a>
<a href="tuic://edit/src/main.rs?line=42">Edit main.rs:42</a>
<a href="tuic://terminal?repo=/path/to/repo">Open terminal</a>

Link pathnames are treated as relative paths (the leading / from URL parsing is stripped).

Repository

tuic.activeRepo(): string | null

Returns the path of the currently active repository, or null if none is active.

var repo = tuic.activeRepo();
// "/Users/me/code/myproject" or null

tuic.onRepoChange(callback)

Register a listener that fires when the active repo changes.

ParamTypeDescription
callback(repoPath: string | null) => voidCalled with the new active repo path

tuic.offRepoChange(callback)

Unregister a previously registered repo-change listener.

tuic.terminal(repoPath)

Open a terminal in the given repository.

ParamTypeDescription
repoPathstringRepository root path (absolute)

UI Feedback

tuic.toast(title, opts?)

Show a native toast notification in the host app.

ParamTypeDescription
titlestringToast title (required)
opts.messagestringOptional body text
opts.level"info" | "warn" | "error"Severity (default: "info")
opts.soundbooleanPlay a notification sound (default: false). Each level has a distinct tone: info = soft blip, warn = double beep, error = descending sweep.
tuic.toast("Import complete", { message: "42 items imported" });
tuic.toast("Rate limited", { message: "Try again in 30s", level: "warn", sound: true });

tuic.clipboard(text)

Copy text to the system clipboard. Works from sandboxed iframes (which cannot access navigator.clipboard directly).

ParamTypeDescription
textstringText to copy

Messaging

tuic.send(data)

Send structured data to the host. The host receives it via pluginRegistry.handlePanelMessage().

ParamTypeDescription
dataanyJSON-serializable payload

tuic.onMessage(callback)

Register a listener for messages pushed from the host.

ParamTypeDescription
callback(data: any) => voidCalled with the message payload

tuic.offMessage(callback)

Unregister a previously registered message listener.

Theme

tuic.theme: object | null

Read-only property containing the current theme as a key-value object. Keys are camelCase versions of CSS custom properties (e.g., --bg-primarybgPrimary).

var theme = tuic.theme;
// { bgPrimary: "#1e1e2e", fgPrimary: "#cdd6f4", accent: "#89b4fa", ... }

tuic.onThemeChange(callback)

Register a listener that fires when the host theme changes.

ParamTypeDescription
callback(theme: object) => voidCalled with the new theme object

tuic.offThemeChange(callback)

Unregister a previously registered theme-change listener.

Version

tuic.version: string

The SDK version string (currently "1.0").

Testing the SDK

An interactive test page is included at docs/examples/sdk-test.html. It runs automatic verification of all SDK methods and provides buttons for interactive testing.

How to launch it

From an AI agent (Claude Code, etc.):

Use the TUIC MCP ui tool to open it as an inline HTML tab:

mcp__tuicommander__ui action=tab id="sdk-test" title="SDK Test Suite" html="<contents of docs/examples/sdk-test.html>" pinned=false focus=true

From a plugin:

Register a plugin that serves the HTML content as a panel tab. The SDK is injected automatically into inline HTML tabs.

From JavaScript (dev console or app code):

// Read the file and open as a tab
const html = await invoke("fs_read_file", { repoPath: "/path/to/tuicommander", file: "docs/examples/sdk-test.html" });
mdTabsStore.addHtml("sdk-test", "SDK Test Suite", html);

The test page verifies:

  • SDK presence and version
  • activeRepo() return value
  • onRepoChange listener registration
  • Theme delivery and onThemeChange
  • onMessage listener registration
  • getFile("README.md") reads file content
  • getFile("../../../etc/passwd") is blocked by traversal guard

Interactive buttons test: open, edit, terminal, toast (all levels), clipboard, getFile, and send.

Timing Notes

The <script> bootstrap in the child page must be synchronous and in <head> to guarantee the message listener is registered before the parent’s iframe.onload fires tuic:sdk-init. If your page loads the bootstrap asynchronously (e.g., as an ES module), there is a race condition — the init message may arrive before the listener exists.

If you cannot guarantee synchronous loading, implement a retry: have the child send { type: "tuic:sdk-request" } to the parent on DOMContentLoaded (or whenever the listener is registered), and the parent will respond with tuic:sdk-init. This fallback is fully supported by the host.

Source Files

FileDescription
src/components/PluginPanel/tuicSdk.tsSDK script injected into iframes
src/components/PluginPanel/PluginPanel.tsxHost-side message handlers
src/components/PluginPanel/resolveTuicPath.tsPath resolution (relative + traversal guard)
docs/examples/sdk-test.htmlInteractive test/example page

Plugin Authoring Guide

TUICommander uses an Obsidian-style plugin system. Plugins extend the Activity Center (bell dropdown), watch terminal output, and interact with app state. Plugins can be built-in (compiled with the app) or external (loaded at runtime from the user’s plugins directory).

Quick Start: External Plugin

  1. Create a directory: ~/.config/com.tuic.commander/plugins/my-plugin/
  2. Create manifest.json:
{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "minAppVersion": "0.3.0",
  "main": "main.js"
}

Note: All manifest fields use camelCase (minAppVersion, agentTypes, contentUri) — this matches the Rust serde serialization format. Do not use snake_case.

// ✅ Correct
{ "minAppVersion": "0.5.0", "agentTypes": ["claude"] }
// ❌ Wrong
{ "min_app_version": "0.5.0", "agent_types": ["claude"] }
  1. Create main.js (ES module with default export):
const PLUGIN_ID = "my-plugin";

export default {
  id: PLUGIN_ID,
  onload(host) {
    host.registerSection({
      id: "my-section",
      label: "MY SECTION",
      priority: 30,
      canDismissAll: false,
    });

    host.registerOutputWatcher({
      pattern: /hello (\w+)/,
      onMatch(match, sessionId) {
        host.addItem({
          id: `hello:${match[1]}`,
          pluginId: PLUGIN_ID,
          sectionId: "my-section",
          title: `Hello ${match[1]}`,
          icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>',
          dismissible: true,
        });
      },
    });
  },
  onunload() {},
};
  1. Save the file — hot reload picks it up. Adding a brand-new plugin directory or symlink while the app is running is also discovered live, so no restart is needed.

Architecture

PTY output ──> pluginRegistry.processRawOutput()
                  |
                  +-- LineBuffer (reassemble lines)
                  +-- stripAnsi (clean ANSI codes)
                  +-- dispatchLine() --> OutputWatcher.onMatch()
                                              |
                                              +-- host.addItem() --> Activity Center bell
                                                                           |
                                                              user clicks item
                                                                           |
                                              markdownProviderRegistry.resolve(contentUri)
                                                                           |
                                                              MarkdownTab renders content

Tauri OutputParser --> pluginRegistry.dispatchStructuredEvent(type, payload, sessionId)
                            |
                            +-- structuredEventHandler(payload, sessionId)

Plugin Lifecycle

  1. Discovery — Rust list_user_plugins scans ~/.config/com.tuic.commander/plugins/ for manifest.json files
  2. Validation — Frontend validates manifest fields and minAppVersion
  3. Importimport("plugin://my-plugin/main.js") loads the module via the custom URI protocol (on Windows the loader rewrites this to http://plugin.localhost/my-plugin/main.js, since WebView2 only serves custom schemes under http://{scheme}.localhost/...)
  4. Module check — Default export must have id, onload, onunload
  5. RegisterpluginRegistry.register(plugin, capabilities) calls plugin.onload(host)
  6. Active — Plugin receives PTY lines, structured events, and can use the PluginHost API
  7. Hot reload — File changes emit plugin-changed events; the plugin is unregistered and re-imported. Creating a new top-level plugin directory or symlink after startup also emits the event (the watcher does not descend into symlink targets, so the create of the link itself is the trigger), and the new plugin is discovered and loaded without a restart
  8. Unloadplugin.onunload() is called, then all registrations are auto-disposed

Crash Safety

Every boundary is wrapped in try/catch:

  • import() — syntax errors or missing exports are caught
  • Module validation — missing id, onload, or onunload logs an error and skips the plugin
  • plugin.onload() — if it throws, partial registrations are cleaned up automatically
  • Watcher/handler dispatch — exceptions are caught and logged, other plugins continue
  • UI callbacks registered through PluginHost (file icons/previews, activity items, context menu actions, ticker messages, dashboards, commands) are isolated the same way; failures are written both to the app log and the plugin’s dedicated log buffer

A broken plugin produces a console error and is skipped. The app always continues.

Manifest Reference

File: ~/.config/com.tuic.commander/plugins/{id}/manifest.json

FieldTypeRequiredDescription
idstringyesMust match the directory name
namestringyesHuman-readable display name
versionstringyesPlugin semver (e.g. "1.0.0")
minAppVersionstringyesMinimum TUICommander version required
mainstringyesEntry point filename (e.g. "main.js")
descriptionstringnoShort description
authorstringnoAuthor name
capabilitiesstring[]noTier 3/4 capabilities needed (defaults to [])
allowedUrlsstring[]noURL patterns allowed for net:http (e.g. ["https://api.example.com/*"])
agentTypesstring[]noAgent types this plugin targets (e.g. ["claude"]). Omit or [] for universal plugins.
binariesstring[]noCLI binaries this plugin may execute via exec:cli (e.g. ["rtk", "mdkb"])

Validation Rules

  • id must match the directory name exactly
  • id must not be empty
  • main must not contain path separators or ..
  • All capabilities must be known strings (see Capabilities section)
  • minAppVersion must be <= the current app version (semver comparison)

Plugin Interface

interface TuiPlugin {
  id: string;
  onload(host: PluginHost): void;
  onunload(): void;
}

The onload function receives a PluginHost object — this is your entire API surface. External plugins cannot import app internals; everything goes through host.

PluginHost API Reference

Tier 0: Logging (always available)

host.log(level, message, data?) -> void

Write to the plugin’s dedicated log ring buffer (max 500 entries). Viewable in Settings > Plugins > click “Logs” on any plugin row.

host.log("info", "Plugin initialized");
host.log("error", "Failed to process", { code: 404 });

Levels: "debug", "info", "warn", "error". The optional data parameter accepts any JSON-serializable value and is displayed alongside the message.

Errors thrown inside onload, onunload, output watchers, and structured event handlers are automatically captured to the plugin’s log. Use host.log() for additional diagnostic output. Error count badges appear on plugins with recent errors in the Settings panel.

Tier 1: Activity Center + Watchers + Providers (always available)

All register*() methods return a Disposable with a dispose() method. You do not need to call dispose() manually — all registrations are automatically disposed when onunload() is called (including during hot reload). Only call dispose() if you need to dynamically remove a registration while the plugin is still running.

host.registerSection(section) -> Disposable

Adds a section heading to the Activity Center dropdown.

host.registerSection({
  id: "my-section",        // Must match sectionId in addItem()
  label: "MY SECTION",     // Displayed as section header
  priority: 30,            // Lower number = higher position
  canDismissAll: false,     // Show "Dismiss All" button?
});

host.registerOutputWatcher(watcher) -> Disposable

Watches every PTY output line (after ANSI stripping and line reassembly).

host.registerOutputWatcher({
  pattern: /Deployed: (\S+) to (\S+)/,
  onMatch(match, sessionId) {
    // match[0] = full match, match[1] = first capture group, etc.
    // sessionId = the PTY session that produced the line
    host.addItem({ ... });
  },
});

Rules:

  • onMatch must be synchronous and fast (< 1ms) — it’s in the PTY hot path
  • pattern.lastIndex is reset before each test (safe to use global flag, but unnecessary)
  • Input is ANSI-stripped but may contain Unicode (checkmarks, arrows, emoji)
  • Arguments are positional: onMatch(match, sessionId) — NOT destructured

host.registerStructuredEventHandler(type, handler) -> Disposable

Handles typed events from the Rust OutputParser.

host.registerStructuredEventHandler("plan-file", (payload, sessionId) => {
  const { path } = payload as { path: string };
  host.addItem({ ... });
});

See Structured Event Types for all types and payload shapes.

host.registerMarkdownProvider(scheme, provider) -> Disposable

Provides content for a URI scheme when the user clicks an ActivityItem.

host.registerMarkdownProvider("my-scheme", {
  async provideContent(uri) {
    const id = uri.searchParams.get("id");
    if (!id) return null;
    try {
      return await host.invoke("read_file", { path: dir, file: name });
    } catch {
      return null;
    }
  },
});

host.addItem(item) / host.removeItem(id) / host.updateItem(id, updates)

Manage activity items:

host.addItem({
  id: "deploy:api:prod",       // Unique identifier
  pluginId: "my-plugin",       // Must match your plugin id
  sectionId: "my-section",     // Must match your registered section
  title: "api-server",         // Primary text
  subtitle: "Deployed to prod", // Secondary text (optional)
  icon: '<svg .../>',          // Inline SVG with fill="currentColor"
  iconColor: "#3fb950",        // Optional CSS color for the icon
  dismissible: true,
  contentUri: "my-scheme:detail?id=api",  // Opens in MarkdownTab on click
  // OR: onClick: () => { ... },          // Mutually exclusive with contentUri
});

host.updateItem("deploy:api:prod", { subtitle: "Rolled back" });
host.removeItem("deploy:api:prod");

Tier 2: Read-Only App State (always available)

host.getActiveRepo() -> RepoSnapshot | null

const repo = host.getActiveRepo();
// { path: "/Users/me/project", displayName: "project", activeBranch: "main", worktreePath: null }

host.getRepos() -> RepoListEntry[]

const repos = host.getRepos();
// [{ path: "/Users/me/project", displayName: "project" }, ...]

host.getActiveTerminalSessionId() -> string | null

const sessionId = host.getActiveTerminalSessionId();

host.getRepoPathForSession(sessionId) -> string | null

Resolves which repository owns a given terminal session by searching all repos and branches for a terminal matching the session ID. Returns null if the session is not associated with any repository (e.g. a standalone terminal or an unknown session ID). Useful in output watcher callbacks where sessionId is provided but you need the repo context.

host.registerOutputWatcher({
  pattern: /Deployed: (\S+)/,
  onMatch(match, sessionId) {
    const repoPath = host.getRepoPathForSession(sessionId);
    if (!repoPath) return; // session not tied to a repo
    // repoPath = "/Users/me/project"
  },
});

host.getSessionCwd(sessionId) -> string | null

Returns the current working directory of the given terminal session, or null if the session is unknown or has no recorded CWD. No capability required.

const cwd = host.getSessionCwd(sessionId);
// → "/Users/me/project" (or null)

host.getActiveRepoPath() -> string | null

Shortcut for the active repository’s path (equivalent to host.getActiveRepo()?.path). Returns null when no repository is active. No capability required.

const repoPath = host.getActiveRepoPath();

host.getClaudeProjectDir(repoPath) -> Promise<string | null>

Resolves a repository path to the absolute path of its Claude Code project directory (~/.claude/projects/<slug>). The slug encoding is handled by the Rust side — plugins should use this instead of constructing paths manually. Requires "fs:read" capability.

const projectDir = await host.getClaudeProjectDir("/Users/me/my-project");
// → "/Users/me/.claude/projects/-Users-me-my-project"
const files = await host.listDirectory(projectDir, "*.jsonl", { sortBy: "mtime" });

host.getPrNotifications() -> PrNotificationSnapshot[]

const prs = host.getPrNotifications();
// [{ id, repoPath, branch, prNumber, title, type }, ...]

host.getSettings(repoPath) -> RepoSettingsSnapshot | null

const settings = host.getSettings("/Users/me/project");
// { path, displayName, baseBranch: "main", color: "#3fb950" }

host.getTerminalState() -> TerminalStateSnapshot | null

Returns the active terminal’s state snapshot.

const state = host.getTerminalState();
// { sessionId, shellState: "busy"|"idle"|null, agentType: "claude"|null,
//   agentActive: boolean, awaitingInput: "question"|null, repoPath }

host.onStateChange(callback) -> Disposable

Register a callback for terminal/branch state changes. Fires on agent start/stop, branch change, shell state change, and awaiting-input change.

const sub = host.onStateChange((event) => {
  // event.type: "agent-started" | "agent-stopped" | "branch-changed"
  //           | "shell-state-changed" | "awaiting-input-changed"
  // event.sessionId, event.terminalId, event.detail (branch name for branch-changed)
});
// sub.dispose() to unsubscribe

Tier 2b: Git Read (capability-gated)

These methods require declaring "git:read" in manifest.json. They provide read-only access to git repository state.

host.getGitBranches(repoPath) -> Promise<Array<{ name, isCurrent }>>

const branches = await host.getGitBranches("/Users/me/project");
// [{ name: "main", isCurrent: true }, { name: "feature/x", isCurrent: false }]

host.getRecentCommits(repoPath, count?) -> Promise<Array<{ hash, message, author, date }>>

const commits = await host.getRecentCommits("/Users/me/project", 5);
// [{ hash: "abc1234", message: "fix: bug", author: "name", date: "2026-02-25" }]

host.getGitDiff(repoPath, scope?) -> Promise<string>

const diff = await host.getGitDiff("/Users/me/project", "staged");
// Returns unified diff string

Tier 3: Write Actions (capability-gated)

These methods require declaring capabilities in manifest.json. Calling without the required capability throws PluginCapabilityError.

host.writePty(sessionId, data) -> Promise<void>

Sends raw bytes to a terminal session. Requires "pty:write" capability.

Prefer sendAgentInput() for user input. writePty sends raw data — it does not handle Enter key semantics for Ink-based agents. Use it only when you need exact byte control.

await host.writePty(sessionId, "\x03"); // Send Ctrl-C

host.sendAgentInput(sessionId, text) -> Promise<void>

Sends user input to an agent session with correct Enter handling. Requires "pty:write" capability.

Ink-based agents (Claude Code, Codex, etc.) run in raw mode and need Ctrl-U + text in one write, then \r in a separate write. Shell sessions receive everything in a single write. This method handles both cases automatically based on the detected agent type.

await host.sendAgentInput(sessionId, "y");       // confirm a prompt
await host.sendAgentInput(sessionId, "explain this code"); // send a message

host.openMarkdownPanel(title, contentUri) -> void

Opens a virtual markdown tab and shows the panel. Requires "ui:markdown" capability.

host.openMarkdownPanel("CI Report", "my-scheme:report?id=123");

host.openMarkdownFile(absolutePath) -> void

Opens a local markdown file in the markdown panel. Requires "ui:markdown" capability. The path must be absolute. This is useful for plugins that ship a README.md or other documentation files.

// Open the plugin's own README
host.openMarkdownFile("/Users/me/.config/com.tuic.commander/plugins/my-plugin/README.md");

host.playNotificationSound(sound?) -> Promise<void>

Plays a notification sound. Requires "ui:sound" capability.

ParameterTypeDefaultDescription
soundstring"info"One of: "question", "error", "completion", "warning", "info"
await host.playNotificationSound("error");      // CI failure, build error
await host.playNotificationSound("question");    // input prompt, awaiting user
await host.playNotificationSound("completion");  // task finished
await host.playNotificationSound();              // defaults to "info"

Tier 3b: Filesystem Operations (capability-gated)

These methods provide sandboxed filesystem access. All paths must be absolute and within the user’s home directory ($HOME).

host.readFile(absolutePath) -> Promise<string>

Read a file’s content as UTF-8 text. Maximum file size: 10 MB. Requires "fs:read" capability.

const content = await host.readFile("/Users/me/.claude/projects/foo/conversation.jsonl");

host.readFileBase64(absolutePath) -> Promise<string>

Read a file’s raw bytes and return them as a base64 string. Maximum file size: 10 MB. Use this for binary previews such as .docx, images, or archives. Requires "fs:read" capability.

const encoded = await host.readFileBase64("/Users/me/Documents/spec.docx");
const bytes = Uint8Array.from(atob(encoded), (ch) => ch.charCodeAt(0));

host.listDirectory(path, pattern?, options?) -> Promise<string[]>

List filenames in a directory, optionally filtered by a glob pattern. Returns filenames only (not full paths). Requires "fs:list" capability.

Options:

  • sortBy: "name" (default, alphabetical) or "mtime" (newest first). Use "mtime" to efficiently find the most recently modified file when the directory contains many historical entries.
const files = await host.listDirectory("/Users/me/.claude/projects/foo", "*.jsonl");
// ["conversation-1.jsonl", "conversation-2.jsonl"]

// Find the currently active session JSONL among 100+ historical ones:
const recent = await host.listDirectory(dir, "*.jsonl", { sortBy: "mtime" });
const activeFile = recent[0]; // most recently written

host.watchPath(path, callback, options?) -> Promise<Disposable>

Watch a path for filesystem changes. Emits batched events after a debounce period. Requires "fs:watch" capability.

const watcher = await host.watchPath(
  "/Users/me/.claude/projects/foo",
  (events) => {
    for (const event of events) {
      console.log(event.type, event.path); // "create" | "modify" | "delete"
    }
  },
  { recursive: true, debounceMs: 500 },
);

// Later: stop watching
watcher.dispose();

Options:

  • recursive — Watch subdirectories (default: false)
  • debounceMs — Debounce window in milliseconds (default: 300)

FsChangeEvent:

interface FsChangeEvent {
  type: "create" | "modify" | "delete";
  path: string;
}

host.writeFile(absolutePath, content) -> Promise<void>

Write content to a file within $HOME. Creates parent directories if needed. Refuses to overwrite directories. Max 10 MB. Requires "fs:write" capability.

await host.writeFile("/Users/me/project/stories/new-story.md", "---\nstatus: pending\n---\n# New Story");

host.renamePath(from, to) -> Promise<void>

Rename or move a file within $HOME. Both paths must be absolute. Source must exist. Creates parent directories for destination if needed. Requires "fs:rename" capability.

await host.renamePath(
  "/Users/me/project/stories/old-name.md",
  "/Users/me/project/stories/new-name.md",
);

host.scanBuildArtifacts(repoPaths, options?) -> Promise<ArtifactEntry[]>

Recursively scan the given repo roots for build-artifact directories (Rust/Maven target/, node_modules/, JS framework caches like .next/.turbo, Python .venv/__pycache__/tool caches, .NET obj/bin, Gradle/CMake/Flutter build/, SwiftPM .build/Pods, .terraform, Elixir _build, Zig, Haskell, Composer vendor/). Read-only. Unlike listDirectory, this ignores .gitignore (artifact dirs are gitignored by design) and stops descending on a match, so a nested node_modules is folded into the outer entry — never double counted. Ambiguously-named dirs (target, bin/obj, build, vendor, …) only count when a toolchain marker sits beside them (e.g. Cargo.toml or pom.xml for target; a .csproj/.fsproj/.vbproj/.sln/.slnx for bin/obj; build.gradle/CMakeLists.txt/pubspec.yaml for build; composer.json for vendor) — a Go sysroot bin or an Xcode PIFCache/target is walked like any other dir, not claimed. The full rule table is ARTIFACT_RULES in src-tauri/src/plugin_fs.rs. Each repoPaths entry is $HOME-scoped and intersected server-side with the app’s actual registered-repository list — a path that isn’t equal to or nested under a genuinely registered repo root is silently dropped, so a plugin cannot widen its scan surface by passing arbitrary $HOME paths. Results for the same normalized set of roots are shared across concurrent callers and reused for 30 seconds. Pass { forceRefresh: true } to bypass a completed cached result; an already-running scan for the same roots is still shared. Requires "fs:scan" capability.

Each ArtifactEntry is { path, kind, size_bytes, last_modified_secs, repo }kind is one of rust | maven | node | jscache | python | dotnet | gradle | cmake | swift | flutter | terraform | elixir | zig | haskell | php, last_modified_secs is the max mtime of the dir’s direct children (a “last build” signal).

const entries = await host.scanBuildArtifacts(host.getRepos().map((r) => r.path));
const refreshed = await host.scanBuildArtifacts(host.getRepos().map((r) => r.path), {
  forceRefresh: true,
});

host.deleteBuildArtifact(path, repoPaths) -> Promise<void>

Delete a build-artifact directory. Destructive. The backend guard requires (all must hold): the path canonicalizes to a basename that is a known artifact dir, it is strictly inside one of repoPaths (never a repo root itself), that containing root is itself a server-verified registered repository (caller-supplied roots not backed by a real registered repo are dropped — the plugin cannot claim an arbitrary $HOME directory as a “repo”), it is $HOME-scoped, and — for ambiguously-named dirs — the same toolchain marker required by the scanner sits beside it (so Rust src/bin sources are refused). Only then is remove_dir_all run. Requires "fs:delete" capability.

await host.deleteBuildArtifact("/Users/me/project/target", ["/Users/me/project"]);

Tier 3c: Status Bar Ticker (capability-gated)

The status bar has a shared ticker area that rotates messages from multiple plugins. Messages are grouped by priority tier:

TierPriorityBehavior
Low< 10Shown only in the popover, not in rotation
Normal10–99Auto-rotates every 5s in the ticker area
Urgent>= 100Pinned — pauses rotation until cleared

Users can click the counter badge (e.g. 1/3 ▸) to cycle manually, or right-click the ticker to see all active messages in a popover.

host.setTicker(options) -> void

Set a ticker message in the shared status bar ticker. Preferred API — supports source labels. If a message with the same id from this plugin already exists, it is replaced. Requires "ui:ticker" capability.

host.setTicker({
  id: "my-status",
  text: "Processing: 42%",
  label: "MyPlugin",         // Shown as "MyPlugin · Processing: 42%"
  icon: '<svg viewBox="0 0 16 16" fill="currentColor">...</svg>',
  priority: 10,
  ttlMs: 60000,
  onClick: () => { /* optional click handler */ },
});

Options:

  • id — Unique message identifier (scoped to your plugin). Reusing an id replaces the previous message.
  • text — Message text displayed in the ticker rotation.
  • label — Optional human-readable source label shown before the text (e.g. "Usage").
  • icon — Optional inline SVG icon.
  • priority — Priority tier (see table above). Default: 0.
  • ttlMs — Auto-expire after N milliseconds. 0 = persistent (must be removed manually). Default: 60000.
  • onClick — Optional callback invoked when the user clicks the message text.

host.clearTicker(id) -> void

Remove a ticker message by id. Requires "ui:ticker" capability.

host.clearTicker("my-status");

host.postTickerMessage(options) -> void (legacy)

Alias for setTicker without label support. Prefer setTicker for new plugins.

host.removeTickerMessage(id) -> void (legacy)

Alias for clearTicker.

Tier 3d: Panel UI (capability-gated)

host.openPanel(options) -> PanelHandle

Open an HTML panel in a sandboxed iframe tab. Returns a handle for updating content or closing the panel. If a panel with the same id is already open, it will be activated and updated. Requires "ui:panel" capability.

const panel = host.openPanel({
  id: "my-dashboard",
  title: "Dashboard",
  html: "<html><body><h1>Hello</h1></body></html>",
  onMessage(data) {
    // Receive structured messages from the iframe
    console.log("Got message from iframe:", data);
    // Send response back
    panel.send({ type: "response", ok: true });
  },
});

// Update content later
panel.update("<html><body><h1>Updated</h1></body></html>");

// Send a message to the iframe at any time
panel.send({ type: "refresh", items: [...] });

// Close the panel
panel.close();

Inside the iframe:

<script>
  // Send message to host
  window.parent.postMessage({ type: "save", config: { ... } }, "*");

  // Receive messages from host
  window.addEventListener("message", (e) => {
    if (e.data?.type === "response") {
      console.log("Host says:", e.data.ok);
    }
  });
</script>

CSS Base Stylesheet + Theme Injection: Every plugin panel iframe receives two automatic CSS injections:

  1. Base stylesheet (pluginBaseStyles.ts) — a complete design foundation with reset, typography, buttons, inputs, cards, tables, badges, toasts, scrollbars, and empty states. All values use CSS custom properties from the app theme. Plugins get a polished, consistent look without writing any CSS.

  2. Theme variables — all CSS custom properties from the app’s :root are injected (e.g. --bg-primary, --fg-primary, --border, --accent, --error, --warning, --success, --text-on-accent). These match the user’s active theme.

Design strategy: Write minimal plugin-specific CSS that overrides the base. The base provides:

Base classDescription
bodyThemed background, font, color
button, .btnDefault button with hover/active states
button.primary, .btn-primaryAccent-colored button
button.danger, .btn-dangerError-colored button
input, textarea, selectThemed form controls with focus ring
.cardBordered container with hover elevation
table, th, tdStyled table with hover rows
.badgeInline label (combine with .badge-p1, .badge-error, .badge-success, .badge-accent, .badge-warning, .badge-muted)
label, .hintForm labels and help text
.filter-barFlex row for search/filter UI
.empty-stateCentered placeholder with .hint
.toast, .toast.error, .toast.successFixed-position notification (add .show to display)
h1h4Themed headings
code, a, hr, smallThemed inline elements
::-webkit-scrollbarStyled scrollbar matching the app

Example — minimal plugin CSS:

<style>
  /* Only what's specific to this plugin */
  body { padding: 16px; }
  .my-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
</style>

Dashboard layout classes. For analytics/status dashboards, the base stylesheet also ships a .dashboard/.dash-* class family that mirrors the built-in Claude Usage dashboard. Use them instead of inventing layout CSS — see docs/plugins-style.md for the full guide, checklist, and class reference.

host.openEditorTab(filePath, repoPath, opts?)

Open a file in the CodeMirror editor tab. Requires "ui:panel" capability.

host.openEditorTab("src/main.ts", "/Users/me/project", { line: 42 });
ParamTypeDescription
filePathstringRelative or absolute file path
repoPathstringRepository root path
opts.fsRootstring?Filesystem root (defaults to repoPath)
opts.linenumber?Line number to scroll to

host.registerFilePreview(options) -> Disposable

Register a custom file preview handler for specific extensions. When the user opens a file matching one of the claimed extensions, the onOpen callback is invoked instead of the default editor. Requires "ui:file-preview" capability.

host.registerFilePreview({
  extensions: ["csv", "tsv"],
  async onOpen(ctx) {
    const raw = await host.readFile(`${ctx.fsRoot}/${ctx.filePath}`);
    host.openPanel({ id: `csv-${ctx.filePath}`, title: "CSV", html: buildTable(raw) });
  },
});
ParamTypeDescription
options.extensionsstring[]File extensions to claim (without dot, case-insensitive)
options.onOpen(ctx: FilePreviewContext) => voidHandler called when the user opens a matching file

FilePreviewContext:

FieldTypeDescription
filePathstringRelative path within the repo
repoPathstringRepository root path
fsRootstringFilesystem root for resolving absolute paths

On plugin unload, claimed extensions are released and files revert to the default open behavior.

host.registerDashboard(options) -> Disposable

Register a one-click entry point for the plugin’s dashboard. When registered, Settings → Plugins shows a Dashboard button in the plugin row that calls options.open() and automatically closes the Settings panel so the dashboard becomes visible.

host.registerDashboard({
  label: "My Plugin",       // optional, defaults to "Dashboard"
  icon: MY_PLUGIN_ICON,     // optional inline SVG string
  open: () => openDashboard(host),
});

A plugin may only register one dashboard — calling registerDashboard a second time replaces the previous entry. Dispose the returned handle in onunload (or rely on automatic cleanup via the plugin’s disposable tracker).

host.registerCommand(options) -> Disposable

Register a plugin command that users can bind to a keyboard shortcut. The command appears in Settings → Keyboard Shortcuts under a dedicated “Plugin Commands” section and can be rebound by the user. The action name is auto-namespaced as plugin:<pluginId>:<id>.

host.registerCommand({
  id: "open-dashboard",          // unique per plugin
  title: "Open My Dashboard",    // label in the Shortcuts UI
  defaultShortcut: "Cmd+Shift+M",// optional — leave unbound by default
  run: () => openDashboard(host),
});

If defaultShortcut conflicts with an existing binding (built-in or another plugin), the command is registered but left unbound; a warning is logged and the user can pick a free combo via Settings. The handle returned is automatically tracked and released on plugin unload.

All standard elements (buttons, inputs, tables) will look correct automatically.

Available CSS variables (from the app’s active theme):

VariableUsage
--bg-primaryMain canvas
--bg-secondarySidebar-level surfaces
--bg-tertiaryInputs, elevated surfaces
--bg-highlightHover states
--fg-primaryPrimary text
--fg-secondaryLabels, secondary text
--fg-mutedTertiary text
--accentLinks, primary actions
--accent-hoverHover on accent
--successPositive states
--warningCaution states
--errorError states
--borderAll borders
--text-on-accentText on colored backgrounds
--text-on-errorText on error backgrounds
--text-on-successText on success backgrounds

Security: The iframe uses sandbox="allow-scripts" without allow-same-origin, blocking access to Tauri IPC and the parent page DOM. The close-panel message type is handled as a system message; all other messages are routed to the onMessage callback.

TUIC SDK (window.tuic)

Every plugin iframe automatically receives the TUIC SDK — a lightweight JavaScript API for host integration. The SDK is injected alongside the base CSS and theme variables.

Feature detection:

if (window.tuic) {
  // Running inside TUICommander — SDK is available
  console.log("TUIC SDK version:", window.tuic.version);
}

Programmatic API:

MethodDescription
tuic.versionSDK version string (e.g. "1.0")
tuic.open(path, opts?)Open a markdown file in a new tab. path is absolute. opts.pinned pins the tab.
tuic.edit(path, opts?)Open a file in the code editor. opts.line (1-based) jumps to a line; omit for the top.
tuic.terminal(repoPath)Open a new terminal in the given repository.
// Open a file
tuic.open("/Users/me/myrepo/README.md");

// Open a pinned file
tuic.open("/Users/me/myrepo/docs/guide.md", { pinned: true });

// Open a terminal in a repo
tuic.terminal("/Users/me/myrepo");

Link interception: Standard HTML links with tuic:// scheme are intercepted automatically — no JavaScript required:

<!-- Opens a markdown file -->
<a href="tuic://open/Users/me/myrepo/README.md">View README</a>

<!-- Opens a pinned markdown file -->
<a href="tuic://open/Users/me/myrepo/docs/guide.md" data-pinned>Pinned Guide</a>

<!-- Opens a terminal -->
<a href="tuic://terminal?repo=/Users/me/myrepo">Open Terminal</a>

URL format:

URLAction
tuic://open/<absolute-path>Open file in markdown tab
tuic://edit/<absolute-path>?line=NOpen file in the code editor (optional line)
tuic://terminal?repo=<repo-path>Open terminal in repository

Security: Paths are validated against the list of known repositories. Paths outside any registered repo are rejected with a warning. The SDK runs inside the sandbox and communicates with the host exclusively via postMessage.

Tier 3e: Sidebar Plugin Panels (capability-gated)

host.registerSidebarPanel(options) -> SidebarPanelHandle

Register a collapsible panel section in the sidebar, displayed below the branch list for each repo. Requires "ui:sidebar" capability.

Panels display structured data (not HTML) — the app renders items natively for visual consistency with the rest of the sidebar.

interface SidebarPanelOptions {
  id: string;              // Unique panel ID (scoped to plugin)
  label: string;           // Section header text
  icon?: string;           // Inline SVG for header
  priority?: number;       // Lower = higher in sidebar (default 100)
  collapsed?: boolean;     // Initial collapsed state (default true)
}

interface SidebarPanelHandle {
  setItems(items: SidebarItem[]): void;     // Replace all items
  setBadge(text: string | null): void;      // Header badge (e.g. "3")
  dispose(): void;                          // Remove panel
}

interface SidebarItem {
  id: string;              // Unique item ID (scoped to panel)
  label: string;           // Primary text
  subtitle?: string;       // Secondary text (smaller, muted)
  icon?: string;           // Inline SVG (fill="currentColor")
  iconColor?: string;      // CSS color
  onClick?: () => void;    // Click handler
  contextMenu?: SidebarItemAction[];  // Right-click actions
}

interface SidebarItemAction {
  label: string;
  action: () => void;
  disabled?: boolean;
}

Example:

const panel = host.registerSidebarPanel({
  id: "active-plans",
  label: "ACTIVE PLANS",
  icon: '<svg ...>...</svg>',
  priority: 10,
  collapsed: false,
});

panel.setItems([
  { id: "plan-1", label: "Feature Plan", subtitle: "In Progress · M", onClick: () => openPlan() },
]);
panel.setBadge("1");

Behavior:

  • Panels appear inside RepoSection, below branches, only when the repo is expanded
  • Items are rendered as native sidebar list items (same style as branches)
  • Right-click on items shows a context menu with plugin-defined actions
  • Badge appears as a small counter pill on the section header
  • On plugin unload, panels are automatically removed

Tier 3f: Context Menu Actions (capability-gated)

host.registerTerminalAction(action) -> Disposable

Register an action in the terminal right-click “Actions” submenu. Requires "ui:context-menu" capability.

The action handler receives a TerminalActionContext snapshot captured at right-click time (not at click time), avoiding race conditions if the user switches terminals between opening the menu and clicking.

interface TerminalActionContext {
  sessionId: string | null;  // PTY session ID of the right-clicked terminal
  repoPath: string | null;   // Repository path that owns the terminal
}

interface TerminalAction {
  id: string;                                              // Unique action ID (scoped to plugin)
  label: string;                                           // Display label in the menu
  action: (ctx: TerminalActionContext) => void;             // Handler
  disabled?: (ctx: TerminalActionContext) => boolean;       // Evaluated at menu-open time
}

Example:

const d = host.registerTerminalAction({
  id: "restart-agent",
  label: "Restart Agent",
  action: (ctx) => {
    if (ctx.sessionId) host.sendAgentInput(ctx.sessionId, "exit");
  },
  disabled: (ctx) => !ctx.sessionId,
});

Behavior:

  • Actions from all plugins are shown in a flat list under the “Actions” submenu
  • The submenu is hidden when no actions are registered
  • disabled callback is re-evaluated each time the context menu opens
  • On plugin unload, actions are automatically removed; stale handler references are no-ops

host.registerContextMenuAction(action) -> Disposable

Register an action in context menus for a specific target type. Requires "ui:context-menu" capability.

type ContextMenuTarget = "terminal" | "branch" | "repo" | "tab";

interface ContextMenuAction {
  id: string;
  label: string;
  icon?: string;              // Inline SVG
  target: ContextMenuTarget;
  action: (ctx: ContextMenuContext) => void;
  disabled?: (ctx: ContextMenuContext) => boolean;
}

interface ContextMenuContext {
  target: ContextMenuTarget;
  sessionId?: string;     // terminal, tab
  repoPath?: string;      // branch, repo, terminal
  branchName?: string;    // branch only
  tabId?: string;         // tab only
}

Example:

host.registerContextMenuAction({
  id: "deploy",
  label: "Deploy Branch",
  target: "branch",
  action: (ctx) => {
    if (ctx.branchName) deploy(ctx.repoPath, ctx.branchName);
  },
});

Behavior:

  • Actions appear after built-in items, separated by a divider
  • disabled callback is re-evaluated each time the context menu opens
  • On plugin unload, actions are automatically removed

Tier 3g: Credential Access (capability-gated)

host.readCredential(serviceName) -> Promise<string | null>

Read credentials from the system credential store by service name. Returns the raw credential JSON string, or null if not found. Requires "credentials:read" capability.

First call from an external plugin shows a user consent dialog. Built-in plugins skip the dialog.

const credJson = await host.readCredential("Claude Code-credentials");
if (credJson) {
  const creds = JSON.parse(credJson);
  const token = creds.claudeAiOauth.accessToken;
}

Platforms:

  • macOS: Reads from Keychain (security find-generic-password -s <service> -w)
  • Linux/Windows: Reads from ~/.claude/.credentials.json

Tier 3h: HTTP Requests (capability-gated)

host.httpFetch(url, options?) -> Promise<HttpResponse>

Make an HTTP request. Non-2xx status codes are returned normally (not thrown as errors). Requires "net:http" capability.

External plugins can only fetch URLs matching their manifest’s allowedUrls patterns.

const resp = await host.httpFetch("https://api.example.com/data", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ key: "value" }),
});
if (resp.status === 200) {
  const data = JSON.parse(resp.body);
}

HttpResponse:

interface HttpResponse {
  status: number;
  headers: Record<string, string>;
  body: string;
}

Security and limits:

  • file://, data://, ftp:// schemes are blocked
  • 30-second timeout, 10 MB response limit, max 5 redirects
  • Localhost (localhost, 127.0.0.1, ::1, [::1], 0.0.0.0) is blocked unless explicitly declared in allowedUrls
  • Built-in plugins (no capabilities array) can fetch any http:// or https:// URL without restrictions

allowedUrls pattern matching:

  • Patterns use prefix matching with an optional trailing * wildcard
  • "https://api.example.com/*" — matches any path under that origin
  • "https://api.example.com/v2/data" — matches that exact URL only
  • "http://localhost:8080/*" — allows localhost on that port (required to unblock localhost)
  • The URL must start with the pattern prefix (before *) to match

Tier 3i: File Tail (capability-gated)

host.readFileTail(absolutePath, maxBytes) -> Promise<string>

Read the last N bytes of a file, skipping any partial first line. Useful for reading recent entries from large JSONL files. Requires "fs:read" capability.

const tail = await host.readFileTail("/Users/me/.claude/hud-tracking.jsonl", 512 * 1024);
const lines = tail.split("\n").filter(Boolean);

Tier 3j: CLI Execution (capability-gated)

host.execCli(binary, args, cwd?) -> Promise<string>

Execute a CLI binary declared in the plugin’s manifest and return its stdout. Requires "exec:cli" capability.

Only binaries listed in the manifest’s binaries field can be executed. The on-disk manifest is the source of truth — the frontend cannot grant access to undeclared binaries.

// manifest.json
{ "capabilities": ["exec:cli"], "binaries": ["mdkb"] }
const raw = await host.execCli("mdkb", ["--format", "json", "status"], "/Users/me/project");
const status = JSON.parse(raw);
console.log(status.index.documents); // 1486

Security and limits:

  • Only binaries declared in the plugin’s binaries manifest field can be executed
  • Working directory must be absolute and within $HOME
  • 30-second timeout
  • 5 MB stdout limit
  • Binary is resolved via PATH lookup and known install locations (~/.cargo/bin/, /usr/local/bin/, etc.)

Tier 4: Scoped Tauri Invoke (whitelisted commands only)

host.invoke<T>(cmd, args?) -> Promise<T>

Invokes a whitelisted Tauri command. Non-whitelisted commands throw immediately.

Whitelisted commands:

CommandArgsReturnsCapability
read_file{ path: string, file: string }stringinvoke:read_file
list_markdown_files{ path: string }Array<{ path, git_status }>invoke:list_markdown_files
read_plugin_data{ plugin_id: string, path: string }stringnone (always allowed)
write_plugin_data{ plugin_id: string, path: string, content: string }voidnone (always allowed)
delete_plugin_data{ plugin_id: string, path: string }voidnone (always allowed)
get_input_buffer_content{ sessionId: string }stringpty:read

Plugin data storage is sandboxed to ~/.config/com.tuic.commander/plugins/{id}/data/. No capability required — every plugin can store its own data.

// Store cache data
await host.invoke("write_plugin_data", {
  plugin_id: "my-plugin",
  path: "cache.json",
  content: JSON.stringify({ lastCheck: Date.now() }),
});

// Read it back
const raw = await host.invoke("read_plugin_data", {
  plugin_id: "my-plugin",
  path: "cache.json",
});
const cache = JSON.parse(raw);

Capabilities

What capabilities are, and what they are not. Capabilities gate what a plugin declares — a plugin that never asked for exec:cli cannot reach the exec commands under its own id. They do not gate what a plugin can impersonate. Plugins load with a bare dynamic import() into the same JavaScript realm as the host, and plugin_id is caller-supplied — it is the only key the Rust capability checks consult. Any plugin can therefore import invoke itself and pass another plugin’s id to inherit that plugin’s exec, credential and allowed-URL grants.

Plugins are isolated from the host’s declared surface, not from each other. Treat every installed plugin as trusted with the union of all installed capabilities, and vet what you install. This matches TUICommander’s threat model — a local tool where the human user is the trust boundary.

A per-plugin token was considered and deliberately rejected: same-realm JavaScript can read it out of the module that holds it, proxy the function that uses it, or monkey-patch the transport, so it would be a boundary in name only. Real isolation requires running each plugin off-realm (a Worker or sandboxed iframe) with a host-created MessagePort as its only channel to the backend, so a plugin’s identity is the port it was handed rather than a string it supplies. That is tracked as future work, not a gap being quietly ignored.

Capabilities gate access to Tier 3 and Tier 4 methods. Declare them in manifest.json:

{
  "capabilities": ["pty:write", "ui:sound"]
}
CapabilityUnlocksRisk
pty:writehost.writePty(), host.sendAgentInput()Can send input to terminals
pty:readhost.invoke("get_input_buffer_content", …)Can read the terminal input line buffer
ui:markdownhost.openMarkdownPanel(), host.openMarkdownFile()Can open panels and files in the UI
ui:soundhost.playNotificationSound(sound?)Can play sounds (question, error, completion, warning, info)
ui:panelhost.openPanel()Can render arbitrary HTML in sandboxed iframe
ui:tickerhost.setTicker(), host.clearTicker()Can post messages to the shared status bar ticker
credentials:readhost.readCredential()Can read system credentials (consent dialog shown)
net:httphost.httpFetch()Can make HTTP requests (scoped to allowedUrls)
invoke:read_filehost.invoke("read_file", ...)Can read files on disk
invoke:list_markdown_fileshost.invoke("list_markdown_files", ...)Can list directory contents
fs:readhost.readFile(), host.readFileBase64(), host.readFileTail()Can read files within $HOME (10 MB limit)
fs:listhost.listDirectory()Can list directory contents within $HOME
fs:watchhost.watchPath()Can watch filesystem paths within $HOME for changes
fs:writehost.writeFile()Can write files within $HOME (10 MB limit)
fs:renamehost.renamePath()Can rename/move files within $HOME
fs:scanhost.scanBuildArtifacts()Can recursively scan registered repos for build-artifact directories (read-only; ignores .gitignore)
fs:deletehost.deleteBuildArtifact()Can delete a build-artifact directory inside a registered repo (guarded remove_dir_all)
exec:clihost.execCli()Can execute CLI binaries declared in manifest binaries field
git:readhost.getGitBranches(), host.getRecentCommits(), host.getGitDiff()Read-only access to git repository state
ui:context-menuhost.registerTerminalAction()Can add actions to the terminal right-click “Actions” submenu
ui:sidebarhost.registerSidebarPanel()Can register collapsible panel sections in the sidebar
ui:file-iconshost.registerFileIconProvider()Can provide file/folder icons for the file browser (e.g. VS Code icon themes)
ui:file-previewhost.registerFilePreview()Can claim file extensions and provide custom preview UIs

Tier 1, Tier 2, and plugin data commands are always available without capabilities.

Agent-Scoped Plugins

Plugins can declare which AI agents they target via the agentTypes manifest field:

{
  "id": "claude-usage",
  "agentTypes": ["claude"],
  ...
}

Behavior

  • Universal plugins (agentTypes omitted or []): receive events from all terminals. This is the default.
  • Agent-scoped plugins (agentTypes: ["claude"]): output watchers and structured event handlers only fire for terminals where the detected foreground process matches one of the listed agent types.

What gets filtered

Dispatch methodFiltered by agentTypes
registerOutputWatcher callbacksYes
registerStructuredEventHandler callbacksYes
All other PluginHost methods (Tier 1-4)No — always available

How agent detection works

TUICommander polls the foreground process of each terminal’s PTY every 3 seconds (via get_session_foreground_process). The process name is classified into an agent type:

Process nameAgent type
claude"claude"
gemini"gemini"
opencode"opencode"
aider"aider"
codex"codex"
amp"amp"
cursor-agent"cursor"
goose"goose"
droid"droid"
git"git"
(anything else)null (plain shell)

Timing considerations

Agent detection is polled, not instant. When a user launches claude in a terminal, there is a brief window (up to 3 seconds) before the first poll detects it. During this window, agent-scoped plugins will not receive events from that terminal. This is by design — it avoids false matches during shell startup.

Example: Claude-only plugin

{
  "id": "claude-usage",
  "name": "Claude Usage Dashboard",
  "version": "1.0.0",
  "minAppVersion": "0.3.0",
  "main": "main.js",
  "agentTypes": ["claude"],
  "capabilities": ["fs:read", "ui:panel", "ui:ticker"]
}

This plugin’s output watchers will only fire when the terminal is running Claude Code. If the user switches to a plain shell or runs Gemini, the watchers are silently skipped.

Example: Multi-agent plugin

{
  "agentTypes": ["claude", "gemini", "codex"]
}

Targets Claude, Gemini, and Codex terminals. All other terminals are ignored.

Content URI Format

scheme:path?key=value&key2=value2

Examples:

  • plan:file?path=%2Frepo%2Fplans%2Ffoo.md
  • stories:detail?id=324-9b46&dir=%2Frepo%2Fstories

Icons

All icons must be monochrome inline SVGs with fill="currentColor" and viewBox 0 0 16 16:

const ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="..."/></svg>';

Never use emoji — they render inconsistently across platforms.

Hot Reload

When any file in a plugin directory changes, the app:

  1. Emits a plugin-changed event with the plugin ID
  2. Calls pluginRegistry.unregister(id) (runs onunload, disposes all registrations)
  3. Re-imports the module with a cache-busting query (?t=timestamp)
  4. Validates and re-registers the plugin

This means you can edit main.js and see changes without restarting the app.

Build & Install

External plugins must be pre-compiled ES modules. Use esbuild:

esbuild src/main.ts --bundle --format=esm --outfile=main.js --external:nothing

Install by copying the directory to:

  • macOS: ~/Library/Application Support/com.tuic.commander/plugins/my-plugin/
  • Linux: ~/.config/com.tuic.commander/plugins/my-plugin/
  • Windows: %APPDATA%/com.tuic.commander/plugins/my-plugin/

Directory structure:

my-plugin/
  manifest.json
  main.js

Plugin Management (Settings > Plugins)

The Settings panel has a Plugins tab with two sub-tabs:

Installed

  • Lists all plugins (built-in and external) with toggle, logs, and uninstall buttons
  • Built-in plugins show a “Built-in” badge and cannot be toggled or uninstalled
  • Error count badges appear on plugins with recent errors
  • “Logs” button opens an expandable log viewer showing the plugin’s ring buffer
  • “Install from file…” button opens a file dialog accepting .zip archives

Browse

  • Shows plugins from the community registry (fetched from GitHub)
  • “Install” button downloads and installs directly
  • “Update available” badge when a newer version exists
  • “Refresh” button forces a new registry fetch (normally cached for 1 hour)

Enable/Disable

Plugin enabled state is persisted in AppConfig.disabled_plugin_ids. Disabled plugins appear in the Installed list but are not loaded.

ZIP Plugin Installation

Plugins can be distributed as ZIP archives:

  1. From Settings: Click “Install from file…” in the Plugins tab
  2. From URL: Use tuic://install-plugin?url=https://example.com/plugin.zip
  3. From Rust: invoke("install_plugin_from_zip", { path }) or invoke("install_plugin_from_url", { url })

ZIP requirements:

  • Must contain a valid manifest.json (at root or in a single top-level directory)
  • All paths are validated for zip-slip attacks (no .. traversal)
  • If updating an existing plugin, the data/ directory is preserved

TUICommander registers the tuic:// URL scheme for external integration:

URLAction
tuic://install-plugin?url=https://...Download ZIP, show confirmation, install
tuic://open-repo?path=/path/to/repoSwitch to repo (must already be in sidebar)
tuic://settings?tab=pluginsOpen Settings to a specific tab
tuic://open/<path>Open markdown file in tab (iframe SDK only)
tuic://terminal?repo=<path>Open terminal in repo (iframe SDK only)

Security: install-plugin requires HTTPS URLs and shows a confirmation dialog. open-repo only accepts paths already in the repository list. open and terminal validate paths against known repos (available only inside plugin iframes via the TUIC SDK, not as OS-level deep links).

Plugin Registry

The registry is a JSON file hosted on GitHub (sstraus/tuicommander-plugins repo). The app fetches it on demand (Browse tab) with a 1-hour TTL cache.

Registry entries include: id, name, description, author, latestVersion, minAppVersion, capabilities, downloadUrl.

The Browse tab compares installed versions to detect available updates.

Per-Plugin Error Logging

Each plugin has a dedicated ring buffer logger (500 entries max). Errors from onload, onunload, output watchers, structured event handlers, and PluginHost-registered UI callbacks are automatically captured.

Plugins can also write to their log via host.log(level, message, data).

View logs in Settings > Plugins > click “Logs” on any plugin row.

Built-in Plugins

Built-in plugins are TypeScript modules in src/plugins/ compiled with the app. They have unrestricted access (no capability checks).

PluginFileSectionDetects
planplanPlugin.tsACTIVE PLANplan-file structured events (repo-scoped)

Note: Session prompt tracking is now a native Rust feature (via input_line_buffer.rs and the Activity Dashboard). The former sessionPromptPlugin built-in has been removed.

See examples/plugins/report-watcher/ for a template showing how to extract terminal output into Activity Center items with a markdown viewer.

To create a built-in plugin, add it to BUILTIN_PLUGINS in src/plugins/index.ts.

Testing

Mock setup for plugin tests

import { describe, it, expect, beforeEach, vi } from "vitest";

vi.mock("../../invoke", () => ({
  invoke: vi.fn(),
  listen: vi.fn().mockResolvedValue(() => {}),
}));

import { invoke } from "../../invoke";
import { pluginRegistry } from "../../plugins/pluginRegistry";
import { activityStore } from "../../stores/activityStore";
import { markdownProviderRegistry } from "../../plugins/markdownProviderRegistry";

beforeEach(() => {
  pluginRegistry.clear();
  activityStore.clearAll();
  markdownProviderRegistry.clear();
  vi.mocked(invoke).mockReset();
});

Testing output watchers

it("detects deployment from PTY output", () => {
  pluginRegistry.register(myPlugin);
  pluginRegistry.processRawOutput("Deployed: api-server to prod\n", "session-1");

  const items = activityStore.getForSection("my-section");
  expect(items).toHaveLength(1);
  expect(items[0].title).toBe("api-server");
});

Testing capability gating

it("external plugin without pty:write throws on sendAgentInput", async () => {
  let host;
  pluginRegistry.register(
    { id: "ext", onload: (h) => { host = h; }, onunload: () => {} },
    [], // no capabilities
  );
  await expect(host.sendAgentInput("s1", "hello")).rejects.toThrow(PluginCapabilityError);
});

CSS Classes

Activity items use these CSS classes (defined in src/styles.css):

ClassElement
activity-section-headerSection heading row
activity-section-labelSection label text
activity-dismiss-all“Dismiss All” button
activity-itemIndividual item row
activity-item-iconItem icon container
activity-item-bodyTitle + subtitle wrapper
activity-item-titlePrimary text
activity-item-subtitleSecondary text
activity-item-dismissDismiss button
activity-last-item-btnShortcut button in toolbar
activity-last-item-iconShortcut button icon
activity-last-item-titleShortcut button text

Structured Event Types

The Rust OutputParser detects patterns in terminal output and emits typed events. Handle them with host.registerStructuredEventHandler(type, handler).

plan-file

Detected when a plan file path appears in terminal output. The emitted path is relative or absolute, as it appeared in the output — the parser does NOT resolve relative paths to absolute:

  • Relative paths (e.g. plans/foo.md, .claude/plans/bar.md) are emitted unchanged
  • Tilde paths (~/.claude/plans/bar.md) are the only ones rewritten — ~ is expanded to the user’s home directory
  • Already-absolute paths are passed through unchanged

Relative-to-absolute resolution (against the terminal session’s CWD via host.getSessionCwd) happens later, in the built-in plan plugin; plans whose CWD cannot be determined are skipped.

{ type: "plan-file", path: string }
// path: relative or absolute, e.g. "plans/foo.md", ".claude/plans/bar.md",
//       "/Users/me/.claude/plans/graceful-rolling-quasar.md"

Repo scoping: The built-in plan plugin only displays plans from terminals whose CWD matches the active repository in the sidebar. Plans from other projects are silently filtered out.

rate-limit

Detected when AI API rate limits are hit.

{
  type: "rate-limit",
  pattern_name: string,           // e.g. "claude-http-429", "openai-http-429"
  matched_text: string,           // the matched substring
  retry_after_ms: number | null,  // ms to wait (default 60000)
}

Pattern names: claude-http-429, claude-overloaded, openai-http-429, cursor-rate-limit, gemini-resource-exhausted, http-429, retry-after-header, openai-retry-after, openai-tpm-limit, openai-rpm-limit.

status-line

Detected when an AI agent emits a status/progress line.

{
  type: "status-line",
  task_name: string,              // e.g. "Reading files"
  full_line: string,              // complete line trimmed
  time_info: string | null,       // e.g. "12s"
  token_info: string | null,      // e.g. "2.4k tokens"
}

pr-url

Detected when a GitHub/GitLab PR/MR URL appears in output.

{
  type: "pr-url",
  number: number,     // PR/MR number
  url: string,        // full URL
  platform: string,   // "github" or "gitlab"
}

progress

Detected from OSC 9;4 terminal progress sequences.

{
  type: "progress",
  state: number,  // 0=remove, 1=normal, 2=error, 3=indeterminate
  value: number,  // 0-100
}

question

Detected when an interactive prompt appears (Y/N prompts, numbered menus, inquirer-style).

{
  type: "question",
  prompt_text: string,  // the question line (ANSI-stripped)
}

usage-limit

Detected when Claude Code reports usage limits.

{
  type: "usage-limit",
  percentage: number,    // 0-100
  limit_type: string,    // "weekly" or "session"
}

Example Plugins

See examples/plugins/ for complete working examples:

ExampleTierCapabilitiesDemonstrates
hello-world1noneOutput watcher, addItem
auto-confirm1+3pty:writeAuto-responding to Y/N prompts
ci-notifier1+3ui:sound, ui:markdownSound notifications, markdown panels
repo-dashboard1+2noneRead-only state, dynamic markdown
claude-status1noneAgent-scoped (agentTypes: ["claude"]), structured events
telegram-notifier1+3net:http, ui:panel, ui:tickerTelegram push notifications, per-event toggles, settings panel

Distributable Plugins

Available from the plugin registry (submodule at plugins/). Installable via Settings > Plugins > Browse.

PluginTierCapabilitiesDescription
mdkb-dashboard2+3exec:cli, fs:read, ui:panel, ui:tickermdkb knowledge base dashboard
rtk-dashboard3exec:cli, ui:panel, ui:context-menuRTK token savings dashboard (binaries: ["rtk"])
csv-preview3ui:file-preview, ui:panel, fs:readPreview CSV/TSV files as sortable HTML tables
docx-preview3ui:file-preview, ui:panel, fs:readPreview Word .docx/.dotx files as HTML with Mammoth.js

Troubleshooting

ProblemCauseFix
Plugin not loadingmanifest.json missing or malformedCheck console for validation errors
requires app version X.Y.ZminAppVersion too highLower minAppVersion or update app
not in the invoke whitelistCalling non-whitelisted Tauri commandOnly use commands listed in the whitelist table
not declared in plugin ... manifest binariesBinary not in manifest binaries fieldAdd the binary name to the binaries array in manifest.json
requires capability "X"Missing capability in manifestAdd the capability to manifest.json capabilities array
Module not foundmain field doesn’t match filenameEnsure "main": "main.js" matches your actual file
Changes not reflectingHot reload cacheSave the file again, or restart the app
default export errorModule doesn’t export default { ... }Ensure your module has a default export with id, onload, onunload

Development Setup

Prerequisites

  • Node.js 24+ (the repository version is pinned in .nvmrc)
  • Rust (stable toolchain via rustup)
  • Tauri CLI (cargo install tauri-cli)
  • git and gh (GitHub CLI) for git/GitHub features

Windows users: See the Windows-specific prerequisites section below before proceeding.

Install Dependencies

pnpm install

Development

Native Tauri App

pnpm tauri dev

Starts the Vite dev server and Tauri app. Frontend files use Vite HMR; Rust changes require restarting the development process.

Browser Mode

When the MCP server is enabled in settings, the frontend can run standalone:

pnpm dev

Connects to the Rust HTTP server via WebSocket/REST.

Note: pnpm dev runs scripts/dev-server.mjs, not vite directly. The dev server is pinned to port 1421 (Tauri’s devUrl), so the launcher checks the port first: if this checkout is already serving there it prints reusing it and exits 0 — the second Tauri app attaches to the running server. Starting a second Vite would wipe the shared node_modules/.vite/deps cache and kill hot reload for the session already running. If the port is held by another checkout or a stray process, the launcher fails with an explicit message instead of serving the wrong sources.

Build

pnpm tauri build

Produces platform-specific installers:

  • macOS: .dmg and .app
  • Windows: .nsis (.exe setup installer)
  • Linux: .deb and .AppImage

Note: The .msi bundle may fail on Windows due to WiX tooling issues. Use --bundles nsis to produce a working .exe installer:

cargo tauri build --bundles nsis

Testing

pnpm test              # Run all tests
pnpm test:coverage     # Coverage report

Test tiers:

  • Tier 1: Pure functions (utils, type transformations)
  • Tier 2: Store logic (state management)
  • Tier 3: Component rendering
  • Tier 4: Integration (hooks + stores)

Framework: Vitest + SolidJS Testing Library + happy-dom

Coverage: ~80%+

Project Structure

See Architecture Overview for full directory structure.

Key Files

FilePurpose
src/App.tsxCentral orchestrator
src-tauri/src/lib.rsRust app setup, command registration
src-tauri/src/pty.rsPTY session management
src/hooks/useAppInit.tsApp initialization
src/stores/terminals.tsTerminal state
src/stores/repositories.tsRepository state
SPEC.mdFeature specification
ideas/index.mdFeature concepts under evaluation

Configuration

App config stored in platform config directory:

  • macOS: ~/Library/Application Support/tuicommander/
  • Linux: ~/.config/tuicommander/
  • Windows: %APPDATA%/tuicommander/

See Configuration docs for all config files.

Makefile Targets

make dev         # Tauri dev mode
make build       # Production build
make test        # Run tests
make lint        # Run linter
make docs        # Build this documentation + search index into docs/book
make docs-serve  # …and serve it at http://127.0.0.1:8123
make clean       # Clean build artifacts

Documentation Site

The book you are reading is mdBook, built by scripts/build-docs.sh and deployed to GitHub Pages by .github/workflows/website.yml. The script is the single source of truth — CI runs the same one, so a local build matches the deployed site.

make docs-serve     # build + serve; open http://127.0.0.1:8123

Requires mdbook (brew install mdbook or cargo install mdbook) and npx.

Search is Pagefind, generated after the mdBook build, which is why file:// previews have no search — the index is fetched over HTTP. mdBook’s own elasticlunr search is disabled in book.toml.

Adding a page means adding it to SUMMARY.md: mdBook only renders — and Pagefind only indexes — chapters listed there.


Windows Prerequisites

Building on Windows requires a few extra tools beyond the standard prerequisites. Install them in this order.

1. Visual Studio Build Tools (C++ compiler)

Download and install Visual Studio Build Tools and select the “Desktop development with C++” workload. VS Build Tools 2019 or later is fine.

Or via winget:

winget install Microsoft.VisualStudio.2022.BuildTools

2. Rust

winget install Rustlang.Rustup

Restart your terminal after installation, then verify:

rustc --version
cargo --version

3. Node.js

winget install OpenJS.NodeJS.LTS

4. CMake

Required to compile whisper-rs (the on-device dictation library).

winget install Kitware.CMake

5. LLVM 18 (libclang — required for whisper-rs bindings)

whisper-rs uses bindgen to generate Rust bindings for whisper.cpp, which requires libclang. Use LLVM 18 — LLVM 19+ produces broken bindings for this crate on Windows.

Download the LLVM 18 installer from GitHub releases (LLVM-18.1.8-win64.exe) and install it. Then set the environment variable so bindgen can find it:

# Add to your PowerShell profile or set permanently in System Environment Variables
$env:LIBCLANG_PATH = "C:\Program Files\LLVM\bin"

If you have LLVM 22+ installed (e.g. from winget), install LLVM 18 to a separate directory and point LIBCLANG_PATH there instead.

6. Tauri CLI

cargo install tauri-cli --version "^2"

Full Windows Build Command

Always set LIBCLANG_PATH before building:

$env:LIBCLANG_PATH = "C:\Program Files\LLVM\bin"   # adjust path if LLVM 18 is elsewhere
cargo tauri build --bundles nsis

The installer will be at:

src-tauri\target\release\bundle\nsis\TUICommander_0.x.x_x64-setup.exe

Windows Known Issues

SymptomCauseFix
whisper-rs-sys build fails with “couldn’t find libclang”LLVM not installed or LIBCLANG_PATH not setInstall LLVM 18 and set LIBCLANG_PATH
whisper-rs-sys compile error: attempt to compute 1_usize - 296_usizeLLVM 19+ generates broken bindings for this crateUse LLVM 18 specifically
WiX .msi bundle failsWiX light.exe tooling issueUse --bundles nsis instead
App window opens but shows a black screenNavigation guard in lib.rs blocked http://tauri.localhost/ (Windows’ internal Tauri URL)Fixed in current code — tauri.localhost is explicitly allowed
Update check failed: windows-x86_64-nsis not foundCustom local build isn’t listed in official release manifestHarmless — auto-update simply won’t trigger

Performance Profiling

Repeatable profiling infrastructure for identifying bottlenecks across the full stack: Rust backend, SolidJS frontend, Tauri IPC, and terminal I/O.

Quick Start

# Install profiling tools (one-time)
scripts/perf/setup.sh

# Run all automated benchmarks (app must be running)
scripts/perf/run-all.sh

# Or run individual benchmarks
scripts/perf/bench-ipc.sh              # IPC latency
scripts/perf/bench-pty.sh              # PTY throughput
scripts/perf/record-cpu.sh             # CPU flamegraph
scripts/perf/record-tokio.sh           # Tokio runtime inspector
scripts/perf/snapshot-memory.sh        # Memory profiling guide

Results are saved in scripts/perf/results/ (gitignored).

Tools

ToolWhat it profilesInstall
samplyRust CPU time (flamegraphs)cargo install samply
tokio-consoleAsync task scheduling, lock contentioncargo install tokio-console
hyperfineCommand-line benchmarkingbrew install hyperfine
Chrome DevToolsJS rendering, memory, layoutBuilt into Tauri webview
Solid DevToolsSolidJS signal/memo reactivity graphBrowser extension

All tools are installed by scripts/perf/setup.sh.

Rust Backend

CPU Flamegraph

scripts/perf/record-cpu.sh --duration 60

Builds a release binary with debug symbols, records under samply for 60 seconds, saves a JSON profile. Open the result with:

samply load scripts/perf/results/cpu-YYYYMMDD-HHMMSS.json

What to look for:

  • Functions with wide bars = high cumulative CPU time
  • std::process::Command in hot paths = subprocess forks
  • serde_json::to_value / serde_json::to_string = serialization overhead
  • parking_lot::Mutex::lock = contention

Tokio Runtime Inspector

scripts/perf/record-tokio.sh

Builds with the tokio-console Cargo feature and launches both the app and the console UI. The console shows live stats for every Tokio task.

What to look for:

  • Tasks with high “busy” time = CPU-bound work on the async executor
  • Tasks with high “idle” time = blocked on I/O or lock contention
  • Tasks stuck in “waiting” = possible deadlock
  • Many short-lived spawn_blocking tasks = check if batching would help

Note: This uses a debug build. Timing numbers are not representative of production performance, but relative proportions and task scheduling patterns are valid.

Building with tokio-console manually

cd src-tauri
RUSTFLAGS="--cfg tokio_unstable" cargo build --features tokio-console

Then run the binary and connect tokio-console separately:

tokio-console

The console subscriber listens on 127.0.0.1:6669 by default.

IPC Latency

scripts/perf/bench-ipc.sh                     # auto-detect repo
scripts/perf/bench-ipc.sh /path/to/repo 50    # 50 iterations

Measures round-trip latency for key git commands via the HTTP API on port 9877. Reports p50, p95, and mean for each endpoint.

Endpoints measured:

  • repo_info (cached after first call, 5s TTL)
  • git_panel_context (cached, 5s TTL)
  • diff_stats, changed_files, branches
  • recent_commits, stash_list, remote_url

Interpreting results:

  • p50 < 5ms for cached endpoints = healthy
  • p50 < 50ms for uncached git commands = healthy
  • p95 > 200ms = investigate (large repo? slow disk? lock contention?)

To measure cold vs warm cache, run bench-ipc.sh twice in quick succession: the first run hits cache misses, the second should show cache hits.

PTY Throughput

scripts/perf/bench-pty.sh       # 10MB default
scripts/perf/bench-pty.sh 50    # 50MB stress test

Creates a PTY session, blasts data through it, and measures throughput in MB/s. Tests the full pipeline: PTY read -> UTF-8 decode -> escape processing -> VT100 parse -> Tauri event emit.

If the API doesn’t support session creation, the script prints manual commands to run in an existing terminal tab for the same measurement.

Frontend

Performance Recording

  1. Open DevTools in TUICommander: Cmd+Shift+I
  2. Go to Performance tab
  3. Click Record
  4. Exercise the scenario for 10-30 seconds
  5. Stop recording

What to look for:

  • Long Tasks (>50ms red bars) = jank
  • Layout/Recalculate Style = CSS forcing reflow
  • requestAnimationFrame gaps = dropped frames
  • Frequent minor GC = allocation pressure

Memory Profiling

Run scripts/perf/snapshot-memory.sh for detailed scenario instructions. Key scenarios:

  1. Terminal memory — open/close 5 terminals, compare heap snapshots
  2. Panel leak check — open/close Settings/Activity/Git panels 10x
  3. Long-running session — compare snapshots at 0/10/20 minutes

Expected baselines:

  • Each terminal: ~1.6MB heap (10k scrollback lines at 80 cols)
  • Panel open/close cycle: <500KB retained after GC
  • 20-minute session: sub-linear growth (not linear)

SolidJS Reactivity

Install Solid DevTools browser extension. In the devtools panel:

  • Check how many times each createMemo re-evaluates
  • Find effects with unexpectedly high execution counts
  • Trace which signal changes trigger cascading updates

Key areas to watch:

  • terminalsStore updates propagating to StatusBar/TabBar/SmartButtonStrip
  • debouncedBusy signal reactivity scope
  • githubStore polling triggering re-renders in unrelated components

Profiling Scenarios

Scenario 1: Startup Performance

scripts/perf/record-cpu.sh --duration 30

Open the app, wait for it to fully load, open DevTools Performance tab. Measure time-to-interactive.

Target: < 2s from launch to first terminal ready.

Scenario 2: Multi-Terminal Steady State

  1. Open 5 terminal tabs
  2. Run an AI agent in 2 of them
  3. Record CPU + memory for 2 minutes
  4. Check: is CPU usage stable? Is memory growing?

Scenario 3: Git-Heavy Workflow

  1. Open a large repo (>1000 commits, >50 branches)
  2. Open the Git panel
  3. Switch branches
  4. Run bench-ipc.sh against this repo

Scenario 4: High-Throughput Output

In a terminal tab:

dd if=/dev/urandom bs=1024 count=10240 | base64    # ~14MB of random base64
yes | head -n 500000                                 # ~2MB of repetitive data
find / -type f 2>/dev/null                           # realistic filesystem output

Monitor CPU usage and app responsiveness during output.

Comparing Results Across Sessions

Results accumulate in scripts/perf/results/:

results/
  ipc-20260328-143000.txt    # IPC latency run
  ipc-20260330-091500.txt    # After optimization
  cpu-20260328-150000.json   # CPU flamegraph
  pty-throughput.log          # PTY throughput history (appended)

Compare IPC results:

diff scripts/perf/results/ipc-{before,after}.txt

The PTY throughput log is append-only — each run adds a line for trend tracking.

Architecture Reference

The profiling targets map to these code areas:

LayerKey filesWhat to measure
Tauri commandssrc-tauri/src/git.rsspawn_blocking overhead, subprocess latency
PTY pipelinesrc-tauri/src/pty.rsRead buffer throughput, event emission rate
IPC serializationsrc-tauri/src/pty.rs, git.rsJSON payload sizes, serde time
State managementsrc/stores/terminals.tsSignal propagation scope, batch effectiveness
Renderingsrc/components/Terminal/CanvasTerminal.tsxGrid frame batching, canvas atlas rebuilds
Pollingsrc/hooks/useAgentPolling.ts, src/stores/github.tsInterval frequency, IPC calls per tick
Bundlevite.config.tsChunk sizes, initial parse/eval time

Documentation Sync Matrix

Every code change that affects user-visible behavior, APIs, or configuration MUST update the corresponding documentation files. This matrix maps codebase areas to their docs.

New Feature Checklist

  • Feature works correctly
  • Keyboard shortcut added (if applicable) — keybindingDefaults.ts + actionRegistry.ts ACTION_META
  • docs/FEATURES.md updated with new feature entry
  • CHANGELOG.md — entry in Unreleased section
  • SPEC.md — feature status updated
  • Domain-specific docs updated (see matrix below)
  • Screenshot taken (if visual/CSS/layout change)
  • src/data/tips.ts — add a Tip of the Day entry for discoverable features

Sync Matrix by Area

Plugin System

When modifying PluginHost API, capabilities, manifest schema, Tauri commands used by plugins, plugin panel rendering (base CSS, theme injection, iframe behavior), or plugin infrastructure (loader, registry, discovery):

FileWhat to update
src/plugins/types.tsPluginHost interface, PluginCapability union, snapshot types
src/plugins/pluginRegistry.tsImplementation in buildHost()
src/components/PluginPanel/pluginBaseStyles.tsBase CSS classes available to all plugin panels
src-tauri/src/plugins.rsKNOWN_CAPABILITIES list (new capabilities)
src-tauri/src/lib.rsRegister new Tauri commands in invoke_handler
docs/plugins.mdPlugin developer guide (API reference, capabilities table, Panel CSS Design Strategy section, examples)
src-tauri/src/mcp_http/plugin_docs.rsAI-optimized plugin reference (PLUGIN_DOCS const — must stay in sync with docs/plugins.md)
docs/api/tauri-commands.mdTauri commands reference table
docs/api/http-api.mdHTTP API reference (if new HTTP endpoints)
docs/backend/mcp-http.mdMCP/HTTP server docs (if new routes)
docs/FEATURES.mdSection 17.1 capabilities list
docs/user-guide/plugins.mdUser installation/management guide

Terminal & PTY

When modifying PTY behavior, output parsing, shell state, or terminal UI:

FileWhat to update
docs/backend/pty.mdPTY session lifecycle, reader threads, output handling
docs/backend/output-parser.mdRate limits, structured events, parsing rules
docs/frontend/canvas-terminal-audit.mdCanvasTerminal feature completeness audit
docs/FEATURES.mdSection 1 (Terminal Management)
docs/user-guide/terminals.mdUser-facing terminal features
docs/api/tauri-commands.mdPTY commands (create_pty, write_pty, resize_pty, etc.)
docs/backend/alacritty-integration.mdAlacritty patch inventory, upstream API usage, update procedure

Keyboard Shortcuts & Actions

When adding or changing shortcuts:

FileWhat to update
src/keybindingDefaults.tsACTION_NAMES + default key combo
src/actions/actionRegistry.tsACTION_META (label, category) — auto-populates Settings and Command Palette
src-tauri/src/native_keys.rsmacOS NSEvent monitor for keys WKWebView never forwards (Ctrl+Tab, F13–F20). Keep it as ONE KeyDown monitor — a second one doubles per-keystroke work on every key typed
src/hooks/useNativeKeyCombo.tsTurns native-key-down back into a combo string identical to keyEventToCombo’s; used by every recorder
docs/FEATURES.mdSection 15 (Keyboard Shortcut Reference)
docs/user-guide/keyboard-shortcuts.mdUser-facing shortcut table
docs/frontend/hooks.mduseNativeKeyCombo entry

Tauri Commands & IPC

When adding or changing Tauri commands:

FileWhat to update
src-tauri/src/lib.rsinvoke_handler! macro registration
docs/api/tauri-commands.mdCommand signature + description
docs/api/http-api.mdHTTP endpoint mapping (if browser/remote mode)
Domain backend doce.g. docs/backend/pty.md, docs/backend/git.md

Tauri events emitted by backend

When adding a new app.emit(event_name, payload) call, document it here and listen in useAppInit.ts:

EventPayloadEmitted fromFrontend listener
session-standby{ session_id: string, standby: bool }pty.rs emit_standby_event()useAppInit.tsterminalsStore.update(termId, { standby })
worktree-created{ repo_path: string, branch: string, worktree_path: string }mcp_transport.rs, session.rs, worktree_routes.rsTBD — frontend switch prompt
worktree-removed{ repo_path: string, branch: string }state.rs notify_worktree_removed() — called by EVERY removal path: worktree.rs (remove_worktree, finalize_merged_worktree, merge_and_archive_worktree, delete_local_branch), worktree_routes.rs (remove_worktree_http, finalize_merged_worktree_http), mcp_transport.rs (repo worktree_remove)useWorktreeSwitchPrompt.tspruneRemovedWorktree() closes the branch terminals and drops the sidebar row
repo-changed (git-state){ repo_path: string }repo_watcher.rsonly when the git-state fingerprint changed (index size + resolved HEAD + porcelain status + the sorted .git/worktrees/* set; skips no-op .git touches). The worktree set is an input because add/remove touches nothing else, so worktree-only changes used to be swallowed and left ghost sidebar rows. Last fingerprint in AppState.repo_git_fingerprints.useAppInit.ts → coalesced one bump/repo/frame via revisionCoalescerrepositoriesStore.bumpRevision
repo-changed (working-tree){ repo_path: string }repo_watcher.rs — non-.git, non-gitignored file changes, debounced 1.5s when the repo is hot (has ≥1 open terminal, set_hot_repos) and 15s when cold. No fingerprint guard. Covers the main checkout and every linked worktree (sync_worktree_watches), which is what keeps a branch’s sidebar diff badge live while an agent works in its worktree; the payload always names the parent repo.same as above — useAppInit.tsrevisionCoalescerbumpRevision + debounced refreshAllBranchStats
head-changed{ repo_path: string, branch: string }repo_watcher.rsonly when the resolved HEAD target changed (resolve_head_target); skips the Linux inotify storm where .git/HEAD events recur without HEAD moving (issue #82). Last target in AppState.repo_head_targets; suppressed-emit count in AppState.repo_head_emits_suppressed.useAppInit.ts → branch rename/activate (also dedupes on activeBranch === branch)
review-progress{ repo_path: string, payload: { pr_number, summary, files, phase, done, llm_used, llm_model } }diff_triage.rs ProgressSink::PrReview during run_pr_review; also sent on event_bus for /events SSEgithubOpsStore listener updates per-PR review progress
conflict-assist-status{ repo_path: string, payload: { pr_number, status, conflicted_files } }conflict_assist.rs emit_conflict_assist_status() lifecycle; also sent on event_bus for /events SSEgithubOpsStore listener updates conflict-assist state
proposals-ready{ repo_path: string, payload: ImprovementScanResult }improvement_scan.rs after run_improvement_scan completes; also sent on event_bus for /events SSEgithubOpsStore listener accumulates proposals for the GitHub Ops dashboard
ctrl-tab"next" | "prev"native_keys.rs — macOS only; the NSEvent is swallowed so AppKit cannot also cycle tabsuseNativeMenuBridge.ts → tab switch
native-key-down{ key: "F13".."F20", cmd, ctrl, alt, shift }native_keys.rs — macOS only, scoped to the main window; the event is passed through (nothing native to suppress)useNativeKeyCombo.ts, attached only while a shortcut recorder is open
mcp-toast{ title, message, level, sound, origin_repo_path? }mcp_transport.rsui action=toast; derives origin from the calling MCP session rather than accepting caller-supplied scopeuseAppInit.ts → repository-labelled toast + repository-scoped Messages item
pty-description-changed`{ session_id: string, description: stringnull }`state.rs — MCP agent spawn / session input updates the orchestrator-owned PTY description

HTTP & MCP Server

When adding routes or changing server behavior:

FileWhat to update
docs/api/http-api.mdREST endpoint reference
docs/backend/mcp-http.mdServer architecture, routing, lazy tool discovery (collapse_tools / meta-tools)
docs/user-guide/remote-access.mdUser setup guide
src-tauri/src/mcp_http/plugin_docs.rsPLUGIN_DOCS (if plugin-facing)

Diagnostics

When modifying cpu_watchdog.rs or the /diagnostics HTTP endpoint:

FileWhat to update
src-tauri/src/cpu_watchdog.rsWatchdog logic, thresholds, snapshot fields
src-tauri/src/mcp_http/log_routes.rs/diagnostics GET/POST handlers
AGENTS.mdDiagnostics section (usage, known failure patterns)
docs/FEATURES.mdSection 20.11 (Runtime Diagnostics)

Agent state detection (working / idle / awaiting)

When changing an awaiting/idle/busy signal — a parser, the hook suppression, or the raw-stream composition:

FileWhat to update
src-tauri/src/output_parser.rsThe parser itself (parse_question, parse_osc777_notify, …)
src-tauri/src/chrome.rsBottom-zone cutoff — anything at or below the input box must stay unparsed
src-tauri/src/pty.rsraw_stream_events composition + suppress_heuristic_question gating
src-tauri/src/state.rsapply_event_to_session_state — the arms that SET and CLEAR awaiting_input. A signal nothing retracts latches the badge
src/components/Terminal/Terminal.tsxThe frontend twin of those arms (terminalsStore awaiting flags)
src-tauri/src/fixtures/agent_prompts/A framed .tcap capture of the failure, recorded via /diagnostics/capture (.raw remains legacy-readable)
src-tauri/src/pty.rs testsA case in the Awaiting-signal fixtures block replaying that capture
src-tauri/src/pty.rs testsA case in the Awaiting RETRACTION block when the failure is a state that never clears — fixtures assert emitted events and cannot express a MISSING one
AGENTS.md“Agent state detection” section (signal table, capture workflow, retraction)

MCP Tool Surface (native tools, upstream proxy, meta-tools)

When changing the tool list, tool handlers, disabled_native_tools, upstream allow/deny filters, or the Speakeasy meta-tools:

FileWhat to update
src-tauri/src/mcp_http/mcp_transport.rsTool definitions, merged_tool_definitions, searchable_tool_definitions, meta-tool handlers (search_tools, get_tool_schema, call_tool), build_mcp_instructions
src-tauri/src/mcp_proxy/registry.rsaggregated_tools, proxy_tool_call (filter is enforced on BOTH — discovery no longer gates dispatch under collapse_tools)
src-tauri/src/tool_search.rsBM25 ToolSearchIndex backing search_tools / get_tool_schema
docs/backend/mcp-http.mdLazy Tool Discovery section, meta-tool table, filter-enforcement note
docs/backend/config.mdcollapse_tools field in AppConfig table
docs/user-guide/settings.mdServices Tab — “Collapse tools” checkbox description

Session tool actions added (swarm Layer 3–4)

  • session action=status — returns {shell_state, idle_since_ms, busy_duration_ms, exit_code, agent_type}. Useful for polling agent progress without streaming output.
  • session action=list response now includes shell_state per entry.

Agent tool actions added (swarm inbox)

  • agent action=inbox response now includes missed_count — number of messages evicted from the FIFO inbox since last read. Non-zero means the orchestrator missed messages and should increase polling frequency.
  • agent action=send response includes delivered (bool) plus, when false, warning and recipient_has_terminal. delivered is false exactly when delivery_path == "inbox_only": no waiter, channel, direct terminal delivery, or already-pending coalesced orchestrator wake will surface it, so it stays unread until the recipient polls. Registered orchestrators add wake_notification_and_inbox, coalesced_wake_and_inbox and lifecycle_summary_and_inbox; none of them exposes a peer payload — the last one is reachable only for a window made entirely of server-authored tuic-auto-* lifecycle notifications, which it prints inline and acknowledges itself. A payload-free wake gets at most one retry after an uncertain PTY write per unread-mail group; coalesced mail does not reset that budget, and inbox/wait observation does. accepted/ok only mean “buffered”. Keep these distinct in every client and in the tool descriptions — reporting inbox_only as success is how a reply to an agent with no PTY silently vanished.
  • agent action=register response includes terminal (bool): false means the identity resolves to no live PTY (live_pty_for_peerNone), so it can never be typed into or woken, and the peer must consume its own inbox via wait/inbox. Identities without a PTY arise from a bridge that sent no x-tuic-session header (agent launched outside a TUIC PTY) — the server then mints an MCP-scoped UUID.
  • agent action=register accepts orchestrator (bool) as the only role declaration seam; omission preserves the current role and child spawn never infers it. Register/list responses surface orchestrator plus mail_wake (managed_pty_lifecycle or none). External/headerless orchestrators are inbox/wait-only because MCP/SSE activity is not an authoritative idle or wake surface.

Provider Registry

When modifying provider types, slot names, credential storage, or the ProvidersTab UI:

FileWhat to update
src-tauri/src/provider_registry.rsProviderType, SlotName, ProviderRegistry structs + Tauri commands
src-tauri/src/credentials.rsCredential::Provider variant for per-provider key storage
src/stores/providerRegistry.tsFrontend store: hydrate, save, slot resolution, CRUD
src/components/SettingsPanel/tabs/ProvidersTab.tsxSettings UI: provider cards, model CRUD, slot assignments
src/hooks/useSmartPrompts.tsresolveSlot("headless") check for headless execution
docs/backend/config.mdproviders.json schema documentation

AI Prompts

When modifying customizable AI service prompts (diff triage, future services):

FileWhat to update
src-tauri/src/config.rsAiPromptsConfig struct, load/save commands
src-tauri/src/diff_triage.rsbuild_chat_request system_prompt param, default_system_prompt()
src/stores/aiPrompts.tsFrontend store: hydrate, save, DEFAULT_DIFF_TRIAGE_PROMPT const
src/components/SettingsPanel/tabs/AiPromptsTab.tsxSettings UI: textarea per service, reset button
src-tauri/src/mcp_http/mcp_transport.rsMCP config tool: list_ai_prompts, load_ai_prompt, save_ai_prompt actions
docs/backend/config.mdai-prompts.json schema documentation

AI Chat

When modifying AI Chat panel, settings, context menu actions, or streaming backend:

FileWhat to update
src-tauri/src/ai_chat.rsBackend: config, streaming, context assembly, Ollama detection
src-tauri/src/ai_chat_registry.rsChat Registry: cross-window state sync, Channel fan-out, subscribe/unsubscribe
src/stores/aiChatStore.tsFrontend store: messages, streaming state, registry subscription (sessionId passed per-call, derived from focused terminal)
src/components/AIChatPanel/AIChatPanel.tsxChat panel component + detach button + registry lifecycle
src/components/AIChatPanel/contextMenuActions.tsTerminal context menu integration
src/components/PanelOrchestrator.tsxSwitches between AIChatPanel and DetachedPlaceholder
src/components/DetachedPlaceholder.tsxPlaceholder shown in main window when panel is detached
src/components/SettingsPanel/tabs/AiChatTab.tsxSettings panel section
src/stores/ui.tsaiChatPanelVisible + aiChatPanelWidth + detachedPanels map
src/panelRouter.tsxPanel adapter registry + routing for detached panel windows
src/utils/panelSync.tsPanelSyncProvider + PanelSyncReceiver for main↔detached communication
src/hooks/initPanelWindow.tsBootstrap for detached panel windows (theme, font, settings)
src/keybindingDefaults.tstoggle-ai-chat + detach-activity-dashboard hotkeys
docs/FEATURES.mdAI Chat feature section
docs/user-guide/ai-chat.mdUser-facing AI Chat guide
docs/api/tauri-commands.mdChat Registry + open_panel_window / close_panel_window / focus_main_window commands

Extended thinking (Opus 4.7+ reasoning)

When modifying reasoning effort, the thinking stream, or its gating:

FileWhat to update
src-tauri/src/ai_agent/conversation_engine.rsReasoningLevel, supports_extended_thinking, resolve_reasoning, ConversationEvent::ReasoningChunk, ChatOptions build + captured_content (thinking+signature) append
src-tauri/src/ai_agent/commands.rsreasoning_effort param + persisted-config fallback + 50ms ReasoningChunk batching
src-tauri/src/ai_chat.rsAiChatConfig.reasoning_effort field
src/stores/conversationStore.tsreasoning_chunk event + reasoningChunks signal + reset on new turn
src/components/AIChatPanel/AIChatPanel.tsx“Thinking” disclosure render
src/components/SettingsPanel/tabs/AiChatTab.tsxExtended-thinking effort dropdown

AI Agent (ReAct loop, knowledge store, MCP terminal tools)

When modifying the AI agent loop engine, tool dispatch, session knowledge store, OSC 133 outcome capture, or the ai_terminal_* MCP tools:

FileWhat to update
src-tauri/src/ai_agent/engine.rsReAct loop, approval flow, ACTIVE_AGENTS registry, system prompt
src-tauri/src/ai_agent/tools.rsTool dispatch: 31 tools (terminal observe incl. get_command_history/explain_last_failure/get_error_fixes/search_scrollback/get_hyperlinks/get_semantic_zones, reactive watches watch_for/list_watches/cancel_watch, filesystem, drive_agent, search, list_sessions). Tool count assertions live in tools.rs #[cfg(test)] — bump them on add/remove
src-tauri/src/terminal_grid.rsGrid reader methods backing agent tools: search_buffer, enumerate_visible_hyperlinks (get_hyperlinks), extract_semantic_zones (get_semantic_zones); VtLogBuffer delegates in state.rs
src-tauri/src/ai_agent/safety.rsSafetyChecker: command safety + file-write sensitive path rules
src-tauri/src/ai_agent/sandbox.rsFileSandbox: path jail for filesystem tools (canonicalize + starts_with)
src-tauri/src/mcp_http/ai_terminal.rsMCP exposure of all 13 ai_terminal_* tools; write-tool confirmation
src-tauri/src/ai_agent/knowledge.rsCommandOutcome, SessionKnowledge, OSC 133 scanner, persist/load/spawn_persist_task
src-tauri/src/ai_agent/context.rsSession-knowledge injection into agent system prompt
src-tauri/src/ai_agent/tui_detect.rsTerminalMode heuristics (Shell vs FullscreenTui)
src-tauri/src/ai_agent/commands.rsTauri commands: start/cancel/pause/resume/status/approve/get_session_knowledge
src-tauri/src/pty.rsChunkProcessor.record_osc133_outcomes + Inferred fallback in silence timer
src-tauri/src/state.rssession_knowledge DashMap, knowledge_dirty set, has_osc133_integration, record_outcome helper
src-tauri/src/lib.rsRegister new commands in invoke_handler; spawn_persist_task at boot
src-tauri/src/mcp_http/mcp_transport.rsai_terminal_* MCP tool defs + dispatch
src/stores/aiAgentStore.tsFrontend agent state (running/paused), tool-call log, approvals
src/components/AIChatPanel/AIChatPanel.tsxAgent banner, approval card, tool-call cards
src/components/AIChatPanel/SessionKnowledgeBar.tsxCollapsible footer summarising the session’s knowledge store
docs/api/tauri-commands.mdstart_agent_loop, cancel_agent_loop, pause_agent_loop, resume_agent_loop, agent_loop_status, approve_agent_action, get_session_knowledge
docs/backend/mcp-http.mdai_terminal_* MCP tools table
docs/FEATURES.mdAI Agent section (Level 2/3 of the AI-assisted terminal roadmap)
ideas/ai-assisted-terminal.mdStatus updates as capability levels ship

Terminal Watcher (event-driven autonomous actions)

When modifying the watcher engine, trigger evaluation, or watcher UI:

FileWhat to update
src-tauri/src/ai_agent/watcher.rsWatcherRule model, WatcherEngine event loop, trigger evaluation, burst guard, fire_rule
src-tauri/src/ai_agent/commands.rsTauri commands: watcher_create, watcher_list, watcher_delete, watcher_toggle, watcher_attach, watcher_detach, watcher_update
src-tauri/src/state.rswatcher_engine OnceLock in AppState, session_visibility DashMap
src-tauri/src/lib.rsCommand registration + WatcherEngine spawn
src/components/WatcherManager/WatcherManager.tsxTemplate CRUD, attach/detach, edit form (toolbar popover)
src/components/WatcherManager/WatcherManager.module.cssPopover styles
docs/backend/ai-watchers.mdArchitecture doc: data model, trigger paths, safety guards
Config: ai-watchers.jsonPersisted watcher rules (app config dir)

Remote Daemon (tuic-remote)

When modifying the remote daemon binary, run_headless, or standalone server behavior:

FileWhat to update
src-tauri/src/bin/tuic_remote.rsBinary entry point
src-tauri/src/lib.rsrun_headless() function
docs/user-guide/remote-access.mdtuic-remote (Beta) section
docs/FEATURES.mdSection 22 (Remote Daemon)
.github/workflows/release.ymlRelease artifact build job

SSH Tunnel Management

When modifying tunnel profiles, supervisor, audit logging, backoff, or tunnel UI:

FileWhat to update
src-tauri/src/tunnels/profile.rsTunnelProfile, ForwardSpec, ProfileOptions structs
src-tauri/src/tunnels/command.rsSSH command-line argument building
src-tauri/src/tunnels/classifier.rsExitReason enum and stderr classification
src-tauri/src/tunnels/agent.rsSSH agent socket discovery
src-tauri/src/tunnels/port.rsLocal port availability check
src-tauri/src/tunnels/backoff.rsBackoffCalculator (delays, jitter, max retries)
src-tauri/src/tunnels/audit.rsAuditLog SQLite schema, insert/query/rotate
src-tauri/src/tunnels/supervisor.rsTunnelSupervisor lifecycle and reconnect loop
src-tauri/src/tunnels/storage.rsProfileStore: TOML load/save (global + per-repo)
src-tauri/src/tunnels/manager.rsTunnelManager: orchestrates supervisors
src-tauri/src/tunnels/commands.rsTauri commands for tunnel CRUD and control
src/stores/tunnels.tsFrontend tunnel state (profiles, statuses)
src/stores/tunnelPanel.tsTunnel panel UI state
src/components/TunnelsPanel/TunnelsPanel.tsxTunnel list with start/stop controls
src/components/TunnelsPanel/TunnelEditorModal.tsxProfile create/edit form
src/components/TunnelsPanel/TunnelStatusBadge.tsxColor-coded status indicator
docs/features/ssh-tunnels.mdFeature architecture doc
docs/FEATURES.mdSection 23 (SSH Tunnel Manager)
docs/user-guide/remote-access.mdSSH Tunnel Management section

Remote Connection Manager

When modifying remote connection config, storage, or transport routing:

FileWhat to update
src-tauri/src/remote_connection.rsRemoteConnection, RemoteTransport, RemoteConnectionStore
src/stores/remoteConnections.tsFrontend remote connections store
src/utils/remoteEventBridge.tsSSE event bridge for remote daemons
src/utils/transport.tsconnectionId-based routing in COMMAND_TABLE
src/utils/canvasTerminalTransport.tsbaseUrl support for remote WebSocket
docs/FEATURES.mdSection 24 (Remote Connection Manager)
docs/user-guide/remote-access.mdRemote Connection Manager section

Git & Worktree Integration

When modifying git operations, worktree logic, or GitHub API:

FileWhat to update
docs/backend/git.mdGit command lifecycle, diff parsing, GitReads port (gix vs CLI op split), moka cache
src-tauri/src/git_reads.rsGitReads port: flipping an op to gix requires a green byte-parity shootout test first
docs/backend/github.mdPR fetching, CI checks, GraphQL
docs/user-guide/worktrees.mdWorktree workflow, configuration
docs/user-guide/github-integration.mdPR monitoring, CI rings
docs/FEATURES.mdSections 7 (Git) and 8 (GitHub)
docs/api/tauri-commands.mdGit/worktree commands

Settings & Configuration

When adding config fields or settings UI:

FileWhat to update
docs/backend/config.mdConfig files, schema, platform directories
docs/user-guide/settings.mdSettings tab breakdown
docs/FEATURES.mdSection 11 (Settings)

Agent Detection

When adding agents or changing detection logic:

FileWhat to update
docs/user-guide/ai-agents.mdAgent support, detection method
docs/backend/output-parser.mdAgent-specific parsing rules
docs/FEATURES.mdSection 6 (AI Agent Support)
src-tauri/src/mcp_http/plugin_docs.rsagentTypes valid values in PLUGIN_DOCS

UI Components & Panels

When adding or modifying panels, status bar, toolbar, sidebar:

FileWhat to update
docs/FEATURES.mdRelevant section (2-5: Sidebar, Panels, Toolbar, Status Bar)
docs/frontend/STYLE_GUIDE.mdIf changing visual patterns
docs/frontend/components.mdComponent tree, panel descriptions
Domain user guidee.g. docs/user-guide/sidebar.md, docs/user-guide/file-browser.md

Markdown Inline Review Comments (tweaks) & Highlight Rendering

When modifying the tweak-comment format, the selection/popover UI, or the DOM highlight wrapping:

FileWhat to update
src/utils/tweakComments.tsMarker format, parse/insert/remove/update, sentinels, convention header
src/utils/tweakDomHighlight.tsDOM-side sentinel→.tweak-highlight span wrapping
src/components/MarkdownTab/CommentOverlay.tsxFloating Comment button + inline popover + hover tooltip
src/components/MarkdownTab/MarkdownTab.tsxSave/delete wiring, write-back to disk
src/components/ui/ContentRenderer.tsxSentinel injection + applyTweakDomHighlights on render (shared by PR detail)
docs/FEATURES.mdSection 3.3 (Markdown Panel) — Inline review comments

TUIC SDK & iframe Integration

When modifying the TUIC SDK, iframe postMessage protocol, path resolution, or tab injection:

FileWhat to update
src/components/PluginPanel/tuicSdk.tsInline SDK script for plugin iframes
src/components/PluginPanel/resolveTuicPath.tsPath resolution (relative/absolute, traversal guard)
src/components/PluginPanel/PluginPanel.tsxHost-side message handlers, SDK injection
docs/tuic-sdk.mdSDK reference — API methods, path resolution, testing
docs/examples/sdk-test.htmlInteractive test page (update when adding SDK methods)
docs/plugins.mdPlugin developer guide (if plugin-facing API changes)

When adding or changing tuic:// schemes:

FileWhat to update
docs/FEATURES.mdSection 17.4 (Deep Links)
docs/plugins.mdIf affecting plugin contentUri format

Documentation Site (mdBook + Pagefind)

When adding, renaming or moving a docs page:

FileWhat to update
docs/SUMMARY.mdRequired — mdBook only renders, and Pagefind only indexes, chapters listed here. A file that is not in SUMMARY.md is invisible to readers and to search
docs/index.md“Popular articles” cards and “Browse by section” list, if the page belongs there
scripts/build-docs.shOnly when the pipeline changes (excluded pages, Pagefind flags, HTML rewrites) — CI and make docs both run this one script
docs/guides/development-setup.md“Documentation Site” section, if the build steps change

Documentation File Index

PathPurpose
Root
SPEC.mdFeature specification, architecture, version
CHANGELOG.mdRelease history (Keep a Changelog format)
AGENTS.mdProject rules, compact reference
CONTRIBUTING.mdContributor guide (test requirements, PR quality gates)
to-test.mdManual testing tracker
docs/
docs/FEATURES.mdCanonical feature inventory (single source of truth)
docs/plugins.mdPlugin developer authoring guide
docs/tuic-sdk.mdTUIC SDK reference (inline + URL tab postMessage protocol)
docs/api/tauri-commands.mdAll Tauri IPC commands
docs/api/http-api.mdREST/HTTP endpoint reference
docs/architecture/overview.mdHigh-level architecture
docs/architecture/data-flow.mdIPC and data flow
docs/architecture/state-management.mdStore patterns
docs/backend/pty.mdPTY session lifecycle
docs/backend/output-parser.mdOutput parsing and structured events
docs/backend/git.mdGit operations
docs/backend/github.mdGitHub API integration
docs/backend/config.mdConfiguration file management
docs/backend/mcp-http.mdMCP/HTTP server, lazy tool discovery, meta-tools
docs/backend/dictation.mdWhisper voice dictation
docs/backend/error-classification.mdError types and backoff
docs/frontend/STYLE_GUIDE.mdVisual design rules
docs/frontend/components.mdComponent tree reference
docs/frontend/hooks.mdCustom hooks
docs/frontend/stores.mdSolidJS stores
docs/frontend/transport.mdTauri/HTTP dual-mode transport
docs/frontend/utilities.mdUtility function reference
docs/features/ssh-tunnels.mdSSH tunnel architecture and module map
docs/user-guide/*.mdUser-facing guides (20 files)
Code-embedded docs
src-tauri/src/mcp_http/plugin_docs.rsAI-optimized plugin reference (PLUGIN_DOCS const)
src/actions/actionRegistry.tsACTION_META → auto-populates HelpPanel + Command Palette
examples/plugins/Reference plugin implementations (7 examples)

Release & Tag Checklist

When Boss asks to tag a release:

  1. Update version: run make bump V=x.y.z (updates all manifests, CHANGELOG, SPEC.md, and generates AI release notes with contributor extraction via scripts/generate-release-notes.sh)
  2. Review release notes — the script shows AI-generated notes for approval (Y/edit/regenerate/quit). Ensure ### Community section in CHANGELOG lists all external contributors with PR links
  3. Commit with message chore: bump version to vX.Y.Z
  4. Tag with git tag vX.Y.Z
  5. GitHub release — create via gh release create vX.Y.Z --generate-notes
  6. Milestone — close the matching milestone if one exists, create the next one

GitHub Issue Management

  • Labels: Use type:, P0-P3:, area:, effort: prefixes. Apply needs triage to new issues.
  • Milestones: Assign issues to version milestones (v0.4.0, v1.0.0, etc.)
  • Issue templates: Bug reports and feature requests use .github/ISSUE_TEMPLATE/*.yml forms
  • Token for project ops: Use GH_TOKEN=$GH_STRAUS gh ... when commands need the project scope (the default gh auth token only has repo + workflow)

Project History

Timeline

Started: February 5, 2026 Total commits: 475+ Contributor: Stefano Straus (solo developer) Convention: Conventional commits with story references

Milestones

DateMilestoneKey Changes
Feb 5Project inceptionFirst commit, worktree terminal support
Feb 6-7Core infrastructureSidebar, toolbar, tab system, terminal persistence
Feb 8Stability & testingPTY stability overhaul, 830 tests at 80% coverage
Feb 8GitHub integrationPR monitoring, CI rings, batch status checks
Feb 15Voice dictationWhisper.rs integration, push-to-talk, model management
Feb 15Remote accessHTTP server, WebSocket streaming, MCP bridge
Feb 15Settings unificationConsolidated settings, Rust config infrastructure
Feb 16Architecture refactorApp.tsx split into hooks, lib.rs split into modules
Feb 16Rust migration14 business logic functions moved from TS to Rust
Feb 16Cross-platformWindows/Linux support, platform detection
Feb 16Native menuSystem menu with keyboard shortcuts

Architecture Evolution

Phase 1: Rapid Prototyping (Feb 5-7)

  • Monolithic App.tsx (~2000+ lines)
  • localStorage for persistence
  • Frontend-heavy business logic
  • Working but unmaintainable

Phase 2: Stabilization (Feb 8)

  • PTY reliability hardened (UTF-8 boundaries, ANSI escape handling, DashMap concurrency)
  • Vitest test infrastructure added (830 tests across 4 tiers)
  • Performance tuning (WebGL renderer, flow control with watermark backpressure)

Phase 3: Feature Expansion (Feb 8-15)

  • GitHub integration (batch PR queries, CI ring visualization)
  • Voice dictation (Whisper with Metal acceleration)
  • Remote access (HTTP/WebSocket, MCP SSE transport)
  • Split panes, prompt library, settings consolidation

Phase 4: Architectural Maturity (Feb 16)

  • Hook extraction: App.tsx split into 8 focused hooks (useTerminalLifecycle, useGitOperations, useAppInit, etc.)
  • Rust module extraction: lib.rs monolith split into state.rs, pty.rs, git.rs, github.rs, agent.rs, worktree.rs, etc.
  • Business logic migration: 14 functions moved from TypeScript to Rust, following the “Logic in Rust” architecture mandate
  • Cross-platform support: Shell detection, platform config directories, conditional compilation

Feature Commit Distribution

AreaCommitsPercentage
Git/Worktree Operations~272~57%
GitHub Integration~52~11%
UI & Styling~36~8%
Terminal & PTY~29~6%
Architecture Refactors~23~5%
Settings & Config~15~3%
Testing~14~3%
Voice Dictation~14~3%
Remote Access & MCP~12~3%
Cross-Platform~6~1%

Commit Conventions

  • Conventional commits: feat:, fix:, refactor:, chore:, perf:, test:, style:, docs:
  • Story references: (Story 093) suffix when linked to a story
  • Git notes: Every commit has a git note for additional context
  • Story tracking: 214+ stories in stories/ directory with YAML frontmatter

Story System

Stories are tracked as markdown files in stories/:

stories/
  200-18c5-complete-P2-move-error-classification-logic-from-ts-to-rust.md
  ┃       ┃         ┃    ┗━ Slug from title
  ┃       ┃         ┗━ Priority (P1/P2/P3)
  ┃       ┗━ Status (complete/pending/blocked/wontfix)
  ┗━ Sequence number + 4-char hex suffix

Format: YAML frontmatter (id, title, status, priority, dates) + markdown body with work log.

CanvasTerminal Feature Audit

Last updated: 2026-08-03 Branch: refactor/solid-architecture

CanvasTerminal is the sole terminal renderer. xterm.js has been fully removed. The renderer is powered by alacritty_terminal (Rust) sending binary grid frames over a Tauri Channel.

Architecture

Terminal.tsx (outer shell)
  +-- Session lifecycle (create/resume/reconnect PTY)
  +-- Parsed event handling (status-line, question, progress, etc.)
  +-- Activity tracking, notifications, auto-retry
  +-- OSC 0/2 title change handling
  +-- Resume/reconnect banners (JSX)
  +-- ComposePanel
  +-- TerminalSearch
  +-- TerminalRef registration with terminalsStore
  |
  +-- CanvasTerminal (sole renderer)
        +-- subscribe_terminal_grid (binary frame push from Rust via Channel/WS)
        +-- Base canvas: text cells, backgrounds, block/box-drawing chars
        +-- Overlay canvas (pointer-events:none): cursor, selection, search highlights, gutter markers
        +-- Custom scrollbar (drag + track click)
        +-- Suggest/intent overlay (DOM divs over canvas)
        +-- Link detection (hover: file paths, web URLs, OSC 8)
        +-- Keyboard input (VT100 + Kitty protocol)
        +-- Touch input (tap/swipe/pinch for mobile/tablet via offscreen textarea)
        +-- IntersectionObserver flow control (skip paint when hidden)
        +-- Plugin raw output forwarding (pluginRegistry.processRawOutput)
        +-- OSC 7 CWD + OSC 133 shell integration
        +-- Imperative controllers (no reactive frame-path state)
              +-- selection + search
              +-- cancellable link verification + caches
              +-- smooth-scroll position + styled-row cache
              +-- keyboard/IME/mouse listener lifecycle

Frame decode, row reconciliation, scheduling, and paint remain colocated in CanvasTerminal. The extracted controllers own independent state and cleanup; they do not add Solid signals, effects, or store writes to the render hot path.

Key insight: Terminal.tsx handles parsed events, session lifecycle, banners, and compose panel. CanvasTerminal is purely a renderer + input handler with no session logic.

Binary Frame Format

Each frame: 26-byte header + variable row data. The header ends with a historyBase: u32 (lines evicted from the history top so far); historyBase + (historySize - displayOffset + screenRow) is the eviction-stable absolute index the smooth-scroll row cache keys by, so a cached row never aliases onto a different line after the scrollback cap rotates. keyboard_flags bits 0–4 remain the public keyboard-mode mask; bit 5 carries the active primary/alternate-screen identity and is removed before exposing keyboardFlags to input code. Per cell: 4 bytes codepoint + 3 bytes fg RGB + 3 bytes bg RGB + 1 byte attrs bitmask = 11 bytes. Decoded in decodeBinaryFrame using struct-of-arrays (SoA) typed arrays — zero per-cell object allocation.

Primary and alternate grids can reuse identical numeric row coordinates while representing unrelated content. A bit-5 transition therefore starts a new renderer generation: smooth-scroll animation, delayed row fetches, selection, search, link verification, reconciliation, and absolute-row caches are invalidated as one transaction. Partial transition frames wait for a full replacement instead of merging into the previous grid.

Performance Notes

  • RAF coalescing: All paint triggers (frame arrival, keydown selection clear, mousedown) go through scheduleRepaint() which schedules a single requestAnimationFrame. No synchronous paint calls — prevents double-paint in a single event loop turn.
  • send_grid_frame clone guard: Frame is only cloned for the grid_watch channel when receiver_count() > 0 (i.e. WS clients connected). Desktop-only path (Tauri Channel) is zero-copy.
  • screen_text_rows_ref(): TerminalGrid exposes a borrowed &[String] view of cached screen rows. Used in process_chunk for chrome cutoff detection to avoid cloning 50 Strings per PTY chunk. Downstream parsers (slash-menu, choice-prompt) share a single owned snapshot computed once per chunk.
  • No per-chunk parser logs: Slash-menu detection can remain active during a large output burst, so its hot path emits events only when a menu is found and never writes a debug record for every parse.
  • Trim in-place: read_screen_text() and row_to_text() use String::truncate() instead of .trim_end().to_string(), eliminating one allocation per row.

Feature Table

Rendering

FeatureStatusNotes
Cell rendering (text + colors)OKfillText per cell, SoA typed arrays
Bold / italic / dim / underline / strikeoutOK
Inverse videoOKresolveFg/resolveBg swap
Block elements (U+2580-259F)OKdrawBlockChar() draws as geometry
Box-drawing (U+2500-257F)OKdrawBoxDrawingChar() draws as geometry
LigaturesOKAdjacent cells with matching attrs grouped into text runs
Cursor shapes (block/beam/underline)OKcomputeCursorRect()
Cursor blinkOK700ms interval, reset on keypress
Unfocused cursor (outline)OKstrokeRect
Overlay canvas (cursor+selection+search)OKSeparate canvas cleared+redrawn every frame; base canvas only repaints dirty rows
DPI/Retina scalingOKdpr * logical sizing + ctx.scale()
DPR change listenerOKmatchMedia(resolution) re-register on change
Theme colors (ANSI 16)OKColors come from Rust/Alacritty in frame data
Default fg/bg from terminal themeOKgetTerminalTheme(settingsStore.state.theme)
Scrollbar themedOKUses var(--fg-primary) CSS custom property with configurable opacity

Zoom / Font

FeatureStatusNotes
Per-terminal fontSizeOKReads terminalsStore[terminalId].fontSize
Global defaultFontSizeOKFallback when per-terminal not set
Font family reactiveOKcreateEffect watches settingsStore.state.font
Font weight reactiveOK
Line height (snapped)OKsnapLineHeight()
Zoom Cmd+/-OKVia terminalsStore.setFontSize
Font preloadOKdocument.fonts.load() targeting configured terminal font

Scroll

FeatureStatusNotes
Mouse wheel scrollOKterminal_scroll IPC
Scrollbar visibilityOKShows when historySize > 0
Scrollbar thumb dragOKCustom implementation
Scrollbar track click-to-positionOK
Arrow Down snap-to-bottomOKWhen displayOffset > 0
Page Up/DownOKVia Terminal.tsx refMethods using terminal_scroll_info IPC
scrollToTopOKVia Terminal.tsx refMethods
scrollToBottomOKVia Terminal.tsx refMethods
scrollToLine (absolute)OKterminal_scroll_to IPC
Viewport lock (ESC[3J suppression)N/AWontfix — no equivalent needed in canvas path

Resize

FeatureStatusNotes
ResizeObserverOK
Debounce (100ms)OKclearTimeout + setTimeout(remeasure, 100)
Minimum size guardOKGuards both resize_pty IPC and remeasure()
resize_pty IPCOK

Input / Keyboard

FeatureStatusNotes
VT100 escape sequencesOKkeyToSequence() in terminalInput.ts
Kitty keyboard protocol (flag 1)OKkittySequenceForKey()
Shift+Enter (ESC CR)OK
Shift+Tab (CSI Z)OK
macOS Ctrl+letter (emacs)OKUses e.code for reliability
macOS Left Option as MetaOKaltSequenceFromCode()
Windows Ctrl+V pasteOK
Cmd+Enter passthroughOK
IME compositionOKcompositionstart/compositionend; hidden input positioned at cursor coords via syncImePosition() for East Asian IME candidate windows
Bracketed pasteOK\x1b[200~...\x1b[201~
Image paste detectionOKChecks items[i].type.startsWith("image/")
Resume banner keyboardOKSpace/Enter/Escape/printable
Touch tap/swipe/pinch (mobile)OKinstallTouchHandlers via offscreen textarea

Selection & Clipboard

FeatureStatusNotes
Mouse drag selectionOK
Double-click word selectOKterminal_select_start with word:true
Triple-click line selectOK
Cmd+C copy with selectionOKterminal_select_text IPC
Trailing-space trim on copyOKline.replace(/\s+$/, "")
Copy-on-selectOKcopySelection() called from onMouseUp
getSelection() ref methodOKgetLocalSelectionText() reads from rowMap codepoints

Focus

FeatureStatusNotes
focus() ref methodOKcanvasTerminalRef?.focus()
Auto-focus on tab activationOKVisibility effect in Terminal.tsx
onFocus callback propOKWired in CanvasTerminalProps
Focus/blur cursor visualOK
focus() ref raceOKResolved via deferred ref registration
FeatureStatusNotes
File path detection (hover)OKAsync row text fetch + regex
File path Cmd+click openOK
Pointer cursor on linkOK
Web URL links (http/https)OKwebUrlRe regex in checkLinksAtRow
OSC 8 hyperlinksOKterminal_hyperlink_at IPC, priority over other link types
FeatureStatusNotes
Cmd+F opens search barOK
Escape closes searchOK
Search results highlightingOKpaintSearchHighlights on overlay canvas
Next/prev match navigationOKsearchNext/searchPrev with wrap-around
searchBuffer() ref methodOKterminal_search_buffer IPC
openSearch/closeSearch refOK

Terminal Bell

FeatureStatusNotes
Visual flashOKframe.bellbell-flash CSS class (150ms)
Audio bellOKnotificationsStore.play("info") via Terminal.tsx

OSC Handlers

FeatureStatusNotes
OSC 0/2 and structured intent title changeOKHandled in Terminal.tsx wrapper; spawn labels remain replaceable, explicit user renames are protected
OSC 7 cwd trackingOKpty-cwd-{sessionId} event
OSC 133 command blocksOKpty-osc133-{sessionId} event
OSC 133 gutter decorationOKpaintGutterMarkers on overlay canvas
User-prompt scrollbar markersOKGreen ticks at userPromptLines — distinct from command-block marks, drawn in paintGutterMarkers
Cmd+Up/Down block navigationOKReads commandBlocks + activeBlock
OSC 9 progress barOKterminal()?.progress → 2px green bottom-edge fill on tab

TerminalRef Methods

MethodStatusNotes
fit()OKDelegates to refresh() (full redraw via ResizeObserver)
write(data)OKpty.write(sessionId, data)
writeln(data)OKpty.write(sessionId, data + "\n")
input(data)OKpty.write(sessionId, data)
clear()OKSends \x1b[2J\x1b[H\x1b[3J via pty.write
refresh()OKClears buffer + requests fresh frame
focus()OK
getSessionId()OK
openSearch()OK
closeSearch()OK
toggleCompose()OKextractCurrentInput reads canvas row text
openComposeWithText(text)OK
searchBuffer(query)OKterminal_search_buffer IPC
scrollToLine(lineIndex)OKterminal_scroll_to IPC
getSelection()OKgetLocalSelectionText()
scrollToTop()OK
scrollToBottom()OK
scrollPages(pages)OK
getBufferLines(start, end)OKterminal_get_lines IPC

Other

FeatureStatusNotes
File drag-and-drop (internal)OKapplication/x-tuic-path MIME from file tree
OS file drag-and-dropOKFinder/Explorer drag via tauri://drag event
Parsed eventsOKHandled by Terminal.tsx wrapper
Suggest overlayOKDOM divs over canvas
Intent row highlightOK
Notifications (sounds)OKHandled by Terminal.tsx wrapper
Flow control / backpressureOKIntersectionObserver: skip paint+ack when hidden
Plugin raw output forwardingOKpty-output-{sessionId}pluginRegistry.processRawOutput

Remaining Gaps

None. All tracked gaps have been resolved or marked wontfix.

Font Size Audit

Date: 2026-05-11 Purpose: Harmonize font sizes across all panels, modals, and overlays.

Scale

VariableSize
--font-2xs10px
--font-xs11px
--font-sm12px
--font-md13px
--font-base14px
--font-lg15px
--font-xl16px
--font-2xl18px
--font-3xl24px

Current State

Panels

ComponentTitleLabelContentHint/DetailButton
FileBrowsersmmdxs
Referencessmxs
Outlinesmxs
TaskQueuelgsmbasesmsm
Sidebarsmmdxssm
AIChatPanelsmbasexssm

Settings Panel

ComponentTitleLabelContentHint/DetailButton
Settings shellxlbasebasesmmd
Settings navxslg
AgentsTabbasesm2xssm
GitHubTablgsmsmxssm
PluginsTabbasesmxssm
SmartPromptsTabbasesm2xssm
DictationSettingsbasesmmd

Overlays / Dialogs

ComponentTitleLabelContentHint/DetailButton
CommandPalettebasesmxs
BranchSwitcherbasebasesm
PromptOverlaybasesm
ContextMenubasesm
ConfirmDialoglgmd
CreateWorktreesmmdxssm

Dominant Pattern

RolePanelsSettings (current)Overlays/Dialogs
Title/headinglgxl (shell h2), lg (section h3)lg
Labelsmbase (+2px)base / sm
Content/inputsmmdbase (+1-2px)base
Hint/detailxssm (+1px)sm / xs
Nav itemsmlg (+3px)
Button (in-panel)smmd (+1px)md (footer)
Toggle labelsmbase (+2px)

Target (harmonized)

Settings should match the panel/overlay scale. Proposed target:

RoleTargetCSS Variable
Modal title (h2)lg--font-lg
Section heading (h3)md--font-md
Nav itemmd--font-md
Form label / toggle labelsm--font-sm
Content / input / selectsm--font-sm
Hint / secondary textxs--font-xs
In-panel buttonsm--font-sm
Footer / dialog buttonsm--font-sm
Slider valuesm--font-sm

Hardcoded px values (off-scale)

FileValueShould be
McpPopup.module.css10px, 11px, 12px, 13px2xs, xs, sm, md
KnowledgeHistoryOverlay.module.css10px2xs
shared/dialog.module.css11pxxs
Sidebar.module.css (remoteBadge)9pxbelow scale — keep or raise to 2xs
OutlinePanel.module.css (kindBadge)10px2xs
AIChatPanel.module.css13px, 10pxmd, 2xs

Changes Required

Settings.module.css

SelectorCurrentTarget
.header h2xllg
.navItemlgmd
.section h3lgmd
.group labelbasesm
.group select/inputbasesm
.hintsmxs
.hintInlinesmxs
.infobasesm
.warningbasesm
.toggle spanbasesm
.slider spanbasesm
.footerResetmdsm
.footerDonemdsm
.actions buttonmdsm
.saveBtnmdsm
.groupNamebasesm
.groupNameInputbasesm
.inputbasesm
.urlRow labelbasesm
.urlFullbasesm
.mcpStatusTextbasesm
.downloadBtnbasesm
.schedulerUnitSelectbasesm
.schedulerCronInputbasesm
.schedulerGoalInputbasesm

Tab-specific CSS

FileSelectorCurrentTarget
PluginsTab.module.css.pluginNamebasesm
AgentsTab.module.css.agentName, .configNamebasesm
SmartPromptsTab.module.css.promptNamebasesm
DictationSettings.module.cssvariousbase/mdsm

Keyboard Shortcuts Comparison Across Tools

Research date: 2026-02-22

Legend

  • = not bound / not used by default
  • ? = could not verify from documentation (tool not installed or docs incomplete)
  • Cursor inherits VS Code shortcuts for non-AI features (it’s a VS Code fork)

TUICommander Current Shortcuts (for reference)

ShortcutTUICommander
Cmd+Shift+DToggle git diff panel
Cmd+EToggle file browser
Cmd+JToggle task queue
Cmd+KClear scrollback
Cmd+Shift+MToggle markdown panel
Cmd+NNew file
Cmd+OOpen file
Cmd+Alt+NToggle ideas panel
Cmd+RRun saved command
Cmd+\Split vertically
Cmd+Shift+DGit Panel
Cmd+Shift+TReopen closed tab
Cmd+Shift+REdit saved command
Alt+arrowsNavigate panes

Main Comparison: Cmd+Letter Shortcuts

ShortcutiTerm2WarpGhosttyKitty (macOS)VS CodeCursor (extra)ZedClaude Code CLItmux
Cmd+ASelect all blocksSelect all(same as VS Code)Select allN/A (prefix-based)
Cmd+BBookmark blockToggle sidebarToggle sidebarToggle left dock
Cmd+CCopyCopyCopyCopyCopy(same)Copy
Cmd+DSplit verticallySplit pane rightNew split rightAdd selection to next find match(same as VS Code)Select next occurrence
Cmd+E– (unbound by default)Buffer search (use selection)
Cmd+FFindFindFind (Cmd+F)Find(same)Find
Cmd+GFind next occurrenceBrowse last cmd outputFind next / Go to line(same)Search: select next match
Cmd+HReplace(same)
Cmd+IReinput commandsTrigger suggestionOpen Composer (AI)
Cmd+JJump to markToggle panel(same as VS Code)Toggle bottom dock
Cmd+KClear bufferClear blocksClear screenChord prefix (Cmd+K then…)Inline AI editClear (terminal) / chord prefix
Cmd+LFocus terminal inputSelect current lineOpen AI Chat
Cmd+MSet mark– (unbound)Minimize window
Cmd+NNew windowNew OS windowNew file(same as VS Code)New file
Cmd+OFile searchOpen file(same)Open folder
Cmd+PCommand paletteQuick open / go to file(same)
Cmd+QQuitQuitQuitQuitQuitQuit
Cmd+RClear screenResize windowOpen recent(same as VS Code)Toggle right dock
Cmd+SSave(same)Save
Cmd+TNew tabNew tabNew tabNew tabShow all symbols(same)
Cmd+UUndo cursor(same)
Cmd+VPastePastePastePastePaste(same)Paste
Cmd+WClose tab/windowClose tabClose surfaceClose windowClose editor(same)Close
Cmd+XCut(same)Cut
Cmd+ZUndoUndo(same)Undo
Cmd+\Find cursorWarp DriveSplit right
Cmd+,SettingsConfigEdit configSettingsSettingsSettings

Cmd+Shift+Letter Shortcuts

ShortcutiTerm2WarpGhosttyKitty (macOS)VS CodeCursor (extra)Zed
Cmd+Shift+CCopy modeCopy commandCollab panel
Cmd+Shift+DSplit horizontallySplit pane downNew split downClose windowShow debugDuplicate selection
Cmd+Shift+EShow explorerProject panel
Cmd+Shift+FFind in filesFind in project
Cmd+Shift+GFind previousFind previousSearch: select prev match
Cmd+Shift+HReplace in files
Cmd+Shift+IReinput as rootSet tab titleFull-screen Composer
Cmd+Shift+JScrollback to fileToggle search detailsCursor Settings
Cmd+Shift+KClear selected linesDelete line
Cmd+Shift+LNext layoutSelect all occurrencesOpen AI Chat w/ selectionSelect all matches
Cmd+Shift+MShow problems panelDiagnostics
Cmd+Shift+NNew window(same)New window
Cmd+Shift+OGo to symbol(same)Go to symbol
Cmd+Shift+PNav paletteCommand palette(same)Command palette
Cmd+Shift+RSpawn task
Cmd+Shift+SShare blockSave as(same)Save as
Cmd+Shift+TReopen closed tabReopen closed editor(same)Reopen closed item
Cmd+Shift+UShow output panel
Cmd+Shift+VMarkdown preview
Cmd+Shift+WClose windowClose windowClose window
Cmd+Shift+XShow extensions
Cmd+Shift+ZRedoRedo(same)Redo

Alt/Option Shortcuts

ShortcutiTerm2WarpGhosttyKittyVS CodeCursorZedClaude Code CLI
Alt+arrowsBookmark up/down
Alt+Left/RightWord nav (if configured)Word nav (macOS default)
Alt+BWord backwardWord backward
Alt+FWord forwardWord forward
Alt+PSwitch model
Alt+N
Alt+TToggle thinking
Alt+YCycle paste history
Alt+ZToggle word wrap
Alt+1-9Tab navigation
Alt+ClickCursor jumpMulti-cursor

tmux Default Key Bindings (prefix Ctrl+B, then key)

tmux uses a completely different model: prefix key (Ctrl+B by default) followed by a command key. It does not use Cmd shortcuts.

After PrefixAction
dDetach session
cCreate window
nNext window
pPrevious window
wList windows
,Rename window
&Kill window
%Split vertical
Split horizontal
oSwap panes
xKill pane
zToggle pane zoom
{Move pane left
}Move pane right
SpaceToggle layouts
qShow pane numbers
tDisplay clock
?List all shortcuts
sList sessions
$Name session
0-9Select window by number

Windows Platform Conventions

TUICommander is cross-platform: macOS uses Cmd, Windows/Linux use Ctrl. This section maps our shortcuts to their Ctrl equivalents and identifies Windows-specific conflicts.

Windows System Reserved Shortcuts

These are reserved by Windows itself and must never be used:

ShortcutWindows System Action
Ctrl+CCopy (also: interrupt in terminals)
Ctrl+VPaste
Ctrl+XCut
Ctrl+ASelect all
Ctrl+ZUndo
Ctrl+YRedo (Windows convention!)
Ctrl+Alt+DeleteSecurity screen
Win+key combosAll reserved for OS (Start, Settings, Lock, etc.)
Ctrl+Shift+EscTask Manager
Alt+TabWindow switcher
Alt+F4Close window
F11Toggle fullscreen (browsers, Explorer)

Critical: Ctrl+Y = Redo on Windows. This is deeply ingrained muscle memory for Windows users. While macOS uses Cmd+Shift+Z for redo, Windows universally uses Ctrl+Y. This makes Ctrl+Y (the Windows equivalent of Cmd+Y) a bad choice for diff toggle on Windows, despite being “safe” on macOS.

Windows Terminal Shortcuts

ShortcutWindows Terminal Action
Ctrl+Shift+TNew tab
Ctrl+Shift+DDuplicate tab
Ctrl+Shift+WClose tab
Ctrl+Shift+NNew instance
Ctrl+PCommand palette
Ctrl+Shift+FFind
Ctrl+,Settings
Alt+Shift+DSplit pane (auto direction)
Alt+Shift+PlusSplit pane right
Alt+Shift+MinusSplit pane down
Ctrl+Alt+1-9Switch to tab N
Alt+arrowsMove focus between panes

VS Code on Windows (Ctrl instead of Cmd)

macOS (Cmd)Windows (Ctrl)VS Code ActionConflict with TUI?
Cmd+DCtrl+DAdd selection to next find matchYES - same conflict
Cmd+ECtrl+EQuick open recentLow - different action than macOS
Cmd+GCtrl+GGo to lineYES
Cmd+JCtrl+JToggle panelYES
Cmd+KCtrl+KChord prefixYES
Cmd+MCtrl+MToggle Tab key moves focusDifferent from macOS Cmd+M!
Cmd+NCtrl+NNew fileYES
Cmd+RCtrl+ROpen recentYES
Cmd+\Ctrl+\Split editorSimilar semantics (good)
Cmd+Shift+DCtrl+Shift+DShow debug / run panelModerate
Cmd+Shift+GCtrl+Shift+GSource control panelDifferent from macOS!
Cmd+Shift+LCtrl+Shift+LSelect all occurrencesYES

Key Windows-specific differences from macOS VS Code:

  • Ctrl+M in VS Code Windows = “Toggle Tab key moves focus” (not minimize like macOS Cmd+M). This means Ctrl+M is actually available for our use on Windows without system conflict.
  • Ctrl+Shift+G in VS Code Windows = Source Control panel (not “find previous” like macOS). Our use for “git operations panel” is actually semantically aligned!

Cross-Platform Mapping of Our Shortcuts

macOSWindows/LinuxTUI FeatureWindows Conflicts
Cmd+DCtrl+DDiff panelVS Code multi-select, shell EOF
Cmd+ECtrl+EFile browserVS Code quick open recent
Cmd+JCtrl+JTask queueVS Code toggle panel
Cmd+KCtrl+KPrompt libraryVS Code chord prefix
Cmd+Shift+MCtrl+Shift+MMarkdown panelAvoids macOS Cmd+M (minimize window)
Cmd+NCtrl+NNew fileAligned with VS Code/Zed/browsers
Cmd+OCtrl+OOpen fileAligned with VS Code/Zed
Cmd+Alt+NCtrl+Alt+NIdeas panelNo known conflicts
Cmd+RCtrl+RRun commandVS Code open recent, browsers reload
Cmd+\Ctrl+\SplitVS Code split editor (same semantics)
Cmd+Shift+DCtrl+Shift+DGit PanelVS Code debug (conflicts!)
Cmd+Shift+TCtrl+Shift+TReopen tabSame semantics everywhere (good)
Alt+arrowsAlt+arrowsNavigate panesWindows Terminal pane nav (same!)

Analysis

1. Cmd+D Conflict Analysis

Cmd+D is heavily used across all tools:

ToolCmd+D ActionSeverity
iTerm2Split verticallyHIGH - core feature
WarpSplit pane rightHIGH - core feature
GhosttyNew split rightHIGH - core feature
VS CodeAdd selection to next find matchHIGH - used constantly
Cursor(same as VS Code)HIGH
ZedSelect next occurrenceHIGH
Kitty– (unbound)No conflict
Claude CodeCtrl+D = exit sessionLOW (Ctrl, not Cmd)

Verdict: Cmd+D is the WORST possible choice for “toggle diff panel.” Every terminal uses it for splitting, and every code editor uses it for multi-select. Users embedded in any of these tools will have muscle memory conflicts.

2. Other Current TUICommander Conflicts

Our ShortcutConflicts With
Cmd+D (diff)iTerm2/Warp/Ghostty (split), VS Code/Zed (multi-select)
Cmd+E (file browser)Zed (buffer search). Low conflict otherwise
Cmd+J (task queue)iTerm2 (jump to mark), VS Code (toggle panel), Zed (toggle bottom dock)
Cmd+K (prompt library)iTerm2/Warp/Ghostty (clear), VS Code (chord prefix), Cursor (inline AI), Zed (clear/chord)
Cmd+Shift+M (markdown)Low conflict — Cmd+M freed for macOS minimize
Cmd+N (new file)Aligned with VS Code/Zed (new file). Ghostty/Kitty use it for new window — minor mismatch
Cmd+R (run command)Ghostty (clear), Kitty (resize), VS Code (open recent), Zed (toggle right dock)
Cmd+\ (split)iTerm2 (find cursor), Warp (Warp Drive), Zed (split right)
Cmd+Shift+D (Git Panel)VS Code (debug), Warp (n/a)
Cmd+Shift+T (reopen tab)VS Code/Warp/Zed (reopen closed tab) – GOOD, same semantics!

3. “Safe” Cmd/Ctrl+Letter Combos (cross-platform analysis)

These combos are NOT used by any of the researched tools (or used by at most 1 tool for a minor feature). Both macOS and Windows equivalents are checked.

macOSWindowsmacOS StatusWindows StatusVerdict
Cmd+ECtrl+EOnly Zed (buffer search)VS Code (quick open recent)MODERATE - low conflict on both
Cmd+HCtrl+HmacOS: hide app. AVOIDVS Code: replaceAVOID (macOS system)
Cmd+UCtrl+UOnly VS Code (undo cursor)VS Code (undo cursor)MODERATE
Cmd+YCtrl+YUnused on macOSRedo on Windows! System-levelAVOID (Windows redo)
Cmd+;Ctrl+;UnusedUnusedSAFE cross-platform
Cmd+’Ctrl+’UnusedVS Code (toggle terminal)MODERATE

Truly safe Cmd+Shift / Ctrl+Shift combos:

macOSWindowsStatus
Cmd+Shift+RCtrl+Shift+ROnly Zed (spawn task). Mostly free.
Cmd+Shift+BCtrl+Shift+BOnly Zed (outline panel). VS Code: build task. MODERATE.

System shortcuts to avoid:

  • macOS: Cmd+H (hide), Cmd+M (minimize), Cmd+Q (quit), Cmd+Tab (app switcher)
  • Windows: Ctrl+Y (redo), Ctrl+C/V/X/A/Z (clipboard/undo), Ctrl+Shift+Esc (task manager), Alt+F4 (close)

Option A: Cmd+Shift+D / Ctrl+Shift+D – “Show Debug” in VS Code (both platforms), “Duplicate tab” in Windows Terminal, “Split horizontal” in iTerm2/Warp/Ghostty. Moderate conflicts but less than Cmd+D. The VS Code “Debug” association is conceptually adjacent to “Diff.” Windows Terminal’s “Duplicate tab” is not commonly used.

Option B: Cmd+Y ELIMINATED – While unused on macOS, Ctrl+Y is the universal Redo shortcut on Windows. This would create a severe conflict for Windows users. Not viable for a cross-platform app.

Option C: Cmd+U / Ctrl+U – Only VS Code uses it (undo cursor). Low conflict on both platforms. But “U” has no mnemonic connection to “diff.”

Option D: Keep Cmd+D but document the conflict – Users in TUICommander are not simultaneously in VS Code’s editor or iTerm2’s terminal; TUICommander IS the terminal. However, users with iTerm2 muscle memory will instinctively hit Cmd+D to split.

Option E: Cmd+; / Ctrl+; – Unused across ALL tools on ALL platforms. Zero conflicts. No system-level reservation. Ergonomically less discoverable but completely safe.

Recommendation: Cmd+Shift+D / Ctrl+Shift+D is the pragmatic choice. It’s the “diff/debug” mental model (VS Code uses it for Debug/Run panel, which is conceptually adjacent). Terminal emulators use Cmd+Shift+D for horizontal split / duplicate tab, but TUICommander already uses Cmd+\ for splitting. The Windows Terminal “duplicate tab” conflict is minor. If you want absolute zero conflicts, use Cmd+; / Ctrl+;.

5. Alt+P and Alt+N Analysis

ShortcutUsed By
Alt+PClaude Code CLI (switch model). No other tool uses it.
Alt+NUnused across all tools. SAFE.
Alt+TClaude Code CLI (toggle thinking). No other tool uses it.

Alt+letter shortcuts are generally safe territory because:

  • Terminal emulators pass them through to the shell
  • Code editors rarely use them (Zed recently removed Alt+letter defaults for keyboard layout compatibility)
  • Claude Code CLI uses a few (Alt+P, Alt+T) but these are in its own input context

6. System Shortcuts to Avoid (Cross-Platform)

macOS

ShortcutmacOS System Action
Cmd+HHide application
Cmd+MMinimize window
Cmd+QQuit application
Cmd+TabApp switcher
Cmd+SpaceSpotlight
Cmd+,Preferences (convention)

Note on Cmd+M: TUICommander previously used Cmd+M for “toggle markdown panel” but this conflicted with macOS’s “Minimize window” system shortcut, causing the panel to open unexpectedly when users tried to minimize. Changed to Cmd+Shift+M.

Windows

ShortcutWindows System Action
Ctrl+YRedo (universal Windows convention)
Ctrl+Shift+EscTask Manager
Alt+F4Close window
Win+anythingOS-reserved (Start menu, Snap, Settings, Lock, etc.)
Ctrl+Alt+DeleteSecurity screen
Ctrl+CCopy / terminal interrupt (dual meaning)
F11Toggle fullscreen (browsers, Explorer)

Note on Ctrl+Shift+M: The markdown panel shortcut maps to Ctrl+Shift+M on Windows/Linux. In VS Code, Ctrl+Shift+M opens the Problems panel — minor conflict but acceptable since TUICommander is a different app context.

Linux

Linux follows Windows conventions (Ctrl-based) but has fewer system reservations. Desktop environments (GNOME, KDE) use Super (Win) key for OS functions. Ctrl+Alt+T is conventionally “open terminal” in GNOME/Ubuntu. Ctrl+Alt+Delete varies by distro.


Sources

macOS Tools

Cross-Platform Editors

Windows

CLI Tools

Solid Frontend Refactoring Plan

Status: Work units 1–7 implemented and validated
Baseline commit: 65c653c8
Worktree: refactor/solid-architecture

Purpose

This document maps the current SolidJS frontend and defines an incremental refactoring program. The work is intended to improve maintainability, test isolation, load performance, and runtime diagnosability without changing product behavior or replacing SolidJS.

The refactoring is not a line-count reduction exercise. A smaller file is useful only when it establishes a real ownership boundary, removes a dependency, isolates a lifecycle, or enables deferred loading.

Goals

  • Make App.tsx a composition root instead of the owner of unrelated application lifecycles.
  • Establish feature boundaries that can be changed and tested independently.
  • Keep expensive, optional UI code out of the initial desktop and mobile load paths.
  • Preserve the current direct canvas terminal hot path and its performance invariants.
  • Reduce runtime dependency cycles and implicit store-to-store coupling.
  • Keep each change behavior-preserving, independently testable, and independently revertible.

Non-goals

  • Replacing SolidJS, Tauri, or the canvas terminal.
  • Redesigning the UI or changing user-visible behavior.
  • Moving backend business logic into the frontend.
  • Rewriting all stores or introducing a new state-management framework.
  • Creating generic abstractions before a current feature needs them.
  • Combining feature work with structural refactoring.

Measured Baseline

Measurements were taken from the baseline commit after a clean pnpm install --frozen-lockfile and pnpm build.

Source inventory

CategoryFilesLines
Production TypeScript27042,848
Production TSX16552,705
Production CSS11723,447
Test and test-support sources28561,948
Total frontend837180,948

The production frontend contains 393 createSignal, 142 createMemo, 190 createEffect, 51 onMount, and 144 onCleanup call sites. These totals are not defects by themselves; they identify the amount of lifecycle behavior that must remain observable while modules are moved.

Build and test baseline

  • pnpm build: pass, 13.17 seconds in the measured run.
  • pnpm test --run: 281 test files and 4,651 tests passed.
  • The first test run exposed an uninitialized plugins submodule. The referenced commit was no longer fetchable from the remote, so the exact commit was transferred from the primary local checkout before rerunning the suite.

Initial load baseline

EntryInitial JS/CSS payloadGzip payload
Desktop index.html4,589,184 bytes1,419,904 bytes
Mobile mobile.html1,819,210 bytes610,692 bytes

The desktop preload graph currently includes:

  • main application: 399,406 bytes gzip;
  • CodeMirror: 543,046 bytes gzip;
  • diff viewer: 308,732 bytes gzip;
  • Markdown parser/sanitizer: 42,452 bytes gzip;
  • shared application and transport chunks.

CodeMirror and the diff viewer are emitted as separate chunks, but they are static dependencies of the desktop entry and are therefore still preloaded at startup. The mobile entry also preloads the CodeMirror chunk despite having no source-level path to the editor. The multi-entry chunk/preload arrangement must be corrected rather than merely adding more manualChunks entries.

The build also reports ineffective dynamic imports for openUrl.ts, dragDrop.ts, and useFileDrop.ts because the same modules remain statically imported elsewhere.

Current Architecture Map

index.tsx
  -> App.tsx
       -> application bootstrap and hydration
       -> native-window and browser-mode branching
       -> global event listeners and detached-window synchronization
       -> terminal lifecycle and completion notifications
       -> repository, branch, worktree, and Git operations
       -> action registry, keyboard shortcuts, and native menu dispatch
       -> dialogs, overlays, panels, and application layout
       -> plugin initialization and context-action registration
       -> TerminalArea
            -> terminal canvas
            -> editor, diff, Markdown, HTML, and plugin tabs

mobile/index.tsx
  -> MobileApp
       -> HTTP/WebSocket transport
       -> mobile session, activity, command, and settings views

invoke.ts
  -> native Tauri invoke/listen facade
  -> transport.ts for browser and remote operation

transport.ts
  -> IPC-to-HTTP command mapping
  -> HTTP request execution
  -> PTY WebSocket subscriptions
  -> application SSE subscriptions

The desktop and mobile entries correctly share stores and transport contracts, but optional desktop views currently leak into the startup graph.

Coupling Hotspots

The fan-in and fan-out values below count internal TypeScript/TSX module dependencies. Type-only imports are excluded from the runtime-cycle list.

ModuleLinesFan-outNotable responsibilities
src/App.tsx3,053132bootstrap, events, actions, notifications, panels, dialogs, layout
src/components/Terminal/CanvasTerminal.tsx3,28824frame lifecycle, canvas paint, input, selection, links, search, scrolling, DOM lifecycle
src/transport.ts2,374command map, HTTP, WebSocket, SSE; imported by 53 modules
src/hooks/useGitOperations.ts2,25724repository refresh, branch switching, worktrees, merge cleanup, terminal reassignment
src/components/SettingsPanel/tabs/ServicesTab.tsx2,239local MCP, upstream MCP, bridges, Tailscale, remote machines
src/components/TabBar/TabBar.tsx1,60727four tab types, two ordering modes, menus, drag/drop, scrolling, rename
src/components/FileBrowserPanel/FileBrowserPanel.tsx1,51927tree state, file operations, pointer/native drag, menus, keyboard navigation

App.tsx is the dominant coupling hotspot: its fan-out of 132 is almost five times the next-highest module. Moving its body into one large useAppController would preserve that coupling and is explicitly rejected.

High fan-in modules are legitimate shared boundaries but require stable contracts: appLogger.ts (142 importers), invoke.ts (108), repositories.ts (71), terminals.ts (54), and transport.ts (53).

Runtime Dependency Cycles

The production runtime import graph contains three strongly connected components:

  1. Transport/store cycle: tunnels -> perfTrace -> repositories -> remoteEventBridge -> remoteConnections -> transport -> appLogger -> invoke.
  2. Sidebar component cycle: PrSection -> GitHubPanel -> RemoteOnlyPrPopover -> RepoSection.
  3. Appearance component cycle: AppearanceTab -> ColorSwatchPicker.

The larger 12-module plugin cycle seen in the unfiltered graph is composed of type-level relationships and is not a runtime cycle. It should not be treated as equivalent to the three cycles above.

Runtime cycles are not automatically bugs, but they make initialization order implicit and make lazy loading less predictable. They should be removed through dependency direction, not barrel-file reshuffling.

Hotspot Responsibilities and Safe Seams

App.tsx

Existing cohesive seams are visible in the code and can be extracted without inventing new behavior:

  • detached-panel registration and event routing;
  • application bootstrap, update checks, and deep-link initialization;
  • tab activation synchronization across terminal, Markdown, diff, and editor stores;
  • completion notification and idle-triggered triage lifecycle;
  • plugin context-action registration;
  • native window, file-open, and menu event bridges;
  • command/action construction;
  • dialog and overlay rendering.

Each seam should expose a narrow function or component contract. Store reads should remain inside the owner when possible instead of being forwarded through a large dependency object.

CanvasTerminal.tsx

The terminal already has useful lower-level modules for transport, frame decoding, glyph caching, grid rendering, input encoding, touch input, and timing. The remaining component still owns these distinct lifecycles:

  • frame subscription, reconciliation, and resize;
  • base/overlay/cursor rendering coordination;
  • smooth scrolling, scrollback cache, overscan, and scrollbar interaction;
  • selection, copy, and drag auto-scroll;
  • link detection, asynchronous path verification, and link menus;
  • keyboard, IME, paste, mouse protocol, and touch handling;
  • search state and imperative public API;
  • resource cleanup.

The hot path must not be converted into reactive store state. Extraction should use explicit controller state and injected narrow ports so frame decoding and paint scheduling remain imperative.

useGitOperations.ts

This hook is already an extraction from App.tsx, but its public facade now covers several domains:

  • repository refresh and stale-result suppression;
  • branch selection and serialized switching;
  • worktree creation, recovery, setup, and removal;
  • PR autofix, conflict assistance, and merge cleanup;
  • terminal creation and worktree reassignment;
  • CWD-based terminal ownership tracking.

Its generation counters, FIFO branch-selection queue, creation grace period, and removal deduplication are correctness mechanisms. They must remain owned by the relevant coordinator and receive characterization tests before being moved.

ServicesTab.tsx

The file already contains three UI domains with natural boundaries: local MCP/bridge configuration, upstream MCP servers, and remote machines. These can become sibling components sharing existing setting fields and transport types. This is a low-risk structural extraction.

TabBar.tsx

The grouped and free-order render branches duplicate terminal, diff, Markdown/plugin, and editor tab rendering. Extracting typed tab view components will remove real behavioral duplication while leaving ordering and drag/drop coordination in TabBar.

Target Dependency Direction

entrypoints
  -> application composition
       -> feature controllers
            -> stores and transport ports
       -> feature views
            -> shared UI primitives

backend transport ports
  -> invoke/http/ws/sse adapters

pure models and helpers
  -> no stores, DOM, transport, or UI imports

Rules for new boundaries:

  1. Components render and bind interaction; coordinators own lifecycle; pure helpers transform values.
  2. A feature may depend on shared infrastructure, but shared infrastructure must not import the feature.
  3. Transport adapters must not depend on UI stores. Connection lookup and logging should enter through narrow ports.
  4. Avoid new barrel imports across feature boundaries when they obscure the concrete dependency.
  5. Keep browser/Tauri parity at the existing invoke and transport boundaries.
  6. Do not introduce a new global event bus or state framework.

Implementation Sequence

Every work unit below must be independently green and revertible. No unit combines a behavior change with structural movement.

Work unit 1: Correct deferred-loading boundaries

This is the first implementation because it has the clearest measurable runtime benefit and does not require altering application behavior.

  • Lazy-load editor and diff views at the tab-content routing boundary.
  • Ensure Markdown/Mermaid dependencies load only for content that needs them.
  • Remove static imports that make existing dynamic imports ineffective.
  • Correct the multi-entry preload graph so the mobile entry does not preload CodeMirror.
  • Record desktop and mobile initial preload bytes in an automated build report.

Acceptance criteria:

  • index.html does not preload CodeMirror or the diff viewer before either feature is opened.
  • mobile.html does not preload CodeMirror, diff, or desktop-only view code.
  • Opening an existing persisted editor or diff tab still works after startup.
  • Build and full frontend tests remain green.

Measured result:

EntryBaseline gzipWork unit 1 gzipReduction
Desktop index.html1,419,904 bytes418,607 bytes70.5%
Mobile mobile.html610,692 bytes67,417 bytes89.0%

The build now fails if an optional editor, diff, Markdown/Mermaid, or compose asset returns to either initial entry graph. It also enforces 500 KiB and 100 KiB gzip budgets for the desktop and mobile entrypoints respectively. The reporting and checks live in scripts/report-frontend-bundles.mjs.

Validation performed for work unit 1:

  • pnpm build: passed, including entry-graph and gzip-budget checks.
  • pnpm vitest --run --maxWorkers=4: 281 files and 4,651 tests passed.
  • The unconstrained Vitest run exhausted the local fork pool; its single timeout passed in isolation, and the complete concurrency-limited rerun passed.
  • Focused AI chat and terminal tests passed after isolating the lazy Markdown renderer in the AI chat unit test.
  • The full run also reports three pre-existing asynchronous promise leaks from pluginLoader.test.ts; they do not fail the suite and are outside this work unit.
  • make check: attempted. TypeScript passed, then the command stopped on three pre-existing Biome formatting errors in useAppInit.ts and its tests; none of those files is changed by this work unit.
  • Browser-mode verification is pending because the required brainstorming/x-xcan/ab-stealth.sh wrapper is absent from both local checkouts and the in-app browser runtime failed to initialize. No CSS or layout files changed in this work unit. This checkpoint was superseded by the final browser validation recorded below.

Work unit 2: Establish App lifecycle boundaries

Add characterization tests before moving each lifecycle. Extract in this order:

  1. tab activation synchronization;
  2. completion notifications and idle-triggered triage;
  3. detached-panel and native event bridges;
  4. plugin context-action registration;
  5. bootstrap/update/deep-link lifecycle;
  6. dialog/overlay rendering;
  7. action and native-menu construction.

App.tsx remains responsible for composing the hooks, passing feature callbacks, and laying out the application. The work unit is complete when it no longer implements feature lifecycles directly and its imports describe application-level modules rather than every leaf store.

Progress on 2026-07-21:

  • Extracted tab and active-terminal synchronization, completion notifications, idle triage, detached-panel routing, file-open and reattach bridges, plugin context actions, application bootstrap, native menu dispatch, terminal context menus, dialog integrations, automation bridges, appearance and system lifecycles, plugin runtime ownership, quick-switcher visibility, shortcut registration, dictation hotkeys, shell-exit handling, and application shortcut actions into focused hooks.
  • ApplicationOverlays now accepts eight domain contracts instead of a 43-property relocation boundary. Git dialogs, confirmation/folder-drop dialogs, and post-merge cleanup own focused render groups; none receives an entire hook return object. App.tsx retains composition and layout ownership, and the existing Settings/Help lazy boundaries remain unchanged.
  • Added 67 focused characterization tests across 14 files. They cover lifecycle registration and cleanup, activation ordering, timer cancellation, native event routing, action construction, dialog state, plugin registration, and overlay behavior.
  • App.tsx decreased from the 3,053-line baseline to 1,078 lines and contains no direct createEffect, onMount, onCleanup, or native listen calls.
  • pnpm vitest --run --maxWorkers=4: 295 files and 4,718 tests passed without leaked-resource reports.
  • pnpm build: passed in 26.96 seconds, including entry-graph and gzip-budget checks. The resulting initial payload is 419,480 desktop gzip bytes and 67,427 mobile gzip bytes.
  • make check: TypeScript passed. Biome then stopped on the same three pre-existing formatting errors in useAppInit.ts, useAppInit.test.ts, and tweakComments.test.ts; targeted checks for the new work pass.
  • Browser-mode verification remains pending because the required stealth wrapper is absent from both checkouts and the in-app browser runtime could not initialize. No CSS or layout behavior changed in this work unit. This checkpoint was superseded by the final browser validation recorded below.

Work unit 3: Split settings service domains

  • Extract local MCP and bridge settings.
  • Extract upstream MCP server management.
  • Extract remote machine management.
  • Keep persistence and authorization behavior unchanged.
  • Lazy-load service subpanels only if measurement shows a meaningful benefit; file splitting alone is not sufficient justification.

Result on 2026-07-21:

  • ServicesTab is now a composition boundary for three sibling domains: LocalServicesPanel, UpstreamMcpPanel, and RemoteMachinesPanel.
  • Upstream configuration, OAuth helpers, persistence, and status polling moved together. The existing immediate refresh and three-second cadence are preserved, and cleanup ownership is covered by a focused lifecycle test.
  • Remote-machine forms, transport presentation, CRUD, and connection controls moved together with their existing remoteConnectionsStore contract.
  • Existing helper imports remain compatible through re-exports from ServicesTab; no caller or persisted configuration shape changed.
  • Three focused files pass 21 tests. The concurrency-limited full suite passes 297 files and 4,725 tests.
  • pnpm build passes with 419,482 desktop gzip bytes and 67,427 mobile gzip bytes. make check reaches the same three pre-existing Biome failures and no changed file fails a targeted check.
  • The split is intentionally eager: measurement showed no startup benefit that would justify lazy-loading settings subpanels.

Work unit 4: Unify tab rendering

  • Introduce typed view components for terminal, diff, Markdown/plugin, and editor tabs.
  • Reuse the same view components in grouped and free-order layouts.
  • Keep ordering, overflow, drag/drop, and context-menu coordination in TabBar until their contracts are explicit.
  • Add parity tests that run the same tab behaviors in both ordering modes.

Result on 2026-07-21:

  • Added typed TerminalTabView, DiffTabView, MarkdownTabView, and EditorTabView components. Grouped and free/terminals-first modes now render the same components instead of maintaining duplicated JSX branches.
  • Ordering, overflow, drag/drop, context menus, and visibility filtering remain in TabBar; presentation-specific differences such as pinned icons and global-workspace metadata are explicit props.
  • Added a parity matrix that renders and selects every tab kind in both grouped-by-type and free modes. The complete TabBar suite passes 47 tests.
  • TabBar.tsx decreased from 1,607 to 964 lines; the shared typed views occupy 437 lines, for a net removal of 206 lines of duplicated rendering logic.
  • The full suite passes 297 files and 4,727 tests. pnpm build passes with 419,554 desktop gzip bytes and 67,427 mobile gzip bytes.

Work unit 5: Split Git operation coordinators

Preserve the existing public facade initially so callers do not change at the same time as internals.

  • Extract repository refresh with generation and deduplication state.
  • Extract branch switching with its FIFO serialization queue.
  • Extract worktree creation/removal and recovery state.
  • Extract merge/autofix/conflict workflows.
  • Extract CWD-based terminal reassignment.
  • Replace the oversized facade only after consumers and tests show stable smaller contracts.

Result on 2026-07-21:

  • useGitOperations remains the caller-compatible composition facade, while focused coordinators now own repository refresh, branch selection, terminal/worktree reassignment, worktree creation, worktree removal, and merge/autofix/conflict workflows under src/hooks/git/.
  • Correctness state moved with its behavior: refresh generations and request deduplication, the FIFO branch-selection queue, creation grace tracking, removal locking, OSC 7 reassignment debouncing, and workflow recovery state each have a single lifecycle owner.
  • Agent command seeding is isolated in a pure helper while retaining the compatibility exports from useGitOperations.
  • useGitOperations.ts decreased from the 2,257-line baseline to 775 lines. The complete existing hook suite passes all 155 tests, including stale refresh suppression, serialized switching, worktree recovery/removal, CWD reassignment, and merge/conflict workflows.
  • Focused TypeScript and Biome validation passes for the facade and every new coordinator. Full-suite and production-build results are recorded below after the work-unit integration run.

Work unit 6: Decompose the canvas terminal

This is last because it is both performance-sensitive and behavior-dense.

  • Extract selection and search state behind an imperative controller.
  • Extract link discovery and verification behind a cancellable controller.
  • Extract smooth-scroll/cache/scrollbar state without adding reactive dependencies.
  • Extract keyboard/IME/mouse binding and cleanup.
  • Keep frame decode, scheduling, and paint coordination together until profiling demonstrates a safe seam.
  • Keep the public CanvasTerminalRef contract stable during decomposition.

Result on 2026-07-21:

  • Added imperative controllers for selection/search state, cancellable link verification and caches, smooth-scroll position/cache/handoff state, and DOM input-listener ownership. None introduces Solid signals or store updates on the frame path.
  • Selection extraction covers forward/reverse multi-row text, offscreen range detection, cached-copy reset, and search navigation. Link checks now use an explicit generation token and dispose queued verification on unmount.
  • Scroll state owns the fractional position, pending absolute offset, backend settle handoff, gesture distance, styled-row cache, and requested chunks. Frame decode, row reconciliation, scheduling, and canvas paint remain together in CanvasTerminal.
  • Keyboard, IME, paste, mouse, wheel, and scrollbar listeners now share one idempotent binding lifecycle. The pre-subscription cleanup guarantee remains intact if unmount occurs while transport subscription is pending.
  • CanvasTerminal.tsx decreased from 3,288 to 3,194 lines. Four new controller suites add 12 focused tests; the complete terminal-focused run passes 15 files and 173 tests. TypeScript and targeted Biome checks pass.

Each extraction requires focused unit tests plus terminal-specific regression tests. Visual changes are not expected; if canvas output changes, it requires explicit visual verification rather than relying on HTTP inspection.

Work unit 7: Remove runtime dependency cycles

The production runtime graph is enforced by pnpm architecture:cycles, which is part of the standard make check path. pnpm architecture:cycles:test exercises the checker itself against temporary acyclic and cyclic graphs.

Cycle removal can be interleaved only where a preceding work unit creates the required seam:

  • inject connection lookup and logging into transport adapters to break the transport/store cycle;
  • move shared sidebar models/actions below the four sidebar views;
  • make ColorSwatchPicker receive values and callbacks rather than importing its settings owner.

Do not create adapter modules whose only purpose is to hide a cycle while preserving both directions.

Result on 2026-07-21:

  • transport.ts no longer imports application stores. Logging and remote-base- URL lookup enter through transportRuntime ports configured by appLogger and remoteConnectionsStore; transport keeps safe no-op/unavailable defaults during module initialization.
  • Shared PR merge eligibility and PrStateBadge now sit below sidebar views. GitHubPanel, PrSection, RemoteOnlyPrPopover, and RepoSection no longer import one another in a cycle; compatibility exports remain on RepoSection.
  • ColorSwatchPicker receives its preset list as a prop. Preset data lives in a shared leaf module instead of importing the owning AppearanceTab.
  • Added pnpm architecture:cycles, which parses production TypeScript with the compiler API, excludes type-only edges, and fails on strongly connected runtime components. The resulting graph contains 475 production files and zero runtime cycles.
  • Focused transport/store validation passes 4 files and 163 tests. Focused sidebar/appearance validation passes 4 files and 136 tests. TypeScript and targeted Biome checks pass.

Validation Contract

Each work unit must include:

  1. Relevant characterization tests written before extraction.
  2. Focused Vitest execution while iterating.
  3. pnpm test --run before handoff.
  4. pnpm build and comparison of the generated preload graph.
  5. make check before integration.
  6. Browser-mode verification against the worktree test instance for affected interactive UI.
  7. A screenshot after any visual, CSS, or layout change.
  8. perfDebug comparison for any claim about responsiveness or frame behavior.

Runtime performance claims require measurements. File size or line count alone is not evidence of a faster UI.

Final validation on 2026-07-24

  • The complete Vitest suite passes 302 files and 4,762 tests without leaked timers. Test teardown now cancels terminal cooldown/question timers and the activity persistence debounce owned by isolated store modules.
  • The production build passes. The desktop initial payload is 1,518,882 bytes raw and 433,460 bytes gzip; the mobile initial payload is 202,544 bytes raw and 63,836 bytes gzip. All optional-asset budgets pass.
  • pnpm architecture:cycles analyzes 475 production runtime files and reports zero cycles.
  • cargo nextest run --no-fail-fast passes all 3,927 Rust tests, with 10 tests skipped. The tunnel supervisor tests now wait for terminal state transitions instead of sampling them at fixed timing boundaries under parallel load.
  • Repository-wide make check passes, including TypeScript, Biome, Rust tests, and dependency audits. Formatting drift exposed by the final dependency upgrade was normalized during integration.
  • No production Rust behavior, CSS, or layout files changed. Rust edits are limited to compile-time lint cleanup and test timing robustness. Visual output was not changed intentionally. Browser-mode verification through the mandatory stealth wrapper covered the main terminal layout and the Appearance and Services settings views; screenshots showed no clipping, overlap, or missing controls.

Commit and Rollback Strategy

  • One cohesive extraction or loading-boundary change per commit.
  • Preserve public contracts until the implementation behind them is stable.
  • Do not rename and behavior-change the same code in one commit.
  • Keep characterization tests in the same commit as the seam they protect or in the immediately preceding commit.
  • If a work unit cannot remain independently green, its boundary is too broad and must be divided.

Completion Criteria

The program is complete when:

  • optional editor/diff/Markdown code is absent from unrelated startup paths;
  • App.tsx is a composition root with no feature lifecycle implementation;
  • grouped and free-order tabs share render components;
  • Git refresh, switching, worktree, and terminal-reassignment coordinators have isolated tests;
  • canvas terminal controllers have explicit lifecycle ownership and preserve measured frame behavior;
  • the three current runtime dependency cycles are gone;
  • all existing behavior remains covered by a green full suite and the required browser/visual checks.