Skip to content

Input System

gdbforge handles keyboard and mouse input through tcell, routes events based on interaction mode, and will support a Vim-like command system via the global CmdLine.

Companion docs: termforge: UI Architecture · WINDOW_MANAGEMENT.md · ARCHITECTURE.md


Table of contents


Input overview

Keyboard / Mouse / async workers
        ↓
App.Run (UI thread · pollEventBatch)
        ├── PollEvent → tcell.Event
        │     ├── EventKey / EventMouse / EventResize → App.HandleEvent
        │     │       ├── EventResize → UpdateCanvas (grid + chrome rects)
        │     │       └── EventKey → App.HandleKey → mode handler / key sequences / widgets
        │     └── EventInterrupt → HandleInterrupt → EventBus → *Ctl
        └── paint ticker (16ms) when dirty
sequenceDiagram
    participant Input as Keyboard / Mouse
    participant Worker as Worker goroutine
    participant App as App
    participant Screen as tcell.Screen
    participant Dbg as DebuggerApp
    participant Widget as Widget
    participant HI as HandleInterrupt
    participant Bus as EventBus
    participant Ctl as *Ctl handler

    Input ->> Screen: terminal bytes
    Screen ->> App: PollEvent · tcell.Event
    App ->> Dbg: HandleKey(ev)
    Dbg ->> Widget: HandleEvent(ev)
    Note over Worker,Screen: Async: PostInterrupt(payload)
    Worker ->> Screen: PostEvent(EventInterrupt)
    Screen ->> App: PollEvent · EventInterrupt
    App ->> HI: HandleInterrupt
    HI ->> Bus: Dispatch(typed msg)
    Bus ->> Ctl: Register handler
    App ->> App: Draw → Grid → Screen

Design principles:

  1. One thread owns input and rendering (App.Run polls tcell directly — no background PollEvent goroutine).
  2. Async sources post PostInterrupt → screen.PostEvent(EventInterrupt) — never call widget methods from reader goroutines.
  3. Typed reactions live on *Ctl handlers registered on EventBus, not in a giant app switch.
  4. Mode-aware routing lives in DebuggerApp, not App.

Keyboard handling

Event types

tcell event Handling
EventKey Primary keyboard input
EventResize Terminal size change → reallocate canvas
EventInterrupt Async injection (GDB output, redraw wakes)
EventMouse Mouse click/scroll (enabled via EnableMouse)

Dispatch (current)

  1. App.HandleEvent — global shortcuts (Ctrl+D quit, resize → UpdateCanvas, redraw interrupt). A resize needs no application hook: UpdateCanvas reallocates the grid and the chrome layout recomputes its rects (see WINDOW_MANAGEMENT.md).
  2. App.HandleKey — dispatches to the handler registered for AppState.Mode() via RegisterModeHandler:
  3. Global (every mode) — withGlobalKeys in setup.go runs first. Job-control is three orthogonal mini-machines (not Mode):
    • Mode — keymaps / Esc / : / / ModeLua pane keys (platform.Mode).
    • Activity — activity.go: snapshot of InferiorRunning + Lua job busy. Ctrl-C: Lua job → cancel; else if Confirm Asking → confirming interrupt; else debugger PTY interrupt. Ctrl-Z: inferior running → suspend inferior; else Lua job → cancel; else suspend gdbforge (App.Suspend).
    • Confirm — confirm_router.go: Ctrl-D quit / y-n gates (GDB QuitGate / Delve ConfirmGate). Mode may stay Insert while typing y/n. Works with any focused pane (Code, GDB, cmdline, Lua, …).
  4. ModeNormal — : enters command mode; / enters search mode; Esc restores the last non-Code/non-GDB pane when one was focused (e.g. Breakpoints), else focuses the CodeWidget leaf when :set esctocode (default); i focuses the remembered GDB leaf and enters insert; Up/Down/Space/e/n/s/c are global for Code/GDB (n → search-next when a pattern is active, else MI -exec-next; s/c → -exec-step/-exec-continue); */# search word under cursor forward/back; N previous search match; other panes keep their own Up/Down/Space; other keys go through the Trie then the focused widget.
  5. ModeInsert — GDB console (after i); Esc → normal (+ last non-Code/non-GDB pane, or CodeWidget when esctocode). If a CodeWidget is focused, n/s/c still send next/step/continue (Handled fallthrough — not when GDB or another pane owns focus).
  6. ModeCommand — all keys go to CmdWidget (after global Ctrl-Z / Ctrl-D).
  7. ModeSearch — all keys go to CmdWidget in search kind; live highlight on the focused SearchHost; Enter commits; Esc reverts.
  8. ModeCompletion — wildmenu: arrows cycle; Esc → prior mode; typed keys edit source line and re-query.
  9. ModeLua — keys go to the active LuaWidget until Esc.
