Skip to content

Architecture Overview

This document describes the high-level architecture of gdbforge: subsystems, boundaries, data flow, and the design principles that govern implementation decisions.

gdbforge is not a clone of Vim. It borrows Vim's interaction model — modes, a : command line, split windows — but where Vim has one data model (text buffers), gdbforge carries several application-specific models: source, disassembly, breakpoints, threads, call stack.

Companion docs: termforge: UI Architecture · PTY_ARCHITECTURE.md · COMMAND_SYSTEM.md · DEBUGGER_INTEGRATION.md · DIRECTORY_STRUCTURE.md


Built on termforge

The generic half of that interaction model is not in this repository. It is termforge, a standalone Go module for keyboard-driven terminal applications: the Widget interface and widget set, the binary split-tree window manager, tabs, the Canvas → Grid → tcell rendering pipeline, the colon-command tree with tab completion, key-sequence bindings, modes, the typed event bus, and the CompositeTerminal terminal-emulator pane.

termforge was extracted from gdbforge rather than adopted into it. The framework grew in-tree while the debugger was being built, then moved out to its own module once the boundary was clean enough to enforce. gdbforge is its first and largest consumer, which is why the two read like siblings.

gdbforge supplies termforge supplies
DebuggerApp, the composition root termforge.App — event loop, screen, draw orchestration
Debugger panes (Code, Assembly, Breakpoints, Threads) DocumentView, TableWidget, CompositeTerminal to build them from
The :gdb … command tree and debugger key bindings CommandParser, CommandRegistry, KeyBindingRegistry
Named debugger workspaces and pane policy WidgetTree geometry, focus, and marks
GDB MI and Delve rpc2 clients ptyx PTY plumbing the backends read and write through

Concretely: DebuggerApp embeds *termforge.App and implements termforge.AppApi, and every pane implements termforge.Widget. The boundary is compiler-enforced — termforge does not depend on this module, so it cannot import debugger code. See DEPENDENCIES.md for the import rules and the termforge docs site for the framework side.

To build a different terminal application on the same machinery, start from termforge and its runnable demo, not from this repository.


MVC (current)

The debugger app is organized as Model–View–Controller with a composition root:

One shared model, two peer controllers (GUI *Ctl + MCP/AI), views only for humans. DebuggerApp wires everything; domain logic lives on host-backed controllers (breakCtl, consoleCtl, …). AI tools call the same domain surface as the GUI. A future Lua controller can bind that surface too (must use the PTY write mux). Raw gdb_command remains an escape hatch only.

flowchart TB
    subgraph root ["Composition root"]
        App["DebuggerApp<br/>App wiring + host adapters"]
        Shell["LayoutShell<br/>tab tree · pane marks · focus"]
        Sess["DebugSession<br/>backend · GDB widgets · debug ctls"]
        BE["backend.Backend<br/>GDB · Delve"]
    end

    subgraph controllers ["Controllers · GUI"]
        GUI["*Ctl<br/>break · asm · console · lua · dlv · …"]
        MCP["MCP / AI · GdbMcpService<br/>peer on same Session"]
    end

    subgraph events ["UI thread events"]
        Poll["PollEvent batch"]
        HI["HandleInterrupt"]
        Bus["platform.EventBus"]
    end

    subgraph model ["Shared model"]
        Dom["Domain snapshots<br/>BreakpointList · ThreadList · …"]
        Surf["gdbforge/domain.DebugDomain"]
    end

    subgraph views ["Views · humans only"]
        W["Widgets<br/>Code · GDB · Threads · …"]
    end

    App --> Shell
    App --> Sess
    Sess --> BE
    App -->|"initControllers host = a"| GUI
    BE --> Dom
    GUI --> Dom
    App --> Surf
    MCP --> Surf

    GUI -->|"Register Subscribe"| Bus
    Poll --> HI
    HI -->|"typed payload"| Bus
    Bus --> GUI
    Dom --> W
    W -->|"host intents"| App
    App --> GUI
Layer Owns Lives in
Composition root Wire App, hosts, modes, stop orchestration DebuggerApp (app.go, facade.go, controllers.go)
LayoutShell Pane marks, Code/GDB placement, layout apply, focus/jump-back internal/app/workspace*.go, layout_host.go (embedded on app)
DebugSession Backend, debug state, GDB/DLV widgets, debug *Ctl group debug_session.go (embedded on app)
Backend GDB vs Delve policy; owns concrete client internal/gdbforge/backend
Model Session + domain snapshots (on *Ctl, not app fields) breakCtl.list, debugInfoCtl, asmCtl, internal/gdbforge/models
Domain surface Peer ops for AI / future Lua internal/gdbforge/domain · internal/app/debug_domain.go
Controller Intents → mutate model → paint / Send; Register on EventBus GUI: *Ctl · MCP: internal/mcp
View Paint + host intents / callbacks internal/gdbforge/widgets, termforge
View (widget)  --Host / OnSubmit-->  DebuggerApp (forwards)
*Ctl           --Send / Query-->     Model (Session via Backend, BreakpointList, …)
*Ctl           --SetItems / Paint--> View
MCP / AI       --Send / Query-->     same Model  (no widget ownership)

GUI and MCP/AI share the same models (e.g. breaks.list → BreakpointList); widgets never own Backend / Session, ptyx.TTY, or domain merge logic. MCP does not paint — views exist for the human TUI only.

Controllers and host interfaces

Controllers (*Ctl) own domain logic and models. They must not hold *DebuggerApp directly — each talks to the composition root through a narrow host interface wired in initControllers():

