Developer Guide¶
Audience: engineers onboarding to gdbforge, code reviewers, and contributors implementing UI or debugger features.
Companion docs: README.md · ARCHITECTURE.md · DIRECTORY_STRUCTURE.md
Table of contents¶
- How to read the codebase
- Glossary
- Development environment
- Application lifecycle
- Adding a widget
- Layout and splits
- Rendering rules
- GDB output path
- Threading rules
- Debugging with Delve
- Troubleshooting
- How to extend
- File index by feature
How to read the codebase¶
30-minute path (orientation)¶
| Order | File | Why |
|---|---|---|
| 1 | cmd/gdbforge/main.go → internal/app/app.go → setup.go |
Entry + app wiring |
| 2 | termforge/term_app.go |
Event loop, grids, draw flush |
| 3 | termforge/widget.go |
Widget contract |
| 4 | termforge/widget_tree.go, layout_tree.go |
Split layout |
| 5 | termforge/canvas.go |
Drawing abstraction |
| 6 | termforge/grid.go, cell.go |
Border composition |
| 7 | termforge/input_line.go, console_pane.go |
Shared REPL editor + transcript |
| 8 | internal/gdbforge/widgets/gdb_widget.go |
GDB console view (paint + callbacks) |
| 9 | internal/app/gdb_console.go |
GDB controller (owns Session / MI) |
| 10 | internal/gdb/gdb_client.go |
PTY backend |
| 11 | docs/ARCHITECTURE.md |
Big picture (MVC) |
Half-day path (implement a feature)¶
Add: termforge/widget_tree.go, node.go, tab.go, cmd_widget.go, internal/gdb/mi_msg.go, termforge/platform/buffer.go, termforge/ptyx/events.go, and skim docs/diagrams/*.mermaid.
Mental model¶
Application startup
├── DebugSession.init backend, gdbWidget, debug *Ctl models
├── LayoutShell TabWidget, pane marks, focus policy
├── App PollEvent / PostInterrupt / draw
├── EventBus *Ctl Register handlers (UI thread)
└── Cross-cutting lua, search, serial, exec, cmdline
DebuggerApp (composition root)
├── embeds App + LayoutShell + DebugSession
├── initControllers() each *Ctl.host = a
├── HandleInterrupt thin: string exits + Bus.Dispatch
├── HandleKey modes, trie, widget dispatch
└── host adapters one-liner forwards for *Host interfaces
Controller (*Ctl)
├── owns model (BreakpointList, …)
├── Register(EventBus) Subscribe typed handlers
└── calls host.Backend() / host.RequestFrame() — not *DebuggerApp internals
LayoutShell
├── TabWidget, leaf marks (code/gdb/asm/last)
└── layoutHost decoupled from full app
Widget (view)
├── Draw / HandleEvent
└── host intents / SetOn* → app → *Ctl
Async path:
PTY reader → PostInterrupt(msg) → EventInterrupt → PollEvent → HandleInterrupt → EventBus → *Ctl → paint
Rules:
- Models live on
DebugSessioncontrollers; widgets are views. - Controllers use
hostinterfaces — never embed*DebuggerApp. - High-rate PTY output →
PostInterrupt→ controller handler → paint API. - Pane policy belongs in
LayoutShell, notTabWidget. HandleCoreEventsis legacy — usePostInterrupt+EventBus.
Glossary¶
| Term | Meaning |
|---|---|
| Composition root | DebuggerApp — embeds LayoutShell + DebugSession; wires hosts and modes |
| LayoutShell | Pane policy over TabWidget (marks, focus, :layout); was Workspace |
| DebugSession | Backend, GDB widgets, debug *Ctl group (debug_session.go) |
Controller (*Ctl) |
Domain owner — host XxxHost, Register on EventBus |
| Controller host | Narrow iface (breakHost, luaHost, …) — ctl dependency surface |
| Widget host | List widget intents (BreakpointHost, …); app forwards to *Ctl |
| Model | Domain state on *Ctl / internal/gdbforge/models (e.g. BreakpointList) |
| Widget | View — HandleEvent, Draw (panes also DrawStatusLine); host intents / callbacks only; no Send |
| Backend | gdbforge/backend.Backend — semantic debugger ops + capability flags; GDBBackend / DLVBackend |
| ConsoleUpdate | debugger.ConsoleUpdate — unified console/stop delta from PushConsoleOutput |
| StopInfo | debugger.StopInfo — normalized stop event for the stop pipeline |
| Service | External-system adapter (ptyx / GDBClient / dlv.Client / GdbMcpService); never imports UI |
| Session | ptyx.Session — Send, Close, Subscribe, WithWrite; via app.GDB(); MCP/AI external API |
| PTY mux | Exclusive write lock + fan-out reads on one ptmx |
| Window manager | Split tree, tabs, :buffer binding — creates/destroys widgets, binds to models |
| Canvas | Local drawing context for a Rect |
| Grid | Off-screen [][]Cell framebuffer |
| Node | Split tree node (leaf or split) |
| WidgetTree | Split tree + focus + geometry (BuildLayout) |
| Workspace / LayoutShell | Middle chrome band; LayoutShell = pane-policy layer (workspace*.go) |
| CmdLine | Top-level : command input band |
| Event bus | PostInterrupt → HandleInterrupt → platform.EventBus → *Ctl |
| CommandID | Int token; termforge.CmdUnknown in infra; app IDs private |
| AppState | platform.AppState — Mode, PTYOwner (ui/mcp/app), EqualAlways |
| Trie | Prefix tree for multi-key bindings (<C-w>h, …) |
| SubmitMsg | CmdLine submitted — carries CmdID, Args, full Text |
| MI2 | GDB machine interface v2 |
| MiMsg | Parsed batch of MI lines (helper / tests) |
| MiUpdate | Streaming display update from GdbInputState.PushRaw |
| GdbInputState | Newline splitter; streams complete MI lines (no debounce timer) |
| BreakGutter | Per-line/addr BP view (Numbers, Enabled, Condition) for Code/Asm |
| autoAsm | Swap location leaf to Assembly when source is missing; reclaim Code when it returns |
| InputLine | Shared readline editor (text, cursor, history) |
| ConsolePane | Lua REPL shell (scrollback + walking prompt) — not GDB/IO/exec |
| CompositeTerminal | xterm emulator + WireTTY for GDB / IO / exec panes |
Development environment¶
Requirements¶
- Go 1.25+ (see
go.mod) - GDB installed (for
GDBWidgetprototype) - UTF-8 terminal
- Optional: Delve for Go debugging
Build¶
task build # all cmd/* binaries → bin/
go build ./... # compile check only
go test ./... # run tests
View docs locally¶
See HOSTING.md.
Run gdbforge prototype¶
Application lifecycle¶
sequenceDiagram
participant Main
participant App as App
participant Screen as tcell.Screen
Main->>App: NewApp()
App->>Screen: Init, EnableMouse
Main->>App: InitB · AddWidget / AddRowWidget
loop until exit
App->>Screen: pollEventBatch (PollEvent)
App->>App: handleUIEventBatch
alt EventInterrupt
App->>App: HandleInterrupt → EventBus
else EventKey / Mouse / Resize
App->>App: HandleEvent → HandleKey / UpdateCanvas
end
App->>App: present when dirty
end
App->>Screen: Fini
| Phase | Code | Side effects |
|---|---|---|
| Init | NewApp |
Opens screen, enables mouse |
| Canvas setup | UpdateCanvas |
Allocates grids at terminal size, rebuilds chrome rects |
| Register widgets | AddWidget / AddRowWidget |
Appends to the WidgetsList with its placement |
| Layout | WidgetsList.BuildLayout per frame |
Tab fills the screen; cmdline is a pinned tree leaf (H-1); wildmenu floats on H-2 |
| Run | Run |
Blocks until Ctrl+D |
| Close | Close / defer |
Restores terminal |
Adding a widget¶
Widgets are views. Before adding a widget, ensure the corresponding model exists and is updated by services via the event bus.
-
Define or use an application model that holds the pane's state.
-
Create
internal/gdbforge/widgets/my_widget.go(ortermforge/for generic widgets):
type MyWidget struct {
termforge.BaseWidget
/* state */
}
func NewMyWidget() *MyWidget {
w := &MyWidget{
BaseWidget: termforge.BaseWidget{PaneName: "MyPane"},
}
return w
}
func (w *MyWidget) HandleEvent(ev tcell.Event) { /* ... */ }
func (w *MyWidget) Draw(c Canvas) { /* draw within rows 0..c.H()-1 */ }
// DrawStatusLine inherited from BaseWidget; override for custom status text
Set PaneName for the per-pane status bar label shown when this pane has focus. Do not draw on row c.H() inside Draw — the layout system owns that row.
- Register via the window manager when the user displays the model:
// :buffer mymodel → window manager creates widget bound to existing MyModel
layout.NewSplit(Vertical, NewMyWidget(myModel))
- Wire the command line with a
CommandRegistry(completions use the app event bus):
a.cmdWidget = termforge.NewCmdWidget(a.commandReg)
a.cmdWidget.Ctx = a.ctx
a.cmdWidget.SetPostInterrupt(a.PostInterrupt)
bar := termforge.NewCompletionBarWidget(a.ctx) // the wildmenu CompletionView
// initBuiltins also: platform.Subscribe(ctx.Bus, a.onBreakpointsChangedMsg)
-
Build the command tree with the DSL in
ExapData()(internal/app/command_tree.go) — see COMMAND_SYSTEM.md. -
Subscribe a controller to the messages it cares about, one typed handler each:
func (c *myCtl) Register(bus *platform.EventBus) {
platform.Subscribe(bus, c.onSubmit) // func(termforge.SubmitMsg)
platform.Subscribe(bus, c.onRefresh) // func(myRefreshMsg)
}
- Bind key chords in
InitKeyBindings():
a.keyBindings.Bind(
commands.NewCommand("move-left", func(args ...any) { a.OnFocusLeft() }),
"<C-w>l", "<C-w><Left>",
)
Rules:
- Never call
screen.SetContentwith absolute coordinates — useCanvas. - Never set your own position — layout assigns
Canvas. - Keep service/process logic out of the widget — widgets read models; services update models via events.
- Never call services from widget code.
Layout and splits¶
tree := NewWidgetTree(initialWidget)
tree.Split(Vertical, rightWidget) // left | right
tree.Split(Horizontal, bottomWidget) // top / bottom (on focused pane)
Split focused pane:
First= original widgetSecond= new widgetRatio= 0.5
TabWidget.Draw builds then paints the active tree:
tree.BuildLayout(c) // assign rects, draw borders
tree.Draw(c) // widgets → clear status rows → redraw grid → status lines
See WINDOW_MANAGEMENT.md.
Rendering rules¶
- Borders — only layout engine draws split separators (into Grid).
- Widget content — draw inside local
(0,0)..(W-1,H-1)viaCanvasmethods (all route through Grid). - Unicode — use
DrawANSITextfor strings;SetContentfor single runes. - Clipping — check
col < c.W()before drawing.
Incremental diff rendering uses BackCells in Grid.Draw. See termforge: Rendering.
GDB output path¶
Two paths after the 3-PTY refactor:
- CLI console (
:b gdb) — PTY #1 bytes →WireCLI→CompositeTerminal(xterm paint). No MI parser. - MI control (PTY #2) —
Subscribe→GdbOutputMsg→consoleCtl→GdbInputState.PushRaw→ app state (breakpoints, threads, etc.).
flowchart LR
CLI["CLI PTY #1"]
MI["MI PTY #2"]
Wire["WireCLI"]
Term["CompositeTerminal"]
Bridge["GdbOutputMsg bridge"]
State["GdbInputState.PushRaw"]
CLI --> Wire --> Term
MI --> Bridge --> State
Do not read from a PTY in Draw. Do not call widget methods from reader or bridge goroutines — only PostEvent.
PushRaw streams complete MI lines (MiUpdate); incomplete lines stay in lineBuf until the next chunk. GDB pane keys are raw tty bytes via CompositeTerminal.HandleKey; the app controller owns MI Session.Send on PTY #2.
In-app AI: :AI … → GdbMcpService.Ask → GdbCommand (write lock + capture). See DEBUGGER_INTEGRATION.md.
Threading rules¶
| Thread | May do |
|---|---|
| Main / tcell loop | HandleEvent, Draw, SetContent, Grid, PushRaw / buffer updates |
| ptyx reader goroutine | Read GDB / exec / inferior PTY, broadcast to subscribers |
| :AI goroutine | HTTP to LLM; GdbCommand / WithWrite on Session |
| Bridge goroutine | range channel → PostEvent only |
Never: call Draw or screen.SetContent from a background goroutine.
Debugging with Delve¶
Debug the gdbforge prototype:
dlv debug ./cmd/gdbforge --headless --listen=:2346 --api-version=2
# separate terminal:
dlv connect :2346
Note: debugging a tcell app requires running in a real terminal for screen I/O, or accepting that screen calls may fail under Delve without PTY.
Debug the docs server:
Troubleshooting¶
| Problem | Likely cause | Fix |
|---|---|---|
| Blank screen | Forgot UpdateCanvas before draw |
Call after init and on resize |
| Garbled borders | Nested splits without grid | Check BuildLayout before Draw |
| GDB hangs | Target binary missing | Build hello or fix gdb_client.go target |
| No GDB output | Reader goroutine exited | Check channel close / PTY errors |
| Keys affect all widgets | Normal mode forwards to tab after trie | Expected until focus mode is wired |
| Cmd line invisible | Wrong band order or height | Check the AddWidget / AddRowWidget order in setup.go |
| Mermaid not rendering in docs | CDN blocked | Check network; view raw .md |
| Port 8765 in use | Previous docserve running | fuser -k 8765/tcp or --port 8766 |
How to extend¶
| Task | Start here |
|---|---|
| New application model | App startup in internal/app; subscribe to event bus |
| New debugger pane | Model + widget pair; register builtin in initBuiltins or open via :e / layout |
| New service / backend | Implement ptyx.Session (or wrap ptyx), new internal/<backend>/ |
New : command |
Add Cmd / Group / LeafRest in command_tree.go; implement action in actions.go — COMMAND_SYSTEM.md |
:! / Exec pane |
EXEC_SHELL.md |
| New key chord | InitKeyBindings() → keyBindings.Bind(...) |
| Tab switching | Extend tab.go, draw header in TabWidget.Draw |
| Diff rendering | Add backBuffer, per-frame clear; extend BackCells diff |
| Focus mode | Wire ModeFocus in HandleKey, suppress tab dispatch |
Always update docs when changing architecture-visible behavior.
File index by feature¶
| Feature | Files |
|---|---|
| Event loop + bus | termforge/app.go, termforge/platform/event_bus.go |
| App API / dispatch | termforge/app.go (AppApi), internal/app/app.go + input.go |
| Interaction modes | termforge/platform/mode.go (via App / AppState) — includes ModeSearch |
| Key-sequence bindings | termforge/commands + internal/app/keybindings.go |
| Widget interface | widget.go |
| Per-pane status line | status_line.go, base_widget.go |
| Split tree | node.go, layout_tree.go, widget_tree.go, tab.go |
| Drawing | canvas.go, grid.go, cell.go, rect.go, utf.go |
| Tabs | tab.go |
| Command tree / parser / DSL | termforge/commands/ — COMMAND_SYSTEM.md |
| Command / search line | cmd_widget.go (CmdKindCommand / CmdKindSearch), history.go; completions via CompletionMsg + completion_bar.go |
Viewport / search |
viewport_search.go, SearchHost; wired in internal/app/search.go — INPUT.md |
| TableWidget lists | table_widget.go, table_search.go; BP/threads/callstack embed; /search via SearchHost |
| Table paint stack | rect_viewport.go, cell_buffer.go, table.go, table_paint.go |
| Breakpoint sync | stopped.go — Publish/Subscribe BreakpointsChangedMsg; DEBUGGER_INTEGRATION.md |
| Breakpoint YAML | persist/ + saveBreakpointsOnQuit / restoreSavedBreakpoints; breakpoint persistence |
| Debugger panes | termforge/input_line.go, console_pane.go; widgets/gdb_widget.go + internal/app/gdb_console.go; logger_widget.go |
| Shared models | internal/gdbforge/models/; sync in breakpoints.go, debug_info.go |
| GDB backend | gdb/gdb_client.go, gdb/mi*.go |
| Text model | core/buffer.go, core/viewport.go |
| UI events / commands | termforge/event.go, termforge/command.go |
| Debugger events | core/events.go |
| Entry point | cmd/gdbforge/main.go → internal/app (app.go + companions) |
| Docs server | cmd/docserve/main.go |
Related documentation¶
- COMMAND_SYSTEM.md — command tree, DSL, parser, tab completion
- EXEC_SHELL.md —
:!exec panes, rest-args, Ctrl-O - termforge: UI Architecture — deep UI dive
- DEBUGGER_INTEGRATION.md — GDB MI2 details
- ROADMAP.md — what's planned
- CONTRIBUTING.md — commit conventions