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 → app.go → setup.go |
Entry + app wiring |
| 2 | internal/termui/term_app.go |
Event loop, grids, draw flush |
| 3 | internal/termui/widget.go |
Widget contract |
| 4 | internal/termui/widget_tree.go, layout_tree.go |
Split layout |
| 5 | internal/termui/canvas.go |
Drawing abstraction |
| 6 | internal/termui/grid.go, cell.go |
Border composition |
| 7 | internal/termui/input_line.go, console_pane.go |
Shared REPL editor + transcript |
| 8 | internal/gdbforge/widgets/gdb_widget.go |
GDB console view (paint + callbacks) |
| 9 | cmd/gdbforge/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: internal/termui/widget_tree.go, node.go, tab.go, cmd_widget.go, internal/gdb/mi_msg.go, internal/core/buffer.go, internal/core/events.go, and skim docs/diagrams/*.mermaid.
Mental model¶
Application startup
├── Backend (backend.NewGDB / NewDLV) — GDB vs Delve policy
├── Services (ptyx / Session / GdbMcpService, …) — talk to external systems
├── Event bus (termui.Event channel)
├── Controllers (*Ctl) — own models (BreakpointList, ThreadList, …)
├── Logger
└── Runtime (TermApp, Workspace → TabWidget)
User displays a model (:buffer, :split)
└── Window manager creates Widget → binds to existing Model (via host / SetItems)
TermApp
├── AppApi (implemented by DebuggerApp)
│ ├── HandleKey(*tcell.EventKey) — mode router, trie, widget dispatch
│ ├── HandleResize() — top-level widget rects
│ └── HandleCoreEvents(termui.Event) — ALL domain events land here
├── screen tcell.Screen — poll, lifecycle, Show (owned by TermApp)
├── events chan termui.Event — bus; services and widgets may publish
├── DebuggerApp fields
│ ├── backend Backend — GDB / Delve
│ ├── breaks / asm / bufs / … — *Ctl domain owners
│ ├── ws *Workspace — pane policy over TabWidget
│ └── cmdWidget *CmdWidget
├── top-level widgets (tab + cmd line)
│ └── CmdWidget.Events → events channel
├── frontBuffer (*Grid) — shared draw target + BackCells diff
└── select loop: drain bus OR PollEvent → draw → flush
Widget (view)
├── displays Model state
├── HandleEvent(tcell.Event) — local keys / mouse
├── Draw(Canvas)
└── host intents / SetOn* — when app-level action needed (not service calls)
Data flow (target):
Service → Event Bus → Model → Widget
GDB/Delve path (MVC):
Backend PTY reader → Subscribe → EventInterrupt → *Ctl (consoleCtl / …) → GDBWidget paint
Inferior TTY → Subscribe → InferiorOutputMsg → inferiorIOCtl → OutputWidget.AppendInferior
Domain lists → *Ctl models → SetItems on Thread / CallStack / Breakpoint / Asm views
GdbMcpService / :AI → same Session (WithWrite exclusive, Subscribe shared)
Rules:
- Models are created at startup on controllers; widgets are views (often singleton builtins).
- Widgets never call services directly — controllers own
Send/ Query; models hold domain state. - High-rate service output → controller → paint APIs on the view.
- Domain actions → host interface methods or
SetOn*— not widget business logic. - App command IDs are private to the application package; only
termui.CmdUnknownlives in infra. - Mode and key-sequence routing belong in
DebuggerApp, not inTermAppor individual widgets. - Pane policy (marks, sticky GDB, layout apply) belongs in
Workspace, notTabWidget. - Console editing/layout is shared (
InputLine/ConsolePane); debugger backends only supply protocol glue. - PTY writes are exclusive (
WithWrite); all subscribers see output. Do not spawn a second GDB for AI. - Prefer
Backendmethods over newisDLV()branches.
Glossary¶
| Term | Meaning |
|---|---|
| Composition root | DebuggerApp — wires Backend, *Ctl, Workspace, chrome; orchestration only |
Controller (*Ctl) |
Domain owner (breakCtl, consoleCtl, …) — intents, refresh, SetItems |
| Host interface | Narrow iface widgets/ctls call (BreakpointHost, breakHost, …); app implements |
| Model | Domain state on *Ctl / internal/gdbforge/models (e.g. BreakpointList) |
| Widget | View — HandleEvent, Draw, DrawStatusLine; host intents / callbacks only; no Send |
| Backend | gdbforge/backend.Backend — GDB vs Delve policy surface |
| Service | External-system adapter (ptyx / GDBClient / dlv.Client / GdbMcpService); never imports UI |
| Session | core.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 | (1) Middle chrome band; (2) cmd/gdbforge.Workspace pane-policy layer over Tab |
| CmdLine | Top-level : command input band |
| Event bus | TermApp.events channel; all events → HandleCoreEvents |
| CommandID | Int token; termui.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 | Shared natural REPL shell (scrollback + walking prompt) |
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 TermApp
participant Screen as tcell.Screen
Main->>App: NewTermApp()
App->>Screen: Init, EnableMouse
Main->>App: InitB · AddWidget · HandleResize()
loop until Ctrl+D
App->>Screen: select: termui.Event OR PollEvent
alt termui.Event
App->>App: HandleCoreEvents
else tcell
App->>App: HandleEvent · HandleKey / HandleResize
App->>App: Draw + grid flush + Show
end
end
App->>Screen: Fini
| Phase | Code | Side effects |
|---|---|---|
| Init | NewTermApp |
Opens screen, enables mouse |
| Canvas setup | UpdateCanvas |
Allocates grids at terminal size |
| Register widgets | AddWidget |
Appends to widget slice |
| Initial layout | HandleResize() in NewDebuggerApp |
Tab + completion bar (H-2) + cmdline (H-1) |
| 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(orinternal/termui/for generic widgets):
type MyWidget struct {
termui.BaseWidget
/* state */
}
func NewMyWidget() *MyWidget {
w := &MyWidget{
BaseWidget: termui.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 = termui.NewCmdWidget(a.commandReg)
a.cmdWidget.Ctx = a.ctx
a.cmdWidget.Events = a.Events()
a.completionBar = termui.NewCompletionBarWidget(a.ctx) // Subscribes to CompletionMsg
// initBuiltins also: platform.Subscribe(ctx.Bus, a.onBreakpointsChangedMsg)
-
Build the command tree with the DSL in
ExapData()(cmd/gdbforge/command_tree.go) — see COMMAND_SYSTEM.md. -
Handle legacy bus events in the application when needed:
func (app *MyApp) HandleCoreEvents(ev termui.Event) {
msg, ok := ev.(termui.CommandEvent)
if !ok { return }
switch msg.CommandID() { /* ... */ }
}
- 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 RENDERING.md.
GDB output path¶
flowchart LR
PTY["ptyx reader"]
Fan["Subscribe fan-out"]
Bridge["UI bridge · PostEvent"]
Widget["GDBWidget.HandleEvent"]
State["GdbInputState.PushRaw"]
Upd["MiUpdate"]
Cons["ConsolePane.AppendLines"]
PTY --> Fan --> Bridge --> Widget --> State --> Upd --> Cons
Do not read from a GDB channel in Draw. Do not call widget methods from the reader or bridge goroutine — only PostEvent.
PushRaw streams complete MI lines (MiUpdate); incomplete lines stay in lineBuf until the next chunk. Console editing/layout lives in InputLine / ConsolePane; the app controller owns MI and Session.Send.
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 rect (y = H instead of H-1) |
Fix in HandleResize() |
| 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 cmd/gdbforge; subscribe to event bus |
| New debugger pane | Model + widget pair; register builtin in initBuiltins or open via :e / layout |
| New service / backend | Implement core.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 | term_app.go |
| App API / dispatch | term_app.go (AppApi), cmd/gdbforge/app.go + input.go |
| Interaction modes | internal/platform/mode.go (via TermApp / AppState) — includes ModeSearch |
| Key-sequence bindings | internal/commands + cmd/gdbforge/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 | internal/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 cmd/gdbforge/input.go — INPUT.md / USER_GUIDE.md |
| Breakpoint sync | stopped.go — Publish/Subscribe BreakpointsChangedMsg; DEBUGGER_INTEGRATION.md |
| Breakpoint YAML | persist/ + saveBreakpointsOnQuit / restoreSavedBreakpoints; breakpoint persistence |
| Debugger panes | internal/termui/input_line.go, console_pane.go; widgets/gdb_widget.go + cmd/gdbforge/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 | termui/event.go, termui/command.go |
| Debugger events | core/events.go |
| Entry point | cmd/gdbforge/ (main.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 - UI_ARCHITECTURE.md — deep UI dive
- DEBUGGER_INTEGRATION.md — GDB MI2 details
- ROADMAP.md — what's planned
- CONTRIBUTING.md — commit conventions