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, connection diagnose, and orphan-watchdog re-exec |
internal/server |
Launch / attach to odek serve, resolve the auth token, progress-aware shutdown with escalation countdown |
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/settings |
Persisted front-end preferences (theme, bell, notify, plain, verbosity, thinking, resume, reduce-motion) at ~/.bodek/config.json; flag > file > default resolution with a theme-only BODEK_THEME env override (BODEK_CONFIG relocates the file); odek's config is untouched |
internal/watchdog |
Orphan guard: re-execs a tiny watchdog that terminates odek serve when the bodek parent dies for any reason (per-platform impls) |
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. While a batch drains,handleEventparks its state-consuming follow-ups (sendQueued,planFollowup, attention,flushKicks) inbatchCarryinstead of returning them, so the flat batch never drops a queue pop, bell, or tab refresh. The client also merges consecutive thinking deltas (16 at a time) before enqueueing. Assistant text deltas enqueue immediately so short progress notes cannot wait for a later frame; their redraws still coalesce in the TUI. 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 — see the approval empty-draft rule for the same modifier-free fallback on terminals that cannot deliver Alt chords (macOS Option-as-UTF-8); one rule, two surfaces, keep them in sync. 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 enables mouse cell-motion by default (--plainis the documented opt-out, skipping mouse reporting) 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. On Alt-less terminals (macOS Option-as-UTF-8),^Uclears the whole draft first, restoring the bare-key path — never leave such a terminal with no reachable approval binding. 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). - Sessions other front-ends created (the odek WebUI keeps its session
tokens in the browser) must load:
client.SessionDetailfollows odek's mint-only bootstrap reply ({"session_id","bootstrapped":true}plus anX-Session-Tokenheader, no transcript) with one refetch using the minted token, and never returns that stub as a session. Replayed user turns showprincipal_prompt(what was typed), notcontent, which carries attachments and @-resources inlined in untrusted wrappers.handleSessionDetailrefuses a resume whilebusy— the running turn's frames would stream into a replayed message atcurIdx. - 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. - Assistant notes are reply segments even when no reasoning event precedes them (OpenAI buffered notes need odek v2.29.7 or later). Plain mode prints completed segments before tools/reasoning and drains only unprinted text on completion, error or disconnect. Batch ingestion captures each event's linear output before later frames mutate the turn, returning one ordered print command alongside the flat listener and refresh commands.
- Superseded drafts (odek ≥ v2.33):
answer_supersededturns the reply segments streamed since the last tool call intodraftitems — a folded⋯ draft revised · <reason>row that opens like reasoning (inspect + Enter,^E) — and rebuildsmsg.contentfrom the surviving replies, so a turn never shows two answers. UseturnItem.isStep()/foldable(); never treat "not reply, not reasoning" as a step. Replay folds records persisted withsuperseded.done.verified == "fail"(or a persisted / bulk verification-failed marker, stripped from the prose) setsunverified:✗ unverifiedon the head and outcome row. Verificationruntime_eventframes only drive theverifying answerstatus label; the client decodesruntime_eventseparately because its objecteventkey collides with the stringSubType. Plain mode prints the draft remainder once plus a[draft revised · …]note. - 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 (if busy) arms cancel. The chain
below is the authoritative 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. While inspecting, ESC leaves inspection per the chain above (inspect items sit near its tail) rather than arming cancel. - 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 arm the wake marker only (an alert-tier note appears just when a busy turn blocks the wake card). 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. - Process lifecycle: bodek owns the
odek servechild end to end — normal Stop shuts it down gracefully (with progress and a live escalation countdown), and the watchdog covers abnormal parent death. Never break either path by assuming the child outlives the parent, or vice versa.
- 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.
- Tool summaries must not invent successful execution when results omit exit/status metadata. Expanded plan views remain behind existing details controls and preserve the chronological transcript.
- odek v2.26.0 retired
parallel_shell,batch_patch,batch_read,multi_grep, andhttp_batch. Keep historical entries on the generic raw/JSON path; do not restore typed cards, argument previews, icons, summaries, or result schemas. Preservedelegate_tasks, allbg_*views, and supported individualshell,patch,read_file,search_files, andhttp_requestrendering. 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.
- A tool step keeps its short header preview separate from the retained call
arguments. Deliberate expansion shows the invocation before the result,
including while the call runs;
alt+icopies the display-safe invocation. Wrap invocation lines by display cells before paging. Escape control and invisible characters visibly, and label any argument retention limit. Replay must offer the same inspection path as live ingestion. - Deliberate step inspection shows one expanded tool at a time and borrows the
composer rows on short terminals without losing the draft. Keep the selected
tool identifiable and the active detail page visible while paging; sub-agent
children remain chips in the transcript. The pager names whether it shows
invocation or result. Global
^Estill expands all details after leaving inspection. At 40 columns, a one-time hint must fit completely and the inspect footer must teachAlt+I. - Approval cards put the exact display-safe command or operation resource first, visibly escape
controls and invisible characters, and state when odek supplied no command
or working directory. Explain that class trust lasts until the current
connection ends; keep allow-once and deny visible at narrow widths. Default
turn footers right-align outcome, elapsed time, tool count, and known cost; full
telemetry remains under
^Eand/stats. - Keep normalized
step.resultfor copying/error compatibility. Preserve sanitized command/path identity through live and history ingestion; never infer item boundaries from[N]text. Generic previews cap at 128 KiB/200 lines and report omitted output explicitly. - 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.