bodek is a pure front-end: a Bubble Tea TUI that streams from an
odek serve engine over WebSocket. It never re-implements agent behaviour
(tools, approvals, sandbox, skills, memory) — all of that lives in odek.
Keep it that way.
| Path | Responsibility |
|---|---|
cmd/bodek |
CLI entry point: flags, lifecycle, version / upgrade subcommands |
internal/server |
Launch / attach to odek serve, resolve the auth token |
internal/client |
odek serve WebSocket protocol (transport + REST + decoding) |
internal/tokens |
Local persistence of per-session auth tokens |
internal/workspace |
Per-cwd draft, queue, history, and last-session id |
internal/tui |
The Bubble Tea model, update loop, panels, and view |
internal/update |
Self-upgrade: fetch and swap in the latest GitHub release binary |
make build # compile → bin/bodek
make test # go test -race ./... (always use the race detector)
make vet # go vet ./...
make lint # golangci-lint (config: .golangci.yml)
make fmt # go fmt ./...
make cover # coverage report for internal packagesRun all of these and make them pass before committing:
make fmtmake vetmake lint— must report0 issuesmake test— the full race-enabled suite, all packages green
Never commit with failing tests or lint findings. If a change breaks an existing test, fix the code or deliberately update the test — never delete or weaken a test just to get green.
Use Conventional Commits semantics:
<type>(<scope>): <short imperative summary>
<optional body: what and why, not how>
- Types:
feat(user-visible behaviour),fix(bug),refactor,perf,test,docs,chore,ci,build. - Scope: usually the package, e.g.
tui,client,server,tokens. - Summary: lowercase, imperative, no trailing period, ≤ 72 chars.
- Breaking changes:
!after the scope and aBREAKING CHANGE:footer. - One logical change per commit; don't mix refactors with features.
Examples from this repo's history:
fix(tui): interleave reasoning blocks with tool calls chronologically
feat(tui): compact tool steps with Ctrl+E details toggle
- Internal-package coverage is ~99% — keep it there. Any new behaviour needs a test; any bug fix needs a regression test.
- TUI tests drive the model directly: construct a
Model(seenewTestModelininternal/tui), feedclient.Events throughhandleEvent/Update, and assert onm.msgs, rendered output (renderMessage,plain()), or key handling (key("ctrl+e")). - Don't assert on exact timings or spinner frames — keep tests non-flaky.
- The client and server packages are tested against an in-process
odek servestand-in; reuse those fixtures.
- Standard Go style,
go fmtclean, golangci-lint clean (.golangci.yml). - Match the surrounding file's comment density and naming; exported API is rare here — most identifiers stay unexported.
- Minimal diffs: change only what the task requires. No drive-by refactors, renames, or reformatting.
- Security: anything rendered from the wire (tokens, tool output, file
contents) must go through
sanitize()— seeinternal/tui/model.go. Never render raw remote content. Tool results arrive as JSON envelopes wrapped in untrusted-content markers: decode the envelope and fold the wrappers away before display — don't render either verbatim. - The transcript model in
internal/tui: each assistantmessagekeeps a chronologicalitems []turnItemtimeline (reasoning blocks, step references, and reply segments interleaved — one think→reply cycle per segment pair, each rendered independently). Preserve arrival order; don't regress to a single per-turn reasoning blob or a single trailing answer card, and don't reintroduce a separate in-transcript thinking placeholder alongside it.msg.contentstays the "\n\n"-joined blob of all reply segments (appendReply maintains it) for export, stats, and hand-built messages; turn markers (**Cancelled.**etc.) attach to the last reply. The calm default hides reasoning previews and tool responses: the intent rail and step result bodies paint only under^E(details) or a deliberate expand —↑/↓selects an item andEnteropens it; click selects and expands one step; the result peek is gone. While a turn streams, its head line carries the run's elapsed counter right-aligned at the viewport edge (therunStartclock, whole seconds, dropped on finalize — the sealed telemetry row sits under the reply). A deliberately opened live rail still holds completed sentences (or a short frozen stem) so a fastthinking_deltastream cannot ticker it; failed steps still auto-expand once on the live path. The status line stays a quietreasoning/composinglabel while those surfaces own the words; live tool steps use a static▸(one spinner: the status line). A finished step keeps its sealeddurin the right rail — after the typed chip when one exists — so per-tool times outlive completion; resumed history (dur0) shows none.beginWireTurnmust notGotoBottom—refresh()already sticks when the reader is at the bottom, and a yank fights the "↓ new output" contract. Render-only layers (intent rail, turn receipt, live swarm band, sub-agent chip strip, swarm receipt rail) must not reorderitems[]— the parallel-tool swarm is a consecutive overlay on unfinished parent steps and dissolves when one leftover remains. Sub-agent children live onstep.agentsand always paint as chips;agentSel(1-based, 0 = none) focuses one mini-card. The swarm verdict is a receipt rail, never stuffed intomsg.content. - Events arrive from
internal/clientalready in chronological order — keep ingestion order-dependent and idempotent.listen()drains every pending frame into oneeventBatchMsgso athinking_deltafirehose cannot run View per fragment (that filled the 256-deep Events buffer and tripped odek's 30s write timeout — connection lost after any prompt).ingestWireBatchre-armslisten()as a top-level cmd, never nested inside handleEvent's Batch. The client also merges consecutive thinking/token deltas (16 at a time) before enqueueing. The header connection lamp stays lit while a turn runs (◉); idle is●, reconnect◌, down○. Progress stays on the status line — an empty corner was read as a dropped socket. The headerctxgauge is the parent conversation window: odek ≥ v2.3 sendswindowTokens(last parent prompt) plusmaxContextTokens(runtime model limit — beats/api/models).done.inputTokensis billing spend including charged sub-agents — never a gauge. Pre-v2.3 still deltascontextTokens. Absent/zerowindowTokensholds the last fill. The bar saturates at full; the percent stays honest whenwinCtxTokexceedsmaxContext(stale catalog / last-resort max) — never a contradictory100%beside a larger used count. Live tok/s is tracked from this-callusage/donefields (generationTokensPerSecondpreferred, elsetokensPerSecond) and renders in the cockpit stats sheet — the header carries no rate chip. Absent/zero holds the last rate; a new turn clears it. Never divide cumulativeoutputTokensby wall latency. internal/tuiis split by responsibility:model.goholds the core model,inspect.goowns transcript item focus and bounded tool paging,events.goevent handling,input.gokey/text input,input_reassembler.gothe raw terminal input stream,approval.gothe approval flow,chrome.godrawer-sheet / shelf / header-instrument / session-home layout,reconnect.gosocket recovery. Put new code in the matching file instead of growingmodel.go.- First-run home (
welcomeinbanner.go) is the working directory plus one next-action tip. Branding lives in the header — do not reintroduce a splash wordmark. After^L,sessionHomekeeps last prompt / receipt / recents;/newreturns to first-run. - Composer newline is
⇧⏎(shift+enter, alsoalt+enter/^J/ctrl+enter).keyMsgFromCodemust keep ctrl+enter distinct from plain enter (an accidental submit) and must decode shift+printable CSI chords (kitty CSI-u97;2u, xterm27;2;97~) into their uppercase rune instead of dropping them; the friction editor treats theshift+enter/ctrl+entersentinels as no-ops so their literal text never splices intoapprTyped. The newline chord set lives in one helper —newlineChordininput.go— and every Enter-accepting surface routes through it (composer, AC popup, approval composer, clarify); a well-formed enhanced-key chord that fails to decode becomes analt+unmapped-chordsentinel thathandleKeyreports as a transient note instead of silent drop.^Kis gated while an approval or clarify card is head, and queue-strip focus outranks the skill-suggestion chip (its chords never answer a passive card whileqfocusis set). Bares/xsave/skip a pending suggestion only on an empty draft — the same modifier-free fallback approvals use for terminals that cannot deliver Alt chords (macOS Option-as-UTF-8). Enable kitty disambiguate (flag 1) and xtermmodifyOtherKeys=2fromInit— after alt-screen — so Cursor/xterm.js encodes Shift+Enter (CSI 27 ; 2 ; 13 ~) instead of CR. Never enable kitty "report all keys".FilterShiftEnterrewrites those CSI sequences (and the remapped^C/^K/ esc) back into KeyMsgs — Codium/Cursor encode Ctrl+C as CSI once those modes are on.RestoreEnhancedKeysclears leftovers on startup and shutdown. A terminal read can end mid-sequence:AssembleInput(wired inbuildProgramOptions, non-Windows) reassembles it before Bubble Tea parses each read, under two invariants: a read from the terminal never fetches more than the room its caller offers, and a release never ends inside an escape sequence. Bubble Tea parses every read on its own, so a chunk that stops inside a mouse report turns the head into a finished CSI and the coordinate bytes behind it into typed runes — exactly what a wheel burst from a terminal without mode 1006 produces, since Terminal.app'sxterm-256coloradvertiseskmous=\E[Mand answers with legacy 6-byteESC [ Mreports that any burst past Bubble Tea's 256-byte read cuts mid-report. Bounding the read (not the release) is what keeps input flowing: bytes read ahead but not released are invisible to the kqueue/epoll readiness wait that gates the nextRead, so holding one back stalls the rest of the burst until the next keystroke. The release clamp covers what the read bound cannot — a non-file source (tests) reading its full window, and a caller whose buffer shrinks mid-stream. PartialESC [ < …heads wait for their tail; a mouse-shaped head whose tail never arrives is dropped — never echoed into the composer. Only the SGR form is disposable: the legacy X10 form is joined but never dropped, because its coordinate bytes are indistinguishable from typed text. Bracket-paste bodies are user data and are never dropped, whatever they contain. A mouse head a fresh read cannot continue is a truncated report and is discarded so the read is not eaten. The wrapper only wraps a terminal file; a pipe or redirected stdin keeps Bubble Tea's own handling. It also presents itself as aterm.Filewith the real descriptor, because Bubble Tea enables and restores raw mode only for input it recognises as a file (tty_unix.goinitInput) — a plain wrapper would leave the program in cooked mode. One pump goroutine per program instance;Closestops it without closing stdin, which the program does not own. Alt-screen always enables mouse cell-motion so the wheel scrolls (and clicks hit turn heads / answer cards / steps / the queue). A left-click on an answer card (or a collapsed summary) copies that turn'smsg.content— final or the partial stream — and parksfocusIdxso a follow-upalt+ycopies the same card. Success is a footer✓ Copiedflash only (noticeTTL, generation-guarded expire — no transcript notice). Turn heads still fold; step headers and chips still win on their own lines; raw/helpcards are not copy targets. Local clipboard helpers (pbcopy/wl-copy/clip) must run as a plaintea.Cmd—tea.ExecProcessreleases the alt-screen and flickers on every copy. OSC 52 (SSH / helper-less) still usestea.Exec.tea.Exec(attention, OSC 52) must returnafterExecsorestoreAfterExecre-arms mouse and Shift+Enter — Bubble Tea's RestoreTerminal does not.--plainskips mouse reporting. - The management drawer is a bottom sheet: keep ~8 transcript rows above
it (
sheetTranscriptMin); full-bleed only when the terminal cannot fit transcript + sheet. Layout-only — tab grammar (]/[/⏎/esc) stays. Approvals render above a live composer: barea/d/tdecide only while the composer draft is empty (a non-empty draft, paste, or any modifier routes to the composer), andAlt+A/Alt+D/Alt+Tdecide. Text, paste, cursor keys, and Enter retain composer behavior. Friction capturesapprTypedonly after explicitAlt+A; Escape returns to the draft without deciding. Keep the editor rune/row bounded and reset it on head changes. Clarify questions capture the keyboard intoclarifyBuf: the spacebar is Bubble TeaKeySpace(notKeyRunes), so every printable — spaces, punctuation, paste — must append. Wrap the answer by cell width and paint only a capped tail so a long paste cannot pushViewpast the terminal; the buffer keeps the full text. Expiry autocloses the card and parksfocusIdxplus the viewport on the latest transcript message (a surviving queued successor must not yank scrollback). The unfocused queue is a shelf chip;^Qunfolds the strip (qfocus). - The TUI reconnects with backoff and resumes the session after a socket
drop (
reconnect.go) — don't break that by assuming a single connection per run. - Streaming renders are coalesced into one flush per 80ms for
performance; batching new redraw paths the same way keeps the TUI
responsive. Live reply segments glamour-render on that flush (not only
at turn end). The spinner animates the status line only — transcript
rebuilds for live transcript clocks (streaming head counter, step timers)
coalesce at 250ms (
tailClockFlushMsg); the lane ticks whenever a message streams, not only when steps run. Finished message blocks and done tool steps cache their render output; invalidate per message/step on expand, not the whole prefix. - The slash-completion popup holds key capture while open; typed keys must keep flowing to the input. Route keys through the popup first, then fall through to normal input handling.
- ESC closes the topmost window, then inspect chrome, then (if busy)
arms cancel. Order: confirm disarm → palette → drawer edit/detail/tab
→ cockpit → find →
@//popup → queue strip → approval (collapse details, then arm cancellation;Alt+Ddenies) → clarify (arm cancel while busy; the card stays) → skill chip /^E/ open thinking / agent focus / expanded step / help card → cancel gate. Do not let a leftover overlay swallow ESC without dismissing.Ctrl+Xindependently arms turn cancellation before modal routing; the confirmation footer must remain visible above every panel and approval. Inspect Escape returns to the composer first. - Management drawer tabs (memory/skills/tools/config — and jobs) have a detail
submode:
⏎expands the selected row (skill description, full fact text, MCP args, raw config JSON — everything throughsanitize()),esc/qfolds back,ppromotes in place. Tab switches reset it (switchDrawerTab); keep that reset when adding new open paths. - The jobs tab pairs with a REST lifecycle watcher (
jobs_tab.go): the TUI polls/api/jobs(10s in background, 3s while the tab is visible) and diffs status transitions into transient notes. odek ≥ v1.40 also pushesbg_jobframes on start/exit —handleEventroutes them throughkickJobsFetch()for an immediate snapshot, watcher tick as fallback;bg_wakeframes become transient notes. Generation counters (jobsSeq/jobsWatchSeq) drop stale ticks — keep both chains generation-guarded when touching the cadence. The same push-beats-poll pattern drives the other tabs:subagent_stateandmemory_eventframes setkickAgents/kickMemoryflags thatflushKicks()drains into ONE fetch per burst (per-event cmds would starve insideingestWireBatch); the events tab polls the runtime ring at 3s while visible with seq-stamped fetches (eventsMsg.seq— a late landing never clobbers a filter change, and open/toggle/clear bumpeventsTabSeq). Agents-tab rows prefer live card telemetry over the REST snapshot (skipping lost cards), and drawer selections anchor by identity (fact text / TaskID) across rebuilds so a refresh never silently retargets the detail view or the stop gate. - The narration-line plan strip (
planStripLabelinplan.go) patches onplantool_call (applyPlanMutation) so the count moves on that frame. REST (GET /api/sessions/{id}/plan) confirms aftertool_result(250ms debounce) — a fetch on the call races the store and paints the previous snapshot.planDirtydrops every non-confirm reply (create leavesplanVerstale, so a version-newer poll can still be the pre-write store). Only the tool_result confirm fetch may land — that is also how a rejected write reverts the patch. Live poll is 1s whilebusy, 3s on the/plantab;planLiveKickarms it fromsendPrompt/beginWireTurnviaplanFollowup, never as a Tick batched into submit. Generation counters (planDebSeq/planReqSeq/planPollSeq) drop stale ticks — keep them when touching the cadence. - Server-initiated wake turns (odek ≥ v1.40,
system_initiatedon the session frame) open a streaming card from the wire (openWakeTurninevents.go): without it every streamed event drops, because cards only opened on the local send path. The card carries thesystemWakemarker (renders⬡ odek · wake); wake turns are never rendered as user messages, and a wake frame arriving during an operator turn opens nothing. With odek'sturn_startedprotocol the dedicated frame is the primary signal for every turn (wake → wake-marked card, foreign operator turn → plain remote card); the stamped session frame andensureWireTurn's lazy open (first streamed event while idle) remain as fallbacks, so a turn must be missed by all three paths to drop.
- Never modify the odek project from a bodek session. bodek is a pure
front-end; if a fix requires changes in odek (protocol,
odek serve, engine behaviour), do not edit that repo — instead summarize the required change and suggest it to the user as a task for an odek session. - Don't run
git commit/git pushunless the user explicitly asks. - Don't add dependencies without checking
go.modfirst and flagging it to the user. - Keep
README.md(key bindings, commands, feature list) and this file in sync when you change user-visible behaviour. - CI (
.github/workflows) runs build, vet, lint, and race tests on every push — a red pipeline means the commit checklist above was skipped.
- Crowded headers preserve sandbox and connection state before version metadata; compact composer footers must fit one terminal row and retain actionable keys.
- Home hints wrap by display cells, including wide Unicode paths. Keep the home free of a second wordmark or an additional feature dashboard.
- Structured batch summaries must not invent successful execution when normalized results omit exit/status metadata. Expanded plan and batch views remain behind existing details controls and preserve the chronological transcript.
TestTerminalWorkflowLayoutschecks real views across four themes at 40, 80, and 120 columns. SetBODEK_RENDER_PREVIEW_DIRto write optional ANSI fixtures for visual review without an engine/provider; generated captures are not source.
↑/↓traverses chronological tools and reasoning while inspecting, with visible focus;Enterexpands the selected item. Typing returns to the composer.PgUp/PgDnpages a selected tool and Right cycles its sub-agent chips. Copy uses the selected item. Clear/resume must discard stale inspect coordinates.- Expanded tool and sub-agent response bodies show at most eight display rows plus one pager row, shrinking with terminal height. Split embedded newlines before counting and clamp ANSI display widths. Clamp paging at both ends; invalidate per-step caches when selection, offset, height, or theme changes.
- Keep normalized
step.resultfor copying/error compatibility and boundedstep.detailResultfor structured display. Preserve sanitized command/path identity through live and history ingestion; never infer item boundaries from[N]text. Report omitted bodies/items and total subset counts explicitly. Generic previews cap at 128 KiB/200 lines; structured details at 64 KiB/256 items. - Completed plans include the producer's
all N steps completeformat. - All footer modes fit one display row, prioritizing primary and exit actions. Recompute viewport height when the new-output shelf changes. Approval details page with Alt+PgUp/PgDn; compact terminals drop decorative card borders.
/themeuses the existing searchable selector, preselecting/marking the current choice. Enter applies/persists, Escape leaves the theme unchanged. Keep direct/theme namealiases. Late session results cannot contaminate the theme list.- Test state transitions and real event-to-view rendering, including approvals arriving during typing/paste, stop from overlays, tool paging, plan completion, active theme selection, and narrow/short layouts. Semantic small-text colors must pass contrast checks, not only body text.