flowchart TB
    Select["App.Run · UI thread"]
    Poll["pollEventBatch · PollEvent"]
    Batch["handleUIEventBatch"]
    TermHandler["App.HandleEvent"]
    HandleKey["App.HandleKey"]
    Resize["App.UpdateCanvas"]
    HandleInt["HandleInterrupt → EventBus"]
    Router["DebuggerApp · AppState.Mode()"]
    Trie["Trie.SearchPartial"]
    Tab["TabWidget.HandleEvent"]
    Cmd["CmdWidget.HandleEvent"]
    Comp["CompletionView"]

    Select --> Poll --> Batch
    Batch -->|"EventKey / Mouse / Resize"| TermHandler
    Batch -->|"EventInterrupt"| HandleInt
    TermHandler -->|"EventKey"| HandleKey --> Router
    TermHandler -->|"EventResize"| Resize
    Router -->|"ModeNormal"| Trie
    Router -->|"ModeNormal"| Tab
    Router -->|"ModeInsert"| Tab
    Router -->|"ModeCommand"| Cmd
    Router -->|"ModeCompletion"| Comp

Source: diagrams/input_routing.mermaid

Gap: focus-aware routing inside the workspace is partial (WidgetTree.focus exists). Insert mode is wired for the focused console pane.

Widget-level handling

GDB / Delve console keys are handled by shared termforge pieces, then backend-specific callbacks:

Layer File Owns
InputLine termforge/input_line.go Editing + history chords
ConsolePane termforge/console_pane.go Enter / Ctrl-L / PgUp / selection; walking prompt Draw
GDBWidget internal/gdbforge/widgets/gdb_widget.go OnSubmit → echo + Debugger.Send; Ctrl-C/D → interrupt/quit
internal/app/input.go Tab → gdbTabComplete GDB: MI -complete; Delve: dlv.Complete (commands + funcs)
ExecWidget internal/gdbforge/widgets/exec_widget.go Line submit → PTY Send; ANSI scrollback; live bash/ssh prompt

When the GDB pane is focused (insert):

Key Action
Enter Echo (gdb)/(dlv) cmd to scrollback, send to debugger, clear input line
Tab Wildmenu completion — GDB MI -complete; Delve command names + funcs ^<prefix> for b/break/… (e.g. b main.)
Backspace / Delete Edit input (InputLine)
Left / Right, Home / End Move cursor (Ctrl-B/F/A/E)
Up / Down Local readline-style history (Ctrl-P/N)
Ctrl+C Copy selection if any; otherwise interrupt (Delve: only if inferior running; at [Y/n]? sends n)
Ctrl+D Send q / quit
Ctrl+L Clear scrollback (screen reset — prompt returns to top-left)
Ctrl+Z Global: SIGTSTP inferior if running, else suspend gdbforge (job control; fg to resume)
Ctrl+V Paste CLIPBOARD into the input line
Middle-click Paste PRIMARY (X11) when available, else CLIPBOARD — rising-edge only (~120ms debounce; motion while held does not re-paste)
PgUp / PgDn Scroll output viewport
Rune Insert into input buffer
Mouse drag Select scrollback text (copies to CLIPBOARD + PRIMARY)
Double-click Select word under cursor and copy
Triple-click Select whole line and copy

Normal mode: <C-o> jumps back to the previous widget after :b / :e / :! (see EXEC_SHELL.md).

The (gdb) / (dlv) prompt walks down line-by-line under the scrollback while there is free space, then pins to the bottom and scrolls when the pane is full. Delve [Y/n]? confirms (e.g. suspended breakpoint after exit) use the same live-host path as GDB quit confirm. Look stays a native debugger session (not chat labels).

Example: CmdWidget (cmd_widget.go) — uses the same ClipboardIO bridge as Viewport / ConsolePane:

Key Action
Enter Parse command, emit SubmitMsg on event bus
Up / Down History navigation
Tab Complete command name
Backspace on lone : Deactivate widget (app should reset mode — see gap below)
Ctrl+A / Ctrl+E Move caret to start / end of editable text (after : or /)
Ctrl+U Kill from caret to start of editable text (keeps prefix)
Ctrl+V / middle-click Paste into the cmdline (CLIPBOARD / PRIMARY; first line only; middle-click rising-edge)
Ctrl+C / Ctrl+X Copy / cut text after :
Rune / editing keys Insert, move cursor

Command mode entry and exit:

Key Handler Action
: HandleKey in normal mode SetMode(ModeCommand), CmdWidget.Activate()
Click cmdline HandleMouse Same as : (enter command mode); sets caret from click column
Click outside cmdline (command mode) HandleMouse Leave command mode (like Esc), then focus the pane under the pointer
Esc CmdWidget → SubmitMsg{CmdID: CmdExitMode} cmdCtl.onSubmit resets mode, deactivates widget
Enter HandleKey after submit SetMode(ModeNormal), CmdWidget.Deativate()

On Enter, CmdWidget resolves the first token against AutoCompleter, sets CmdID (or termforge.CmdUnknown), and posts the SubmitMsg through App.PostInterrupt. DebuggerApp.HandleInterrupt dispatches it on platform.EventBus, where cmdCtl.onSubmit switches on CmdID.


Key-sequence bindings

Multi-key bindings (Vim-style <C-w>h, etc.) are registered on a commands.KeyBindingRegistry owned by DebuggerApp (internal/app/keybindings.go).

func (a *DebuggerApp) InitKeyBindings() {
    a.keyBindings = commands.NewKeyBindingRegistry()
    a.keyBindings.Bind(
        commands.NewCommand("move-left", func(args ...any) { a.OnFocusLeft() }),
        "<C-w>l", "<C-w><Left>",
    )
}

In normal mode (internal/app/input.go), key→action maps live on a mode key trie (keyBindings via InitKeyBindings): Esc, :, i, Up/Down/Space/e/n/s/c, and window chords. Gated binds use Handled fallthrough so list panes keep Up/Down/Space. Ctrl-Z is not on the trie — it is intercepted by withGlobalKeys for every mode. Insert and completion modes use insertKeys / completionKeys the same way.

Current bindings:

Sequence Action
<C-w>h, <C-w><Right> Focus right pane
<C-w>l, <C-w><Left> Focus left pane
<C-w>k, <C-w><Up> Focus up pane
<C-w>j, <C-w><Down> Focus down pane

Implementation: termforge/collections/trie.go via KeyBindingRegistry.

Design decision: bindings live on the application object, not in App, so key chords remain app-specific while shared packages provide the prefix-tree machinery.


Mouse handling

tcell mouse support is enabled in NewApp (EnableMouse with motion events).

Implemented today:

Action Behavior
Click pane Focus that pane; leave command mode if clicking outside the cmdline
Click cmdline Enter command mode; set caret from column
Scroll wheel Scroll focused viewport (source / console / lists)
Drag in viewport Text selection; copy to CLIPBOARD and X11 PRIMARY (platform/clipboard.go)
Double-click (content) Select word (termforge/viewport_word.go) and copy
Triple-click (content) Select line and copy
Status band Double-click name text → copy full label. Single-click / drag anywhere on the row → split resize as before (status_sel.go)
Middle-click Paste PRIMARY (preferred) or CLIPBOARD — rising edge only (debounce ~120ms)
Thread / Call Stack / Breakpoints click Activate on button release (not every drag sample); skip same-row drag that was a text select; debounce duplicate activate ~300ms

Clipboard note: Selection copy writes both CLIPBOARD and PRIMARY so middle-click paste outside gdbforge (other X11 clients) sees the same text. Middle-paste inside gdbforge prefers PRIMARY.

Still planned: click tab to switch; drag split gutter to resize; click breakpoint gutter to toggle.

Design decision: mouse is an enhancement, not the only UX. All operations must have keyboard equivalents for SSH / minimal terminals.


Interaction modes

stateDiagram-v2
    [*] --> NormalMode
    NormalMode --> FocusMode : focus widget (planned)
    FocusMode --> NormalMode : unfocus / Esc (planned)
    NormalMode --> CommandMode : press colon
    CommandMode --> NormalMode : Esc
    NormalMode --> SearchMode : press slash
    SearchMode --> NormalMode : Esc / Enter
    FocusMode --> CommandMode : press colon (planned)

Source: diagrams/input_modes.mermaid