a.breaks.host = a   // DebuggerApp implements breakHost
a.lua.host = a      // DebuggerApp implements luaHost
// …

Qt analogy: Register + EventBus.Subscribe ≈ connect(signal, slot). A typed message (e.g. GdbOutputMsg) is the signal; the controller handler is the slot. Host interfaces ≈ the minimal parent API a child object is allowed to call.

breakCtl  ──uses──▶  breakHost  ◀──implements──  DebuggerApp
luaCtl    ──uses──▶  luaHost    ◀──implements──  DebuggerApp
dlvCtl    ──uses──▶  dlvHost    ◀──implements──  DebuggerApp
LayoutShell ──uses──▶ layoutHost ◀──implements──  DebuggerApp
Controller Host interface Typical host surface
breakCtl breakHost Backend(), BPWidget(), RequestFrame(), PublishBreakpointsChanged()
consoleCtl consoleHost GDBWidget(), stop pipeline hooks, SendGdbExec peers
debugInfoCtl debugInfoHost GdbMcp(), showFrameSource, ApplyDebugInfoUI
bufferCtl bufferHost Shell() (layout), placeCodeInSlot, buffer widgets
asmCtl asmHost Shell(), assembly widget, Workspace tab ops
inferiorIOCtl inferiorHost OutputWidget(), inferior PTY routing
searchCtl searchHost CmdWidget(), ActiveCodeWidget(), State()
luaCtl luaHost UI + debug + serial surface for scripts (~40 methods)
dlvCtl dlvHost Code refresh, frame sync, debug-info peers
completionCtl completionHost Wildmenu, PublishCompletion
cmdCtl cmdHost Cmdline submit routing

Compile-time checks in controllers.go (var _ breakHost = (*DebuggerApp)(nil)) catch drift when the app stops implementing a host.

List widgets (threads, breakpoints, call stack) still take *DebuggerApp as a widget host (BreakpointHost, ThreadHost, …) for activation intents — separate from controller hosts, same idea: narrow surface, app forwards into *Ctl.

Consoles use WireCLI / WireInferior / WireExec on CompositeTerminal. Lua REPL still uses ConsolePane + InputLine.

Controller Domain Notes
breakCtl Breakpoints, gutter paint (BreakGutter) Code Space + BP list e/d
asmCtl Assembly list/widget, preferAsm / autoAsm :b asm, missing-source swap
bufferCtl Per-path CodeWidget map :b / :edit
debugInfoCtl Threads / call stack Stop refresh
consoleCtl MI bridge on PTY #2; CLI WireCLI lifecycle Submit / parse / gdb-exit
inferiorIOCtl Inferior or serial console → IO pane WireInferior policy
completionCtl / searchCtl / luaCtl / dlvCtl / cmdCtl Completion, / search, Lua, Delve sync, cmdline All use host interfaces

Composition layers (LayoutShell · DebugSession)

DebuggerApp embeds two structural layers so the app struct stays wiring, not domain:

DebuggerApp
├── *App              UI loop (PollEvent, draw)
├── LayoutShell (embed)   tab tree, pane marks, focus, :layout apply
├── DebugSession (embed)  backend, gdbWidget, debug *Ctl group
└── cross-cutting         lua, search, serial, exec, keybindings, modes
Layer Owns Does not own
LayoutShell TabWidget, leaf marks (code/gdb/asm/last), placeCodeInSlot, swapFocusedWidget, ApplyLayout Breakpoints, GDB MI, session lifecycle
DebugSession backend, debug state, gdbWidget, gdbMcp, breaks/asm/bufs/debugInfo/console/inferiorIO/dlv Tab geometry, focus marks, cmdline
DebuggerApp initControllers, host adapter methods, global keys, stop orchestration glue Per-domain merge logic (lives on *Ctl)

LayoutShell uses layoutHost (not *DebuggerApp) for pane policy — same decoupling pattern as controller hosts.

See internal/app/facade.go for the in-code summary.

UI event path (why refactoring was possible)

Background work (GDB PTY, Lua jobs, exec) must not call widgets directly. Everything wakes the UI thread, then controllers react:

flowchart LR
    Worker["Worker goroutine"]
    Post["App.PostInterrupt"]
    Screen["tcell.PostEvent"]
    Poll["PollEvent · UI thread"]
    HI["HandleInterrupt"]
    Str["string · gdb-exit / widget forward"]
    Bus["platform.EventBus.Dispatch"]
    Ctl["*Ctl Register handlers"]

    Worker --> Post --> Screen --> Poll --> HI
    HI --> Str
    HI --> Bus --> Ctl
  1. PostInterrupt(payload) — thread-safe enqueue via screen.PostEvent(EventInterrupt) (replaces the old events chan + HandleCoreEvents switch and the removed uiEvents channel).
  2. HandleInterrupt — thin app shell: string session exits + Bus.Dispatch(data).
  3. Register on each *Ctl — typed handler per message (GdbOutputMsg, codeRefreshMsg, SubmitMsg, …).

This separation is what made steps 1–6 safe: controllers could move behind host interfaces without fighting a monolithic interrupt switch. The bus handles events; host interfaces handle dependencies.

Legacy note: older docs refer to HandleCoreEvents and App.events — removed in favor of PostInterrupt + EventBus.

Orthogonal input mini-machines

Global job-control keys are not Mode policy. Three orthogonal mini-machines compose at withGlobalKeys:

Machine Owns Lives in
Mode Keymaps, Esc, : / /, ModeLua platform.Mode + mode handlers
Activity Ctrl-C / Ctrl-Z from inferior + Lua job busy internal/app/activity.go
Confirm Ctrl-D quit / y-n gates; confirming interrupt internal/app/confirm_router.go + QuitGate / ConfirmGate

