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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ feat(tui): compact tool steps with Ctrl+E details toggle
messages; turn markers (`**Cancelled.**` etc.) attach to the last reply.
The calm default hides reasoning previews and tool responses: the intent
rail and step result bodies paint only under `^E` (details) or a
deliberate expand — `Tab`/`Shift+Tab` selects an item and `Enter` opens it;
deliberate expand — ``/`` selects an item and `Enter` opens it;
click selects and expands one step; the result peek is gone. While a turn
streams, its head line carries the run's elapsed counter right-aligned
at the viewport edge (the `runStart` clock, whole seconds, dropped on
Expand Down Expand Up @@ -315,8 +315,8 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`

## Interaction and response bounds

- `Tab`/`Shift+Tab` traverses chronological tools and reasoning, with visible focus;
`Enter` expands the selected item. Typing returns to the composer. `[`/`]`
- ``/`` traverses chronological tools and reasoning while inspecting, with visible focus;
`Enter` expands the selected item. Typing returns to the composer. `PgUp`/`PgDn`
pages a selected tool and Right cycles its sub-agent chips. Copy uses the
selected item. Clear/resume must discard stale inspect coordinates.
- Expanded tool and sub-agent response bodies show at most eight display rows
Expand Down
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ own front-end settings are separate; see [Configuration](#configuration).

- **Live reasoning** — the model's pre-tool thinking is captured per beat
but stays hidden in the calm default: the transcript holds still while
odek thinks. `^E` unfolds every stored block; `Tab` / `Shift+Tab` selects an item
odek thinks. `^E` unfolds every stored block; `↑`/`↓` (while inspecting)
selects an item
and `Enter` opens just that item — a live opened block holds finished sentences until the
next lands, never a token ticker. Elapsed time per beat; the clock
freezes when that think cycle yields (a tool or the reply). A turn that
Expand All @@ -217,7 +218,7 @@ own front-end settings are separate; see [Configuration](#configuration).
glyph: `●` idle, `◉` a turn in flight, `◌` reconnecting, `○` down.
- **Sub-agents** — a delegation paints an always-on chip strip under the
parent step (`⟳ SA1 explore · ✓ SA2 lint · ✗ SA3 types`), so you can
see who is running or who failed without expanding. Click a chip, or select its parent tool with `Tab` and press `Right`
see who is running or who failed without expanding. Click a chip, or select its parent tool with `↑↓` and press `Right`
to cycle agent focus: identity + live beat
(current tool, step, budget, cost). `^E` / expand shows that
agent's logs, artifacts, and framed result in bounded pages. Tasks the wire hasn't
Expand Down Expand Up @@ -403,8 +404,8 @@ own front-end settings are separate; see [Configuration](#configuration).
| `alt+r` | Re-send the last prompt (`/retry`) |
| `alt+f` | Search the transcript (`⏎`/`n` next match · `N` previous · a hit expands the hidden step or reasoning block) |
| `^F` | Fold/unfold the most recent turn card (or click any turn head) |
| `Tab` / `Shift+Tab` | Select the next / previous tool or reasoning item; `Enter` expands it, `Esc` returns to the composer |
| `[` / `]` (inspecting a tool) | Previous / next response page; `Right` cycles sub-agent chips when present |
| `↑`/`↓` (inspecting) | Select the previous / next tool or reasoning item; `Enter` expands it, `Esc` returns to the composer |
| `PgUp`/`PgDn` (inspecting a tool) | Previous / next response page; `Right` cycles sub-agent chips when present |
| `^X` | Stop the running turn from any panel or inspection state (`y` confirms); unrelated expanded items stay open |
| `^R` | Browse & resume saved sessions |
| `^O` | Switch the model |
Expand Down Expand Up @@ -434,11 +435,12 @@ prompt can start with `?`, `[`, or any other character.

### Inspecting tool responses

Select an item with `Tab` / `Shift+Tab`, then press Enter to expand it. Tool
responses display at most eight body rows plus a paging indicator, with fewer
rows in short terminals. Use `[` / `]` to page, `Alt+Y` to copy the retained
response, and Escape to return to typing. Clicking a tool header also selects
it. The global `^E` details toggle uses the same page limits.
Click a tool header or reasoning block to inspect it; while inspecting, `↑`/
`↓` move between items and Enter expands the focused one. Tool responses
display at most eight body rows plus a paging indicator, with fewer
rows in short terminals. Use `PgUp`/`PgDn` to page, `Alt+Y` to copy the
retained response, and Escape to return to typing. The global `^E`
details toggle uses the same page limits.

Batch results retain command/file labels and original item counts; bracketed
log lines are never treated as extra commands. Plans render creation, updates,
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func (m *Model) showHelp() {
{"^Y", "copy the latest reply"},
{"alt+r", "re-send the last prompt (/retry)"},
{"^F", "fold/unfold the latest turn card"},
{"tab", "focus the next sub-agent chip · else open/close reasoning"},
{"↑↓ (inspecting)", "focus the previous/next item"},
{"Pg↑↓", "page the transcript"},
{"^P^N", "recall prompts"},
{"^G", "jump to the latest output"},
Expand Down
22 changes: 11 additions & 11 deletions internal/tui/ergonomics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,23 @@ func TestInspectReachesToolAfterReasoning(t *testing.T) {
busyTurn(m)
m.msgs[1].items = []turnItem{{thinking: true, text: "reasoning"}, {stepIdx: 0}, {stepIdx: 1}}
m.msgs[1].steps = []step{{name: "shell", done: true, result: "first"}, {name: "shell", done: true, result: "second"}}
m.Update(key("tab"))
m.moveInspect(false)
if m.inspect == nil || m.inspect.itemIdx != 0 {
t.Fatal("Tab must visibly select reasoning")
t.Fatal("first traversal must visibly select reasoning")
}
m.Update(key("tab"))
m.handleKey(key("down"))
m.Update(key("enter"))
if m.inspect.stepIdx != 0 || !m.msgs[1].steps[0].expanded || m.msgs[1].steps[1].expanded {
t.Fatal("Enter must expand only the selected tool")
}
m.Update(key("tab"))
m.handleKey(key("down"))
m.Update(key("enter"))
if !m.msgs[1].steps[1].expanded {
t.Fatal("Tab must reach the next tool")
t.Fatal("down must reach the next tool")
}
m.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
m.handleKey(key("up"))
if m.inspect.stepIdx != 0 {
t.Fatal("Shift+Tab must move backward")
t.Fatal("up must move backward")
}
m.Update(key("esc"))
if m.inspect != nil || m.confirm != confirmNone {
Expand All @@ -43,7 +43,7 @@ func TestInspectTypingReturnsToComposer(t *testing.T) {
m := newTestModel()
busyTurn(m)
m.msgs[1].steps = []step{{name: "shell"}}
m.Update(key("tab"))
m.moveInspect(false)
m.Update(key("h"))
if m.inspect != nil || m.ta.Value() != "h" {
t.Fatalf("typing should resume composer: %q", m.ta.Value())
Expand Down Expand Up @@ -90,16 +90,16 @@ func TestExpandedToolResponsesStayBoundedAndPage(t *testing.T) {
if strings.Contains(out, "line 099") {
t.Fatal("first page should not contain tail")
}
m.Update(key("]"))
m.handleKey(key("pgdown"))
next, _, _ := m.renderStep(m.msgs[1].steps[0], false, 1, 0, 0)
if next == out || m.msgs[1].steps[0].detailOffset == 0 {
t.Fatal("paging did not advance")
}
for i := 0; i < 100/m.toolDetailRows()+3; i++ {
m.Update(key("]"))
m.handleKey(key("pgdown"))
}
last := m.msgs[1].steps[0].detailOffset
m.Update(key("["))
m.handleKey(key("pgup"))
if m.msgs[1].steps[0].detailOffset >= last {
t.Fatal("paging beyond tail must not trap navigation")
}
Expand Down
10 changes: 5 additions & 5 deletions internal/tui/hints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,28 +131,28 @@ func TestErrorResultAutoExpands(t *testing.T) {
}
}

func TestTabSelectsToolsAndReasoning(t *testing.T) {
func TestArrowsSelectToolsAndReasoning(t *testing.T) {
m := liveTurnModel()
runMiniTurn(t, m, "read_file", "one")
runMiniTurn(t, m, "shell", "two")
m.handleEvent(client.Event{Type: "done"})
last := len(m.msgs) - 1
m.Update(key("tab"))
m.moveInspect(false)
if m.inspect == nil || m.inspect.stepIdx != 0 {
t.Fatal("Tab must select the first tool in the latest turn")
t.Fatal("first traversal must select the first tool in the latest turn")
}
m.Update(key("enter"))
if !m.msgs[last].steps[0].expanded {
t.Fatal("Enter must expand the selected tool")
}
m.Update(key("tab"))
m.handleKey(key("down"))
m.Update(key("enter"))
if !m.msgs[last].steps[1].expanded {
t.Fatal("next tool must be reachable independently")
}
ti := len(m.msgs[last].items)
m.msgs[last].items = append(m.msgs[last].items, turnItem{thinking: true, text: "deliberation"})
m.Update(key("tab"))
m.handleKey(key("down"))
m.Update(key("enter"))
if !m.msgs[last].items[ti].open {
t.Fatal("reasoning must remain individually reachable")
Expand Down
10 changes: 5 additions & 5 deletions internal/tui/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ func (m *Model) handleInspectKey(msg tea.KeyMsg) bool {
return false
}
switch msg.String() {
case "tab", "down":
case "down":
m.moveInspect(false)
case "shift+tab", "up":
case "up":
m.moveInspect(true)
case "enter", " ":
p := m.inspect
Expand All @@ -169,11 +169,11 @@ func (m *Model) handleInspectKey(msg tea.KeyMsg) bool {
m.invalidateInspect()
m.refresh()
m.revealInspect()
case "[", "]":
case "pgup", "pgdown":
if m.inspect.stepIdx >= 0 {
s := &m.msgs[m.inspect.msgIdx].steps[m.inspect.stepIdx]
delta := m.toolDetailRows()
if msg.String() == "[" {
if msg.String() == "pgup" {
delta = -delta
}
s.detailOffset = max(0, s.detailOffset+delta)
Expand Down Expand Up @@ -235,7 +235,7 @@ func (m *Model) toolDetailPage(s *step, details []string, width int) []string {
s.detailOffset = offset
end := min(len(rows), offset+limit)
out := append([]string(nil), rows[offset:end]...)
label := fmt.Sprintf("%d–%d/%d · Tab select · [ ] page", offset+1, end, len(rows))
label := fmt.Sprintf("%d–%d/%d · PgUp PgDn page", offset+1, end, len(rows))
out = append(out, m.th.stepArg.Render(ansi.Truncate(label, max(1, width), "")))
return out
}
3 changes: 0 additions & 3 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -1128,9 +1128,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// bare letters belong to the composer.
m.toggleCollapseLast()
return m, nil
case "tab", "shift+tab":
m.moveInspect(msg.String() == "shift+tab")
return m, nil
case "end":
// End doubles as jump-to-latest — only with an empty input, so its
// cursor-movement meaning inside a draft keeps working.
Expand Down
117 changes: 117 additions & 0 deletions internal/tui/retire_bindings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package tui

import (
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"

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

// ── Tab / [ ] retirement: transcript inspector bindings are removed ────────
// Traversal keeps its arrow forms (up/down inside the inspector); Tab,
// Shift+Tab, and the [ ] paging keys are gone from code and docs. Bare
// [ and ] must always type into the composer.

// armedInspector arms an inspect focus on a step so the retired bindings can
// be probed.
func armedInspector(t *testing.T) *Model {
t.Helper()
m := newTestModel()
busyTurn(m)
m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"ls"}`})
m.handleEvent(client.Event{Type: "tool_result", Name: "shell", Data: strings.Repeat("line\n", 60)})
m.handleEvent(client.Event{Type: "tool_call", Name: "read_file", Data: `{"path":"x.go"}`})
m.handleEvent(client.Event{Type: "tool_result", Name: "read_file", Data: "body"})
m.handleEvent(client.Event{Type: "done"})
m.moveInspect(false)
if !m.validInspect() {
t.Fatal("precondition: inspector must be armed")
}
return m
}

