Skip to content

Debugger Integration

gdbforge connects to debug targets through backend.Backend (internal/gdbforge/backend), which wraps adapters that implement ptyx.Session (Debugger + lifetime + PTY mux). Supported today: GDB (gdb.GDBClient, 3 PTYs: CLI + MI + inferior) and Delve (dlv.Client, headless rpc2 + dlv connect CLI PTY + inferior --tty) via -g gdb|dlv. Controllers call semantic backend ops (breakpoints, frame/thread select, exec) — not raw MI or Delve CLI strings. The session is owned by DebuggerApp (through Backend) and shared by the console view, in-app :AI, and MCP. Program I/O is wired to the IO pane (:b io) via CompositeTerminal + WireTTY.

Companion docs: PTY_ARCHITECTURE.md (master/slave dual PTY, Delve TCP) · ARCHITECTURE.md · termforge: UI Architecture · EXEC_SHELL.md · PLUGINS.md


Table of contents


Integration overview

Application data flows Service → Controller → Model → Widget (MVC).

flowchart TB
    subgraph UI["UI · views"]
        GDBW["GDBWidget · CompositeTerminal"]
        IOW["OutputWidget · CompositeTerminal"]
        ExecW["ExecWidget · CompositeTerminal"]
    end

    subgraph termforge["termforge bridge"]
        Wire["WireTTY · xterm emulator"]
    end

    subgraph App["Application · internal/app"]
        Ctrl["consoleCtl · MI bridge + quit"]
        InfCtl["inferiorIOCtl · wire/unwire IO"]
        Models["models · BreakpointList ThreadList CallStack"]
        AI[":AI OnAI"]
        MCP["GdbMcpService"]
    end

    subgraph Domain["Domain · core"]
        SessIF["Session / Debugger / PTYWriter"]
        PtyMsg["PtyOutputMsg"]
        UIMsg["GdbOutputMsg · WireTTY"]
    end

    subgraph Ptyx["PTY · ptyx.TTY"]
        CLI["CLI PTY · GDB console"]
        MI["MI PTY · backend Session"]
        Inf["Inferior PTY · program stdio"]
    end

    subgraph BackendPkg["backend.Backend"]
        Client["GDBClient or dlv.Client"]
        Parser["InputState · MiUpdate / Delve parse"]
    end

    subgraph External["External"]
        GDB["GDB · new-ui mi2"]
        Prog["Debugged program"]
        LLM["Claude / OpenAI API"]
    end

    GDBW --> Wire --> CLI
    IOW --> Wire --> Inf
    ExecW --> Wire
    Ctrl -->|"owns Backend"| Client
    Ctrl -->|"MI Subscribe only"| MI
    Client --> CLI
    Client --> MI
    Client --> Inf
    Inf <--> Prog
    MCP -->|"Session = MI PTY"| SessIF
    AI --> MCP
    AI --> LLM
    SessIF --> Client
    MI -->|"Subscribe fan-out"| PtyMsg
    PtyMsg -->|"coalesce"| UIMsg
    UIMsg --> Ctrl
    CLI --> GDB
    MI --> GDB
    Ctrl --> Parser

Dependency rules:

  • internal/gdb, internal/dlv, and termforge/ptyx must not import termforge
  • DebuggerApp owns backend.Backend (concrete GDB or Delve client); views never hold Session
  • External APIs use app.GDB() ptyx.Session (works for -g dlv too)
  • Controllers use Backend semantic ops (InsertBreakpoint, SelectFrame, Exec, …) and capability flags (NavigationAsync, WireCLILineTap, DeferBreakpointRefresh, …) — not isDLV() / isGDB() branches or MI string literals in internal/app/
  • Never Close() the session from MCP/AI — the app owns lifetime

Unified backend API

Controllers (breakCtl, debugInfoCtl, consoleCtl, stop pipeline / code_nav, inferiorIOCtl) are protocol-agnostic. They talk to shared domain types and a single backend.Backend interface; GDB MI and Delve rpc2/CLI details stay inside GDBBackend and DLVBackend.

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.* · BreakInfo StackFrame ThreadInfo"]
    debuggerPkg["debugger.* · StopInfo ConsoleUpdate InferiorIO"]
  end

  subgraph api ["Backend interface"]
    BackendIface[backend.Backend]
    Capabilities["Capabilities · NavigationAsync WireCLILineTap DeferBreakpointRefresh …"]
    SemanticOps["Semantic ops · InsertBreakpoint SelectFrame Exec FetchStackList …"]
  end

  subgraph gdb_impl ["GDBBackend"]
    GdbCLI["CLI PTY → GDBWidget"]
    GdbMI["MI PTY → GdbInputState"]
    GdbOps["MI strings inside backend only"]
  end

  subgraph dlv_impl ["DLVBackend"]
    DlvCLI["dlv connect PTY → human console"]
    DlvRPC["go-delve rpc2 → machine ops"]
    DlvParse["dlv.InputState → ConsoleUpdate"]
  end

  ui --> BackendIface
  ui --> models
  ui --> debuggerPkg
  BackendIface --> Capabilities
  BackendIface --> SemanticOps
  BackendIface --> gdb_impl
  BackendIface --> dlv_impl
  inferiorIO --> debuggerPkg

