diff --git a/AGENTS.md b/AGENTS.md index 5bdd681..86197bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 680ff47..a9cd60a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 | @@ -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, diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 537ca9e..0c3b2c4 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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"}, diff --git a/internal/tui/ergonomics_test.go b/internal/tui/ergonomics_test.go index b988da5..fe18239 100644 --- a/internal/tui/ergonomics_test.go +++ b/internal/tui/ergonomics_test.go @@ -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 { @@ -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()) @@ -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") } diff --git a/internal/tui/hints_test.go b/internal/tui/hints_test.go index 8ec6703..4e4a830 100644 --- a/internal/tui/hints_test.go +++ b/internal/tui/hints_test.go @@ -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") diff --git a/internal/tui/inspect.go b/internal/tui/inspect.go index 8062210..3ac08a5 100644 --- a/internal/tui/inspect.go +++ b/internal/tui/inspect.go @@ -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 @@ -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) @@ -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 } diff --git a/internal/tui/model.go b/internal/tui/model.go index 151712a..772bba7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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. diff --git a/internal/tui/retire_bindings_test.go b/internal/tui/retire_bindings_test.go new file mode 100644 index 0000000..eeb1663 --- /dev/null +++ b/internal/tui/retire_bindings_test.go @@ -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) + } + } +} diff --git a/internal/tui/steps_test.go b/internal/tui/steps_test.go index 9bd4163..49c2f47 100644 --- a/internal/tui/steps_test.go +++ b/internal/tui/steps_test.go @@ -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) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 65bb22b..19b74b8 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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) @@ -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