From a3dc49344f8aa1fc5f60ccc4097fa824ab139e9a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 27 Aug 2026 07:21:58 +0200 Subject: [PATCH 1/3] docs: add planning-mode ui integration design Design contract for bodek's read-only surface onto odek's engine plan state: WS-trigger/debounced-REST architecture, drawer tab, live strip, transcript specialization, edge cases and test strategy. Verified against odek serve's /api/sessions/{id}/plan endpoint and tool_call pairing. --- docs/PLANNING_MODE_UI.md | 269 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/PLANNING_MODE_UI.md diff --git a/docs/PLANNING_MODE_UI.md b/docs/PLANNING_MODE_UI.md new file mode 100644 index 0000000..5f01d66 --- /dev/null +++ b/docs/PLANNING_MODE_UI.md @@ -0,0 +1,269 @@ +# Planning Mode — UI Integration Design (bodek) + +Status: **implemented** on `feat/planning-mode-ui` (Option A strip, semantic +transcript rows, drawer tab after Events — decisions §8.1–8.3 resolved). + +odek's planning system (`docs/PLANNING.md` in the odek repo) gives the engine a +structured task plan: one `plan` tool (`create`/`update`/`complete`/`get`), a +mutex-serialized store, and a protected `[Current plan:` system message that +survives trimming and restarts. Planning is **on by default**; kill switches are +CLI → env → global config. This document designs bodek's read-only surface onto +that state. + +bodek is a pure front-end — everything here consumes existing odek protocol. +**No changes to odek are required or proposed.** + +--- + +## 1. Engine contract (verified facts) + +Protocol surfaces available today: + +| Surface | Shape | Notes | +|---|---|---| +| `GET /api/sessions/{id}/plan` | `{session_id, version, found, steps:[{id,title,status,note?}]}` | Read-only, GET-only by contract (POST falls through to session mutators). 404 = unknown session; `found:false` = no parseable plan; all-done collapsed plan = version set, `steps: []`. Auth + rate limiting identical to sibling session endpoints (`handleSessionByID`). | +| Session WS `tool_call{name:"plan"}` | `Data` = full JSON args | Fires for every mutation; plan calls ride ordinary parallel tool batches. | +| Session WS `tool_result{name:"plan"}` | `Data` = rendered plan text | Pairs with its call via the existing LIFO name matcher. | +| Runtime events `plan_created`/`plan_updated` | counts + version only | Delivered via `GET /api/events` ring → **already visible in bodek's Events tab**. Not forwarded on the session WS. | + +Plan state model (engine): `steps[].status ∈ {pending, in_progress, done, +blocked}`; titles ≤200 chars, notes flattened; store caps from resolved config +(defaults 12 steps / 2000 render chars); monotonic `version` per effective +mutation (no-op mutations don't bump). The plan is advisory steering, not a +contract. + +Two properties matter for the UI: + +1. **WS carries triggers, REST carries truth.** The session WS has no dedicated + plan event, but every mutation shows up as a `plan` tool_call/result pair. + The structured view lives behind the REST GET. So: watch the WS, then fetch + REST. +2. **REST survives restarts.** The endpoint parses the newest parseable plan + message out of the persisted transcript (`loop.ExtractPlan`), so a fresh + fetch after reconnect/attach resumes correct state with no replay work. + +--- + +## 2. Design goals + +- **Ambient visibility**: "what is the agent doing overall?" answerable at a + glance, without opening anything. +- **Zero-footprint absence**: planning disabled, no plan yet, or an old engine + without the endpoint ⇒ literally zero new pixels. Same discipline as the + redundant-indicator removal rule. +- **Read-only**: REST is GET-only by engine contract; bodek never mutates plan + state (steering lives with the model). +- **Order-faithful transcript**: plan tool steps stay timeline items in arrival + order; we specialize rendering, not ingestion. +- **Everything model-derived through `sanitize()`** — titles/notes come off the + wire. + +--- + +## 3. Architecture + +### 3.1 Client layer (`internal/client`) — `runs.go` sibling file: `plan.go` + +```go +type PlanStep struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` // pending | in_progress | done | blocked + Note string `json:"note"` // omitted when empty +} + +type PlanSnapshot struct { + SessionID string `json:"session_id"` + Version int `json:"version"` + Found bool `json:"found"` + Steps []PlanStep `json:"steps"` // nil-safe: treated as empty +} +``` + +`Client.SessionPlan(sessionID string) (PlanSnapshot, error)` — plain GET, +`url.PathEscape(id)`, mirrors the `/api/sessions/{id}` call path. New +dependency-free code; tested against `httptest` fixtures like `spec_test.go` +(200 found, `found:false`, 404, malformed body). + +### 3.2 State layer (`internal/tui/plan.go` — new file) + +Model additions: + +```go +plan PlanSnapshot // last accepted snapshot +planFetchSeq int // request ordinal — guards out-of-order replies +planPolling bool // drawer plan tab visible (arms tick poll) +``` + +TEA messages: `planMsg{snap PlanSnapshot; seq int; err error}`, +`planTickMsg{seq int}` (reuses the runs-tab tick pattern). + +Refresh triggers (all funnel into one debounced fetch): + +1. **WS trigger**: any `tool_call`/`tool_result` with `Name == "plan"` schedules + a fetch ~250 ms trailing-edge — collapses the common create→update burst of + one iteration into a single request. Never fires while idle-to-plan changes + (there are none: idle means no tool calls). +2. **Lifecycle triggers** (immediate, not debounced): tab activation, session + switch (`SessionSwitch`), successful reconnect resume (`reconnect.go` hook), + attach to an existing session. +3. **Fallback poll**: every 3 s while the drawer plan tab is visible only + (identical lifecycle to `runPollEvery`). No blind timers when hidden. + +Acceptance rule: a fetched snapshot replaces local state iff +`snap.Version >= m.plan.Version` and it belongs to the current session. +Monotonic guard makes duplicate/out-of-order responses harmless — ingestion +stays idempotent. + +Error posture: silent degradation. A failed/404 fetch hides the strip, marks +the tab "unavailable", never surfaces as an error card. The feature must not +generate noise when an old `odek serve` lacks the route. + +### 3.3 Event handling hook (`internal/tui/events.go`, minimal diff) + +In the `tool_call` case: if `ev.Name == "plan"`, also call +`m.schedulePlanRefresh()`. That's the entire change to hot-path code — one line +plus the helper. + +--- + +## 4. UX surfaces + +### A. Transcript step specialization *(recommended, medium effort)* + +Today each plan step renders as a generic row with JSON arg preview — and plan +calls fire *per status change*, so long runs would drown in rows like +`plan {"verb":"update","updates":[…]}`. + +Specialize rendering, keep ingestion untouched: + +``` +✔ plan create · 5 steps [Ctrl+E reveals raw args/result] +🔄 plan s2 → in_progress +✔ plan s1 → done +``` + +Verbs map to compact glyphs: create `+N steps`, complete `id → done`, +update per-update list, get folded to nothing visible beyond the row glyph. +All rendered text still passes through `sanitize()`; `expandAll` (Ctrl+E) +keeps revealing the raw args/result exactly like other tools. + +Open sub-question for the tally/stats: keep counting plan calls in `toolTotal`. +They are genuine tool calls; hiding them from stats would be dishonest. + +### B. Live plan strip *(recommended, small)* + +A single transient line adjacent to the busy indicator (thinking spinner / +last-tool line — placed below the last user input per established preference), +shown only when: run active **and** `plan.found`. + +``` +⠸ 🧠 thinking ▸ plan 2/5 · s3 wire flag parsing · ⛔1 +``` + +- Content: `done/total`, first `in_progress` title truncated to fit, blocked + count when nonzero. Version silently consistency-checks the poll cadence. +- Hidden when: no plan, all done (collapsed), disconnected, old engine. +- All-done confirmation rides the final WS trigger: strip is replaced by the + normal idle state; the persistent record lives in the tab, not chrome. + +### C. Drawer Plan tab *(recommended, medium)* + +New `panelPlan` placed after Events (drawer tabs today: sessions, runs, events, +memory, skills, tools, config — models stays its own ^O overlay), giving +1 sessions · 2 runs · 3 events · 4 plan · 5 memory · 6 skills · 7 tools · +8 config; digits shift by one from memory onward. Rendered exactly like the Telegram surface for +cross-surface consistency: + +``` +📋 Plan — v7 · 2/4 done · 1 blocked ⏎ detail · esc fold/close + + ⬜ p1 scaffold command skeleton + ✅ p2 wire flag parsing + 🔄 p3 resolve config precedence note preview… + ⛔ p4 license policy decision blocked +``` + +- Header summary first; one row per step: status glyph, id, title (flattened, + truncated), note preview. +- Detail submode follows house rules: `⏎` expands the selected row's full + note/title through `sanitize()`; `esc`/`q` folds back; `p` promote is a + no-op here (nothing to promote); tab switches reset the submode + (`switchDrawerTab` already does this). +- Strictly read-only — no mutation controls exist to fake. +- Empty states: `found:false` → muted "no active plan in this session."; + collapsed all-done → "✓ all steps done · vN"; unavailable → "plan endpoint + unavailable". + +### D. `/plan` slash command *(recommended, trivial)* + +Registry entry ("structured task plan of this session") opening panelPlan. Free +autocomplete + palette listing. No inline printing variant until wanted — the +tab is cheap to open. + +### E. Block notifications *(deferred)* + +A `⛔` arriving mid-turn is arguably notable, but notices expire/distract and +the WS-triggered strip update already lands within ~300 ms. Skip v1; +revisit if long-run ergonomics demand it. + +--- + +## 5. Edge cases + +| Case | Handling | +|---|---| +| Old `odek serve` without the route | Silent degrade: strip hidden, tab unavailable. No retries while degraded except explicit tab activation. | +| Unknown session (404) pre-first-prompt | Same silent path; once the `session` event names us, normal behavior applies. | +| Out-of-order REST replies | Monotonic `version` guard + request seq mismatch discard. | +| Create→update burst in one iteration | Trailing-edge debounce (~250 ms) collapses to one fetch. | +| Parallel batch plan calls | Store serializes server-side; client pairs results via existing LIFO matcher unchanged. | +| Reconnect / restart resume | Fetch on reconnect-success — REST reads persisted transcript. | +| Session switch / attach | Clear snapshot before switching; immediate fetch after acceptance. | +| Hostile titles/notes | `sanitize()` on every render; server already flattens + caps lengths. | +| Collapsed all-done plan | `steps == []`, version intact — tab summary line, strip hidden. | +| Drawer width overflow | Tab strip already collapses to ellipsis + active — free. | +| Tests & flakiness | Drive `handleEvent`/msgs directly (newTestModel); no wall-clock assertions; poll ticks are explicit messages. | + +--- + +## 6. Testing strategy (per AGENTS.md) + +- `internal/client/plan_test.go`: fixtures mirroring `spec_test.go` — shape, + `found:false`, 404 error mapping, malformed body tolerance. +- `internal/tui/plan_test.go`: + - strip appears/hides across run-active × found × collapsed matrix; + - WS trigger → scheduled refresh cmd observed (not executed); + - monotonic guard rejects stale versions; duplicate WS triggers idempotent; + - tab rendering incl. truncation, note preview, blocked glyph, detail expand, + sanitize coverage with hostile strings; + - reconnect/session-switch hooks issue fresh fetches. +- Regression rules: race detector suite green, no timing assertions, coverage + stays at the package norm. + +## 7. Milestones (strictly sequential) + +| # | Scope | +|---|---| +| M-P1 | client types + `SessionPlan` + tui state, debounce, guards, hooks (transcript unaffected) | +| M-P2 | transcript semantic rendering for `plan` steps (+ stats decision documented above) | +| M-P3 | drawer Plan tab + detail submode + visible-poll lifecycle | +| M-P4 | live strip + `/plan` command + README/keybinding sync | + +Each milestone ships README touch-ups in the same commit where user-visible +behaviour changes land (repo rule). + +--- + +## 8. Open questions for brainstorm + +1. **Strip vs chip**: dedicated mini-line next to the busy indicator (as drawn) + vs appending `▸ plan 2/5` into the existing status line? The mini-line can + hold the current step title; the chip costs zero layout risk. +2. **Transcript visibility**: semantic single-liners (proposed) vs suppressing + plan rows entirely and surfacing only the final "plan updated" heartbeat? + Suppression is quieter but breaks the "every act leaves evidence" property. +3. **Tab position**: accept digits 5–8 shifting (insert plan at 5) for + semantic grouping? +4. Anything else wanted from v1 — e.g. copy-step-as-text action, or a jump + from strip to tab? From 0bd55da22d2fa75122b5e167e9e8f6625384eec5 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 27 Aug 2026 07:22:07 +0200 Subject: [PATCH 2/3] feat(client): structured session plan fetch Typed PlanSnapshot/PlanStep decoding for GET /api/sessions/{id}/plan with the session-scoped token. Covers snapshot decode, found:false, 404 error mapping and malformed-body tolerance against httptest fixtures. --- internal/client/plan.go | 62 ++++++++++++++++++++ internal/client/plan_test.go | 107 +++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 internal/client/plan.go create mode 100644 internal/client/plan_test.go diff --git a/internal/client/plan.go b/internal/client/plan.go new file mode 100644 index 0000000..ebc6859 --- /dev/null +++ b/internal/client/plan.go @@ -0,0 +1,62 @@ +package client + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// ── structured plan view (GET /api/sessions/{id}/plan) ────────────────────── +// +// odek serve exposes the engine's plan state read-only: the newest parseable +// "[Current plan:" system message, parsed with the same strict extractor the +// restart-resume path uses. GET-only by contract; see docs/PLANNING_MODE_UI.md. + +// PlanStepStatus is one step's lifecycle state on the wire. +type PlanStepStatus string + +const ( + PlanPending PlanStepStatus = "pending" + PlanInProgress PlanStepStatus = "in_progress" + PlanDone PlanStepStatus = "done" + PlanBlocked PlanStepStatus = "blocked" +) + +// PlanStep is one row of the engine's task plan. +type PlanStep struct { + ID string `json:"id"` + Title string `json:"title"` + Status PlanStepStatus `json:"status"` + Note string `json:"note,omitempty"` // omitted when empty +} + +// PlanSnapshot is one /plan response. found=false means the transcript carries +// no parseable plan yet; a collapsed all-done plan still reports found=true +// with an empty Steps slice. +type PlanSnapshot struct { + SessionID string `json:"session_id"` + Version int `json:"version"` + Found bool `json:"found"` + Steps []PlanStep `json:"steps"` +} + +// 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. +func (c *Client) SessionPlan(sessionID, sessionToken string) (PlanSnapshot, error) { + resp, err := c.do(http.MethodGet, + c.baseURL+"/api/sessions/"+url.PathEscape(sessionID)+"/plan", sessionToken) + if err != nil { + return PlanSnapshot{}, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return PlanSnapshot{}, fmt.Errorf("session plan: status %s", resp.Status) + } + var out PlanSnapshot + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return PlanSnapshot{}, fmt.Errorf("session plan: %w", err) + } + return out, nil +} diff --git a/internal/client/plan_test.go b/internal/client/plan_test.go new file mode 100644 index 0000000..5730086 --- /dev/null +++ b/internal/client/plan_test.go @@ -0,0 +1,107 @@ +package client + +import ( + "net/http" + "strings" + "testing" + + ws "golang.org/x/net/websocket" +) + +// Tests for GET /api/sessions/{id}/plan (odek serve contract, docs/ +// PLANNING_MODE_UI.md §1): structured snapshot decoding, the found:false +// shape, HTTP error mapping, and malformed-body tolerance. The endpoint is +// read-only by engine contract; the client never sends anything but GET. + +func TestSessionPlan_DecodesSnapshot(t *testing.T) { + var gotPath, gotMethod string + mux := newPlanTestMux(t, &gotPath, &gotMethod, `{ + "session_id": "s1", "version": 3, "found": true, + "steps": [ + {"id": "p1", "title": "Scaffold command skeleton", "status": "done"}, + {"id": "p2", "title": "Wire flag parsing", "status": "in_progress", "note": "flag order matters"} + ] + }`) + cl, _ := newTestServer(t, mux) + + snap, err := cl.SessionPlan("s1", "tok") + if err != nil { + t.Fatalf("SessionPlan: %v", err) + } + if !strings.HasPrefix(gotPath, "/api/sessions/s1/plan") { + t.Errorf("request path = %q, want prefix /api/sessions/s1/plan", gotPath) + } + if gotMethod != http.MethodGet { + t.Errorf("method = %q, want GET only", gotMethod) + } + if snap.SessionID != "s1" || snap.Version != 3 || !snap.Found { + t.Fatalf("snapshot header wrong: %+v", snap) + } + if len(snap.Steps) != 2 { + t.Fatalf("steps = %+v, want 2", snap.Steps) + } + if snap.Steps[0].ID != "p1" || snap.Steps[0].Status != PlanDone { + t.Errorf("step0 = %+v", snap.Steps[0]) + } + if snap.Steps[1].Note != "flag order matters" { + t.Errorf("step1 note = %q", snap.Steps[1].Note) + } +} + +func TestSessionPlan_FoundFalse(t *testing.T) { + mux := newPlanTestMux(t, nil, nil, + `{"session_id": "s9", "version": 0, "found": false}`) + cl, _ := newTestServer(t, mux) + + snap, err := cl.SessionPlan("s9", "") + if err != nil { + t.Fatalf("found:false must still be a valid snapshot: %v", err) + } + if snap.Found || len(snap.Steps) != 0 { + t.Errorf("snapshot = %+v, want not-found with no steps", snap) + } +} + +func TestSessionPlan_HTTP404(t *testing.T) { + mux := newPlanTestMux(t, nil, nil, "", http.StatusNotFound) + cl, _ := newTestServer(t, mux) + + if _, err := cl.SessionPlan("ghost", ""); err == nil { + t.Fatal("expected error on 404") + } else if !strings.Contains(err.Error(), "404") { + t.Errorf("error should mention status, got: %v", err) + } +} + +func TestSessionPlan_MalformedBody(t *testing.T) { + mux := newPlanTestMux(t, nil, nil, `{"version": `) + cl, _ := newTestServer(t, mux) + + if _, err := cl.SessionPlan("s1", ""); err == nil { + t.Fatal("expected error on malformed JSON") + } +} + +// newPlanTestMux builds a mux serving exactly one canned plan response under +// /api/sessions/…/plan and records the request path/method for assertions. +func newPlanTestMux(t *testing.T, path *string, method *string, body string, code ...int) *http.ServeMux { + t.Helper() + status := http.StatusOK + if len(code) > 0 { + status = code[0] + } + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {})) + mux.HandleFunc("/api/sessions/", func(w http.ResponseWriter, r *http.Request) { + if path != nil { + *path = r.URL.Path + } + if method != nil { + *method = r.Method + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + }) + return mux +} From adcb6e877b0c4bf4fef17a8bb9183cebe0899feb Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 27 Aug 2026 07:22:20 +0200 Subject: [PATCH 3/3] feat(tui): planning tab, live strip, semantic plan steps Read-only planning surface fed by odek's plan endpoint. WS plan tool_call triggers a debounced structured fetch guarded by the store's monotonic version; session switches drain in-flight replies; old engines without the route degrade silently. Surfaces: drawer tab 4 with Telegram-parity rows and summary badge, detail submode per house grammar; live plan summary on the busy line only while a run is active and a non-collapsed plan exists; semantic one-liners replace JSON blobs for plan tool steps (ingestion order and stats untouched); /plan slash command. README synced. --- README.md | 10 +- internal/tui/commands.go | 3 + internal/tui/commands_e2e_test.go | 20 ++ internal/tui/drawer.go | 1 + internal/tui/drawer_test.go | 14 +- internal/tui/events.go | 16 +- internal/tui/integration_test.go | 4 +- internal/tui/mgmt.go | 22 ++- internal/tui/model.go | 20 ++ internal/tui/panels.go | 11 ++ internal/tui/plan.go | 319 ++++++++++++++++++++++++++++++ internal/tui/plan_steps.go | 74 +++++++ internal/tui/plan_steps_test.go | 102 ++++++++++ internal/tui/plan_strip_test.go | 113 +++++++++++ internal/tui/plan_tab_test.go | 144 ++++++++++++++ internal/tui/plan_test.go | 155 +++++++++++++++ internal/tui/view.go | 9 +- 17 files changed, 1024 insertions(+), 13 deletions(-) create mode 100644 internal/tui/plan.go create mode 100644 internal/tui/plan_steps.go create mode 100644 internal/tui/plan_steps_test.go create mode 100644 internal/tui/plan_strip_test.go create mode 100644 internal/tui/plan_tab_test.go create mode 100644 internal/tui/plan_test.go diff --git a/README.md b/README.md index 7c79578..657d7da 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ command and press `⏎`. | `/runs` | Headless REST runs — live status, remote approvals, cancel | | `/run ` | Start a headless run (fresh session) and watch it in the runs tab | | `/events` | The `odek.event/v1` runtime feed | +| `/plan` | Structured task plan of this session (live status) | | `/memory` | Facts by target, pending-episode promote, consolidate | | `/skills` | Skill provenance badges & promote | | `/tools` | Tool registry with enabled state & MCP servers | @@ -187,10 +188,10 @@ command and press `⏎`. ### The management drawer -`/sessions`, `/runs`, `/events`, `/memory`, `/skills`, `/tools`, and +`/sessions`, `/runs`, `/events`, `/plan`, `/memory`, `/skills`, `/tools`, and `/config` all open tabs of **one drawer** with a shared grammar: -- `]` / `[` cycle tabs · `1`–`7` jump · `r` refresh · `esc` closes. +- `]` / `[` cycle tabs · `1`–`8` jump · `r` refresh · `esc` closes. - **Every management row opens a detail view on `⏎`** — the full text behind the gate: a skill's description and provenance, a fact or pending episode's body, an MCP server's command/args/limits, raw JSON for nested @@ -203,6 +204,11 @@ command and press `⏎`. `p` refresh pending approvals, `e` drill into the run's event trail. - **Events** — the `odek.event/v1` ring: `f` filter to this session, `x` clear filters (a runs-tab drill-in scopes it to one run). +- **Plan** — the engine's structured task plan (Telegram-parity renderer): + summary badge (`v7 · 2/4 done · 1 blocked`) plus one row per step; `⏎` + expands the selected step's full title/note. Read-only — the plan is + steered by the model; while a run is active and a plan exists, a live + `▸ plan 2/4 · ` summary rides the busy line. - **Memory** — `a`/`A` add user/env facts, `d` delete fact (`y` confirms), `p` promote a pending episode, `c`/`E` consolidate. - **Skills** — provenance badges plus a dim description line; `p` promote diff --git a/internal/tui/commands.go b/internal/tui/commands.go index a209ea8..8eacba9 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -50,6 +50,9 @@ func slashCommands() []command { {"events", "runtime event feed", func(m *Model, _ string) tea.Cmd { return m.openEvents() }}, + {"plan", "structured task plan of this session", func(m *Model, _ string) tea.Cmd { + return m.openPlan() + }}, {"memory", "facts, pending episodes, consolidate", func(m *Model, _ string) tea.Cmd { return m.openMemory() }}, diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 20a1c75..fa44738 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -250,6 +250,24 @@ func TestE2EAllCommands(t *testing.T) { t.Fatal("/quit did not set quitting") } }, + "/plan": func(t *testing.T, m *Model) { + if m.panel != panelPlan { + t.Fatalf("/plan opened panel %d", m.panel) + } + // Execute the opener's fetch by hand (drive does not run + // command closures): against the stand-in's sessions handler + // the transcript carries no plan message, so the wire returns + // found:false and the tab shows its empty copy. + m.Update(exec(m.fetchPlan())) + if m.planAvail != planAvailable || !m.planInit || + strings.Contains(m.panelMsg, "unavailable") { + t.Fatalf("wire state = avail %d init %v msg %q", + m.planAvail, m.planInit, m.panelMsg) + } + if !strings.Contains(m.panelMsg, "no active plan in this session.") { + t.Fatalf("found:false copy wrong: %q", m.panelMsg) + } + }, } // Command → the line a user types for it (some need pre-seeded state). @@ -274,6 +292,8 @@ func TestE2EAllCommands(t *testing.T) { lines[name] = "/attach " + path case "/unattach": m.attachments = append(m.attachments, client.Attachment{Name: "notes.txt", Content: "hello"}) + case "/plan": + m.sessionID, m.authToken = "s1", "a1" // fetch targets a session } line := lines[name] if line == "" { diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index 6c72e26..cbaa758 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -113,6 +113,7 @@ func drawerTabs() []drawerTab { {"sessions", panelSessions, func(m *Model) tea.Cmd { return m.openSessions() }}, {"runs", panelRuns, func(m *Model) tea.Cmd { return m.openRuns() }}, {"events", panelEvents, func(m *Model) tea.Cmd { return m.openEvents() }}, + {"plan", panelPlan, func(m *Model) tea.Cmd { return m.openPlan() }}, {"memory", panelMemory, func(m *Model) tea.Cmd { return m.openMemory() }}, {"skills", panelSkills, func(m *Model) tea.Cmd { return m.openSkills() }}, {"tools", panelTools, func(m *Model) tea.Cmd { return m.openTools() }}, diff --git a/internal/tui/drawer_test.go b/internal/tui/drawer_test.go index 5425edc..31a1d01 100644 --- a/internal/tui/drawer_test.go +++ b/internal/tui/drawer_test.go @@ -78,9 +78,9 @@ func TestDrawerTabCycling(t *testing.T) { m := wired(t) m.Update(exec(m.openRuns())) - // ] walks the full ring: runs → events → memory → skills → tools → - // config → sessions. - want := []panelMode{panelEvents, panelMemory, panelSkills, panelTools, panelConfig, panelSessions} + // ] walks the full ring: runs → events → plan → memory → skills → + // tools → config → sessions. + want := []panelMode{panelEvents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig, panelSessions} for _, w := range want { _, cmd := m.Update(key("]")) m.Update(exec(cmd)) @@ -96,8 +96,8 @@ func TestDrawerTabCycling(t *testing.T) { } // Digits jump straight to any tab. for d, w := range map[string]panelMode{ - "1": panelSessions, "2": panelRuns, "3": panelEvents, "4": panelMemory, - "5": panelSkills, "6": panelTools, "7": panelConfig, + "1": panelSessions, "2": panelRuns, "3": panelEvents, "4": panelPlan, + "5": panelMemory, "6": panelSkills, "7": panelTools, "8": panelConfig, } { _, cmd := m.Update(key(d)) m.Update(exec(cmd)) @@ -108,13 +108,13 @@ func TestDrawerTabCycling(t *testing.T) { // The strip renders every tab name, and r refreshes a management tab // the same as a core tab (they are drawer tabs now). out := plain(m.View()) - for _, name := range []string{"sessions", "runs", "events", "memory", "skills", "tools", "config"} { + for _, name := range []string{"sessions", "runs", "events", "plan", "memory", "skills", "tools", "config"} { if !strings.Contains(out, name) { t.Errorf("tab strip missing %q:\n%s", name, out) } } m.Update(exec(m.fetchSessionsPage("", 0, false))) - _, cmd = m.Update(key("4")) + _, cmd = m.Update(key("5")) // memory tab (plan is 4 since its insertion) m.Update(exec(cmd)) _, cmd = m.Update(key("r")) if cmd == nil { diff --git a/internal/tui/events.go b/internal/tui/events.go index 49d7b30..60802c3 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -29,11 +29,15 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { stream := false // high-frequency event: coalesce the render (see queueRender) switch ev.Type { case "session": + prevSession := m.sessionID m.sessionID = ev.SessionID if ev.AuthToken != "" { m.authToken = ev.AuthToken m.tokens.Set(ev.SessionID, ev.AuthToken) } + if prevSession != "" && ev.SessionID != prevSession { + m.planResetPending = true // switch/attach: drop + refetch at the tail + } if ev.Model != "" { m.model = ev.Model m.resolveMaxContext() @@ -75,6 +79,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "tool_call": arg := argPreview(ev.Data) + if ev.Name == "plan" { + if s := planArgSummary(ev.Data); s != "" { + arg = s // semantic one-liner replaces the JSON blob (docs §4A) + } + } if i := m.cur(); i >= 0 { m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: arg, subagent: isSubagent(ev.Name), started: time.Now()}) @@ -82,6 +91,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.lastTool = ev.Name m.lastArg = arg + if ev.Name == "plan" { + // Every engine plan mutation rides an ordinary tool_call: schedule + // the debounced structured-view refresh (see plan.go). + m.planTrig = true + } m.status = "running " + ev.Name case "tool_result": @@ -316,7 +330,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.refresh() // A turn that just ended (done / error) drains the next queued prompt. - return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.sendQueued()) + return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.sendQueued(), m.planFollowup()) } // stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index 534b49e..1be2e59 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -284,7 +284,9 @@ func standIn(t *testing.T, token string) *Model { t.Cleanup(func() { cl.Close() }) m := New(cl, Options{Model: "m"}) - m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + // 120 cols: wide enough for the full eight-tab drawer strip to render + // uncollapsed (≈90 cells), keeping tab-name assertions meaningful. + m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) return m } diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index 0497efd..28fd43c 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -535,7 +535,7 @@ func (m *Model) cfgRowsRender(w int) []string { // mgmtPanel reports whether p is a management drawer tab. func mgmtPanel(p panelMode) bool { switch p { - case panelMemory, panelSkills, panelTools, panelConfig: + case panelPlan, panelMemory, panelSkills, panelTools, panelConfig: return true } return false @@ -587,6 +587,26 @@ func (m *Model) mgmtDetailLines(w int) []string { th := m.th var out []string switch m.panel { + case panelPlan: + st := m.planStepAt(m.panelSel) + if st == nil { + return []string{th.acDim.Render("no step selected")} + } + out = append(out, th.acSel.Render("› "+planGlyph(st.Status)+" "+sanitize(st.ID))) + meta := []string{string(st.Status)} + if m.planInit { + meta = append(meta, fmt.Sprintf("v%d", m.plan.Version)) + } + out = append(out, th.acDetail.Render(strings.Join(meta, " · "))) + if t := strings.TrimSpace(st.Title); t != "" { + out = append(out, "") + out = append(out, wrapText(sanitize(t), w)...) + } + if n := strings.TrimSpace(st.Note); n != "" { + out = append(out, "") + out = append(out, th.acDetail.Render("note:")) + out = append(out, wrapText(sanitize(n), w)...) + } case panelSkills: s := m.skillSelected() if s == nil { diff --git a/internal/tui/model.go b/internal/tui/model.go index fd6dff2..decc2e9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -221,6 +221,17 @@ type Model struct { toolTotal int // cumulative tool calls this session sessionStart time.Time // first-prompt timestamp, for session wall-clock + // Planning surface state (see plan.go): WS triggers → debounced REST fetch. + plan client.PlanSnapshot // last accepted snapshot + planVer int // accepted snapshot version (monotonic guard) + planInit bool // any snapshot accepted for this session + planAvail planAvailability // endpoint health tri-state + planTrig bool // a plan tool_call awaits tail-batch pickup + planResetPending bool // session changed; reset+refetch at tail + planDebSeq int // debounce window sequence + planReqSeq int // fetch request sequence + planPollSeq int // armed poll tick sequence + status string notices []string noticeExp []time.Time // parallel to notices; zero = sticky, else expires at @@ -441,6 +452,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case runsTickMsg: return m, m.handleRunsTick(msg) + case planMsg: + return m, m.handlePlanMsg(msg) + + case planDebounceMsg: + return m, m.handlePlanDebounce(msg) + + case planTickMsg: + return m, m.handlePlanTick(msg) + case runActionMsg: return m, m.handleRunAction(msg) diff --git a/internal/tui/panels.go b/internal/tui/panels.go index f167632..b35b880 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -21,6 +21,7 @@ const ( panelModels panelRuns panelEvents + panelPlan panelMemory panelSkills panelTools @@ -568,6 +569,13 @@ func (m *Model) panelSelect() tea.Cmd { if m.panelSel < len(m.sessions) { return m.resumeSession(m.sessions[m.panelSel].ID) } + case panelPlan: + if m.planStepAt(m.panelSel) != nil { + m.panelDetail = true // readable step text through sanitize() — house grammar + m.detailScroll = 0 + m.refresh() + } + return nil case panelModels: entries := m.modelEntries() if m.panelSel < len(entries) { @@ -1058,6 +1066,9 @@ func (m *Model) renderPanel(w, h int) string { title += th.acDetail.Render(" · this session") } rows = m.eventRows(w - 6) + case panelPlan: + title = m.planTitle() + rows = m.planRows(w - 6) case panelMemory: title = "❖ memory" rows = m.memRowsRender(w - 6) diff --git a/internal/tui/plan.go b/internal/tui/plan.go new file mode 100644 index 0000000..fc0416d --- /dev/null +++ b/internal/tui/plan.go @@ -0,0 +1,319 @@ +package tui + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ── planning surface state (docs/PLANNING_MODE_UI.md §3.2) ───────────────── +// +// bodek's WS carries triggers only: every engine plan mutation arrives as an +// ordinary plan tool_call/tool_result pair. The structured truth lives behind +// GET /api/sessions/{id}/plan. So: watch the WS, then fetch REST — debounced +// (a create→update burst is one request), guarded monotonically by the +// store's version, and silently degraded when an old engine lacks the route. + +const ( + planDebounceEvery = 250 * time.Millisecond // WS-trigger coalescing window + planPollEvery = 3 * time.Second // drawer-plan-tab visible cadence +) + +// planAvailability tri-states the endpoint: unknown (not tried), available, +// unavailable (404/transport — silent degrade, retries only via explicit +// triggers, never the background poll). +type planAvailability uint8 + +const ( + planUnknown planAvailability = iota + planAvailable + planUnavailable +) + +// planDebounceMsg arms the trailing edge of the WS-trigger window; a newer +// trigger supersedes it via sequence compare (noticeTimer pattern). +type planDebounceMsg struct{ seq int } + +// planTickMsg re-arms the tab-visible poll (runsTickMsg pattern). +type planTickMsg struct{ seq int } + +// planMsg carries one SessionPlan fetch outcome. want/seq identify the +// request so superseded replies cannot touch fresh state. +type planMsg struct { + want string + seq int + snap client.PlanSnapshot + err error +} + +// schedulePlanRefresh arms (or re-arms) the debounced refresh timer. +func (m *Model) schedulePlanRefresh() tea.Cmd { + m.planTrig = false + m.planDebSeq++ + seq := m.planDebSeq + return tea.Tick(planDebounceEvery, func(time.Time) tea.Msg { + return planDebounceMsg{seq: seq} + }) +} + +// handlePlanDebounce fires the fetch only for the newest armed window. +func (m *Model) handlePlanDebounce(msg planDebounceMsg) tea.Cmd { + if msg.seq != m.planDebSeq || m.sessionID == "" { + return nil // superseded window or not attached to a session yet + } + return m.fetchPlan() +} + +// fetchPlan issues one snapshot request pinned to the current session. +func (m *Model) fetchPlan() tea.Cmd { + if m.cl == nil || m.sessionID == "" { + return nil + } + m.planReqSeq++ + cl := m.cl + want := m.sessionID + token := m.authToken + seq := m.planReqSeq + return func() tea.Msg { + snap, err := cl.SessionPlan(want, token) + return planMsg{want: want, seq: seq, snap: snap, err: err} + } +} + +// handlePlanMsg accepts-or-rejects a reply and keeps the tab-visible poll +// alive. Stale sequencing, foreign sessions, and version regressions are all +// dropped without touching state (ingestion stays idempotent); errors mark +// the surface unavailable — never surfaced as noise. +func (m *Model) handlePlanMsg(msg planMsg) (cmd tea.Cmd) { + defer func() { + if m.panel == panelPlan && m.planAvail != planUnavailable { + cmd = tea.Batch(cmd, m.armPlanPoll()) + } + }() + if msg.err != nil { + m.planAvail = planUnavailable + if m.panel == panelPlan { + m.syncPlanPanelMsg() + } + return nil + } + m.planAvail = planAvailable + if msg.seq != m.planReqSeq || msg.want != m.sessionID { + return nil // superseded by a newer request or a session switch + } + if msg.snap.SessionID != "" && msg.snap.SessionID != m.sessionID { + return nil // defensive: wire disagrees about the target session + } + if m.planInit && msg.snap.Version < m.planVer { + return nil // monotonic guard: stale snapshot, found:false included + } + m.plan = msg.snap + m.planVer = msg.snap.Version + m.planInit = true + if m.panel == panelPlan { + m.syncPlanPanelMsg() + } + m.refresh() + return nil +} + +// armPlanPoll schedules the next visible-tab tick (exactly one armed tick per +// cycle; closing the tab drains the chain via the seq check). +func (m *Model) armPlanPoll() tea.Cmd { + m.planPollSeq++ + seq := m.planPollSeq + return tea.Tick(planPollEvery, func(time.Time) tea.Msg { + return planTickMsg{seq: seq} + }) +} + +// handlePlanTick polls while the plan tab is visible; an unavailable endpoint +// stops the chain (re-entry or 'r' restarts it). +func (m *Model) handlePlanTick(msg planTickMsg) tea.Cmd { + if m.panel != panelPlan || msg.seq != m.planPollSeq || + m.planAvail == planUnavailable { + return nil + } + return m.fetchPlan() +} + +// resetPlanState drops accepted knowledge (session switch / attach): pending +// timers and in-flight replies drain via their sequence bumps. +func (m *Model) resetPlanState() { + m.plan = client.PlanSnapshot{} + m.planVer, m.planInit = 0, false + m.planAvail = planUnknown + m.planDebSeq++ + m.planReqSeq++ +} + +// planFollowup / openPlan ─ rendering & drawer integration ──────────────── + +// openPlan is the drawer tab opener: same grammar as openRuns — reset the +// selection, show a status line, fetch immediately. The visible poll chain +// is maintained by handlePlanMsg's re-arm branch. +func (m *Model) openPlan() tea.Cmd { + m.panel = panelPlan + m.panelSel = 0 + m.panelEdit = panelEditNone + m.panelDetail = false + m.detailScroll = 0 + m.syncPlanPanelMsg() + m.relayout() + m.refresh() + return m.fetchPlan() +} + +// planStripLabel renders the live-run summary shown next to the busy +// indicator: "plan 2/5 · · ⛔1". Empty unless a run is active, +// the endpoint is healthy, and a non-collapsed plan exists. +func (m *Model) planStripLabel() string { + if !m.busy || !m.planInit || m.planAvail != planAvailable || !m.plan.Found { + return "" + } + total := len(m.plan.Steps) + if total == 0 { + return "" // collapsed all-done plan — the drawer tab carries the record + } + done, blocked := 0, 0 + active := "" + for _, st := range m.plan.Steps { + switch st.Status { + case client.PlanDone: + done++ + case client.PlanBlocked: + blocked++ + case client.PlanInProgress: + if active == "" { + active = collapse(sanitize(st.Title)) + } + } + } + s := fmt.Sprintf("plan %d/%d", done, total) + if active != "" { + s += " · " + truncate(active, 32) + } + if blocked > 0 { + s += fmt.Sprintf(" · ⛔%d", blocked) + } + return s +} + +// syncPlanPanelMsg refreshes the tab's empty/unavailable copy from state. +func (m *Model) syncPlanPanelMsg() { + switch { + case m.planAvail == planUnavailable: + m.panelMsg = "plan unavailable on this engine · r retries" + case m.planAvail == planUnknown && !m.planInit: + m.panelMsg = "loading plan…" + case m.planInit && !m.plan.Found: + m.panelMsg = "no active plan in this session." + default: + m.panelMsg = "" + } +} + +// planGlyph maps a step status to its row glyph. +func planGlyph(s client.PlanStepStatus) string { + switch s { + case client.PlanDone: + return "✅" + case client.PlanInProgress: + return "🔄" + case client.PlanBlocked: + return "⛔" + case client.PlanPending: + return "⬜" + default: + return "⬜" // unknown future statuses degrade to pending + } +} + +// planStepAt returns the selected step, if any. +func (m *Model) planStepAt(i int) *client.PlanStep { + if i >= 0 && i < len(m.plan.Steps) { + return &m.plan.Steps[i] + } + return nil +} + +// planTitle composes the header line: base title plus the Telegram-parity +// summary badge ("v7 · 2/4 done · 1 blocked"), styled like the events tab's +// filter badge so it never participates in row selection. +func (m *Model) planTitle() string { + base := "📋 plan" + if !m.planInit || m.planAvail != planAvailable || !m.plan.Found { + return base + } + done, blocked := 0, 0 + for _, st := range m.plan.Steps { + switch st.Status { + case client.PlanDone: + done++ + case client.PlanBlocked: + blocked++ + } + } + badge := fmt.Sprintf("v%d", m.plan.Version) + if total := len(m.plan.Steps); total == 0 { + badge += " · ✓ all done" + } else { + badge += fmt.Sprintf(" · %d/%d done", done, total) + if blocked > 0 { + badge += fmt.Sprintf(" · %d blocked", blocked) + } + } + return base + m.th.acDetail.Render(" · "+badge) +} + +// planRows renders one line per step: glyph, id, truncated title, dim note +// preview — same shape discipline as eventRows. +func (m *Model) planRows(w int) []string { + rows := make([]string, 0, len(m.plan.Steps)) + for _, st := range m.plan.Steps { + label := planGlyph(st.Status) + " " + sanitize(st.ID) + if t := sanitize(st.Title); t != "" { + label += " " + collapse(t) + } + detail := "" + if n := sanitize(st.Note); n != "" { + detail = " " + collapse(n) + } + maxLabel := w - 2 - lipgloss.Width(detail) + if maxLabel < 8 { + maxLabel = 8 + } + rows = append(rows, + m.th.acItem.Render(" "+truncate(label, maxLabel))+m.th.acDetail.Render(detail)) + } + return rows +} + +// planTrig / planResetPending consume-at-tail helpers: ordinary event cases +// share one render-coalescing exit, so these two flags ride the existing +// batch instead of restructuring hot paths. +func (m *Model) planFollowup() tea.Cmd { + var cmds []tea.Cmd + if m.planResetPending { + m.planResetPending = false + m.resetPlanState() + if c := m.fetchPlan(); c != nil { + cmds = append(cmds, c) + } + } + if m.planTrig { + cmds = append(cmds, m.schedulePlanRefresh()) + } + switch len(cmds) { + case 0: + return nil + case 1: + return cmds[0] + default: + return tea.Batch(cmds...) + } +} diff --git a/internal/tui/plan_steps.go b/internal/tui/plan_steps.go new file mode 100644 index 0000000..9978e6f --- /dev/null +++ b/internal/tui/plan_steps.go @@ -0,0 +1,74 @@ +package tui + +import ( + "encoding/json" + "strconv" + "strings" +) + +// ── transcript specialization for plan steps (docs/PLANNING_MODE_UI.md §4A) ─ +// +// Plan mutations are ordinary tool_call events, so without help each status +// change renders as an opaque JSON preview row and long runs drown in them. +// We keep ingestion untouched (arrival order, LIFO pairing, stats) and swap +// ONLY the stored preview text for a one-line semantic summary; anything we +// cannot summarize falls back to the generic preview, never the other way. + +// planUpdateEntry mirrors one entry of the update verb's updates array. +type planUpdateEntry struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// planArgs is the subset of the plan tool's argument surface that renders. +type planArgs struct { + Verb string `json:"verb"` + Steps []json.RawMessage `json:"steps"` + Updates []planUpdateEntry `json:"updates"` + StepID string `json:"step_id"` +} + +// planArgSummary condenses a plan tool_call payload into a short human line, +// e.g. "create · 5 steps" / "p3 → in_progress" / "complete p2". +// Everything model-authored passes through sanitize(); the result keeps the +// same width discipline as argPreview. +func planArgSummary(data string) string { + var args planArgs + if err := json.Unmarshal([]byte(strings.TrimSpace(data)), &args); err != nil { + return "" + } + switch args.Verb { + case "create": + if len(args.Steps) == 0 { + return "create" + } + return sanitize(truncate("create · "+strconv.Itoa(len(args.Steps))+" steps", 72)) + case "update": + parts := make([]string, 0, len(args.Updates)) + for _, u := range args.Updates { + if u.ID == "" { + continue + } + movements := u.ID + " → " + if u.Status != "" { + movements += u.Status + } else { + movements += "updated" + } + parts = append(parts, sanitize(collapse(movements))) + } + if len(parts) == 0 { + return "" + } + return truncate(strings.Join(parts, " · "), 72) + case "complete": + if args.StepID == "" { + return "" + } + return sanitize(collapse(args.StepID)) + " → done" + case "get": + return "state check" + default: + return "" + } +} diff --git a/internal/tui/plan_steps_test.go b/internal/tui/plan_steps_test.go new file mode 100644 index 0000000..ede80aa --- /dev/null +++ b/internal/tui/plan_steps_test.go @@ -0,0 +1,102 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Tests for plan tool_call transcript specialization (docs/PLANNING_MODE_UI.md +// §4A): semantic one-liners replace the JSON preview, hostile model-authored +// text is sanitized/truncated, and anything unparseable falls back to the +// generic argPreview path. + +func TestPlanArgSummary_Verbs(t *testing.T) { + cases := []struct { + name string + arg string + want string // prefix "has:" → substring match, else exact + }{ + {"create", `{"verb":"create","steps":[{"id":"a"},{"id":"b"}]}`, "has:create · 2 steps"}, + {"update single", `{"verb":"update","updates":[{"id":"p3","status":"in_progress"}]}`, + "p3 → in_progress"}, + {"update multi", `{"verb":"update","updates":[{"id":"s1","status":"done"},{"id":"s2","status":"blocked"}]}`, + "has:s1 → done · s2 → blocked"}, + {"complete", `{"verb":"complete","step_id":"p2"}`, "p2 → done"}, + {"get", `{"verb":"get"}`, "state check"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := planArgSummary(tc.arg) + if sub, ok := strings.CutPrefix(tc.want, "has:"); ok { + if !strings.Contains(got, sub) { + t.Fatalf("planArgSummary(%s) = %q, want contains %q", tc.arg, got, sub) + } + return + } + if got != tc.want { + t.Fatalf("planArgSummary(%s) = %q, want %q", tc.arg, got, tc.want) + } + }) + } +} + +func TestPlanArgSummary_Fallbacks(t *testing.T) { + for _, arg := range []string{ + ``, // empty + `not json at all`, // unparseable + `{"verb":"exotic"}`, // unknown verb — future engines stay generic + `{"verb":"update"}`, // update without usable entries + `{"verb":"complete"}`, // complete without step_id + `[1,2,3]`, // wrong shape entirely + } { + if got := planArgSummary(arg); got != "" { + t.Errorf("planArgSummary(%q) = %q, want empty fallback", arg, got) + } + } +} + +func TestPlanArgSummary_HostileText(t *testing.T) { + // sanitize()'s threat model is the terminal: control/escape bytes must + // never survive into stored previews. Angle brackets are inert glyphs + // here (steps render as styled text, never markdown) and may remain. + got := planArgSummary(`{"verb":"complete","step_id":"\u001b[31mevil\u001b[0m"}`) + if strings.ContainsRune(got, '\x1b') { + t.Fatalf("escape byte survived sanitization: %q", got) + } + if !strings.Contains(got, "") { + t.Fatalf("expected sanitized-but-complete id, got %q", got) + } + wide := planArgSummary(`{"verb":"create","steps":[` + + strings.Repeat(`{"id":"s"},`, 20) + `{"id":"z"}]}`) + if len([]rune(wide)) > 200 { // far above the 72-cap would mean a bypass + t.Fatalf("summary runaway length: %d runes", len([]rune(wide))) + } +} + +func TestPlanToolCall_StoresSemanticPreview(t *testing.T) { + m := &Model{} + m.msgs = append(m.msgs, message{}) // open assistant turn so cur() >= 0 + m.handleEvent(client.Event{ + Type: "tool_call", Name: "plan", + Data: `{"verb":"update","updates":[{"id":"p4","status":"blocked"}]}`, + }) + + i := m.cur() + if i < 0 || len(m.msgs[i].steps) != 1 { + t.Fatalf("step not ingested: msgs=%d cur=%d", len(m.msgs), i) + } + step := m.msgs[i].steps[0] + if step.arg != "p4 → blocked" { + t.Fatalf("stored preview = %q, want semantic one-liner", step.arg) + } + if m.lastArg != "p4 → blocked" { + t.Fatalf("lastArg = %q, want the same summary", m.lastArg) + } + // Non-plan tools keep the generic preview untouched. + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"ls -la"}`}) + if got := m.msgs[i].steps[1].arg; got != "ls -la" { + t.Fatalf("generic preview changed: %q", got) + } +} diff --git a/internal/tui/plan_strip_test.go b/internal/tui/plan_strip_test.go new file mode 100644 index 0000000..badf112 --- /dev/null +++ b/internal/tui/plan_strip_test.go @@ -0,0 +1,113 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Tests for the live plan strip (docs/PLANNING_MODE_UI.md §4B) and the /plan +// slash command. The strip's zero-footprint rule: idle, no-plan, collapsed, +// or degraded engine ⇒ empty label, no layout cost. + +func TestPlanStrip_VisibilityMatrix(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} + m.sessionID = "s1" + + // Idle ⇒ nothing. + if got := m.planStripLabel(); got != "" { + t.Fatalf("idle strip = %q", got) + } + + m.busy = true + // Busy but no knowledge yet ⇒ nothing. + if got := m.planStripLabel(); got != "" { + t.Fatalf("uninitialized strip = %q", got) + } + + acceptPlan(m, planFixture()) + got := m.planStripLabel() + for _, want := range []string{"plan 1/4", "wire flag parsing", "⛔1"} { + if !strings.Contains(got, want) { + t.Errorf("strip %q missing %q", got, want) + } + } + if strings.Contains(got, "scaffold") { // only the active step shows + t.Error("strip leaked a non-active step title") + } + + // Collapsed all-done ⇒ nothing (the drawer tab owns the record). + allDone := planFixture() + allDone.Version = 9 + allDone.Steps = []client.PlanStep{} + acceptPlan(m, allDone) + if got := m.planStripLabel(); got != "" { + t.Fatalf("collapsed strip = %q", got) + } + + // Degraded endpoint ⇒ nothing. + acceptPlanErrStrip(m) + if got := m.planStripLabel(); got != "" { + t.Fatalf("degraded strip = %q", got) + } +} + +// acceptPlanErrStrip mirrors the tab test helper without importing across +// files beyond the package (single namespace, distinct name). +func acceptPlanErrStrip(m *Model) { + m.planReqSeq++ + handlePlanErrMsg(m) +} + +func handlePlanErrMsg(m *Model) { + m.handlePlanMsg(planMsg{want: m.sessionID, seq: m.planReqSeq, + err: errors.New("session plan: status 404 Not Found")}) +} + +func TestStatusLine_CarriesStripWhenBusy(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} + m.sessionID = "s1" + m.busy = true + m.status = "running shell" + m.lastTool = "shell" + acceptPlan(m, planFixture()) + + out := plain(m.statusLine()) + if !strings.Contains(out, "▸ plan 1/4") { + t.Errorf("status line missing strip:\n%s", out) + } + + m.busy = false + if strings.Contains(plain(m.statusLine()), "▸") { + t.Error("idle status line must not carry the strip") + } +} + +func TestSlashCommand_PlanOpensTab(t *testing.T) { + found := false + var open func(m *Model, args string) tea.Cmd + for _, c := range slashCommands() { + if c.name == "plan" { + found = true + open = c.run + } + } + if !found { + t.Fatal("/plan missing from the registry") + } + m := newTestModel() + m.cl = &client.Client{} + m.sessionID = "s1" // fetch requires an attached session + if cmd := open(m, ""); cmd == nil { + t.Fatal("/plan must issue the fetch") + } + if m.panel != panelPlan { + t.Fatalf("/plan panel = %d", m.panel) + } +} diff --git a/internal/tui/plan_tab_test.go b/internal/tui/plan_tab_test.go new file mode 100644 index 0000000..ea96127 --- /dev/null +++ b/internal/tui/plan_tab_test.go @@ -0,0 +1,144 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Tests for the drawer Plan tab surface (docs/PLANNING_MODE_UI.md §4C): +// opener states, Telegram-parity rows + summary badge, detail submode per +// house grammar (⏎ expand / esc fold), and the silent-degrade empty states. +// Snapshots are injected through handlePlanMsg so no server is involved. + +var errPlanRoute = errors.New("session plan: status 404 Not Found") + +func planFixture() client.PlanSnapshot { + return client.PlanSnapshot{ + SessionID: "s1", Version: 7, Found: true, + Steps: []client.PlanStep{ + {ID: "p1", Title: "scaffold command skeleton", Status: client.PlanDone}, + {ID: "p2", Title: "wire flag parsing", Status: client.PlanInProgress}, + {ID: "p3", Title: "resolve config precedence", Status: client.PlanBlocked, + Note: "license gate needs a human decision before merge"}, + {ID: "p4", Title: "live strip + /plan", Status: client.PlanPending}, + }, + } +} + +// acceptPlan feeds one snapshot as the newest in-flight reply and returns +// handlePlanMsg's re-arm command (non-nil while the tab is visible). +func acceptPlan(m *Model, snap client.PlanSnapshot) tea.Cmd { + m.planReqSeq++ + return m.handlePlanMsg(planMsg{want: m.sessionID, seq: m.planReqSeq, snap: snap}) +} + +func TestPlanTab_OpenerAndRenderedRows(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} // fetch closures are built but never executed + m.sessionID = "s1" + + if c := m.openPlan(); c == nil { + t.Fatal("openPlan must issue the initial fetch") + } + if m.panel != panelPlan || m.panelMsg != "loading plan…" { + t.Fatalf("opener state = panel %d msg %q", m.panel, m.panelMsg) + } + + before := m.planPollSeq + if rearm := acceptPlan(m, planFixture()); rearm == nil || m.planPollSeq == before { + t.Fatal("accepting while the tab is visible must re-arm the poll") + } + if m.panelMsg != "" { + t.Fatalf("loaded tab shows status %q", m.panelMsg) + } + + out := plain(m.View()) + for _, want := range []string{ + "plan", "v7 · 1/4 done · 1 blocked", + "✅ p1", "🔄 p2", "⛔ p3", "⬜ p4", + "wire flag parsing", "resolve config precedence", + } { + if !strings.Contains(out, want) { + t.Errorf("plan tab missing %q", want) + } + } + for _, banned := range []string{"{", "verb"} { // no raw JSON on the tab + if strings.Contains(out, banned) { + t.Errorf("plan tab leaked raw payload (%q)", banned) + } + } +} + +func TestPlanTab_DetailFoldHouseGrammar(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} + m.sessionID = "s1" + m.openPlan() + if c := acceptPlan(m, planFixture()); c == nil { + t.Fatal("precondition: accept should re-arm on a visible tab") + } + + m.panelSel = 2 // the blocked step with the note + m.Update(key("enter")) + if !m.panelDetail { + t.Fatal("enter did not open the step detail") + } + out := plain(m.View()) + if !strings.Contains(out, "license gate needs a human decision") { + t.Errorf("detail missing full note:\n%s", out) + } + m.Update(key("q")) // q folds like esc (house rule) + if m.panelDetail { + t.Fatal("q did not fold the detail") + } + if m.panel != panelPlan { + t.Fatalf("folding left the tab: %d", m.panel) + } + m.Update(key("esc")) + if m.panel != panelNone { + t.Error("esc did not close the drawer") + } +} + +func TestPlanTab_EmptyAndDegradedStates(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} + m.sessionID = "s1" + m.openPlan() + + // Collapsed all-done plan: newer version, found with zero steps — the + // header badge (✓ all done · vN) is the collapsed indicator, engine parity. + allDone := planFixture() + allDone.Version = 9 + allDone.Steps = []client.PlanStep{} + acceptPlan(m, allDone) + if got := plain(m.View()); !strings.Contains(got, "v9 · ✓ all done") { + t.Errorf("collapsed badge wrong:\n%s", got) + } + + // found:false — the transcript carries no parseable plan at all. + acceptPlan(m, client.PlanSnapshot{SessionID: "s1", Version: 10, Found: false}) + if got := plain(m.View()); !strings.Contains(got, "no active plan in this session.") { + t.Errorf("found:false copy wrong:\n%s", got) + } + + // Endpoint failure degrades silently; polling drains on the dead route. + acceptPlanErr(m) + if got := plain(m.View()); !strings.Contains(got, "plan unavailable on this engine") { + t.Errorf("degraded state copy wrong:\n%s", got) + } + m.planPollSeq++ + if c := m.handlePlanTick(planTickMsg{seq: m.planPollSeq}); c != nil { + t.Fatal("unavailable endpoint must drain the visible poll") + } +} + +func acceptPlanErr(m *Model) { + m.planReqSeq++ + m.handlePlanMsg(planMsg{want: m.sessionID, seq: m.planReqSeq, err: errPlanRoute}) +} diff --git a/internal/tui/plan_test.go b/internal/tui/plan_test.go new file mode 100644 index 0000000..aca0ed0 --- /dev/null +++ b/internal/tui/plan_test.go @@ -0,0 +1,155 @@ +package tui + +import ( + "errors" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Tests for the planning-surface state machine (docs/PLANNING_MODE_UI.md +// §3.2): WS-trigger scheduling + debounce sequencing, monotonic acceptance, +// tab-visible poll lifecycle, and the session-switch reset hook. Assertions +// ride synchronous observables (seq counters, flags, accepted state); fetch +// closures are never executed — the wire contract lives in internal/client, +// and invoking arbitrary batch children (listen…) would block a test. + +var errTestPlanRoute = errors.New("session plan: status 404 Not Found") + +func planCallEvent(name string) client.Event { + return client.Event{Type: "tool_call", Name: name, Data: "{}"} +} + +func TestPlanWSTrigger_DebouncedRefresh(t *testing.T) { + m := &Model{} + before := m.planDebSeq + m.handleEvent(planCallEvent("shell")) + if m.planDebSeq != before { + t.Fatal("non-plan tool_call must not arm the refresh") + } + + m.handleEvent(planCallEvent("plan")) + if m.planDebSeq != before+1 { + t.Fatalf("plan tool_call must arm a fresh window: %d → %d", before, m.planDebSeq) + } + + // Sequence guard: superseded windows are dropped, the live one fetches + // (only while attached to a session), and fetching bumps the request seq. + if c := m.handlePlanDebounce(planDebounceMsg{seq: m.planDebSeq - 1}); c != nil { + t.Fatal("stale debounce window must be dropped") + } + if c := m.handlePlanDebounce(planDebounceMsg{seq: m.planDebSeq}); c != nil { + t.Fatal("no session attached → no fetch") + } + m.sessionID = "s1" + m.cl = &client.Client{} // non-nil so fetchPlan builds its closure + reqBefore := m.planReqSeq + if c := m.handlePlanDebounce(planDebounceMsg{seq: m.planDebSeq}); c == nil { + t.Fatal("live window must issue the fetch command") + } + if m.planReqSeq != reqBefore+1 { + t.Fatalf("request seq = %d, want %d", m.planReqSeq, reqBefore+1) + } +} + +func TestPlanFollowup_SessionSwitchResetsAndRefetches(t *testing.T) { + m := &Model{sessionID: "s1"} + m.planInit = true + m.planVer = 7 + m.planAvail = planAvailable + + // First contact on a brand-new model must not trip the hook. + m0 := &Model{cl: &client.Client{}} + reqAt := m0.planReqSeq + m0.handleEvent(client.Event{Type: "session", SessionID: "s9"}) + if m0.planResetPending || m0.planReqSeq != reqAt || m0.planInit { + t.Fatal("initial session event must not schedule a reset") + } + // Identical id on the live model: no-op. + m.handleEvent(client.Event{Type: "session", SessionID: "s1"}) + if m.planResetPending { + t.Fatal("repeated id must not schedule a reset") + } + + // Real switch: state resets and the immediate refetch goes out. + m.cl = &client.Client{} // refetch needs a client handle + m.handleEvent(client.Event{Type: "session", SessionID: "s42"}) + if m.planInit || m.planVer != 0 || m.planAvail != planUnknown { + t.Fatalf("state not reset: init=%v ver=%d avail=%d", + m.planInit, m.planVer, m.planAvail) + } + if m.sessionID != "s42" { + t.Fatalf("session id not adopted: %q", m.sessionID) + } + if m.planReqSeq <= reqAt { + t.Fatal("switch must drain in-flight replies and issue a refetch") + } +} + +func TestPlanAccept_MonotonicGuard(t *testing.T) { + m := &Model{sessionID: "s1"} + steps := []client.PlanStep{{ID: "a", Title: "A", Status: client.PlanDone}} + + m.planReqSeq++ + m.handlePlanMsg(planMsg{want: "s1", seq: m.planReqSeq, snap: client.PlanSnapshot{ + SessionID: "s1", Version: 3, Found: true, Steps: steps, + }}) + if !m.planInit || m.planVer != 3 || len(m.plan.Steps) != 1 || m.planAvail != planAvailable { + t.Fatalf("first snapshot not accepted: %+v avail=%d", m.plan, m.planAvail) + } + + // Superseded sequencing / foreign session / version regression: all drops. + m.planReqSeq++ // superseded sequence + m.handlePlanMsg(planMsg{want: "s1", seq: m.planReqSeq - 1, + snap: client.PlanSnapshot{SessionID: "s1", Version: 4, Found: true}}) + m.planReqSeq++ // foreign session + m.handlePlanMsg(planMsg{want: "other", seq: m.planReqSeq, + snap: client.PlanSnapshot{SessionID: "other", Version: 4, Found: true}}) + m.planReqSeq++ // stale not-found + m.handlePlanMsg(planMsg{want: "s1", seq: m.planReqSeq, + snap: client.PlanSnapshot{SessionID: "s1", Version: 2, Found: false}}) + if m.planVer != 3 || len(m.plan.Steps) != 1 { + t.Fatalf("stale replies mutated state: ver=%d steps=%d", m.planVer, len(m.plan.Steps)) + } +} + +func TestPlanUnavailable_SilentDegrade(t *testing.T) { + m := &Model{sessionID: "s1"} + m.planReqSeq++ + cmd := m.handlePlanMsg(planMsg{want: "s1", seq: m.planReqSeq, err: errTestPlanRoute}) + if m.planAvail != planUnavailable { + t.Fatalf("avail = %d, want unavailable", m.planAvail) + } + if cmd != nil { + t.Fatal("hidden tab must not re-arm anything on error") + } +} + +func TestPlanPoll_TabVisibleLifecycle(t *testing.T) { + m := &Model{sessionID: "s1", cl: &client.Client{}} + m.panel = panelPlan + + cmd := m.armPlanPoll() + if cmd == nil || m.planPollSeq == 0 { + t.Fatal("arm must schedule exactly one tick") + } + if c := m.handlePlanTick(planTickMsg{seq: m.planPollSeq}); c == nil { + t.Fatal("fresh tick on visible tab must refetch") + } + if c := m.handlePlanTick(planTickMsg{seq: m.planPollSeq - 1}); c != nil { + t.Fatal("superseded tick must drain") + } + m.panel = panelNone + if c := m.handlePlanTick(planTickMsg{seq: m.planPollSeq}); c != nil { + t.Fatal("closed tab must stop polling") + } + + // An error reply stops the chain while the tab stays open. + m.panel = panelPlan + m.planAvail = planUnknown + m.planReqSeq++ + m.handlePlanMsg(planMsg{want: "s1", seq: m.planReqSeq, err: errTestPlanRoute}) + if c := m.handlePlanTick(planTickMsg{seq: m.planPollSeq}); c != nil { + t.Fatal("unavailable endpoint must not keep polling") + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 68bf87e..8f1c300 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -271,9 +271,16 @@ func (m *Model) statusLine() string { if e := m.elapsed(); e != "" { el = th.headerMeta.Render(" · " + e) } + // Live plan strip (docs/PLANNING_MODE_UI.md §4B): rides the same row, + // silent unless a run is active AND a plan exists — absence costs zero + // pixels. Bounded to a short label so small terminals keep the row sane. + strip := "" + if s := m.planStripLabel(); s != "" { + strip = th.acDetail.Render(" ▸ " + s) + } // A blank row above separates the indicator from the transcript tail — // inputAreaHeight accounts for it so the layout math stays exact. - return "\n" + th.spinner.Render(m.sp.View()) + " " + th.statusBusy.Render(label) + el + return "\n" + th.spinner.Render(m.sp.View()) + " " + th.statusBusy.Render(label) + el + strip } // statusLineVisible reports whether the status line occupies a row, keeping