Source: docs/diagrams/unified_backend.mermaid.

Shared domain (internal/gdbforge/models, internal/gdbforge/debugger)

Package Types Used by
models BreakInfo, StackFrame, ThreadInfo, list merge helpers Controllers, widgets, MCP parsers
debugger StopInfo, FrameInfo, ConsoleUpdate Stop pipeline, consoleCtl, inferior routing (InferiorIO)

Controllers merge snapshots into models.* lists and paint widgets. The stop pipeline builds debugger.StopInfo from backend console updates — widgets never parse MI or Delve text.

backend.Backend surface

Semantic ops (controllers call these instead of crafting MI / CLI):

Op Purpose
InsertBreakpoint / ClearBreakpointAt / DisableBreakpoint / … Breakpoint intents from Code, BP pane, Space
SelectFrame / SelectThread Call stack / thread pane navigation
Exec / ExecUI -exec-* or Delve CLI equivalents
FetchStackList / RefreshThreadsAndStack / FetchBreakpoints Pane refresh queries
PushConsoleOutput PTY chunk → debugger.ConsoleUpdate

Capability flags (replace scattered isDLV() routing):

Flag GDB Delve
NavigationAsync() false — MI frame select is synchronous true — rpc2 + deferred code refresh
WireCLILineTap() false true — Delve CLI line buffer for run/stack side effects
DeferBreakpointRefresh() false true — defer breakpoints query during [Y/n]?
BreakRefreshImmediate() true — =breakpoint-* triggers refresh false
RequiresInferiorTTYRestart() false — live -inferior-tty-set true — --tty fixed at spawn
PaintTargetInConsole() false true — mirror inferior stdout in debugger pane

Implementation files: backend/gdb_backend.go, backend/dlv_backend.go, backend/ops.go, backend/break_cmds.go, backend/dlv_rpc.go.

GDB vs Delve implementation split

Layer GDB Delve
Human console CLI PTY #1 → GDBWidget dlv connect PTY → GDBWidget
Machine control MI PTY #2 → GdbInputState go-delve rpc2 (Client.RPC)
Inferior stdio PTY #3 or external -inferior-tty-set --tty slave (restart to switch)
Protocol strings -break-insert, -stack-select-frame, … only in backend rpc2 methods + CLI fallback scrape

Lua extensions (gdbforge.gdb, set_inferior_tty, probe spawn scripts) remain unchanged — they orchestrate external tools and call the same host APIs; they do not import backend directly.


Debugger interface

type Debugger interface {
    Send(cmd string) error
    SendRaw(raw string) error
}

type PTYWriter interface {
    Send(cmd string) error
    SendRaw(raw string) error
}

// Session: lifetime + exclusive write + shared read.
type Session interface {
    Debugger
    Close()
    Subscribe() (ch <-chan PtyOutputMsg, cancel func())
    WithWrite(ctx context.Context, fn func(w PTYWriter) error) error
}

Minimal by design — sends commands to the backend. Responses arrive asynchronously via Subscribe / UI events, not as return values from Send.

Design rationale: MI2 and GDB CLI are streaming protocols. Blocking Send → response would deadlock when async *stopped records arrive mid-command. GdbMcpService.GdbCommand adds a windowed capture on a private subscription for tool results (best-effort until tokenized MI waiters).

Ownership: DebuggerApp creates and owns backend.Backend in initBuiltins (backend.NewGDB / NewDLV → Close). gdbMcp is a peer constructed from app.GDB(). Views (GDBWidget, OutputWidget, …) receive paint APIs and host intents / SetOn* only. Domain models live on controllers (breakCtl.list, …).

Future extensions (separate interfaces):

Interface Purpose
BreakpointManager Add/remove/list breakpoints
RegisterReader Read register sets
MemoryReader Read/write memory

These will publish typed messages on platform.EventBus rather than returning synchronously.


PTY mux

gdbforge uses one unified type — *ptyx.TTY (Start / Open / AttachPath) — for all PTY roles.

GDB (3 PTYs)

PTY Created Role UI
#1 CLI ptyx.Start(gdb …) — no --interpreter=mi2 Native GDB console (readline) :b gdb via WireCLI → CompositeTerminal
#2 MI ptyx.Open() + new-ui mi2 /dev/pts/N Backend ptyx.Session; MI parser No widget — consoleCtl bridge only
#3 Inferior ptyx.Open() or AttachPath Program stdin/stdout :b io via WireInferior → CompositeTerminal

Delve (2 PTYs)

PTY Role UI
#1 CLI dlv exec / dlv connect — parser on same stream :b gdb via WireCLI
#2 Inferior dlv exec --tty :b io via WireInferior

Write / read rules (MI PTY and Delve CLI PTY)

Direction Rule
Write Exclusive — WithWrite / Send / SendRaw share one mutex; only one of UI / MCP / App holds it at a time
Read Shared — every Subscribe() channel receives the same chunks

Writers (PTYOwner on AppState):