// TestTabNoLongerMovesInspect: tab/shift+tab are retired — they must not
// move the inspector focus anymore (arrows still do).
func TestTabNoLongerMovesInspect(t *testing.T) {
m := armedInspector(t)
before := *m.inspect
m.handleKey(key("tab"))
if m.inspect == nil || *m.inspect != before {
t.Error("tab still moved the inspector focus")
}
m.handleKey(tea.KeyMsg{Type: tea.KeyShiftTab})
if m.inspect == nil || *m.inspect != before {
t.Error("shift+tab still moved the inspector focus")
}
// Arrows remain the traversal form inside the inspector.
m.handleKey(key("down"))
if m.inspect == nil {
t.Error("down must still traverse inspector items")
}
}

// TestBracketsNoLongerPageToolDetail: the [ ] paging case is gone — the
// detail offset must not move on those keys (seeded mid-list so both
// directions are observable).
func TestBracketsNoLongerPageToolDetail(t *testing.T) {
m := armedInspector(t)
p := *m.inspect
if p.stepIdx < 0 {
t.Skip("focus landed on reasoning; tool paging not applicable")
}
s := &m.msgs[p.msgIdx].steps[p.stepIdx]
s.expanded = true
s.detailOffset = 16 // mid-list: [ would decrease, ] would increase
m.handleKey(key("["))
if s.detailOffset != 16 {
t.Errorf("[ still paged the tool detail (offset %d)", s.detailOffset)
}
m.handleKey(key("]"))
if s.detailOffset != 16 {
t.Errorf("] still paged the tool detail (offset %d)", s.detailOffset)
}
// Bare runes return the keyboard to the composer (inspector cleared).
// PgDn is the paging form once the inspector is re-armed.
if m.inspect != nil {
t.Error("bare [ must return focus to the composer while inspecting")
}
m.moveInspect(false)
m.handleKey(key("pgdown"))
if s.detailOffset <= 16 {
t.Errorf("pgdown did not advance the page (offset %d)", s.detailOffset)
}
}

