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 handling
- Key-sequence bindings
- Mouse handling
- Interaction modes
- Vim-like command system
- Async input from debugger
- Keybindings
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:
- One thread owns input and rendering (
App.Runpolls tcell directly — no backgroundPollEventgoroutine). - Async sources post
PostInterrupt→screen.PostEvent(EventInterrupt)— never call widget methods from reader goroutines. - Typed reactions live on
*Ctlhandlers registered onEventBus, not in a giant appswitch. - Mode-aware routing lives in
DebuggerApp, notApp.
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)¶
App.HandleEvent— global shortcuts (Ctrl+Dquit, resize →UpdateCanvas, redraw interrupt). A resize needs no application hook:UpdateCanvasreallocates the grid and the chrome layout recomputes its rects (see WINDOW_MANAGEMENT.md).App.HandleKey— dispatches to the handler registered forAppState.Mode()viaRegisterModeHandler:- Global (every mode) —
withGlobalKeysinsetup.goruns first. Job-control is three orthogonal mini-machines (not Mode):- Mode — keymaps / Esc /
:// ModeLua pane keys (platform.Mode). - Activity —
activity.go: snapshot ofInferiorRunning+ 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 (GDBQuitGate/ DelveConfirmGate). Mode may stay Insert while typing y/n. Works with any focused pane (Code, GDB, cmdline, Lua, …).
- Mode — keymaps / Esc /
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);ifocuses 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;Nprevious search match; other panes keep their own Up/Down/Space; other keys go through the Trie then the focused widget.ModeInsert— GDB console (afteri); Esc → normal (+ last non-Code/non-GDB pane, or CodeWidget whenesctocode). If a CodeWidget is focused,n/s/cstill send next/step/continue (Handled fallthrough — not when GDB or another pane owns focus).ModeCommand— all keys go toCmdWidget(after global Ctrl-Z / Ctrl-D).ModeSearch— all keys go toCmdWidgetin search kind; live highlight on the focusedSearchHost; Enter commits; Esc reverts.ModeCompletion— wildmenu: arrows cycle; Esc → prior mode; typed keys edit source line and re-query.ModeLua— keys go to the activeLuaWidgetuntil 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
CmdWidgetwith a leading/(separate history; no Tab). Target is the focused pane'sSearchHost(viewport_search.go).*/#search the word under the cursor; on Codenis always GDB next (likes/c) andNis prev match; on other panesn/Njump 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.
NewTabTwoHozSplitWinscreates 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:
- User presses
:→DebuggerAppsetsModeCommand,CmdWidget.Activate()(internal/app/input.go). - User types
:b, presses Tab → parserSuggestionNames→Publish(CompletionMsg); the wildmenu window opens and app entersModeCompletion. - User presses Enter →
CommandParser.Parse+Execute→ leafActionruns (e.g.OnFocusLeft). - 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:
- MI PTY reader →
Subscribefan-out → UI bridgePostEvent(GdbOutputMsg)(bridge only callsPostEvent, never widgets) - UI thread
HandleInterrupt→consoleCtl.onOutput→GdbInputState.PushRaw - 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 |
Related documentation¶
- COMMAND_SYSTEM.md — command tree, DSL, parser, tab completion
- WINDOW_MANAGEMENT.md — CmdLine placement
- DEBUGGER_INTEGRATION.md — GDB/Delve input forwarding, Delve Tab/
funcscompletion, yes/no confirms - DEVELOPER_GUIDE.md — adding event handlers