Shell Integration
Sidecar's interactive shell allows users to type directly into tmux sessions
from within the TUI. Tmux remains the PTY backend. Sidecar renders ordered
control-mode bytes through the shared tty.Model, whose VT behavior is behind
the screenmodel adapter rather than implemented in plugin code.
Package Structure
internal/tty/ # Shared tmux terminal abstraction tty.go # Core Model and State types keymap.go # Bubble Tea -> tmux key translation messages.go # Owner/target/generation-scoped messages session.go # tmux operations (send-keys, capture-pane, resize) scheduler.go # Keyed fallback-poll generation ownership control_*.go # Session-keyed tmux -C transport and manager capture_range.go # Atomic bounded history capture cursor.go # Cursor positioning helpers paste.go # Paste handling (clipboard, bracketed paste) terminal_mode.go # Capture-fallback mode recovery output_buffer.go # Absolute, bounded live/history buffer editor_session.go # Shared inline-editor tmux lifecycle internal/plugins/workspace/ interactive.go # Workspace-specific interactive mode logic interactive_selection.go # Text selection in interactive mode terminal_viewport.go # Pure shared terminal viewport renderer terminal_control.go # Workspace target/layout policy for tty.Model terminal_history.go # Lazy absolute scrollback loading terminal_search.go # Loaded-history search terminal_links.go # Safe URL/path detection and activation native_terminal.go # Native cursor and contextual mouse mode view_preview.go # Agent/shell preview composition mouse.go # Scroll handling types.go # InteractiveState type internal/plugins/filebrowser/ inline_edit.go # Inline editor mode using tty.Model handlers.go # Message handling for inline edit
Data Flow
User Keypress -> handleInteractiveKeys() -> tty.MapKeyToTmux() -> tmux send-keys Pane output -> tmux -C ordered %output bytes -> session-pooled control actor -> seeded screenmodel adapter -> adaptive per-feed presentation publication -> owner/target/generation-scoped Tea message -> OutputBuffer + cursor/modes/history -> pure terminal viewport + native Bubble Tea cursor Open/resync/history -> bounded capture seed/range Control unavailable/dead -> one scoped capture-poll fallback + clean reseed
Core Abstractions
tty.Model
Embeddable component for interactive tmux functionality:
gotype Model struct { Config Config // Exit key, copy/paste keys, scrollback lines State *State // Current interactive state Width int Height int OnExit func() tea.Cmd OnAttach func() tea.Cmd } // Usage: p.inlineEditor = tty.New(&tty.Config{ ExitKey: "ctrl+\\", ScrollbackLines: 600, }) cmd := p.inlineEditor.Enter(sessionName, paneID)
tty.State
gotype State struct { Active bool TargetPane string // tmux pane ID (e.g., "%12") TargetSession string LastKeyTime time.Time // Input timing and fallback polling decay CursorRow, CursorCol int CursorVisible bool PaneHeight, PaneWidth int BracketedPasteEnabled bool MouseReportingEnabled bool OutputBuf *OutputBuffer PollGeneration int // For invalidating stale fallback polls }
tty.OutputBuffer
Thread-safe bounded buffer with hash-based change detection:
gofunc (b *OutputBuffer) Update(content string) bool { rawHash := maphash.String(seed, content) if rawHash == b.lastRawHash { return false } // Skip ALL processing content = mouseEscapeRegex.ReplaceAllString(content, "") b.lines = strings.Split(content, "\n") return true } func (b *OutputBuffer) LinesRange(start, end int) []string
Key Mapping (keymap.go)
gofunc MapKeyToTmux(msg tea.KeyPressMsg) (key string, useLiteral bool) { if msg.Mod.Contains(tea.ModCtrl) && msg.Code >= 'a' && msg.Code <= 'z' { return "C-" + string(msg.Code), false } switch msg.Code { case tea.KeyEnter: return "Enter", false case tea.KeyBackspace: return "BSpace", false case tea.KeyTab: return "Tab", false case tea.KeyUp: return "Up", false } if msg.Text != "" { return msg.Text, true // Literal mode } return "", true }
Modified keys use CSI sequences:
gocase "shift+up": return "\x1b[1;2A", true case "ctrl+up": return "\x1b[1;5A", true case "alt+up": return "\x1b[1;3A", true case "shift+tab": return "\x1b[Z", true
For printable characters, tmux send-keys -l prevents interpretation.
Capture fallback and semantic observation
goconst ( PollingDecayFast = 50ms // During active typing PollingDecayMedium = 200ms // After 2s inactivity PollingDecaySlow = 250ms // After 10s inactivity KeystrokeDebounce = 20ms // Delay after keystroke )
Control-mode bytes are the ordinary presentation source for every visible terminal surface. Adaptive capture polling exists only until the first seeded frame and after control/model failure. Workspace agent and shell observation continues independently for provider activity evidence; those captures never overwrite a model-owned presentation buffer.
The control actor writes every ordered byte into screenmodel immediately. Presentation alone is adaptive: the first changed frame after idle publishes inline, sustained changed frames publish at no more than 30 fps per pane-model feed, and one tokenized timer guarantees the newest trailing frame. Seed, reseed, and generation changes bypass the cap; input never enters this cadence. Ordinary Frame() snapshots omit diagnostic cells, while screen comparison explicitly requests the canonical grid.
For isolated performance diagnostics, run with both SIDECAR_PPROF=<port> and SIDECAR_TERMINAL_PERF=1, then read GET /debug/terminalperf. The endpoint is localhost-only and returns fixed numeric counts plus output-to-frame samples, p95, and maximum in microseconds. Its fixed vocabulary covers model publication, terminal views, row hits/misses/bypasses, link resolution, global Sessions list/preview work, application/Workspace/sidebar views, project preview composes/cache hits, and document frame builds/cache hits/scans/resolution. A pulse-only interval should reuse the project preview and perform no terminal, document, or row work. Keep counters off during CPU profiles, use a separate diagnostic process, and never interpret process-wide model-frame counts as per-feed fps when more than one pane-model feed may be live.
Set SIDECAR_TERMINAL_TRACE=1 only in an isolated proof run to log privacy-safe
capture metadata (surface, role, reason, and generation). It never logs
session or pane identity, terminal text, commands, paths, titles, or provider
payloads. This distinguishes intentional semantic observation from presentation
fallback.
Visibility and focus
| State | Active | Idle |
|---|---|---|
| Visible + focused | 200ms | 2s |
| Visible + app unfocused | clamped to unfocused cadence | clamped |
| Not visible | 10-20s | 10-20s |
Keyed generation ownership
tty.KeyedScheduler owns a generation per logical source
(agent:<name>, shell:<tmuxName>, terminal-panel). Every schedule allocates
a fresh token, and the token travels through capture, result, retry, and
continuation messages. Reset invalidates pending timers and in-flight results.
gotoken, cmd := scheduler.Schedule(key, delay, makeMessage) if scheduler.IsCurrent(key, token) { /* apply result */ }
Control subscriptions are pooled by tmux session because a control client cannot observe panes in another session. Subscription close and manager stop invalidate and drain queued callbacks before returning.
Cursor Positioning (cursor.go)
Query
gofunc QueryCursorPositionSync(target string) (row, col, paneHeight, paneWidth int, visible, ok bool) { cmd := exec.Command("tmux", "display-message", "-t", target, "-p", "#{cursor_x},#{cursor_y},#{cursor_flag},#{pane_height},#{pane_width}") }
Rendering
Focused live terminal surfaces expose a tea.Cursor through the plugin
CursorProvider capability. Workspace, filebrowser, and notes compute exact
application coordinates and suppress the cursor under modals, while scrolled
back, outside the visible slice, or when another surface owns focus. A painted
cursor is not added to native-cursor content.
Height Mismatch Adjustment
When display height differs from tmux pane height:
goif paneHeight > displayHeight { relativeRow = cursorRow - (paneHeight - displayHeight) } else if paneHeight > 0 && paneHeight < displayHeight { relativeRow = cursorRow + (displayHeight - paneHeight) }
Scrolling
Scrolling operates on the captured buffer. No tmux copy-mode involved.
gotype Plugin struct { previewOffset int // Lines from bottom (0 = at bottom/live) autoScrollOutput bool // Auto-follow new output? }
- Scroll UP: pause auto-scroll, increment
previewOffset - Scroll DOWN: decrement
previewOffset, re-enable auto-scroll at 0 - Live capture stays bounded at 600 lines; older 600-line ranges load lazily
- Absolute buffer coordinates keep selections and search matches stable
- Scrollback is bounded by the shared 10,000-line tmux history policy
- Instant response (pure state manipulation, no subprocess calls)
Copy/Paste
- Copy:
alt+c(configurable viainteractiveCopyKey), orsuper+c(Cmd+C) as a built-in that a configured key does not replace. Cmd+C only arrives when the emulator passes it through — terminals that keep it for themselves (iTerm2) never deliver it, soalt+cstays the portable chord. - Paste:
alt+v(configurable viainteractivePasteKey)
Paste wraps text with bracketed paste sequences (\x1b[200~...\x1b[201~) when the application has enabled bracketed paste mode.
Fallback Terminal Mode Recovery (terminal_mode.go)
When capture fallback owns presentation, detects bracketed paste and mouse reporting modes by scanning the fallback snapshot. Healthy model-backed presentation receives these modes from the shared screen model.
Width Synchronization
Tmux panes are resized in background at all times (not just interactive mode):
gofunc ResizeTmuxPane(paneID string, width, height int) { // resize-window, fallback to resize-pane for older tmux }
Resize triggers: window resize, sidebar toggle/drag, selection change, agent/shell creation, interactive mode entry.
Inline Edit Mode (Filebrowser)
Uses tty.Model plus tty.EditorSession for vim/nano/emacs editing in the file
preview pane. Session creation is history-safe and asynchronous:
gofunc (p *Plugin) enterInlineEditMode(path string) tea.Cmd { return func() tea.Msg { session, err := tty.StartEditorSession(tty.EditorSessionOptions{Path: path}) return InlineEditStartedMsg{Session: session, Err: err} } }
Entry and Exit
Workspace Plugin:
- Enter:
enter/Ewhen preview pane focused with output tab - Exit:
Ctrl+\(instant) or double-Escape (150ms delay) - Attach:
Ctrl+]/tonly whentmux_full_attachis on (default off)
Filebrowser Plugin:
- Enter:
eorEnteron a file (if inline edit enabled) - Exit:
Ctrl+\or double-Escape - Attach:
Ctrl+]only whentmux_full_attachis on (default off)
Feature Flags
json{ "features": { "tmux_interactive_input": true, "tmux_inline_edit": true, "tmux_full_attach": false, "workspace_terminal_panel": true } }
Configuration
json{ "plugins": { "workspace": { "interactiveExitKey": "ctrl+\\", "interactiveAttachKey": "ctrl+]", "interactiveCopyKey": "alt+c", "interactivePasteKey": "alt+v", "tmuxCaptureMaxBytes": 2097152, "copyOnSelect": false } } }
Critical Rules
- Scope every async result by owner, target/role, activation, and keyed generation.
- Carry the generation end to end through capture, result, retry, and continuation.
- Never call subprocesses from
Init()orView(); useStart()/tea.Cmd. - Do not mutate model state in
tea.Cmdcallbacks; return a scoped message. - Hash raw capture before cleaning/splitting, but mode changes must still clear coordinate state.
- Preserve newer live overlap when delayed history ranges prepend.
- Control clients are per tmux session and close/stop must invalidate and drain deliveries.
- Keep keyed capture fallback working until the first accepted model frame and after control failure; never run it as the healthy renderer.
- Consume all model bytes immediately and cadence only presentation snapshots; preserve immediate idle-leading output, a newest trailing frame, and unconditional seed/generation publication.
- Use native cursor only for the focused live viewport; hide it offscreen, under modals, and in scrollback.
- Treat source OSC as untrusted; the sanitizer and independent fuzz oracle must cover nested 7-bit/C1 forms and removal-boundary synthesis.
- Width sync matters; resize panes/control clients when terminal geometry changes.
References
- Tmux integration notes -- detailed tmux CLI techniques, cursor tracking, resize sync, bracketed paste, mouse forwarding, modified keys, adaptive polling, debugging
- Original spec:
docs/plans/implemented/spec-tmux-interactive-input.md