// TestBracketsTypeIntoComposer: bare [ and ] always type.
func TestBracketsTypeIntoComposer(t *testing.T) {
m := newTestModel()
m.handleKey(key("["))
m.handleKey(key("]"))
if got := m.ta.Value(); got != "[]" {
t.Fatalf("composer draft = %q, want \"[]\"", got)
}
}

// TestTabIdleNoInspect: tab at the composer with no inspector open must do
// nothing (no traversal side effects).
func TestTabIdleNoInspect(t *testing.T) {
m := newTestModel()
m.handleKey(key("tab"))
if m.inspect != nil {
t.Error("tab armed the inspector from idle")
}
}

// TestInspectFooterRetiredLabels: the inspector footer no longer advertises
// Tab or [ ] keys.
func TestInspectFooterRetiredLabels(t *testing.T) {
m := armedInspector(t)
foot := plain(m.footer())
for _, banned := range []string{"Tab next", "[ ] page", "Tab select"} {
if strings.Contains(foot, banned) {
t.Errorf("footer still advertises retired binding %q: %q", banned, foot)
}
}
}
2 changes: 1 addition & 1 deletion internal/tui/steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ func TestExpandedOutputCap(t *testing.T) {
{name: "shell", done: true, result: strings.Repeat("line\n", 250)},
}})
m.toggleStep(0, 0)
if out := plain(m.conversation()); !strings.Contains(out, "[ ] page") {
if out := plain(m.conversation()); !strings.Contains(out, "PgUp PgDn page") {
t.Errorf("expanded output should have bounded pages:\n%s", out)
}
}
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -725,8 +725,8 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st
continue
}
// Calm default: reasoning previews paint only on demand — ^E
// (details) or a deliberate open (tab / click). The transcript
// holds still while odek thinks.
// (details) or a deliberate open (click / inspect + Enter). The
// transcript holds still while odek thinks.
if !items[it].open && !m.expandAll {
if m.inspect != nil && m.inspect.msgIdx == msgIdx && m.inspect.itemIdx == it && m.inspect.stepIdx < 0 {
addBlock(th.asstWork.Render(th.acSel.Render("› reasoning · Enter expand")), false)
Expand Down Expand Up @@ -1764,7 +1764,7 @@ func (m *Model) footerContent() string {
)
}
if m.validInspect() {
return m.panelFooter("inspect", "Tab next", "⏎ expand", "[ ] page", "esc compose")
return m.panelFooter("inspect", "↑↓ next", "⏎ expand", "Pg↑↓ page", "esc compose")
}
// The status bar carries no static key cheatsheet (the welcome splash and
// /help cover that) — only the live run state: a cancel hint while busy on
Expand Down