See INPUT.md § Dispatch.

What DebugDomain means (naming)

In architecture, domain is the debugger problem space (breakpoints, threads, stack) and its data in internal/gdbforge/models.

The Go type DebugDomain is not “the whole domain” and not “many ways to set a breakpoint.” It is a port / facade: a small menu of domain operations so peer controllers (AI today, Lua later) can call the app without importing DebuggerApp.

Piece Role
models/ (BreakpointList, …) Domain data (shared truth)
domain.DebugDomain interface Domain operations exposed to peers
internal/app/debug_domain.go One real implementation (same BP path as GUI Space)
GUI widgets May call app helpers directly; they do not need the interface
AI / future Lua Call through DebugDomain only

There is still one way to place a breakpoint (ToggleInsertClear → Send). The interface only adds doors into that path (and allows a fake domain in tests).

C++ analogy: an abstract class / pure virtual API. Architecture labels that fit: port, use-case API, facade. The name DebugDomain means “operations belonging to the debugger domain,” not “interface = domain” in every codebase.


Table of contents


System context

gdbforge runs as a terminal application. It owns the UI event loop, renders into an off-screen grid, and communicates with debugger backends through backend.Backend (-g gdb|dlv). GDB (MI2) and Delve share the same UI controllers via that policy surface.

flowchart LR
    User["Developer"]
    Term["Terminal"]
    gdbforge["gdbforge · App"]
    BE["backend.Backend"]
    GDB["GDB MI2 / Delve"]
    Target["Debug target"]

    User --> Term
    Term <--> gdbforge
    gdbforge --> BE
    BE <-->|"PTY#1 debugger"| GDB
    BE <-->|"PTY#2 stdio"| Target
    GDB -.->|"inferior tty"| Target

GDB and the inferior use separate PTYs: MI on PTY #1, program stdin/stdout on PTY #2 (IO console). Master/slave map, Delve --tty vs TCP headless, and external terminals: PTY_ARCHITECTURE.md. Protocol details: DEBUGGER_INTEGRATION.md. Unified controller/backend layering: DEBUGGER_INTEGRATION.md.

flowchart TB
  subgraph ui ["Controllers — protocol-agnostic"]
    breakCtl[breakCtl]
    debugInfoCtl[debugInfoCtl]
    consoleCtl[consoleCtl]
    stopped[stopped / code_nav]
    inferiorIO[inferiorIOCtl]
  end

  subgraph shared ["Shared domain"]
    models["models.*"]
    debuggerPkg["debugger.* StopInfo ConsoleUpdate"]
  end

  subgraph api ["backend.Backend"]
    SemanticOps["Semantic ops + capabilities"]
  end

  subgraph impl ["Implementations"]
    GDB["GDBBackend · MI"]
    DLV["DLVBackend · rpc2 + CLI"]
  end

  ui --> api
  ui --> shared
  api --> GDB
  api --> DLV

(Full diagram: docs/diagrams/unified_backend.mermaid.)


Application framework

The central idea behind termforge is that Vim's interaction model maps cleanly onto a broader class of applications — not only text editors. gdbforge is one instance of the pattern below.

Vim termforge
Single data model (text buffers) Multiple application-specific data models
Buffers hold file content Models hold domain state (breakpoints, orders, registers, …)
Windows display buffers Widgets display models
:buffer opens a file :buffer displays an application model

Each application defines its own set of models during startup. Examples:

GDB application

  • CodeModel, BreakpointModel, ThreadModel, RegisterModel, MemoryModel, ConsoleModel, LoggerModel

Trader application (hypothetical)

  • OrdersModel, PortfolioModel, WatchlistModel, ChartModel, LoggerModel

MSP application (hypothetical)

  • MSPV2InfoModel, LoggerModel, …

All models are created during application initialization. They live for the entire lifetime of the application, subscribe to application events, and continuously maintain their state.

The same termforge framework (split tree, :buffer, :split, :tab) serves all applications; only the models and services differ.


Startup

When an application starts, it creates:

Component Lifetime Role
Services Application Communicate with external systems; produce events
Event bus Application Distributes events to subscribers
Models Application Own state; subscribe to events; update continuously
Logger Application Structured logging infrastructure
Runtime infrastructure Application Event loop, window manager, command line

Widgets are not created at startup. Models exist for the entire lifetime of the application and continuously receive updates from underlying services. The window manager creates widget instances only when the user asks to display a model.


Services

Services communicate with the outside world. They publish events through the event bus and never communicate directly with widgets (target architecture).

Service Application
backend.Backend GDB vs Delve policy — owned by DebuggerApp; wraps gdb.GDBClient or dlv.Client
ptyx.Session (app.GDB()) Shared debugger session (name is historical; works for -g dlv too)
execcli.ExecClient Vim-style :! shell / SSH PTYs — owned by DebuggerApp
mcp.GdbMcpService In-app :AI / tool access to the live Session (app.GDB())
IBKRClient Trader (planned)
MSPV2Client MSP monitoring (planned)

Each application wires its own services during startup. Controllers update domain models and push snapshots to views; MCP is a peer controller on the same Session. Prefer Backend methods over new isDLV() branches.


Application data flow

Application state flows in one direction:

Service
    ↓
Event Bus
    ↓
Model
    ↓
Widget (View)

Widgets never subscribe directly to external services. Models own application state; widgets simply display models.

Examples:

Backend / MCP     →  breakCtl.list (BreakpointList)  →  BreakpointWidget + Code/Asm gutters
Backend           →  consoleCtl                      →  GDBWidget (paint + OnSubmit)
Backend           →  debugInfoCtl (CallStack/Threads) → CallStackWidget / ThreadWidget
Backend           →  asmCtl (AssemblyList)           → AssemblyWidget (when supported)
GdbMcpService     →  same Session (WithWrite + Subscribe)
Layer Responsibility
Backend / Session Talk to external systems; GDB/Delve policy; Send / Subscribe / WithWrite
Event bus Route events to controllers / model refresh
Model Own application state on *Ctl; live for the app lifetime
Controller *Ctl (+ app orchestration) — intents, refresh, sync views
Widget Display a model / console chrome; host intents / callbacks only; no Send

The sections below on terminal input, domain events, and GDB output describe how this flow is wired in the current Go implementation (PostInterrupt, EventBus, ptyx, etc.).


Application models

Models are the source of truth for application state. They are created at startup and live until the application exits.

Property Behavior
Creation Declared and initialized during application startup
Updates Subscribe to the event bus; react to service events
Lifetime Independent of any widget
Sharing Multiple widgets may display the same model simultaneously
OrdersModel
      |
+-----+------+
|            |
OrdersWidget OrdersWidget

A widget's lifetime is independent from its model. Closing a pane destroys the widget, not the model. Opening :buffer orders again creates (or activates) a new widget bound to the existing OrdersModel.

Generic model interfaces

Models are application-specific (BreakpointModel, OrdersModel, MSPV2InfoModel, …), but they expose generic interfaces understood by reusable widgets. Widgets never depend on application-specific model types.

TextWidget   →  TextModel
GraphWidget  →  GraphModel
TableWidget  →  TableModel   (aspirational — debugger lists use SetFill adapters today)
TreeWidget   →  TreeModel

The application implements concrete models; the widget depends only on the small interface it needs.


Widget philosophy

Widgets are views. A widget should contain little or no business logic. It receives a model (usually through an interface) and renders it.

Widget Role
LoggerWidget Scrollable log output
GraphWidget Time series, histograms, scatter plots
TableWidget Tabular data (implemented in termforge; BP/threads/callstack embed it)
TreeWidget Hierarchical data
TextWidget Line-oriented text

Widgets should be reusable across applications whenever possible. TableWidget is implemented (RectViewport, columns, SetFill); gdbforge debugger list panes embed it with thin adapters. A future generic TableModel interface remains aspirational.


Generic widgets

Widgets operate on small interfaces rather than concrete model implementations.

For example, GraphWidget depends on GraphModel. GraphModel represents graph data only — not how it should be drawn. Different applications may implement GraphModel:

Application model Implements
StockChartModel GraphModel
MSPV2InfoModel GraphModel
CPULoadModel GraphModel

The same GraphWidget displays all of them.

Rendering style (line graph, histogram, scatter, etc.) is a responsibility of the widget, not the model. The model provides data; the widget decides how to render it.


Buffer concept

The meaning of :buffer differs from Vim:

Vim This framework
Buffer Text file Named application model

:buffer does not open a file. It creates (or activates) a widget displaying the corresponding model. The model already exists — only the view is created on demand.

GDB application examples:

:buffer code
:buffer breakpoints
:buffer threads
:buffer registers
:buffer memory
:buffer console
:buffer logger

Trader application examples:

:buffer orders
:buffer portfolio
:buffer chart

MSP application examples:

:buffer msp
:buffer logger

Models are created during application startup — not on demand when the user runs :buffer.

See WINDOW_MANAGEMENT.md and INPUT.md.


Why not :attach

The architecture intentionally avoids a runtime attachment mechanism such as:

:attach logger
:attach breakpoints

Such commands would expose the internal dependency graph between services, models, and widgets. The user should not be required to understand that wiring.

The relationship between services, models, and widgets is defined during application startup. Commands should express user intent (:buffer logger) rather than implementation details (:attach logger).

Instead, every application declares its available models during initialization. The user only chooses which model to display — via :buffer, :split, :vsplit, or :tab. All models already exist; the window manager binds widgets to them.


Design philosophy

The framework extends Vim's interaction model rather than copying its implementation.

Vim has one data model: the text buffer. This framework supports many application-defined data models. Each application declares its available models at startup; the user interacts with them using familiar Vim commands.

Vim:

File
    ↓
Text Buffer
    ↓
Window

This framework:

Application
      ↓
Application Models
      ↓
Widgets (Views)
      ↓
Window Manager

Internally, :buffer, :split, :vsplit, and :tab create views over existing models instead of opening files. The user still works with familiar Vim concepts, but the underlying objects are no longer limited to text files — they can represent breakpoints, orders, telemetry, or any other domain state.


Platform layer

The Platform package contains reusable infrastructure independent from any specific application.

Component Role
Logger Structured logging
EventBus Event distribution between services and models
Buffer Reusable line-oriented data structure (no UI knowledge)
Lua Scripting and plugin host
SSH Remote access primitives
Runtime utilities Shared helpers used across applications

Platform components do not import terminal or widget packages. Today many of these live in or near termforge/ptyx; the target is a dedicated platform layer that applications and termforge both depend on.

Design decision: Buffer belongs to Platform because it is a reusable data structure with no UI knowledge. Scroll position and cursor visibility are presentation concerns — see termforge layer.


termforge layer

termforge is responsible for presentation: turning model state into terminal output and routing local input.