Mode Keys go to Purpose Status
Normal Trie + focused FocusKeyHandler Navigation, key sequences, workspace input Implemented
Insert Focused console (GDB/IO/exec) or Code-gated n/s/c Type into debugger / program; Esc → normal Implemented
Command CmdWidget (CmdKindCommand) : UI commands Implemented
Search CmdWidget (CmdKindSearch) + SearchHost pane / live buffer search; */# word; n/N next/prev Implemented
Completion Wildmenu + source line edit Tab completion (ModeCompletion) Implemented
Lua Active LuaWidget :lua snake then :b snake (cell demos); gdbforge.print → :b io Implemented

Mode state lives in platform.AppState on App (State()):

// termforge/platform/mode.go
type Mode int
const (
    ModeNormal Mode = iota
    ModeInsert
    ModeCommand
    ModeCompletion
    ModeLua
    ModeSearch
)

DebuggerApp registers mode handlers in InitB wrapped with withGlobalKeys (Activity Ctrl-C/Z + Confirm Ctrl-D). Layout policy: :set equalalways / :set noequalalways; :layout default|panels|classic|wide (+ optional asm). IO pane: :set clearoutput / :set noclearoutput. PTY owner is set while the console, :AI/MCP, or App writers hold the write mux. Focus roles (Code / GDB / last pane) live on LayoutShell (workspace_policy.go; methods promoted on DebuggerApp).

Design decision: modes mirror Vim's normal / insert / command separation, adapted for debugger UX:

  • Normal mode avoids accidentally typing into GDB when navigating; trie handles multi-key chords.
  • Insert mode is pane-local typing (GDB CLI, IO stdin, exec shell).
  • Command mode is for UI operations, not debugger commands.
  • Search mode muxes the same CmdWidget with a leading / (separate history; no Tab). Target is the focused pane's SearchHost (viewport_search.go). * / # search the word under the cursor; on Code n is always GDB next (like s/c) and N is prev match; on other panes n / N jump matches.
  • Ctrl-Z / Ctrl-D are mode-independent (GDB-like job control / quit).

Gaps:

  • Dedicated Focus mode (pane-local keys exclusive of global) is still planned.
  • NewTabTwoHozSplitWins creates a horizontal split of its two widgets.

Vim-like command system

The CmdLine accepts : prefixed commands. Press : to activate CmdWidget; type a command and press Enter.

Full reference: COMMAND_SYSTEM.md — command tree ownership, DSL, CommandParser, tab completion.

Architecture (current)

flowchart LR
    CmdLine["CmdWidget"]
    Parser["commands.CommandParser"]
    Tree["CommandRegistry.Root"]
    Bus["platform.EventBus"]
    Action["CommandNode.Action"]

    CmdLine --> Parser
    Parser --> Tree
    CmdLine -->|"Tab · CompletionMsg"| Bus
    CmdLine -->|"Enter"| Action

Flow:

  1. User presses : → DebuggerApp sets ModeCommand, CmdWidget.Activate() (internal/app/input.go).
  2. User types :b, presses Tab → parser SuggestionNames → Publish(CompletionMsg); the wildmenu window opens and app enters ModeCompletion.
  3. User presses Enter → CommandParser.Parse + Execute → leaf Action runs (e.g. OnFocusLeft).
  4. Tree is built at startup via DSL in ExapData() (internal/app/command_tree.go).

Legacy note

Older docs described a flat termforge.AutoCompleter + CommandID + SubmitMsg path for every colon command, dispatched by a single HandleCoreEvents hub. That hub is gone. Tree leaves now execute via CommandParser directly, and SubmitMsg survives only for infra events (CmdExitMode, goto-line) handled by cmdCtl on the bus.

Command categories

Category Examples Dispatch
Model / window :buffer code, :buffer breakpoints, :vs, :split, :close Window manager binds widget to existing model — partial (:vs / :split wired)
Tab :tabnew, :tabclose Bus handler → tab widget (planned)
Debugger :gdb break, :gdb info registers Colon tree under gdb; GDB CLI still typed in the GDB pane
UI :quit / :q confirm if inferior alive (Ctrl-D); :q! / :quit! force exit

The :buffer <name> command displays an application model, not a file. Each <name> must be declared at startup (e.g. code, breakpoints, console). There is no :attach command — all models exist from initialization. See ARCHITECTURE.md.

Design decision: UI commands and GDB CLI commands share familiar ideas (:gdb break file), but routing stays out of the widgets. A widget publishes an intent; the command tree or a bus subscriber in internal/app decides whether to mutate layout, talk to services, or exit.


