Skip to content

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

  1. How to read the codebase
  2. Glossary
  3. Development environment
  4. Application lifecycle
  5. Adding a widget
  6. Layout and splits
  7. Rendering rules
  8. GDB output path
  9. Threading rules
  10. Debugging with Delve
  11. Troubleshooting
  12. How to extend
  13. 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 DebugSession controllers; widgets are views.
  • Controllers use host interfaces — never embed *DebuggerApp.
  • High-rate PTY output → PostInterrupt → controller handler → paint API.
  • Pane policy belongs in LayoutShell, not TabWidget.
  • HandleCoreEvents is legacy — use PostInterrupt + 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 GDBWidget prototype)
  • 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

./docs/serve.sh
# Open http://127.0.0.1:8765/

See HOSTING.md.

Run gdbforge prototype

go run ./cmd/gdbforge

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.

  1. Define or use an application model that holds the pane's state.

  2. Create internal/gdbforge/widgets/my_widget.go (or termforge/ 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.

  1. 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))
  1. 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)
  1. Build the command tree with the DSL in ExapData() (internal/app/command_tree.go) — see COMMAND_SYSTEM.md.

  2. 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)
}
  1. 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.SetContent with absolute coordinates — use Canvas.
  • 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 widget
  • Second = new widget
  • Ratio = 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

  1. Borders — only layout engine draws split separators (into Grid).
  2. Widget content — draw inside local (0,0)..(W-1,H-1) via Canvas methods (all route through Grid).
  3. Unicode — use DrawANSIText for strings; SetContent for single runes.
  4. 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:

  1. CLI console (:b gdb) — PTY #1 bytes → WireCLI → CompositeTerminal (xterm paint). No MI parser.
  2. 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:

dlv debug ./cmd/docserve -- --port 8765

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