Component Role
Canvas Local-coordinate drawing context
Grid Off-screen cell framebuffer
Viewport Scroll window over line Buffer; cursor, selection, ANSI path
TableWidget Columnar grid over RectViewport; row selection, /search
Widget View interface (Draw, HandleEvent); panes add DrawStatusLine as NodeWidget
WidgetTree Split-tree geometry + focus
Window manager Tabs, splits, model-to-widget binding

Design decision: Viewport belongs to termforge because it manages scrolling, cursor visibility, and rendering. Buffer belongs to Platform because it holds data with no presentation logic.

Implementation today: termforge (Canvas, Grid, WidgetTree) plus scroll/view helpers still migrating from termforge/ptyx.


High-level architecture

flowchart TB
    subgraph Presentation["Presentation · termforge"]
        App["App"]
        RootLayout["Root: TabBar / Workspace / CmdLine"]
        SplitTree["Split tree · WidgetTree"]
        Widgets["Widgets: Code, GDB, Cmd, …"]
        Render["Canvas → Grid → tcell"]
    end

    subgraph Application["Application · internal/app + internal/gdbforge"]
        DebuggerApp["DebuggerApp · composition root"]
        Ctls["*Ctl · break · asm · console · …"]
        WS["Workspace · pane policy"]
        BE["backend.Backend"]
        AppState["AppState · modes"]
        HandleInt["HandleInterrupt → EventBus.Dispatch"]
    end

    subgraph Domain["Domain · internal/gdbforge + termforge/ptyx"]
        Events["platform.EventBus · typed Subscribe"]
        Models["models · breakpoints, threads, stack"]
        History["History / Autocomplete · termforge"]
        DebuggerIF["ptyx.Session / CommandSink"]
    end

    subgraph Infrastructure["Infrastructure · gdb / dlv / ptyx"]
        Client["GDBClient · dlv.Client · PTY"]
        MI["MI / Delve parse"]
    end

    App --> RootLayout --> SplitTree --> Widgets --> Render
    Widgets --> Application
    DebuggerApp --> Ctls
    DebuggerApp --> WS
    DebuggerApp --> BE
    Application --> Domain
    Domain --> Infrastructure

Source: diagrams/module_boundaries.mermaid


Main subsystems

Subsystem Package Responsibility
Services App layer (internal/app, internal/gdb, internal/dlv, …) Communicate with external systems; produce events
Event bus platform.EventBus Distribute typed messages to controller subscribers
Models internal/gdbforge/models on *Ctl Own application state; controllers push SetItems / paint
Workspace internal/app/workspace*.go Pane marks, placement, focus policy, layout apply above Tab
Window manager termforge (WidgetTree, TabWidget) Generic layout / focus / splits (no debugger roles)
Terminal application termforge.App Event loop, screen init, widget registry, redraw orchestration
Root layout termforge (planned RootLayout) Fixed TabBar, flexible Workspace band, fixed CmdLine
Split tree termforge.WidgetTree, Node Recursive pane division inside Workspace
Widget layer termforge.Widget + gdbforge/widgets Views; host intents / callbacks; no business logic
Rendering Canvas, Grid, Cell Local coordinates, border composition, terminal flush
Domain events platform.EventBus Decouple widgets from app logic; typed handlers per *Ctl
Text model (legacy) platform.Buffer, Viewport Line storage — Code/Asm/Help/FileList; list panes BP/threads/stack use TableWidget
Generic TableModel — Not yet — widgets use SetFill + typed SetItems
CmdLine helpers termforge.History, termforge.AutoCompleter Command-line UX (no tcell in API surface)
Key sequences termforge.Trie Prefix-tree matcher for multi-key bindings
App modes platform.AppState Interaction mode + PTY owner + layout policy (equalalways)
Debugger backend gdbforge/backend, ptyx, gdb / dlv, ptyx.Session Policy surface + MI/Delve PTY + inferior stdio PTY
AI / tools mcp.GdbMcpService Same-process :AI on live Session
Application shell internal/app (DebuggerApp + *Ctl) Composition root: UI, Backend, controllers, MCP; modes + HandleInterrupt

Data flow

Service → model → widget (application layer)

At the application level, state always flows downward:

Service → Event Bus → Data Model → Widget

Widgets display models. Models subscribe to application events. Services never talk to widgets directly. The GDB and terminal sections below describe the current wiring toward this target.

Input → action → redraw

gdbforge uses two parallel event planes:

Plane Type Path
Terminal tcell.Event PollEvent → App.HandleKey (mode handler table) / App.UpdateCanvas
Domain any payload Producer → App.PostInterrupt → HandleInterrupt → platform.EventBus.Dispatch

Widgets handle terminal input locally (keys, cursor). When a widget needs the application to act — submit a : command, quit, forward to GDB — it hands a payload to App.PostInterrupt, which wakes the UI thread through tcell. DebuggerApp.HandleInterrupt then dispatches it on the bus, where each *Ctl has registered a handler for the message types it cares about.

sequenceDiagram
    participant Input as Keyboard / Mouse
    participant App as termforge.App
    participant Dbg as DebuggerApp
    participant Widget as Widget · Tab / CmdWidget
    participant Bus as platform.EventBus
    participant Ctl as Ctl subscriber
    participant Render as Redraw

    Input ->> App: PollEvent · tcell.EventKey
    App ->> App: HandleKey(ev) · modeHandlers[Mode()]
    App ->> Dbg: mode handler · withGlobalKeys
    Dbg ->> Dbg: gates + key-sequence routing
    Dbg ->> Widget: HandleEvent(ev)
    Widget ->> App: PostInterrupt(SubmitMsg)
    App ->> Dbg: PollEvent · tcell.EventInterrupt
    Dbg ->> Bus: Dispatch(data)
    Bus ->> Ctl: typed handler from Register(bus)
    Ctl ->> Render: RequestFrame → Draw → Grid → Screen