Async input from debugger

GDB MI output is not keyboard input but arrives through the same event loop so it stays ordered with keys and draw:

screen.PostEvent(tcell.NewEventInterrupt(msg))  // events.GdbOutputMsg (MI PTY only)
screen.PostEvent(tcell.NewEventInterrupt("gdb-exit"))

CLI console bytes paint directly via WireCLI → CompositeTerminal (no GdbOutputMsg for pane display).

MI bridge path:

  1. MI PTY reader → Subscribe fan-out → UI bridge PostEvent(GdbOutputMsg) (bridge only calls PostEvent, never widgets)
  2. UI thread HandleInterrupt → consoleCtl.onOutput → GdbInputState.PushRaw
  3. Each complete MI line → MiUpdate → app state refresh (breakpoints, threads, source, …)

Inferior / exec bytes use WireTTY on their PTY masters.

Incomplete MI lines stay in GdbInputState.lineBuf until the next \n. There is no debounce timer.

See DEBUGGER_INTEGRATION.md for 3-PTY layout (CLI + MI + inferior), :AI, and EventBus-driven breakpoint refresh (BreakpointsChangedMsg).

Design rationale: MI chunks may split mid-line; newline splitting is enough. Streaming per complete record keeps the console snappy while all UI mutation stays on the tcell event loop.


Keybindings

Normal mode

Key Action Status
Esc Focus last non-Code/non-GDB pane if one was active; else CodeWidget leaf when esctocode (default); else leave insert → normal only Implemented
i Focus GDB leaf (remembered) and enter insert mode Implemented
Up / Down Move CodeWidget cursor line (global) Implemented
Space Toggle breakpoint at CodeWidget cursor (global) Implemented
e Enable/disable breakpoint at CodeWidget cursor (yellow when disabled) Implemented
n GDB next via MI -exec-next (normal; also insert when CodeWidget focused) Implemented
s GDB step via MI -exec-step (normal; also insert when CodeWidget focused) Implemented
c GDB continue via MI -exec-continue (normal; also insert when CodeWidget focused) Implemented
: Enter command mode Implemented
/ Enter search mode (focused pane) Implemented
* / # Search word under cursor forward / back Implemented
n / N Code: n = GDB next (like s/c), N = prev search; else search next/prev Implemented
Ctrl+W h/j/k/l or arrows Focus direction (via trie) Implemented
Ctrl+W o Only focused pane Implemented
Ctrl+O Jump back after :b / :edit / :! Implemented
Ctrl+D Send q to GDB (confirm if inferior alive) Implemented
Ctrl+Z Suspend inferior if running, else suspend gdbforge (any mode) Implemented
Tab / Shift-Tab Cycle focus Planned
1-9 Switch tab Planned

Focused pane (widget keys)

Keys reach the focused leaf when not consumed by the trie / command mode:

Widget Key Action
CodeWidget e Enable/disable breakpoint at cursor (yellow when disabled; same as BreakpointWidget e)
CodeWidget Space Insert/remove breakpoint at cursor line
BreakpointWidget (:b breakpoint) j/k or Up/Down, Enter / click-release Select row; browse Code with blue cursor (━━▶ stays on StopLocation); green when row is stop PC
BreakpointWidget e Toggle enable (remove/re-add in GDB; row stays)
BreakpointWidget d Delete from list and GDB
ThreadWidget / CallStackWidget j/k or Up/Down, Enter / mouse release Bold selection; activate sends MI -thread-select / -stack-select-frame (not CLI thread/frame, so the GDB console stays quiet); updates Code browse; green on stop PC; missing / .so → not available
OutputWidget (:b io, alias :b output) PgUp/PgDn; type + Enter Program stdin/stdout (inferior PTY); <C-l> clear; Ctrl-C → inferior; global Ctrl-D quits debugger; Ctrl-Z → SIGTSTP via inferior PTY

Full sync path: DEBUGGER_INTEGRATION.md. Persist: breakpoint persistence.

Insert / Lua (focused panes)

When not in normal mode, keys go to the focused console or Lua widget (after global Ctrl-Z). See mode table above.

Command mode

Key Action Status
Enter Execute command Implemented
Esc Cancel, return to normal mode Implemented
Up / Down Command history Implemented
Tab Completion Implemented
Ctrl+Z Suspend (global) Implemented