Owner Who Console paint
ui GDB / Exec / IO console (keys → PTY via WireTTY) Yes (xterm emulator)
mcp :AI / GdbCommand MI stream only; optional mirror via GdbTargetPrint
app Silent MI / App writes: -break-list, file list, breakpoint toggle/delete, stop-driven thread/stack Query MI stream only
flowchart LR
  UI["Controller PTYOwnerUI"]
  MCP["GdbMcpService PTYOwnerMCP"]
  App["App silent Query PTYOwnerApp"]
  Lock["write lock"]
  UI --> Lock
  MCP --> Lock
  App --> Lock
  Lock --> MIPTY["MI ptmx"]
  MIPTY --> Fan["broadcast"]
  Fan --> ChUI["consoleCtl Subscribe"]
  Fan --> ChMCP["MCP Subscribe"]

GDB CLI and exec (:!) use separate *ptyx.TTY instances wired with WireTTY. The MI bridge coalesces PtyOutputMsg → GdbOutputMsg for the parser only — not for GDB pane paint (CLI bytes paint via WireTTY on PTY #1).

Inferior I/O (dual / triple PTY)

Architecture overview (master/slave, Delve TCP, external terminal diagrams): PTY_ARCHITECTURE.md.

There is no GDB MI command that writes program stdin. gdbforge allocates a bare PTY (ptyx.Open), keeps the master in-process, and tells GDB to attach the inferior to the slave:

gdbforge (GDB session)
 ├── PTY #1 CLI master  ←→  gdb console (user types here in :b gdb)
 ├── PTY #2 MI master   ←→  gdb MI backend (Send/Subscribe for app/MCP)
 └── PTY #3 master      ←→  program stdin/stdout   ← IO pane (:b io)
         │
         └── slave path → GDB: -inferior-tty-set /dev/pts/N (via MI PTY)
Side Who holds it Purpose
Master (PTY #3) gdbforge (*ptyx.TTY) Read program stdout; write program stdin via WireTTY
Slave (PTY #3) inferior (via GDB) Program’s terminal

Startup (gdb.NewGDBClientOpts):

  1. Start GDB in console mode on PTY #1 (ptyx.Start) — no --interpreter=mi2
  2. Wait up to 90s for the first (gdb) prompt on CLI PTY
  3. ptyx.Open() → PTY #2 (MI); send new-ui mi2 <slave path> on CLI PTY
  4. Wait for MI ready on PTY #2
  5. Send -gdb-set mi-async on on MI PTY — required so GDB keeps reading MI while the target runs (details)
  6. ptyx.Open() or AttachPath → PTY #3 (inferior)
  7. Send -inferior-tty-set <slaveName> on MI PTY only after MI is ready
  8. Wire CLI PTY → GDBWidget; inferior PTY → OutputWidget via WireTTY

Pass GDB options after --: gdbforge -- -nx -x script.gdb elf. gdb.HasInitScript detects -x/-ex so the app skips default break main when an init script is present.

External terminal stdio (TUI targets)

For TUI inferiors (htop, games, …) or programs that need a real terminal emulator, route stdio externally instead of the in-app IO pane.

:b io vs :set inferior-tty

:b io (internal) :set inferior-tty (external)
Best for Normal debug prints, interactive shells in-pane Full-screen TUI/curses, dedicated emulator
Rendering CompositeTerminal (xterm in tcell) External emulator owns display
Who holds PTY master gdbforge (*ptyx.TTY) → WireTTY GDBFORGE_TERMINAL (kitty, xterm, …)

Advantages of :set inferior-tty: smooth high-volume output, real VT features, program I/O does not compete with gdbforge’s debugger panes for redraw, live attach on GDB (-inferior-tty-set without restart).

Mode How IO pane
internal (default) Internal PTY + wire :b io Program stdin/stdout
external :set inferior-tty or :set inferior-tty external (opens terminal), GDBFORGE_INFERIOR_TTY, Lua open_external_tty / set_inferior_tty("external") Note only — no subscribe
GDB Delve (-g dlv)
Attach stdio -inferior-tty-set live (no restart) dlv exec --tty only at spawn; :set inferior-tty restarts Delve
Switch back :set inferior-tty internal same (restart with internal PTY)
Best for TUI :set inferior-tty / :lua terminal_debug / gdbserver_tui :lua dlv_ext_port [port] [args…] (alias dlv_port) — headless dlv in another window + dlv_connect; stdio never leaves that window
Terminal picker GDBFORGE_TERMINAL (kitty, xterm, mate-terminal, gnome-terminal, …) same

Pattern B — local GDB/dlv, external pts

  1. Lua gdbforge.open_external_tty() opens kitty/xterm/… (GDBFORGE_TERMINAL) running gdbforge --hold-inferior-tty, which keeps the window alive and hands its pts to the inferior: it releases the pts from its own session (TIOCNOTTY) so the program can make it its controlling terminal. Without that, GDB warns Failed to set controlling terminal: Operation not permitted and the program sees open /dev/tty: no such device or address — fatal for Go TUIs, curses and getpass (details).
  2. gdbforge.set_inferior_tty(pts) → GDB -inferior-tty-set (live). Delve restarts dlv exec --tty … with the new path (same program args).
  3. Examples: lua/external_tty, lua/terminal_debug.

Pattern A — gdbserver / headless dlv in the other window

  1. GDB: gdbforge.spawn_terminal("gdbserver", ":2345", "./my_tui") then target remote.
  2. Delve: :lua dlv_ext_port / dlv_port (or spawn_dlv_headless + dlv_connect) — inferior inherits that terminal’s stdio.
  3. Examples and how to use each script: lua/README.md; code: lua/gdbserver_tui, lua/dlv_ext_port.

Do not hold an internal PTY master and point -inferior-tty-set / --tty at an external slave at the same time. Closing the external window does not auto-rewire IO — use :set inferior-tty internal.

IO console (OutputWidget, pane name IO, :b io, alias :b output):

Action Path
Program prints WireTTY → CompositeTerminal (xterm paint)
User keys CompositeTerminal.HandleKey → WireTTYInput → tty.SendRaw
Host lines ([lua] …) WriteHostLine / AppendHostLine (inject-only, not a PTY)
Ctrl-C / Ctrl-D / Ctrl-Z Raw bytes to inferior PTY via xterm key trie
Pane resize CompositeTerminal.Resize → tty.SetSize
Serial kgdb console :terminal wires serialmux.TermTTY() to IO pane (or GDBFORGE_EXTERNAL_SERIAL=1 for minicom)

Wiring policy lives in internal/app/io_console.go (inferiorIOCtl); the widget holds CompositeTerminal only.

Separation rules:

  • GDB console keys go only to the GDB PTY — they never become program stdin
  • IO console keys go only to the inferior PTY — they are not GDB commands
  • When a dedicated inferior TTY is active, program stdout stays on PTY #2. Raw non-MI text on the GDB PTY is still painted in the GDB console (GDB make / shell child output, load messages) — it is not redirected to the IO pane
  • + MI status records (e.g. +download during load) are filtered from display; human-readable load lines remain
flowchart LR
  subgraph gdbforge
    GDBPane["GDB pane · CLI PTY"]
    IOPane["IO pane · inferior PTY"]
    M1["PTY#1 CLI"]
    M2["PTY#2 MI"]
    M3["PTY#3 inferior"]
  end
  GDBPane --> M1
  IOPane --> M3
  M1 --> GDB["gdb console"]
  M2 --> GDB
  GDB -.->|"-inferior-tty-set"| M3
  M3 --> PROG["program"]

Session model on AppState: SourceFiles (refreshed from -file-list-exec-source-files on stop / :edit), StopFile / StopLine (StopLocation — real PC from *stopped, drives ━━▶), CurrentFile / CurrentLine (browse / frame selection — blue cursor), theme colors (MarkColor, MarkDimColor, BreakColor, BreakDisabledColor, BreakCondColor, PCColor, StackBreakColor, CodeSelColor, MutedColor; see :set), EscToCode (Esc focuses CodeWidget; :set esctocode / :set noesctocode; default on), BreakMain (insert break main on GDB session start; skipped when restoring ./.gdbforge/breakpoints.yaml or when HasInitScript; :set breakmain / :set nobreakmain; default on), GdbListenPrint (paint App/MCP replies in GDB console; :set gdblistenprint / :set nogdblistenprint; default on), ContinueAfterClear. Each open source file has its own CodeWidget (:edit name); :b filename switches among open file buffers and builtins. :edit opens a FileListWidget of project sources. When source is missing and the backend supports assembly, asmCtl.autoAsm swaps the location leaf to Assembly and reclaims Code when a readable frame returns. Breakpoint gutters sync via =breakpoint-* / Space hooks → coalesced -break-list into BreakGutter maps (line and addr).


Breakpoints and source sync

Breakpoints are coordinated across the debugger console, CodeWidget, AssemblyWidget, BreakpointWidget, and MCP. GDB/MCP notifies publish BreakpointsChangedMsg (internal/app/events.go) on platform.EventBus; breakCtl refreshes from that event (coalesced; no sleep/timer debounce).

Breakpoints while the inferior is running

While the program is in continue / ^running, GDB will not process a queued break until the target stops. Space (and BreakpointWidget e/d) go through backend → gdb.SendCmd, which:

  1. Sends -exec-interrupt on the MI PTY (gdb.MIExecInterrupt) via sendDebuggerCmdGDB — not \x03 on the MI pty (why)
  2. Sends break / clear / -break-delete
  3. On insert: sends continue so execution can hit the new breakpoint
  4. On remove: sends continue only if :set continueafterclear (default off)

Requires -gdb-set mi-async on at GDB startup (miAsyncOn in gdb_client.go).

UI Ctrl-C: GDBBackend.Interrupt → GDBClient.Interrupt() → -exec-interrupt when running; InterruptIdle() at idle prompt.

Other App commands (-stack-select-frame, -thread-select, …) also interrupt via the same MI path but do not auto-continue.

AppState.InferiorRunning tracks ^running → *stopped. AppState.ContinueAfterClear is toggled with :set continueafterclear / :set nocontinueafterclear.

Builtins and keys

Surface How to open Keys
BreakpointWidget :b breakpoint (default pane) j/k or Up/Down / Enter / click-release — select and browse Code at that BP (blue cursor; ━━▶ stays on StopLocation); Enter focuses Code; row at stop PC uses stackbreakcolor (stays green when selected); e — toggle enable/disable; d — delete; rows use AppState break colors (red/yellow/orange for conditional)
OutputWidget (IO) :b io (alias :b output; default pane, top-right) Program stdin/stdout via inferior PTY; type + Enter → stdin; PgUp/PgDn scroll; <C-l> clear; Ctrl-C/D → inferior; Ctrl-Z → SIGTSTP; ANSI
ThreadWidget :b threads (default pane) j/k or Up/Down / Enter / click-release — bold selection and MI -thread-select <id> + refresh stack + show code; Enter focuses Code; green when current thread matches StopLocation; filled on stop
CallStackWidget :b callstack (default pane) j/k or Up/Down / Enter / click-release — bold selection and MI -stack-select-frame <level> + show code; Enter focuses Code; green on frame 0 only when it matches StopLocation; shared libs / missing sources → centered not available + path (may autoAsm)
FileListWidget :edit j/k or Up/Down — mark color from :set markcolor; Enter opens; mouse: first click selects, second click on marked row opens CodeWidget
CodeWidget :edit name / stop / :b file Up/Down or j/k — blue browse cursor (codeselcolor); ━━▶ = StopLocation (pccolor); Space — insert/remove break; e — enable/disable (yellow gutter when disabled; orange when conditional). Missing file or .so path: centered not available (Assembly may auto-swap). Global n/s/c (normal; insert when Code focused) → -exec-next / -exec-step / -exec-continue.
AssemblyWidget :b asm / :layout … asm / autoAsm Disassembly; Space toggles addr breakpoint; synced from frame/stop like Code

Empty Breakpoint list shows no breakpoints. Otherwise each row is breakpoint info only (no column header), e.g. 1 y hello.c:23. Disabled rows are gray (n).

Ownership of the list

models.BreakpointList on breakCtl (a.breaks.list) is the shared model (GUI + MCP):

Action Model GDB Code/Asm gutters
e while enabled Row stays, Enabled=false -break-delete Cleared for that line/addr
e while disabled Row stays, Enabled=true break file:line Restored
d Row removed Deleted if it was in GDB Cleared
External b / Space / MCP MergeFromGDB on the model As GDB reports Enabled rows; conditional → orange (BreakCondColor)

Disabled rows are kept across -break-list refresh (they are intentionally absent from GDB). Controllers call syncBreakpointViews() → BreakpointWidget.SetItems + BreakGutter paint on Code/Asm.

Host / callback chain

Wired in internal/app/builtins.go / breakpoints.go (breakCtl):

Hook Handler
GdbMcpService.OnBreakpointsChanged onBreakpointsChanged → Publish(BreakpointsChangedMsg)
EventBus Subscribe coalesced -break-list via breakCtl
BreakpointHost (toggle / delete / activate) breakCtl → model + SendCmd
CodeWidget.SetOnBreakToggle breakCtl → model + SendCmd
AssemblyHost (asm break toggle) breakCtl / asmCtl → model + SendCmd
flowchart TD
  MI["MI =breakpoint-created/deleted"] --> OBC["onBreakpointsChanged"]
  MCP["MCP GdbCommand"] --> MCPCB["OnBreakpointsChanged"]
  MCPCB --> OBC
  OBC --> BUS["Publish BreakpointsChangedMsg"]
  BUS --> SUB["Subscribe → coalesce -break-list"]
  SUB --> MERGE["breaks.list.MergeFromGDB"]
  MERGE --> SYNC["syncBreakpointViews"]
  BP["BreakpointHost e/d"] --> CTRL["breakCtl"]
  CODE["CodeWidget OnBreakToggle"] --> CTRL
  ASM["AssemblyHost ToggleAsmBreak"] --> CTRL
  CTRL --> MERGE
  SYNC --> CW["CodeWidget.SetBreakInfos"]
  SYNC --> AW["AssemblyWidget.SetBreakInfos"]
  SYNC --> BPW["BreakpointWidget.SetItems"]

breakCtl Subscribes to BreakpointsChangedMsg in initBuiltins and runs a coalesced -break-list:

State Behavior
Idle First publish starts a background runBreakpointRefresh
Refresh in flight Further publishes set bpRefreshPending only
After refresh If pending → one trailing -break-list; else clear running flag

No time.Sleep debounce — coalesce is event-driven (pending flag). Redraw uses PostEvent(breakpointsUIMsg) on the UI thread.

Only =breakpoint-created / =breakpoint-deleted trigger a -break-list refresh (not =breakpoint-modified hit-count updates during n/continue).

CodeWidget details

  • Syntax highlight via Chroma (terminal256); line numbers; breakpoint lines use a red number background.
  • PC line: ━━▶ + pccolor row background (StopLocation from *stopped only — not moved by BP list clicks or j/k).
  • Browse cursor (when focused): codeselcolor (default dark blue); independent of ━━▶.
  • Breakpoint list activate → ShowSelection / browse only (blue cursor); does not rewrite StopLocation.
  • Space uses basename locations (break hello.c:23 / clear hello.c:23) under PTYOwnerApp.
  • Horizontal scroll in ANSI mode uses visible columns (not raw byte offsets) so panes stay readable after :vs.

PTY exclusivity remains ptyx.WithWrite; PTYOwner + sticky silence tell GDBWidget when to suppress console paint for App/MCP listener traffic (:set nogdblistenprint). Default is to paint those replies (gdblistenprint on). UI console submit always paints.

Threads and call stack on stop

On each non-exit *stopped (breakpoint, step, Ctrl-C / signal-received, etc.), DebuggerApp refreshes shared models then paints the Threads / Call Stack views.

When the refresh runs (avoid racing the stop reply):

  1. onGdbStopped arms pendingDebugInfo
  2. Trigger on MI PromptReady ((gdb)), or a ~120ms fallback if the prompt is missed
  3. Coalesced worker (scheduleDebugInfoRefresh / pending flag) runs the queries

What the worker does:

  1. Query("-thread-info") — apply only if the capture contains threads= (incomplete/stale captures are retried a few times, not applied)
  2. Query("-stack-list-frames") — apply only if the capture contains stack=
  3. Update models.ThreadList / models.CallStack off the UI thread
  4. PostEvent(debugInfoUIMsg) → on the UI thread: SetItems on the widgets, align Code from the stack, RequestFrame

Independent of BreakpointsChangedMsg (BP marks stay on breakpoint-change events). Clicking a thread still re-queries (onThreadActivate) for an explicit thread <id> switch — stop refresh should not require a click.


Breakpoint persistence

Breakpoints are saved and restored via YAML under the process cwd (usually the build directory):

Path Role
./.gdbforge/breakpoints.yaml Persist file (internal/gdbforge/persist)

Save — on app quit (Close → saveBreakpointsOnQuit): writes the last BreakpointList snapshot (enabled + disabled rows with file / line / enabled).

Restore — on GDB session start (builtins.go):

  1. persist.LoadBreakpoints(".") (missing file → no-op)
  2. If saved BPs exist (or HasInitScript), skip default break main
  3. restoreSavedBreakpoints: merge GDB’s current list (e.g. from -x), break any missing locations, re-apply disabled flags, refresh the BP pane + Code gutters

Example file:

breakpoints:
  - file: hello.c
    line: 23
    enabled: true
  - file: hello.c
    line: 40
    enabled: false

Run gdbforge from the project/build dir so the YAML matches the sources you debug.


GDB integration

Client startup

gdb.NewGDBClientOpts() (gdb_client.go):

  1. Builds GDB argv in console mode (no --interpreter=mi2); injects -iex set pagination off unless already set.
  2. Appends user gdbArgs after -- (e.g. -nx -x script.gdb elf).
  3. ptyx.Start → CLI PTY (#1); ptyx.Open → MI PTY (#2) and inferior PTY (#3).
  4. Waits for (gdb) on CLI PTY (up to 90s); captures startup bytes for WriteBoot.
  5. Sends new-ui mi2 <MI slave path> on CLI PTY; waits for MI ready on PTY #2.
  6. Sends -gdb-set mi-async on on MI PTY (constant miAsyncOn).
  7. Sends -inferior-tty-set <inferior slave path> on MI PTY.
cli, _ := ptyx.Start([]string{"gdb", "-iex", "set pagination off", "hello"}, ptyx.Options{})
mi, _ := ptyx.Open()
// cli.Send("new-ui mi2 " + mi.SlaveName())
// mi.Send("-gdb-set mi-async on")
// mi.Send("-inferior-tty-set " + inf.SlaveName())

GDBClient embeds *ptyx.TTY as the MI session (ptyx.Session). CLITTY() returns PTY #1 for the GDB pane.

Quit / exit: typing q/quit in the GDB pane goes to the CLI PTY. When GDB exits, WireCLI OnExit and/or the MI bridge posts "gdb-exit" → app.Exit() (cgdb-like).

Current limitation: reply correlation (tokenized MI → waiter) is not built yet — GdbCommand uses idle/max window capture on the MI stream.

Send paths

Method Use
Send(cmd) Append \n, send CLI/MI command (takes write lock)
SendRaw(raw) Send raw bytes under write lock (Delve inline ^C; inferior PTY)
Interrupt() Stop running inferior: -exec-interrupt on MI PTY
InterruptIdle() Idle GDB prompt: SIGINT + ^C on CLI PTY (Quit)
SuspendInferior() SIGTSTP like terminal Ctrl-Z (^Z on inferior PTY or kill)
WithWrite(ctx, fn) Hold write lock for multi-step MCP capture
CLIExecToMI(cmd) Map CLI next/step/continue/… → -exec-* so console/n/s/c do not dump source into the GDB pane; Code follows *stopped

Output path

ch, cancel := client.Subscribe()
defer cancel()
for msg := range ch {
    // UI posts EventInterrupt(GdbOutputMsg); MCP/AI parse the same stream
}

Close() (or cancel) closes subscription channels. When the debugger process exits, the MI bridge posts "gdb-exit"; CLI WireTTY OnExit does the same — HandleInterrupt calls app.Exit().


GdbMcpService and in-app AI

Same process as the TUI (required for one debug context).

Piece Role
internal/mcp/gdb_service.go Tool core: GdbCommand under WithWrite + Subscribe capture
internal/mcp/agent.go LLM tool loop (Anthropic or OpenAI)
:AI / :ai Rest-args colon command → OnAI → Ask in a goroutine
:AI why is there a memory leak
  → GdbMcpService.Ask
  → LLM may call gdb_command("info leak") / "b main" / …
  → same Session as the GDB pane (exclusive write, shared read)
  → answer appended to GDB console

Env: ANTHROPIC_API_KEY or OPENAI_API_KEY; optional GDBFORGE_AI_MODEL.

Do not use stdio MCP inside the TUI process (keyboard owns stdin). Optional TCP MCP for external hosts can expose the same tools later; in-app :AI is the primary door.


MI2 parsing pipeline

GDB MI2 emits several record types:

Prefix Type Example
^ Result ^done, ^error, ^running
~ Console stream ~"Hello\n"
@ Target stream @"program output\n"
& Log stream &"warning\n"
* Exec async *stopped,reason="breakpoint-hit"
= Notify async =breakpoint-created,...
(gdb) Prompt Ready for input

GdbInputState (streaming)

PushRaw is called on the UI thread for each coalesced GdbOutputMsg chunk:

  1. Append bytes to lineBuf; split on \n.
  2. For each complete line, classify the MI record and accumulate an MiUpdate.
  3. Return immediately — no timer, no wait for a “full burst”.
Record Effect on MiUpdate
~ / @ Decode stream payload → DisplayLines (ANSI ESC kept; do not strip 0x1b)
Non-MI raw line Paint into DisplayLines (GDB make / shell child stdout on the GDB PTY)
+… status Filtered (e.g. +download noise during load); keep readable load text
& Ignored for display (CLI echo; UI already echoes submits)
^error Set Error state; surface msg=
^done / ^running / … Update GdbState
*stopped Fill Stopped (reason, thread-id)
(gdb) PromptReady

Incomplete lines remain in lineBuf across chunks.

Note: GDB itself often buffers make / shell stdout until the child exits — live line-by-line build output may require :! make in an exec pane instead.

Design rationale: GDB often splits writes mid-line; newline buffering is required. Per-line dispatch is enough for correctness and feels faster than a 100ms debounce.

MiMsg (batch helper)

MiMsg / NewMiMsg / CreateBufferForLine remain as a batch-oriented helper for offline or test parsing. The live GDB pane uses streaming MiUpdate from GdbInputState.

type MiUpdate struct {
    DisplayLines []string
    PromptReady  bool
    State        GdbState
    ErrorMsg     string
    Stopped      *MiStopMsg
}

MI string decoding

DecodeMIString handles GDB's C-style escapes including octal UTF-8 sequences (\342\235\214). ExpandTabs expands tab stops for aligned output.

Implementation: mi.go, mi_msg.go, mi_state.go.


GDB console bridge

GDBWidget is a dumb terminal view (CompositeTerminal + WireCLI). The app owns MI policy on PTY #2 (internal/app/gdb_console.go):

User keys  →  CompositeTerminal.HandleKey  →  WireTTYInput  →  CLI PTY
CLI bytes  →  WireTTY  →  xterm emulator  →  GDBWidget.Draw
MI bytes   →  consoleCtl bridge  →  GdbInputState  →  models / stop pipeline

initBuiltins creates gdb.NewGDBClientOpts; wireCLI attaches CLI PTY with OnExit; startGdbConsoleBridge coalesces MI Subscribe → EventInterrupt(GdbOutputMsg) (~16ms / 64KiB) for parsing only — not GDB pane paint.

Job control (Ctrl-Z): onGdbConsoleSuspend — if InferiorRunning, SuspendInferior; otherwise App.Suspend. Bound globally — see INPUT.md.

sequenceDiagram
    participant User
    participant GDBW as GDBWidget
    participant Term as CompositeTerminal
    participant CLI as CLI PTY
    participant Ctrl as consoleCtl
    participant MI as MI PTY
    participant GDB as GDB

    User->>Term: type / Enter
    Term->>CLI: SendRaw
    CLI->>GDB: console input
    GDB-->>CLI: console output
    CLI-->>Term: WireTTY → xterm paint
    GDB-->>MI: MI records
    MI-->>Ctrl: GdbOutputMsg
    Ctrl->>Ctrl: PushRaw → MiUpdate
Component File Role
CompositeTerminal termforge/composite_terminal.go xterm emulator + key trie + WireTTY
WireTTY termforge/wire_tty.go PTY bytes ↔ terminal controller
GDBWidget widgets/gdb_widget.go View — WireCLI, Draw, focus cursor
consoleCtl internal/app/gdb_console.go MI bridge, quit, OnExit, Send on MI PTY
GdbInputState gdb/mi_state.go Stream PushRaw → MiUpdate (MI PTY only)
ptyx.TTY termforge/ptyx/tty.go Unified PTY: Start / Open / AttachPath

Console layout

The GDB pane uses a full xterm emulator (scrollback, ANSI, cursor at emulator position). GDB’s native readline draws (gdb) prompts and echo — gdbforge does not synthesize a walking prompt on top.

Lua REPL still uses ConsolePane + InputLine (line-based REPL, not a raw tty).


Delve backend (peer of GDB)

Delve plugs into the same unified backend as GDB:

Piece Role
CLI gdbforge -g dlv [-d dlv] prog [args…]
backend.DLVBackend Semantic ops + capabilities; DebuggerApp.backend
internal/dlv.Client Headless dlv exec --headless --accept-multiclient + rpc2 dial + dlv connect CLI PTY
Client.RPC go-delve/delve/service/rpc2 — breakpoints, threads, stack, exec, halt
dlv.InputState CLI PTY parser → debugger.ConsoleUpdate (prompt, confirms, display lines)
Console Same GDBWidget + consoleCtl; prompt token (dlv)
Pane refresh Backend.FetchStackList, RefreshThreadsAndStack, FetchBreakpoints (rpc2-first)
flowchart LR
  CLI["gdbforge -g gdb|dlv"] --> App["DebuggerApp"]
  App --> BE["backend.Backend"]
  BE --> GDB["GDBBackend · gdb.GDBClient"]
  BE --> DLV["DLVBackend · dlv.Client"]
  GDB --> Sess["ptyx.Session + MI"]
  DLV --> RPC["rpc2 machine"]
  DLV --> PTY["dlv connect PTY"]

MVP limits: Delve CLI output parsing remains for human-console edge cases; rpc2 covers machine queries. :edit source-file list from -file-list-exec-source-files is GDB-only (SupportsSourceFileList()). MCP/:AI tools remain GDB-oriented; shared Query uses prompt token (dlv) when on Delve.

Interactive yes/no: After the inferior exits, Delve may ask Set a suspended breakpoint … [Y/n]?. gdbforge detects that prompt (including when it arrives without a trailing newline), paints it as a live host (same idea as GDB quit confirm), and answers with the next console submit. While confirming, breakpoint Query("breakpoints") is deferred so the query line cannot be consumed as y/n. Ctrl-C at a yes/no prompt sends n (cancel); SIGINT/^C is only used when the inferior is actually running.

Tab completion: GDB console Tab uses MI -complete. Under Delve, Tab completes command names from a static list, and for break/b/trace/… locspecs it calls rpc2 ListFunctions with a regex filter via ListFunctionsFilter (e.g. b main. → main.main) — not the Delve connect CLI funcs command on the PTY. Symbol completion is prefix-based; file:line locspecs are not completed yet. See the Flow browser for the full call path.

Examples:

gdbforge -g dlv ./hello
gdbforge -g dlv -d /usr/local/bin/dlv ./pkg

Default entry breakpoint under Delve is break main.main (not break main).

Delve inferior I/O (dual PTY)

Dual-PTY like GDB, but --tty is spawn-only:

Piece Role
dlv.Client Opens a ptyx.TTY (or uses InferiorTTY)
dlv exec --tty <slave> Program stdin/stdout go to :b io or an external terminal — not the Delve console
:set inferior-tty / Lua set_inferior_tty Restarts Delve with a new --tty (same program args)

For Go TUI programs, prefer :lua dlv_port (headless Delve in another window + dlv connect) so stdio never leaves that window and you avoid a mid-session restart. See PTY_ARCHITECTURE.md and External terminal (stdio / TUI targets).


Future OpenOCD integration

OpenOCD exposes a Telnet command port and TCL scripting for embedded targets.

Planned adapter: internal/openocd (not yet created).

Aspect Plan
Transport TCP telnet or pipe to openocd process
Protocol TCL commands + event text (not MI2)
Interface Same ptyx.Session for send/subscribe; adapter translates
UI impact None — new backend package only
flowchart LR
    UI["termforge"]
    Core["ptyx.Session"]
    GDB["gdb.GDBClient"]
    DLV["dlv.Client"]
    OOCD["openocd.Client · planned"]

    UI --> Core
    Core --> GDB
    Core --> DLV
    Core --> OOCD

Design decision: OpenOCD is a separate backend, not a GDB wrapper. Some workflows may use both (OpenOCD for flash/reset, GDB for symbols) — session orchestration belongs in app/core.


Future JTAG integration

JTAG debugging may arrive through:

  1. OpenOCD as transport (preferred — reuse OpenOCD adapter).
  2. Direct JTAG library (e.g., libftdi) for specialized hardware bring-up.

gdbforge UI would expose:

  • Chain scan / device selection pane.
  • TAP state indicator.
  • DR/IR scan views.

These are feature panes (PLUGINS.md), not core UI changes.


Kernel debugging

Current (Lua): kgdb bring-up without Go mux — :lua kgdb_uart (UART + external kdmx) and :lua kgdb_net (Ethernet / target remote). See KERNEL_KGDB.md. Same design rule as remotegdb: GDB owns RSP; gdbforge is MI UI + orchestration.

Kernel debugging still introduces longer-term UI requirements:

Requirement UI response
Multiple address spaces Memory pane with context selector
Crash dumps / vmcore Read-only source + backtrace panes
Remote targets Backend connection manager (not UI)
Module / symbol load Async events → source pane refresh

Further planned options:

  • Optional in-process UART mux (replace external kdmx; same :lua kgdb_uart UX)
  • crash utility integration for dump analysis
  • Custom /proc/kcore readers

Design constraint: kernel workflows must not fork the UI. New panes and backends extend the existing widget and event model.


Design constraints

Constraint Reason
Backends never import termforge Testability, headless automation
Async-only responses MI2 / OpenOCD are streaming
Exclusive PTY write / shared read UI + AI share one ptmx safely
Parse MI in gdb layer Widgets display buffers, not raw protocol
Session config outside MCP Target binary/args from CLI; AI uses live session
Same-process AI One debug context for manual + :AI