Sources: diagrams/event_flow.mermaid · diagrams/event_bus.mermaid

Debugger output → UI

GDB MI output arrives on the MI PTY reader, is fan-out via Subscribe, posted as EventInterrupt(GdbOutputMsg), and parsed by consoleCtl for app state. CLI output paints via WireCLI → CompositeTerminal (not the MI bridge).

sequenceDiagram
    participant GDB as GDB process
    participant CLI as CLI PTY
    participant MI as MI PTY
    participant GDBW as GDBWidget
    participant Ctrl as consoleCtl

    GDB-->>CLI: console bytes
    CLI-->>GDBW: WireTTY → xterm
    GDB-->>MI: MI records
    MI-->>Ctrl: GdbOutputMsg → PushRaw

Source: diagrams/debugger_integration.mermaid

Layering: CompositeTerminal + WireTTY (GDB/IO/exec panes) ← *ptyx.TTY. ConsolePane + InputLine remains for Lua REPL only. Controller owns MI Session on PTY #2.

End-to-end data flow

flowchart TB
    subgraph Input["Input paths"]
        User["User keyboard / mouse"]
        Async["Async sources · GDB PTY, Lua jobs"]
    end

    subgraph Loop["App event loop · termforge"]
        Poll["PollEvent · tcell.Event"]
        KeyRoute["App.HandleKey · mode handler table"]
        Widgets["TabWidget / CmdWidget HandleEvent"]
        Interrupt["App.PostInterrupt · EventInterrupt"]
        Draw["Draw pipeline"]
        Screen["Terminal screen"]
    end

    subgraph AppLayer["Application layer · internal/app"]
        HI["DebuggerApp.HandleInterrupt"]
        Bus["platform.EventBus.Dispatch"]
        Ctls["*Ctl typed handlers"]
        Debugger["backend.Backend · GDB / Delve"]
        Model["models / platform.Buffer state"]
    end

    User --> Poll
    Poll --> KeyRoute --> Widgets
    Widgets -->|"intents via PostInterrupt"| Interrupt
    Async -->|"PostInterrupt"| Interrupt
    Interrupt --> Poll
    Poll --> HI
    HI --> Bus --> Ctls
    Ctls --> Debugger
    Debugger --> Model
    Ctls --> Model
    Model --> Widgets
    Widgets --> Draw
    Draw --> Screen

Source: diagrams/data_flow.mermaid

Design decision: domain events do not fan out to widgets directly. Async producers never touch a widget; they call PostInterrupt, and the payload reaches the UI thread as one tcell.EventInterrupt. DebuggerApp.HandleInterrupt is the only place that unwraps it, and from there platform.EventBus (Subscribe / Publish) routes by message type so producers and consumers wire without constructor injection:

Message Publisher Subscriber
CompletionMsg CmdWidget (Tab) completionCtl → its CompletionView
BreakpointsChangedMsg onBreakpointsChanged (MI / MCP / :e) DebuggerApp.onBreakpointsChangedMsg → coalesced -break-list

Breakpoint sync details: DEBUGGER_INTEGRATION.md.

Terminal input routing (modes, trie, widget dispatch) is also centralized in DebuggerApp, keeping App a generic event loop and draw orchestrator.


Design principles

These principles are non-negotiable for gdbforge. They explain many seemingly verbose abstractions (Canvas, WidgetTree, Grid).

# Principle Rationale
1 Widgets should not know screen coordinates Enables layout changes without touching widget code
2 Widgets draw only inside their assigned Rect Prevents bleed-over; simplifies testing
3 Canvas provides local drawing coordinates Single translation point from local to screen space
4 Layout engine owns positioning Centralizes split ratios, resize, and border gutters
5 Rendering backend should be replaceable Grid → tcell today; could swap to alternate terminal libs
6 Business logic lives in models; widgets are views Services update models via events; widgets never call services
7 Widgets depend on generic model interfaces Same GraphWidget works across applications; models stay app-specific
8 TabBar, CmdLine, Workspace are top-level Split tree stays scoped to Workspace only
9 Only Workspace contains the split tree TabBar/CmdLine never participate in recursive splits
10 Debugger backends must not import UI Keeps GDB/OpenOCD/JTAG testable without a terminal
11 Buffer is Platform; Viewport is termforge Data storage vs scroll/cursor/rendering concerns stay separated

Layer responsibilities

The Platform layer and termforge layer sections above define the reusable infrastructure vs presentation split. The subsections below map those roles onto today's packages and wiring.

Services

  • Communicate with external systems (backend.Backend → GDB/Delve, IBKRClient, MSPV2Client, SSHClient, …).
  • Publish events on the event bus; never import UI packages; never talk to widgets directly.
  • Example: internal/gdbforge/backend wraps gdb.GDBClient / dlv.Client — PTY I/O, MI2 / Delve parse.

Event bus

Two mechanisms, both UI-thread only for widget mutation:

Mechanism Use API
PostInterrupt Cross-thread wakeups (GDB PTY, Lua, exec) App.PostInterrupt → HandleInterrupt → EventBus.Dispatch
EventBus Typed pub/sub between controllers platform.Subscribe, UIComponent.Register

Controllers subscribe in registerUIComponents(); the app shell no longer switches on every message type.

Models

  • Own application state for a domain concern (breakpoints, source, console output, …).
  • Application-specific types (BreakpointList, AssemblyList, …) held by *Ctl controllers on DebugSession.
  • Exist for the application lifetime; independent of widget lifetime.
  • Controllers sync views via SetItems / paint APIs after EventBus handlers run.

