Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ feat(tui): compact tool steps with Ctrl+E details toggle
Absent/zero `windowTokens` holds the last fill. The bar saturates at
full; the percent stays honest when `winCtxTok` exceeds `maxContext`
(stale catalog / last-resort max) — never a contradictory `100%`
beside a larger used count. Live tok/s rides the
header from this-call `usage`/`done` fields (`generationTokensPerSecond`
preferred, else `tokensPerSecond`). Absent/zero holds the last rate; a
new turn clears the chip. Never divide cumulative `outputTokens` by
wall latency.
beside a larger used count. Live tok/s is tracked from this-call
`usage`/`done` fields (`generationTokensPerSecond` preferred, else
`tokensPerSecond`) 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 cumulative `outputTokens` by wall latency.
- `internal/tui` is split by responsibility: `model.go` holds the core
model, `inspect.go` owns transcript item focus and bounded tool paging, `events.go` event handling, `input.go` key/text input,
`input_reassembler.go` the raw terminal input stream, `approval.go`
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,19 +322,20 @@ own front-end settings are separate; see [Configuration](#configuration).
session spend or sub-agent tokens. The bar saturates at full; if the
observed window exceeds the advertised model limit the percent goes
over 100% instead of a contradictory `100%` next to a larger used
count. Live `plan N/M` and `▶ N jobs` /
`✗ job` instruments ride the same bar when a plan or background job is
active.
count. `▶ N jobs` / `✗ job` instruments ride the same bar when a
background job is active; plan progress rides the busy line while a
turn runs and the `/plan` tab otherwise.
- **Per-turn footers & `/stats`** — token counts and latency ride every turn
head (`⚡` latency, `⌂` context, `↳` output tokens, `↗` tok/s, `⚒` tools);
`/stats` opens a sheet that rolls up the session (speed, TTFT, LLM time,
cost, cache, context). The `⎇` glyph is reserved for git commits in the
transcript.
- **Generation speed** — live `↗ tok/s` in the header from `usage` frames
- **Generation speed** — live `↗ tok/s` in the cockpit stats sheet
(`/server`) from `usage` frames
(prefers `generationTokensPerSecond` when the stream measured TTFT;
otherwise end-to-end `tokensPerSecond`). The same rate seals onto the
turn footer after `done`. Missing/zero rates are held, never invented
from cumulative output ÷ wall latency; a new turn clears the chip.
from cumulative output ÷ wall latency; a new turn clears it.
- **Cost tracking** — when odek has token prices configured, the header shows
the running session spend, each turn footer its estimated cost, and
`/stats` adds the `max_cost_usd` cap when set; hidden entirely otherwise.
Expand Down
10 changes: 10 additions & 0 deletions internal/client/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package client

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -41,6 +42,12 @@ type PlanSnapshot struct {
Steps []PlanStep `json:"steps"`
}

// ErrPlanUnavailable marks a 404 from the plan route — the engine is too
// old to serve it. Permanent for this process; every other error (timeout,
// 5xx, transport blip) is transient and must not stop the client's poll
// chain (ErrJobsUnavailable pattern).
var ErrPlanUnavailable = errors.New("session plan route unavailable")

// SessionPlan fetches the structured plan of a session. The token is the
// session-scoped auth token (same as cancel/resume); rate limiting and auth
// match every sibling session endpoint.
Expand All @@ -51,6 +58,9 @@ func (c *Client) SessionPlan(sessionID, sessionToken string) (PlanSnapshot, erro
return PlanSnapshot{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return PlanSnapshot{}, fmt.Errorf("%w: status %s", ErrPlanUnavailable, resp.Status)
}
if resp.StatusCode != http.StatusOK {
return PlanSnapshot{}, fmt.Errorf("session plan: status %s", resp.Status)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/client/plan_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"errors"
"net/http"
"strings"
"testing"
Expand Down Expand Up @@ -70,6 +71,8 @@ func TestSessionPlan_HTTP404(t *testing.T) {
t.Fatal("expected error on 404")
} else if !strings.Contains(err.Error(), "404") {
t.Errorf("error should mention status, got: %v", err)
} else if !errors.Is(err, ErrPlanUnavailable) {
t.Errorf("404 must wrap ErrPlanUnavailable (permanent, stops the poll chain), got: %v", err)
}
}

Expand Down
23 changes: 3 additions & 20 deletions internal/tui/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,23 +147,6 @@ func (m *Model) shelfView() string {

// ── header instruments ──────────────────────────────────────────────────────

func (m *Model) headerPlanLabel() string {
if !m.planInit || m.planAvail != planAvailable || !m.plan.Found {
return ""
}
total := len(m.plan.Steps)
if total == 0 {
return ""
}
done := 0
for _, st := range m.plan.Steps {
if st.Status == client.PlanDone {
done++
}
}
return fmt.Sprintf("plan %d/%d", done, total)
}

func (m *Model) headerJobsLabel() string {
n := 0
failed := false
Expand All @@ -184,11 +167,11 @@ func (m *Model) headerJobsLabel() string {
return ""
}

// headerInstruments renders the header's status strip. Plan progress no
// longer rides here — the /plan tab owns it — and jobs remain: a running
// or failed job is actionable from any surface.
func (m *Model) headerInstruments() string {
var parts []string
if s := m.headerPlanLabel(); s != "" {
parts = append(parts, s)
}
if s := m.headerJobsLabel(); s != "" {
parts = append(parts, s)
}
Expand Down
20 changes: 11 additions & 9 deletions internal/tui/chrome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,24 @@ func TestApprovalKeepsComposer(t *testing.T) {

func TestHeaderInstruments(t *testing.T) {
m := newTestModel()
m.jobs = []client.Job{{Status: "running"}, {Status: "running"}}
if got := m.headerJobsLabel(); got != "▶ 2 jobs" {
t.Errorf("jobs label = %q, want ▶ 2 jobs", got)
}
// Plan progress no longer rides the header (the /plan tab owns it) —
// even with an accepted, progressing plan on the model.
m.planInit = true
m.planAvail = planAvailable
m.plan = client.PlanSnapshot{Found: true, Steps: []client.PlanStep{
{ID: "a", Status: client.PlanDone},
{ID: "b", Status: client.PlanPending},
}}
m.jobs = []client.Job{{Status: "running"}, {Status: "running"}}
if got := m.headerPlanLabel(); got != "plan 1/2" {
t.Errorf("plan label = %q, want plan 1/2", got)
}
if got := m.headerJobsLabel(); got != "▶ 2 jobs" {
t.Errorf("jobs label = %q, want ▶ 2 jobs", got)
}
head := plain(m.header())
if !strings.Contains(head, "plan 1/2") || !strings.Contains(head, "2 jobs") {
t.Errorf("header missing instruments:\n%s", head)
if strings.Contains(head, "plan 1/2") {
t.Errorf("plan label must not render in the header:\n%s", head)
}
if !strings.Contains(head, "2 jobs") {
t.Errorf("header missing jobs instrument:\n%s", head)
}

m.jobs = []client.Job{{Status: "failed"}}
Expand Down
11 changes: 2 additions & 9 deletions internal/tui/cockpit.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ func (m *Model) popoverView(w, h int) string {
// cockpitServerSection is the server/link card: identity and liveness from
// the server_info/pong snapshot plus the heartbeat round-trip.
func (m *Model) cockpitServerSection() string {
// The engine version row lives in the session stats sheet below (⬢ engine)
// — rendering it here too duplicated it inside the same cockpit.
rows := [][2]string{
{"version", orDash(prefixVersion("odek ", m.odekVersion))},
{"model", orDash(m.model)},
{"stream", boolDash(m.serverStream, "⚡ live deltas", "buffered")},
{"sandbox", boolDash(m.sandbox, "isolated", "host access")},
Expand Down Expand Up @@ -197,14 +198,6 @@ func (m *Model) cockpitRows(title string, rows [][2]string) string {
return b.String()
}

// prefixVersion prepends a label when v is non-empty.
func prefixVersion(label, v string) string {
if v == "" {
return ""
}
return label + v
}

// boolDash renders a yes/no value with distinct labels per state.
func boolDash(v bool, yes, no string) string {
if v {
Expand Down
14 changes: 14 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,20 @@ func (m *Model) statsBody() string {
}
}

// The engine version lives here now — the header dropped its odek
// segment — and it renders even before the first turn: knowing the
// engine build is a fresh-attach question, not a session-rollup one.
if m.odekVersion != "" {
rows = append(rows, row{"⬢", th.statsLabel, "engine", th.statsValue.Render(m.odekVersion)})
}
// Live rate while a turn streams: the sealed rows below only exist
// after done, but the cockpit is the rate's home now — the header
// dropped its chip, so an open sheet must not go blind mid-turn.
if m.busy && m.tokPerSec > 0 {
live := th.statsValue.Render(formatTokPerSec(m.tokPerSec)) + th.statsDim.Render(" · live")
rows = append(rows, row{"↗", th.statTime, "speed", live})
}

// Align values into a column just past the widest label.
gutter := 0
for _, r := range rows {
Expand Down
61 changes: 61 additions & 0 deletions internal/tui/header_simplify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package tui

import (
"strings"
"testing"

"github.com/BackendStack21/bodek/internal/client"
)

// ── header simplification: plan x/t, odek version, and tok/s leave the top
// bar. Plan progress lives in the /plan tab, tok/s and the context gauge in
// the cockpit; the engine version moves INTO the cockpit so it stays reachable.

func headerModel(t *testing.T) *Model {
t.Helper()
m := newTestModel()
m.odekVersion = "v1.40.2"
m.bodekVersion = "v1.11.10"
// A plan with progress would previously paint "plan 1/2" in the header.
m.planInit = true
m.planAvail = planAvailable
m.plan.Found = true
m.plan.Steps = []client.PlanStep{
{ID: "a", Status: client.PlanDone},
{ID: "b", Status: client.PlanPending},
}
// A live rate would previously paint "↗ x tok/s".
m.tokPerSec = 42.5
return m
}

func TestHeaderDropsPlanVersionRate(t *testing.T) {
m := headerModel(t)
head := plain(m.header())
for _, banned := range []string{"plan 1/2", "odek v1.40.2", "tok/s", "↗"} {
if strings.Contains(head, banned) {
t.Errorf("header still shows %q:\n%s", banned, head)
}
}
// Kept: bodek's own version rides the logo; instruments strip keeps jobs.
if !strings.Contains(head, "v1.11.10") {
t.Errorf("bodek version missing from header:\n%s", head)
}
}

func TestCockpitShowsEngineVersion(t *testing.T) {
m := headerModel(t)
m.popover = true // cockpit popover embeds the stats sheet
v := plain(m.View())
if !strings.Contains(v, "v1.40.2") {
t.Errorf("cockpit missing the engine version row:\n%s", firstLines(v, 22))
}
}

func firstLines(s string, n int) string {
parts := strings.Split(s, "\n")
if len(parts) > n {
parts = parts[:n]
}
return strings.Join(parts, "\n")
}
13 changes: 7 additions & 6 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ type Model struct {
thinking string // canonical: "" inherit, or disabled|low|medium|high
expandAll bool // Ctrl+E: render every step's full output/logs

odekVersion string // engine version shown in the header ("" hides it)
odekVersion string // engine version, shown in the cockpit stats sheet ("" hides it)
bodekVersion string // bodek's own version, for the startup update check

panel panelMode
Expand All @@ -275,11 +275,12 @@ type Model struct {
confirm confirmKind // armed destructive action: y fires, any other key disarms
stopTarget string // task_id armed by confirmStopAgent

agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot
agentsSeq int // agents-tab poll generation; stale ticks drop
eventsTabSeq int // events-tab poll generation; stale ticks drop
kickAgents bool // pending agents-tab refresh (flushKicks)
kickMemory bool // pending memory-tab refresh (flushKicks)
agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot
agentsSeq int // agents-tab poll generation; stale ticks drop
eventsTabSeq int // events-tab poll generation; stale ticks drop
kickAgents bool // pending agents-tab refresh (flushKicks)
kickMemory bool // pending memory-tab refresh (flushKicks)
planConfirmIssued bool // tool_result debounce fired a confirm fetch

// Background jobs tab + lifecycle watcher (odek v1.38+ /api/jobs — the
// engine pushes nothing for job lifecycle, so bodek watches REST).
Expand Down
40 changes: 37 additions & 3 deletions internal/tui/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
Expand Down Expand Up @@ -96,6 +97,9 @@ func (m *Model) issuePlanFetch(confirm bool) tea.Cmd {
return nil
}
m.planReqSeq++
if confirm {
m.planConfirmIssued = true // the tool_result debounce fired
}
cl := m.cl
want := m.sessionID
token := m.authToken
Expand All @@ -117,8 +121,17 @@ func (m *Model) handlePlanMsg(msg planMsg) (cmd tea.Cmd) {
}
}()
if msg.err != nil {
m.planAvail = planUnavailable
if m.panel == panelPlan {
// Only a 404 (old engine, route absent) is permanent. A transient
// error — timeout under load, a 5xx, a transport blip — must not
// kill the poll chain: the defer below re-arms and the next tick
// retries. Freezing the strip on one slow GET was the "plan 0/6
// forever" bug.
if errors.Is(msg.err, client.ErrPlanUnavailable) {
m.planAvail = planUnavailable
if m.panel == panelPlan {
m.syncPlanPanelMsg()
}
} else if m.panel == panelPlan {
m.syncPlanPanelMsg()
}
return nil
Expand All @@ -145,6 +158,7 @@ func (m *Model) handlePlanMsg(msg planMsg) (cmd tea.Cmd) {
m.planVer = msg.snap.Version
m.planInit = true
m.planDirty = false
m.planConfirmIssued = false
if m.panel == panelPlan {
m.syncPlanPanelMsg()
}
Expand Down Expand Up @@ -177,7 +191,18 @@ func (m *Model) handlePlanTick(msg planTickMsg) tea.Cmd {
return nil
}
if m.planDirty {
// Stay armed; do not bump planReqSeq over the in-flight confirm.
// The confirm fetch is the only reply that can clear a dirty
// strip — and it can die in flight (superseded by a poll/kick that
// bumped planReqSeq). Waiting forever for a dead reply froze the
// strip at the optimistic patch. BUT a confirm is only legitimate
// AFTER the tool_result debounce fired: re-issuing before that
// would fetch the PRE-write store (create leaves planVer at 0, so
// the monotonic guard cannot reject it) and wipe the optimistic
// steps. So: re-issue only once a confirm was issued; until then
// the debounce owns the fetch and the tick stays armed.
if m.planConfirmIssued {
return tea.Batch(m.fetchPlanConfirm(), m.armPlanPoll())
}
return m.armPlanPoll()
}
return m.fetchPlan()
Expand Down Expand Up @@ -291,6 +316,7 @@ func (m *Model) resetPlanState() {
m.planVer, m.planInit = 0, false
m.planAvail = planUnknown
m.planDirty = false
m.planConfirmIssued = false
m.planLiveKick = false
m.planDebSeq++
m.planReqSeq++
Expand Down Expand Up @@ -463,6 +489,14 @@ func (m *Model) planFollowup() tea.Cmd {
if c := m.kickPlanLive(); c != nil {
cmds = append(cmds, c)
}
// A dirty strip whose confirm died in flight (run went idle before
// the reply landed) would stay frozen until the first busy tick.
// Re-issue immediately at the turn boundary — only after the
// debounce fired (planConfirmIssued), so a pre-write fetch can
// never wipe the optimistic patch.
if m.planDirty && m.planConfirmIssued {
cmds = append(cmds, m.fetchPlanConfirm())
}
}
switch len(cmds) {
case 0:
Expand Down
Loading