Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
87c8919
fix(tui): drop dangling footer separators and name the slash popup
jkyberneees Oct 8, 2026
7c0736b
fix(tui): keep reply text in the rendered column before first flush
jkyberneees Oct 8, 2026
6c359e2
fix(tui): fit cockpit sheet to content and drop duplicated rows
jkyberneees Oct 8, 2026
ca33383
fix(tui): show drawer position instead of a bare ellipsis tab strip
jkyberneees Oct 8, 2026
1a9d5f2
feat(tui): group help card into sections with two-column layout
jkyberneees Oct 8, 2026
eb0386a
feat(tui): paint the header rule as a quiet hairline
jkyberneees Oct 8, 2026
5a3c00e
feat(tui): separate warning hue from the brand accent
jkyberneees Oct 8, 2026
160702f
feat(tui): drop the emoji from just-in-time tips
jkyberneees Oct 8, 2026
d75a5b4
feat(tui): in-card approval actions and code-styled command
jkyberneees Oct 8, 2026
e4ef0c3
feat(tui): anchor first-run home above the composer
jkyberneees Oct 8, 2026
d6e8b4a
fix(tui): match the friction action row to the two-step confirm
jkyberneees Oct 8, 2026
fc3e701
feat(tui): rest the composer at one row and grow with content
jkyberneees Oct 8, 2026
5efc04c
feat(tui): carry step status in a single glyph
jkyberneees Oct 8, 2026
b1f49e7
feat(tui): mark the sandbox with a diamond, not a second green dot
jkyberneees Oct 8, 2026
cf8d0c1
feat(tui): escalate high-risk approval borders to red
jkyberneees Oct 8, 2026
542062c
feat(client): decode answer_superseded, done.verified and runtime_event
jkyberneees Oct 8, 2026
d6a195d
feat(tui): fold superseded drafts and flag unverified answers
jkyberneees Oct 8, 2026
6160e68
test(tui): cover draft, help and cockpit edge paths
jkyberneees Oct 9, 2026
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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,19 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`
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_superseded` turns the reply
segments streamed since the last tool call into `draft` items — a folded
`⋯ draft revised · <reason>` row that opens like reasoning (inspect +
Enter, `^E`) — and rebuilds `msg.content` from the surviving replies, so
a turn never shows two answers. Use `turnItem.isStep()` / `foldable()`;
never treat "not reply, not reasoning" as a step. Replay folds records
persisted with `superseded`. `done.verified == "fail"` (or a persisted /
bulk verification-failed marker, stripped from the prose) sets
`unverified`: `✗ unverified` on the head and outcome row. Verification
`runtime_event` frames only drive the `verifying answer` status label;
the client decodes `runtime_event` separately because its object `event`
key collides with the string `SubType`. 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
Expand Down
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,16 @@ own front-end settings are separate; see [Configuration](#configuration).
Missing success metadata stays neutral rather than claiming that a command
passed.
- **Streaming answers** rendered as Markdown
([glamour](https://github.com/charmbracelet/glamour)).
- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph
per tool and a static live mark (`▸`); result bodies wait behind `^E` or a
([glamour](https://github.com/charmbracelet/glamour)). When odek re-asks
the model after an answer already streamed (completion check or
verification retry, odek ≥ v2.33), the draft folds into a
`⋯ draft revised` row (open it with `^E` or inspect + `⏎`) and only the
replacement shows as the answer. While odek verifies the answer, the status
line reads `verifying answer`; an answer that fails verification carries
`✗ unverified` on its turn heading and outcome row.
- **Tool activity** — every `tool_call`/`tool_result` shown live with one
status glyph per step: a static live mark (`▸`) while running, the tool's
own glyph once it finishes, `✗` when it fails; result bodies wait behind `^E` or a
click so a finished step reads as one calm line. An opened step shows its
invocation before its result, including while the tool is running. Tool
arguments are retained up to 256 KiB, with an explicit omission marker if
Expand Down Expand Up @@ -261,11 +268,11 @@ own front-end settings are separate; see [Configuration](#configuration).
`--resume`; `--new` still wins). When bodek exits, it prints the exact
line for the session that was just closed, so one paste brings it back.
- **Session home** — first-run shows the working directory, the last
session title when one exists, and `type a task · ^K`. After `^L`, the
session title when one exists, and `⏎ send · ⇧⏎ newline · @ attach a file`, anchored just above the composer. After `^L`, the
cleared transcript keeps the last prompt and coding receipt so the
session is still oriented; `/new` returns to the first-run splash. The
footer leads with a mode pill (`composer` / `approval` / `question` / `jobs` / …).
- **Auto-fitting composer** — the input box rests at three rows and grows
- **Auto-fitting composer** — the input box rests at one text row (three with its border) and grows
with your prompt (multi-line or a single long line, wide-char aware) up to
twelve rows or what the terminal can spare; `⇧⏎` inserts a newline
(`ctrl+enter`, `alt+enter`, or `^J` on terminals that cannot tell
Expand All @@ -285,7 +292,7 @@ own front-end settings are separate; see [Configuration](#configuration).
trims, tool execution times) stays silent. Info traces fade after 3s;
errors, warnings, and disconnect notes autoclose after 10s.
- **Just-in-time hints** — the first time a state appears (a held prompt,
a sub-agent swarm, a tool call), a one-time 💡 tip teaches its key
a sub-agent swarm, a tool call), a one-time tip teaches its key
and dwells 8s (5s longer than info traces), then stays silent for the
run. Features surface the moment they matter; no keybinding table
required.
Expand Down Expand Up @@ -389,7 +396,7 @@ own front-end settings are separate; see [Configuration](#configuration).
set the terminal window title (`✓ done — <model>` / `⚠ approval needed —
<model>`) and ring the bell (`--bel=false` mutes); `--notify` adds OSC 9
desktop notifications. Fires only on terminal states — never per token.
- **Sandbox aware** — the header shows `🛡 sandboxed` or `⚠ host access`;
- **Sandbox aware** — the header shows `◆ sandboxed` or `▲ host access`;
pass `--sandbox` to run tool calls inside odek's Docker isolation.
- **Wake turns** — when a background job finishes while the session is
idle (odek ≥ v1.40), the engine wakes the model on its own; bodek opens
Expand Down
74 changes: 74 additions & 0 deletions internal/client/answer_events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package client

import (
"encoding/json"
"net/http"
"testing"
"time"

ws "golang.org/x/net/websocket"
)

func TestDecodeAnswerSupersededAndVerified(t *testing.T) {
var ev Event
if err := json.Unmarshal([]byte(`{"type":"answer_superseded","reason":"verify_retry","cycle":2,"turn_id":"t_1"}`), &ev); err != nil {
t.Fatal(err)
}
if ev.Type != "answer_superseded" || ev.Reason != "verify_retry" || ev.Cycle != 2 {
t.Fatalf("bad answer_superseded decode: %+v", ev)
}
ev = Event{}
if err := json.Unmarshal([]byte(`{"type":"done","verified":"fail"}`), &ev); err != nil {
t.Fatal(err)
}
if ev.Verified != "fail" {
t.Fatalf("done.verified = %q, want fail", ev.Verified)
}
}

func TestDecodeRuntimeFrame(t *testing.T) {
ev, ok := decodeRuntimeFrame([]byte(`{"type":"runtime_event","turn_id":"t_1","event":{"schema":"odek.event/v1","type":"verification_completed","data":{"verdict":"fail","cycles_used":1}}}`))
if !ok || ev.Type != "runtime_event" || ev.TurnID != "t_1" || ev.Runtime == nil {
t.Fatalf("bad runtime decode: %+v ok=%v", ev, ok)
}
if ev.Runtime.Type != "verification_completed" || ev.Runtime.Data["verdict"] != "fail" {
t.Fatalf("bad runtime payload: %+v", ev.Runtime)
}
for _, bad := range []string{
`{"type":"subagent_log","event":{"x":1}}`, // object event on a non-runtime frame stays malformed
`{"type":"runtime_event"}`, // no record
`not json`,
} {
if _, ok := decodeRuntimeFrame([]byte(bad)); ok {
t.Errorf("decodeRuntimeFrame(%s) accepted a malformed frame", bad)
}
}
}

// A runtime_event frame used to fail the Event decode (its object "event"
// key collides with the string SubType) and was silently dropped.
func TestRuntimeEventFrameReachesEvents(t *testing.T) {
mux := http.NewServeMux()
mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {
_ = ws.Message.Send(c, `{"type":"runtime_event","event":{"type":"verification_started"}}`)
_ = ws.Message.Send(c, `{"type":"subagent_log","event":"spawned","name":"SA1"}`)
time.Sleep(200 * time.Millisecond)
}))
cl, _ := newTestServer(t, mux)
for i, want := range []string{"runtime_event", "subagent_log"} {
select {
case ev := <-cl.Events:
if ev.Type != want {
t.Fatalf("event %d = %q, want %q", i, ev.Type, want)
}
if want == "runtime_event" && (ev.Runtime == nil || ev.Runtime.Type != "verification_started") {
t.Fatalf("runtime payload = %+v", ev.Runtime)
}
if want == "subagent_log" && ev.SubType != "spawned" {
t.Fatalf("subagent_log SubType = %q", ev.SubType)
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for %s", want)
}
}
}
37 changes: 36 additions & 1 deletion internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ type Event struct {
// token / thinking
Content string `json:"content"`

// answer_superseded (odek ≥ v2.33): the reply streamed since the last
// tool call was a draft and the next reply replaces it. Reason is
// completion_nudge | verify_retry; Cycle counts re-asks of that kind
// within the run, from 1.
Reason string `json:"reason,omitempty"`
Cycle int `json:"cycle,omitempty"`

// done (odek ≥ v2.33): the final answer's verification outcome — pass |
// fail | uncertain | skipped; absent when verification did not run.
Verified string `json:"verified,omitempty"`

// runtime_event: the odek.event/v1 record. Decoded separately because
// the frame's "event" key is an object, while sub-agent frames carry a
// string there (SubType).
Runtime *RuntimeEvent `json:"-"`

// error
Message string `json:"message"`

Expand Down Expand Up @@ -214,6 +230,21 @@ type ResultDenial struct {
Reason string `json:"reason"`
}

// decodeRuntimeFrame decodes a runtime_event frame, whose object-valued
// "event" key cannot unmarshal into Event.SubType. Any other frame that
// failed the plain decode stays malformed.
func decodeRuntimeFrame(data []byte) (Event, bool) {
var f struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
Event *RuntimeEvent `json:"event"`
}
if err := json.Unmarshal(data, &f); err != nil || f.Type != "runtime_event" || f.Event == nil {
return Event{}, false
}
return Event{Type: f.Type, TurnID: f.TurnID, Runtime: f.Event}, true
}

// EventDisconnected is a synthetic Type emitted on the Events channel when the
// socket closes, so the TUI can react instead of hanging.
const EventDisconnected = "_disconnected"
Expand Down Expand Up @@ -429,7 +460,11 @@ func (c *Client) readLoop() {
_ = c.conn.SetReadDeadline(time.Time{}) // received: drop the deadline while decoding
var ev Event
if err := json.Unmarshal(data, &ev); err != nil {
continue // ignore malformed frames
rt, ok := decodeRuntimeFrame(data)
if !ok {
continue // ignore malformed frames
}
ev = rt
}
mu.Lock()
if ev.Type == "thinking_delta" {
Expand Down
5 changes: 5 additions & 0 deletions internal/client/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ type SessionMessage struct {
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []SessionToolCall `json:"tool_calls,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
// Superseded marks a draft final answer the loop replaced by re-asking
// the model (odek ≥ v2.33); SupersededReason is completion_nudge |
// verify_retry.
Superseded bool `json:"superseded,omitempty"`
SupersededReason string `json:"superseded_reason,omitempty"`
}

// SessionToolCall is one persisted tool invocation (OpenAI wire format).
Expand Down
129 changes: 129 additions & 0 deletions internal/tui/approval_card_polish_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package tui

import (
"strings"
"testing"

"github.com/charmbracelet/lipgloss"

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

// approvalCardRows returns the card body rows as plain text.
func approvalCardRows(m *Model) []string {
return strings.Split(plain(m.approvalBody()), "\n")
}

func rowContaining(rows []string, want string) (string, bool) {
for _, r := range rows {
if strings.Contains(r, want) {
return r, true
}
}
return "", false
}

// TestApprovalActionRowListsDecisions checks the in-card decision row keeps
// allow-once and deny at both narrow and wide widths, and offers trust only
// when the server allows it and the row has room.
func TestApprovalActionRowListsDecisions(t *testing.T) {
for _, width := range []int{40, 100} {
m := newTestModel()
m.resize(width, 30)
busyTurn(m)
m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Name: "shell",
Risk: "shell_exec", Command: "rm -rf build/", Description: "clean build", AllowTrust: true})
row, ok := rowContaining(approvalCardRows(m), "allow once")
if !ok {
t.Fatalf("%d cols: card has no action row:\n%s", width, plain(m.approvalBody()))
}
if !strings.Contains(row, "d deny") {
t.Fatalf("%d cols: action row lost deny: %q", width, row)
}
if width == 100 && !strings.Contains(row, "t trust class") {
t.Fatalf("100 cols: trust offered but missing from action row: %q", row)
}
if width == 40 && strings.Contains(row, "trust") {
t.Fatalf("40 cols: trust must drop before allow-once or deny: %q", row)
}
if lipgloss.Width(row) > m.cardInner() {
t.Fatalf("%d cols: action row %d cells exceeds card inner %d: %q", width, lipgloss.Width(row), m.cardInner(), row)
}
}
}

// TestApprovalActionRowFrictionPath checks friction cards advertise the typed
// confirmation path and Alt+D rather than trust.
func TestApprovalActionRowFrictionPath(t *testing.T) {
m := newTestModel()
m.resize(100, 30)
busyTurn(m)
m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Name: "shell",
Risk: "shell_exec", Command: "make", AllowTrust: true, Friction: true, FrictionApprovals: 3})
// Before the editor opens, 'a' starts the two-step confirm — the row must
// not claim typing alone approves.
row, ok := rowContaining(approvalCardRows(m), "confirm → type approve")
if !ok {
t.Fatalf("friction card has no confirm row:\n%s", plain(m.approvalBody()))
}
if !strings.Contains(row, "d deny") || strings.Contains(row, "trust") {
t.Fatalf("friction action row = %q, want d deny and no trust", row)
}
m.apprEditing = true
row, ok = rowContaining(approvalCardRows(m), "type approve + ⏎")
if !ok {
t.Fatalf("friction editor has no typed-confirmation row:\n%s", plain(m.approvalBody()))
}
if !strings.Contains(row, "Alt+D deny") || strings.Contains(row, "trust") {
t.Fatalf("friction editor row = %q, want Alt+D deny and no trust", row)
}
}

// TestApprovalReasonHiddenWhenRepeated checks a reason that only restates the
// tool name or the Action text is dropped, while a distinct reason stays.
func TestApprovalReasonHiddenWhenRepeated(t *testing.T) {
cases := []struct {
name, reason string
expanded bool
wantShown bool
}{
{"equals tool name", "shell", false, false},
{"equals tool name case-insensitively", "SHELL", false, false},
{"equals action text", "shell commands", false, false},
{"distinct reason shown", "clean build artifacts", false, true},
{"equals tool name expanded", "shell", true, false},
{"distinct reason expanded", "clean build artifacts", true, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := newTestModel()
m.resize(100, 30)
m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Name: "shell",
Risk: "shell_exec", Command: "rm -rf build/", Description: tc.reason})
m.apprExpanded = tc.expanded
body := plain(m.approvalBody())
if got := strings.Contains(body, "Reason:"); got != tc.wantShown {
t.Fatalf("Reason shown = %v, want %v:\n%s", got, tc.wantShown, body)
}
})
}
}

// TestApprovalCardFitsTerminalHeight checks the approval card, with its new
// action row, never pushes View past the terminal height.
func TestApprovalCardFitsTerminalHeight(t *testing.T) {
for _, sz := range []struct{ w, h int }{{80, 16}, {40, 12}} {
m := newTestModel()
m.resize(sz.w, sz.h)
busyTurn(m)
m.handleEvent(client.Event{Type: "approval_request", ID: "approval", Name: "shell",
Risk: "shell_exec", Command: "rm -rf build/", Description: "clean build artifacts", AllowTrust: true})
view := plain(m.View())
if rows := strings.Count(view, "\n") + 1; rows > sz.h {
t.Fatalf("%dx%d: View uses %d rows with approval pending:\n%s", sz.w, sz.h, rows, view)
}
if !strings.Contains(view, "allow once") {
t.Fatalf("%dx%d: approval action row not visible:\n%s", sz.w, sz.h, view)
}
}
}
8 changes: 5 additions & 3 deletions internal/tui/banner.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
)

// welcome is the empty-session home: where the agent will work, and one
// next action. Branding lives in the header; F1 / /help hold the rest.
// next action. Branding lives in the header; F1 / /help hold the rest. The
// block is top-aligned here; home() anchors it above the composer.
func welcome(th theme, width int, cwd, lastSession string) string {
var b strings.Builder
if dir := shortenHome(cwd); dir != "" {
Expand All @@ -22,8 +23,9 @@ func welcome(th theme, width int, cwd, lastSession string) string {
b.WriteString(th.tipText.Render("/new starts fresh") + "\n")
b.WriteByte('\n')
}
b.WriteString(th.tipKey.Render("type a task") + "\n")
hints := []string{"⏎ send", "⇧⏎ newline", "^K commands"}
// One next-action tip: the composer placeholder already invites a task and
// the footer owns ^K, so the home only names what the keys do.
hints := []string{"⏎ send", "⇧⏎ newline", "@ attach a file"}
inner := max(1, width-2)
line := ""
for _, hint := range hints {
Expand Down
Loading
Loading