Widgets

  • Display models; list panes use widget host interfaces (BreakpointHost, …); consoles use SetOn*.
  • Never own business logic; never communicate directly with Backend.
  • Rendering style is decided by the widget, not the model.

Window manager

  • Manages layout (split tree, tabs).
  • termforge.WidgetTree, TabWidget — generic geometry and focus.
  • LayoutShell — gdbforge pane marks, sticky GDB, :layout apply (workspace*.go).

Presentation (termforge)

  • tcell.Screen lifecycle, Canvas, Grid, WidgetTree, poll/draw loop.
  • Must not parse GDB MI — delegates to app/controllers + internal/gdb.

Application (internal/app + internal/gdbforge)

  • Declares available models and services at startup.
  • DebuggerApp embeds termforge.App, LayoutShell, and DebugSession, implements AppApi and all host interfaces:
  • HandleInterrupt — thin dispatch: string session exits + EventBus.Dispatch.
  • AppState — mode, PTY owner, layout policy.
  • keyBindings — multi-key chords.
  • LayoutShell — pane policy over TabWidget; layoutHost for decoupling.
  • DebugSession — backend, debug widgets, debug *Ctl group; init/close lifecycle.
  • Cross-cutting — lua, search, serial, exec, cmdWidget, completion bar.
  • gdbMcp — MCP peer on app.GDB().
  • Defines app-specific command tree (colon commands via CommandParser).

Domain (termforge/ptyx + termforge event types)

See Platform layer. Today termforge/ptyx holds platform primitives migrating toward a dedicated platform package:

  • termforge.Event bus types — Event, CommandEvent, SubmitMsg (termforge/event.go, command.go).
  • core PTY events — PtyOutputMsg (termforge/ptyx/events.go); GdbOutputMsg in internal/gdbforge/events.
  • CommandID — infra constant CmdUnknown in termforge; app-specific command IDs live in internal/app.
  • Buffer — line-oriented storage (Platform; no UI knowledge).
  • History, AutoCompleter for command-line UX (termforge).
  • Debugger / Session / PTYWriter — send API, exclusive write, shared Subscribe.

Infrastructure (termforge/ptyx, internal/gdb, internal/mcp)

  • ptyx.TTY — unified PTY type: Start (process), Open (pair), AttachPath (external slave path). Exclusive WithWrite, Subscribe fan-out, SetSize, Close.
  • gdb.GDBClient — 3 PTYs: CLI (CLITTY), MI (ptyx.Session embed), inferior; bootstrap via new-ui mi2.
  • termforge.CompositeTerminal + WireTTY — xterm bridge for GDB/IO/exec panes.
  • mcp.GdbMcpService — GdbCommand + in-app LLM agent on MI ptyx.Session.
  • MI parsing: MiMsg, GdbInputState in internal/gdb (MI PTY stream only).

Dependency rule: termforge → core ← gdb / ptyx / mcp. Never gdb → termforge.


Core events layer

The UI thread owns all widget mutation. Async producers post EventInterrupt payloads; the app dispatches typed data on platform.EventBus to controllers that registered via Register.

flowchart TB
    subgraph Producers["Producers (any goroutine)"]
        PTY["GDB / inferior PTY readers"]
        CmdW["CmdWidget · PostInterrupt"]
        Lua["Lua worker · PostInterrupt"]
        Exec["Exec PTY"]
    end

    subgraph Loop["App.Run · UI thread"]
        Poll["pollEventBatch · PollEvent"]
        Batch["handleUIEventBatch"]
        HI["DebuggerApp.HandleInterrupt"]
        Bus["platform.EventBus.Dispatch"]
    end

    subgraph Controllers["*Ctl handlers"]
        Console["consoleCtl.onOutput"]
        Breaks["breakCtl …"]
        LuaCtl["luaCtl.onUIMsg"]
        Other["asm · debugInfo · dlv · execIO · cmd …"]
    end

    PTY -->|"PostInterrupt(GdbOutputMsg)"| Poll
    CmdW --> Poll
    Lua --> Poll
    Exec --> Poll
    Poll --> Batch
    Batch -->|"EventInterrupt"| HI
    HI -->|"string exits"| HI
    HI --> Bus
    Bus --> Console
    Bus --> Breaks
    Bus --> LuaCtl
    Bus --> Other

Design decisions:

  • Domain reactions live on controllers, not in a giant switch on DebuggerApp.
  • EventBus is for typed app notifications that can also be published synchronously (e.g. BreakpointsChangedMsg, CompletionMsg) — see COMMAND_SYSTEM.md.
  • PostInterrupt is for cross-thread wakeups into the tcell loop (GDB chunks, Lua UI jobs, exec output). Workers call PostEvent(EventInterrupt); the UI thread receives them through the same PollEvent batch as keyboard input.

GDB output sequence (MI path only — CLI paints via WireCLI):

sequenceDiagram
    participant GDB as GDB MI PTY
    participant Post as PostInterrupt
    participant Screen as tcell.Screen
    participant Poll as PollEvent
    participant HI as HandleInterrupt
    participant Bus as EventBus
    participant Ctrl as consoleCtl

    GDB-->>Post: GdbOutputMsg
    Post->>Screen: PostEvent(EventInterrupt)
    Screen->>Poll: PollEvent · EventInterrupt
    Poll->>HI: HandleInterrupt
    HI->>Bus: Dispatch(GdbOutputMsg)
    Bus->>Ctrl: onOutput → PushRaw → app state

