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
PostInterrupt(payload)— thread-safe enqueue viascreen.PostEvent(EventInterrupt)(replaces the oldevents chan+HandleCoreEventsswitch and the removeduiEventschannel).HandleInterrupt— thin app shell: string session exits +Bus.Dispatch(data).Registeron 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¶
- Built on termforge
- MVC (current)
- What
DebugDomainmeans (naming) - Controllers and host interfaces
- Composition layers (LayoutShell · DebugSession)
- UI event path (why refactoring was possible)
- Orthogonal input mini-machines
- System context
- Application framework
- Startup
- Services
- Application data flow
- Application models
- Widget philosophy
- Generic widgets
- Buffer concept
- Why not :attach
- Design philosophy
- Platform layer
- termforge layer
- High-level architecture
- Main subsystems
- Data flow
- Design principles
- Layer responsibilities
- Core events layer
- Current vs target architecture
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:
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 |
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:
MSP application examples:
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:
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:
This framework:
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:
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/backendwrapsgdb.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*Ctlcontrollers onDebugSession. - Exist for the application lifetime; independent of widget lifetime.
- Controllers sync views via
SetItems/ paint APIs afterEventBushandlers run.
Widgets¶
- Display models; list panes use widget host interfaces (
BreakpointHost, …); consoles useSetOn*. - 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,:layoutapply (workspace*.go).
Presentation (termforge)¶
tcell.Screenlifecycle, 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.
DebuggerAppembedstermforge.App,LayoutShell, andDebugSession, implementsAppApiand all host interfaces:HandleInterrupt— thin dispatch: string session exits +EventBus.Dispatch.AppState— mode, PTY owner, layout policy.keyBindings— multi-key chords.LayoutShell— pane policy overTabWidget;layoutHostfor decoupling.DebugSession—backend, debug widgets, debug*Ctlgroup;init/closelifecycle.- Cross-cutting —
lua,search,serial,exec,cmdWidget, completion bar. gdbMcp— MCP peer onapp.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.Eventbus types —Event,CommandEvent,SubmitMsg(termforge/event.go,command.go).corePTY events —PtyOutputMsg(termforge/ptyx/events.go);GdbOutputMsgininternal/gdbforge/events.CommandID— infra constantCmdUnknownintermforge; app-specific command IDs live ininternal/app.Buffer— line-oriented storage (Platform; no UI knowledge).History,AutoCompleterfor 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). ExclusiveWithWrite,Subscribefan-out,SetSize,Close.gdb.GDBClient— 3 PTYs: CLI (CLITTY), MI (ptyx.Sessionembed), inferior; bootstrap vianew-ui mi2.termforge.CompositeTerminal+WireTTY— xterm bridge for GDB/IO/exec panes.mcp.GdbMcpService—GdbCommand+ in-app LLM agent on MIptyx.Session.- MI parsing:
MiMsg,GdbInputStateininternal/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
switchonDebuggerApp. EventBusis for typed app notifications that can also be published synchronously (e.g.BreakpointsChangedMsg,CompletionMsg) — see COMMAND_SYSTEM.md.PostInterruptis for cross-thread wakeups into the tcell loop (GDB chunks, Lua UI jobs, exec output). Workers callPostEvent(EventInterrupt); the UI thread receives them through the samePollEventbatch 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.
Related documentation¶
| 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 |