Mode and key-sequence routing happen in DebuggerApp.HandleKey before widgets see terminal keys — see INPUT.md.

Legacy: HandleCoreEvents

Older revisions routed all domain events through HandleCoreEvents on a termforge.Event channel. That hub is removed. New code should use PostInterrupt (async → UI thread) and EventBus.Register / Subscribe (controller handlers).

Interfaces and types

classDiagram
    direction TB

    class Event {
        <<interface>>
        +Type() string
    }

    class CommandEvent {
        <<interface>>
        +CommandID() CommandID
    }

    class SubmitMsg {
        +Text string
        +CmdID CommandID
        +Args string
    }

    class PtyOutputMsg {
        +Data string
        +Err error
    }

    class GdbOutputMsg {
        +Data string
        +Err error
    }

    class Quit {
        +Text string
    }

    Event <|.. SubmitMsg
    Event <|.. PtyOutputMsg
    Event <|.. GdbOutputMsg
    Event <|.. Quit
    CommandEvent <|.. SubmitMsg
Type Purpose
Event Base domain event — identified by Type() string
CommandEvent Events carrying a resolved CommandID (e.g. after : command entry)
SubmitMsg CmdLine submitted — Text, CmdID, Args
PtyOutputMsg Raw PTY chunk from Session.Subscribe (GDB MI, MCP)
GdbOutputMsg MI PTY chunk routed to consoleCtl (EventInterrupt → parser)

Command IDs and colon commands

Colon commands use a hierarchical command tree (termforge/commands). See COMMAND_SYSTEM.md for ownership (CommandNode / CommandRegistry / CommandParser), the DSL, and tab completion.

Layer Owns
commands.CommandNode Tree nodes — Name, Children, Action
commands.CommandRegistry Root tree + key-binding trie
commands.CommandParser Runtime cursor — current, token, path
termforge.CmdWidget : input + parser for Tab sync; SetOnExecute → app runs ExecuteParsed()

Legacy termforge.CommandID / SubmitMsg remain for infra events (CmdExitMode, CmdUnknown). Tree leaf commands execute via app-owned ExecuteParsed() → CommandNode.Action.

Wiring (current)

// internal/app/setup.go
a.commandReg = commands.NewCommandRegistry()
a.ExapData()  // internal/app/command_tree.go

a.cmdWidget = termforge.NewCmdWidget(a.commandReg)
a.cmdWidget.Ctx = a.ctx
bar := termforge.NewCompletionBarWidget(a.ctx)          // or CompletionPopupWidget
a.comp.attach(&termforge.CompletionMenu{}, bar)         // ctl subscribes to CompletionMsg

Implementation: termforge/commands/, termforge/cmd_widget.go, termforge/platform/event_bus.go, internal/app/.


Current vs target architecture

The debugger app follows MVC today (see MVC (current)). Remaining gaps are mostly generic framework polish, not session-in-widget ownership.

Component Target Current state
Application models Explicit model per domain; created at startup Done — on *Ctl (BP / threads / call stack / assembly / buffers); AppState for files/location
Generic model interfaces Widgets bind via TextModel, GraphModel, TableModel, … Not yet — widgets use concrete DTOs / host ifaces
Model → widget binding :buffer activates widget for existing model Partial — builtins + :b / :e; models on controllers
Backend → controller → model → view Debugger events update models; widgets paint snapshots Done — backend.Backend + *Ctl; views are hosts / Set*
Composition root Thin app + embedded layers Done — LayoutShell + DebugSession + host adapters
Platform layer Buffer, EventBus, Logger in platform package Partial — platform.EventBus + PostInterrupt in use
Viewport ownership Viewport in termforge; Buffer in Platform Partial — tabular lists migrated to TableWidget; Code/Help/FileList still Viewport
TableWidget Columnar lists off Viewport Done — termforge/table_*.go; BP/threads/callstack adapters
Root layout Tab fills the screen; CmdLine pinned in the tree; wildmenu floating Flat WidgetsList for chrome placement; PinBottom for the cmdline
TabBar Multi-tab with header render TabWidget — single tab, no header
LayoutShell Split tree + pane policy Done — embedded; was Workspace
CmdLine Global : command input CmdWidget; Execute via app (SetOnExecute)
Event bus PostInterrupt → EventBus → *Ctl Done — HandleCoreEvents removed
Key chords Configurable multi-key sequences Trie on DebuggerApp; Ctrl+W focus chords
Interaction modes Mode + Activity + Confirm mini-machines Done — Mode via AppState; Activity activity.go; Confirm confirm_router.go
Rendering Diff-based grid flush Partial — BackCells diff in Grid.Draw
Focus Mode-aware routing WidgetTree.focus + trie focus movement
Split commands :vs, :split Partial — colon tree + LayoutShell
Debugger App-owned Session via Backend; MCP peer; view-only consoles Working — GDB + Delve (-g); DEBUGGER_INTEGRATION.md

Entry point: cmd/gdbforge/main.go, which wires argv to internal/app (app.go, setup.go, …).

Detailed tracker: ROADMAP.md.


Topic Document
Widgets, canvas, grid termforge: UI Architecture
Splits, tabs, command line WINDOW_MANAGEMENT.md
Cells, borders, Unicode termforge: Rendering
Keyboard, modes INPUT.md
Command tree, DSL, parser COMMAND_SYSTEM.md
PTY master/slave, IO, external tty PTY_ARCHITECTURE.md
GDB MI2 / Delve details DEBUGGER_INTEGRATION.md
Package map DIRECTORY_STRUCTURE.md