From a4b5edae25f40d8f9c611291599990e66341749b Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 15:59:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20queue=20strip=20=E2=80=94=20always?= =?UTF-8?q?-visible=20queued=20prompts=20with=20^Q=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queued prompts were invisible between submit and drain: a footer count was the only trace. The strip puts one row per held prompt directly above the input — where the eyes already are — with ▲▼✕ mouse controls and a ctrl+q keyboard focus mode (↑↓/jk select · ←→/hl move · d delete · esc back). Rows fold into an overflow tail past 8, cancel returns held prompts to the composer, and the strip collapses to zero rows when the queue runs dry. Mouse controls render only when the terminal tracks the mouse (--mouse) — glyphs without tracking are dead pixels. The hit test works in terminal cells, not byte offsets: multibyte prompt text used to shift every hit zone so ▼ moved up and ✕ moved down. Mouseless runs get a ^Q hint row that becomes the key legend while focused, and the F1 card now teaches the binding. --- README.md | 5 + cmd/bodek/main.go | 1 + internal/tui/commands.go | 1 + internal/tui/input.go | 4 + internal/tui/integration_test.go | 2 + internal/tui/model.go | 31 +++ internal/tui/model_smoke_test.go | 1 + internal/tui/panels.go | 1 + internal/tui/queue.go | 247 +++++++++++++++++++ internal/tui/queue_strip_test.go | 400 +++++++++++++++++++++++++++++++ internal/tui/view.go | 6 + 11 files changed, 699 insertions(+) create mode 100644 internal/tui/queue.go create mode 100644 internal/tui/queue_strip_test.go diff --git a/README.md b/README.md index d698471..8f97578 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ be written by hand and seed the matching flag defaults. Resolution order: | `tab` | Open/close the latest reasoning block (live turns auto-expand) | | `^R` | Browse & resume saved sessions | | `^O` | Switch the model | +| `^Q` | Focus the queue strip (`↑↓`/`jk` select · `←→`/`hl` move · `d` delete · `esc`/`⏎` back to the input) | | `^T` | Toggle extended thinking for the next turn | | `^J` | Insert a newline in the input | | `^L` | Clear the conversation (two-step confirm: `y` clears, any other key cancels) | @@ -163,6 +164,10 @@ be written by hand and seed the matching flag defaults. Resolution order: Prompts sent while a turn is running are **queued** and sent automatically when the turn ends — a transient note acknowledges each hold, and the count rides both the busy status line and the footer (one drains per turn-end). +Queued prompts stay visible in a **strip directly above the input area**: one +row per prompt with per-row `▲ ▼ ✕` controls (`--mouse`) to reorder or delete, +and a `^Q` keyboard focus mode for the same actions (`↑↓` select, `←→` move, +`d` delete). The strip collapses to zero rows when the queue is empty. While the transcript is scrolled up mid-run, the footer flags `↓ new output`; press `^G` to jump to the latest. If the connection drops, bodek retries with backoff and, after giving up, keeps your draft and offers a manual retry diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 872c100..0d2959f 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -205,6 +205,7 @@ func run() error { Notify: cfg.notify, Plain: cfg.plain, Theme: cfg.theme, + Mouse: cfg.mouse, OnThemeChange: func(name string) error { cfg.persist.Theme = name return settings.Save(cfg.persist) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index f6ed3e6..2f11ac0 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -267,6 +267,7 @@ func (m *Model) showHelp() { {"^P^N", "recall prompts"}, {"^G", "jump to the latest output"}, {"^R", "browse & resume sessions"}, + {"^Q", "manage the queue strip (select · move · delete)"}, {"^O", "switch model"}, {"^T", "toggle extended thinking"}, {"^L", "clear the conversation"}, diff --git a/internal/tui/input.go b/internal/tui/input.go index f7cb573..ce57461 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -201,6 +201,10 @@ func (m *Model) sendQueued() tea.Cmd { } text := m.queue[0] m.queue = m.queue[1:] + m.qsel = clampSel(m.qsel, len(m.queue)) + if len(m.queue) == 0 { + m.qfocus = false + } return m.sendPrompt(text) } diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index fc36afc..76deb8f 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -343,6 +343,8 @@ func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlL} case "ctrl+j": return tea.KeyMsg{Type: tea.KeyCtrlJ} + case "ctrl+q": + return tea.KeyMsg{Type: tea.KeyCtrlQ} case "ctrl+e": return tea.KeyMsg{Type: tea.KeyCtrlE} case "ctrl+k": diff --git a/internal/tui/model.go b/internal/tui/model.go index 0620922..0ed4762 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -105,6 +105,11 @@ type Options struct { Bell bool Notify bool + // Mouse reports that the terminal sends mouse events (--mouse). The + // queue strip gates its ▲▼✕ controls on it: glyphs without tracking + // are dead pixels, so mouseless runs get a ^Q hint instead. + Mouse bool + // Theme names the startup palette (ember-dark, ember-light, // high-contrast, classic). Empty defers to BODEK_THEME, then the // settings file — the same order /theme persists into. @@ -159,6 +164,9 @@ type Model struct { pal palState // ⌘K command palette — the navigation spine skillSuggest *client.Event // pending skill suggestion card (skill_event "suggested") queue []string // prompts typed mid-turn, sent when the turn ends + mouse bool // the terminal reports mouse events (--mouse) + qfocus bool // the queue strip owns the keyboard (ctrl+q) + qsel int // selected strip row while qfocus lastPrompt string // most recent prompt sent — /retry re-sends it focusIdx int // transcript cursor: turn head alt+↑/↓ last jumped to (-1 none) @@ -326,6 +334,7 @@ func New(cl *client.Client, opts Options) *Model { focusIdx: -1, model: opts.Model, sandbox: opts.Sandbox, + mouse: opts.Mouse, thinkOn: false, status: "ready", odekVersion: opts.OdekVersion, @@ -589,6 +598,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refresh() return m, nil } + // The queue strip owns the rows between the status line and the + // input: its controls act on their row. + if m.queueStripClick(msg.Y, msg.X) { + m.refresh() + return m, nil + } // Viewport content begins below the header (2 rows). top := 2 if msg.Y >= top && msg.Y < top+m.vp.Height { @@ -670,6 +685,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // character ("?why", "[TODO]", "reboot…"). Help, jumps, and the // disconnected retry live on non-character keys (F1, alt+arrows, ⏎). + // Queue-strip focus captures everything (except quit) until esc/⏎/ctrl+q + // returns it to the composer. + if m.qfocus { + return m.queueStripKey(msg) + } + switch msg.String() { case "ctrl+c": m.quitting = true @@ -693,6 +714,15 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.ta, cmd = m.ta.Update(tea.KeyMsg{Type: tea.KeyEnter}) return m, tea.Batch(cmd, m.syncAC()) + case "ctrl+q": + // Queue-strip focus: a chord, so typing a q is never hijacked. + // Only latches when there is something queued to manage. + if m.queueStripVisible() { + m.qfocus = true + m.qsel = 0 + m.refresh() + } + return m, nil case "ctrl+t": m.thinkOn = !m.thinkOn state := "off" @@ -991,6 +1021,7 @@ func (m *Model) inputAreaHeight() int { if m.find.open { h++ // the one-row search strip above the input box } + h += m.queueStripHeight() return h } diff --git a/internal/tui/model_smoke_test.go b/internal/tui/model_smoke_test.go index bc90805..3fcedea 100644 --- a/internal/tui/model_smoke_test.go +++ b/internal/tui/model_smoke_test.go @@ -33,6 +33,7 @@ func newTestModel() *Model { th: newTheme(), ta: ta, sp: spinner.New(), + mouse: true, // mirror a --mouse session so control-glyph tests render curIdx: -1, status: "ready", events: make(chan client.Event, 8), diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 7a0d439..2169337 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -733,6 +733,7 @@ func (m *Model) cancelRun() tea.Cmd { m.ta.SetValue(draft) m.ta.CursorEnd() m.queue = nil + m.qfocus, m.qsel = false, 0 // The textarea content just changed out from under the user — say why. note = m.transientNoteCmd("queued prompts returned to the input") } diff --git a/internal/tui/queue.go b/internal/tui/queue.go new file mode 100644 index 0000000..94a4aad --- /dev/null +++ b/internal/tui/queue.go @@ -0,0 +1,247 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ── queued-prompt strip ───────────────────────────────────────────────────── +// +// The strip is the always-visible queue panel above the input area: one row +// per queued prompt with ▲ ▼ ✕ mouse controls, a ctrl+q keyboard focus mode +// (j/k select, h/l move, d delete, esc leaves), and an overflow tail once the +// queue outgrows the cap. The queue itself is bodek-local state (m.queue) — +// prompts are held client-side until sendQueued fires them on turn end. + +// queueStripCap bounds the rendered rows; the rest folds into the tail line. +const queueStripCap = 8 + +// queueStripVisible reports whether the strip occupies rows: it needs queued +// prompts and stays out of the way while an approval owns the input area. +func (m *Model) queueStripVisible() bool { + return len(m.queue) > 0 && m.curApproval() == nil +} + +// queueStripHeight is the number of rows the strip claims above the input, +// keeping View, relayout (via inputAreaHeight), and the mouse math in +// agreement. +func (m *Model) queueStripHeight() int { + if !m.queueStripVisible() { + return 0 + } + h := min(len(m.queue), queueStripCap) + if len(m.queue) > queueStripCap { + h++ // the overflow tail + } + if !m.mouse { + h++ // the ^Q hint row replaces the ▲▼✕ controls + } + return h +} + +// queueStripTop is the absolute screen row of the strip's first row: header, +// viewport, then the busy status line when it shows. +func (m *Model) queueStripTop() int { + top := headerHeight + m.vp.Height + if m.statusLineVisible() { + top += 2 // blank separator row + the status row (statusLine renders both) + } + return top +} + +// queueStripView renders the strip rows (queueStripHeight is the row budget). +// Every row carries the full ▲ ▼ ✕ control set; moves that would leave the +// queue are clamped no-ops rather than hidden, so the targets stay put while +// the queue churns. +func (m *Model) queueStripView() string { + if !m.queueStripVisible() { + return "" + } + th := m.th + window := min(len(m.queue), queueStripCap) + controls := "" + if m.mouse { + // Glyphs only where the terminal tracks the mouse — without + // tracking they are dead pixels, so mouseless runs get the ^Q + // hint row below instead. + controls = th.footerSep.Render(" ▲ ▼ ✕") + } + cw := lipgloss.Width(controls) + rows := make([]string, 0, window+2) + for i := range window { + marker, num := " ", th.footer.Render(fmt.Sprintf("%d ", i+1)) + if m.qfocus && m.qsel == i { + marker = "▸ " + } + text := th.footer.Render(truncate(m.queue[i], max(1, m.width-cw-5))) + rows = append(rows, th.footer.Render(marker)+num+text+controls) + } + if tail := len(m.queue) - window; tail > 0 { + s := fmt.Sprintf("… and %d more", tail) + if m.qfocus && m.qsel >= window && m.qsel < len(m.queue) { + s += " · ▸ " + truncate(m.queue[m.qsel], max(1, m.width-lipgloss.Width(s)-2)) + } + rows = append(rows, th.acDetail.Render(s)) + } + if !m.mouse { + if m.qfocus { + rows = append(rows, th.acDetail.Render(" ↑↓ select · ←→ move · d delete · esc done")) + } else { + rows = append(rows, th.acDetail.Render(" ^q to manage the queue")) + } + } + return strings.Join(rows, "\n") +} + +// clampSel keeps a strip selection inside [0, n). +func clampSel(sel, n int) int { + if n == 0 { + return 0 + } + return min(max(sel, 0), n-1) +} + +// queueDeleteAt removes the i-th queued prompt, clamping the focus-mode +// selection into the new range and leaving focus once the queue runs dry. +func (m *Model) queueDeleteAt(i int) { + if i < 0 || i >= len(m.queue) { + return + } + m.queue = append(m.queue[:i], m.queue[i+1:]...) + m.qsel = clampSel(m.qsel, len(m.queue)) + if len(m.queue) == 0 { + m.qfocus = false + } + m.refresh() +} + +// queueMove swaps the i-th prompt with its neighbor delta rows away, keeping +// the selection on the moved item. Out-of-range moves are no-ops. +func (m *Model) queueMove(i, delta int) { + j := i + delta + if i < 0 || i >= len(m.queue) || j < 0 || j >= len(m.queue) { + return + } + m.queue[i], m.queue[j] = m.queue[j], m.queue[i] + if m.qsel == i { + m.qsel = j + } + m.refresh() +} + +// unstyle drops SGR escape sequences so glyph columns can be located on the +// unstyled row — styling never changes cell positions. +func unstyle(s string) string { + if !strings.Contains(s, "\x1b[") { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { + if j := strings.IndexByte(s[i+2:], 'm'); j >= 0 { + i += j + 3 + continue + } + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// queueStripClick handles a left press inside the strip: the trailing +// controls act on their row, a click on the row body selects it (entering +// focus mode so h/l/d operate immediately). Reports whether the click landed +// on the strip at all. +func (m *Model) queueStripClick(y, x int) bool { + if !m.queueStripVisible() { + return false + } + rel := y - m.queueStripTop() + if rel < 0 || rel >= m.queueStripHeight() { + return false + } + rowsCount := min(len(m.queue), queueStripCap) + if len(m.queue) > queueStripCap { + rowsCount++ // the overflow tail + } + if rel >= rowsCount { + return true // the overflow tail / the mouseless hint row: no controls + } + // Control targets are located on the unstyled row. Bubbletea reports X + // in terminal CELLS, so glyph columns are display widths — byte offsets + // drift 2 cells per multibyte rune and misfire the neighboring control. + row := unstyle(strings.Split(m.queueStripView(), "\n")[rel]) + upC, downC, delC := -1, -1, -1 + cell := 0 + for _, r := range row { + switch r { + case '▲': + if upC < 0 { + upC = cell + } + case '▼': + if downC < 0 { + downC = cell + } + case '✕': + if delC < 0 { + delC = cell + } + } + cell += lipgloss.Width(string(r)) + } + // Check right-to-left: the controls trail the row, so a hit test claims + // the rightmost control whose column the click reached. + if delC >= 0 && x >= delC { + m.queueDeleteAt(rel) + return true + } + if downC >= 0 && x >= downC { + m.queueMove(rel, 1) + return true + } + if upC >= 0 && x >= upC { + m.queueMove(rel, -1) + return true + } + m.qfocus = true + m.qsel = rel + m.refresh() + return true +} + +// queueStripKey routes keys while the strip owns keyboard focus. Bare letters +// other than d are deliberately unbound: the composer keeps them the moment +// focus leaves, and only the explicit chord ctrl+q re-enters. +func (m *Model) queueStripKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if len(m.queue) == 0 { + // The queue drained out from under focus (turn-end pop, cancel + // hand-back): drop focus instead of trapping the keyboard. + m.qfocus = false + return m.Update(msg) + } + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "enter", "ctrl+q": + m.qfocus = false + case "up", "k": + m.qsel = clampSel(m.qsel-1, len(m.queue)) + case "down", "j": + m.qsel = clampSel(m.qsel+1, len(m.queue)) + case "left", "h": + m.queueMove(m.qsel, -1) + case "right", "l": + m.queueMove(m.qsel, 1) + case "d": + m.queueDeleteAt(m.qsel) + } + m.refresh() + return m, nil +} diff --git a/internal/tui/queue_strip_test.go b/internal/tui/queue_strip_test.go new file mode 100644 index 0000000..c5657da --- /dev/null +++ b/internal/tui/queue_strip_test.go @@ -0,0 +1,400 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Queue-strip tests: the always-visible queue panel above the input area. +// The strip renders one row per queued prompt with ▲/▼/✕ mouse controls, a +// ctrl+q keyboard focus mode (j/k select, h/l move, d delete, esc leaves), +// and collapses to zero rows when the queue is empty. + +// queueStripRows splits the rendered strip into its display rows. +func queueStripRows(m *Model) []string { + return strings.Split(m.queueStripView(), "\n") +} + +// clickQueueControl sends a left press on the given control glyph within the +// given strip row, using the same screen math the mouse dispatcher uses. +// Bubbletea reports X in terminal CELLS, so the column is the display width +// of the row prefix before the glyph — not its byte offset. +func clickQueueControl(t *testing.T, m *Model, row int, glyph rune) { + t.Helper() + rows := queueStripRows(m) + if row < 0 || row >= len(rows) { + t.Fatalf("strip row %d out of range, have %d rows", row, len(rows)) + } + un := plain(rows[row]) + x, cell := -1, 0 + for i, r := range un { + if r == glyph { + x = cell + _ = i + break + } + cell += lipgloss.Width(string(r)) + } + if x < 0 { + t.Fatalf("strip row %d has no %q control: %q", row, string(glyph), un) + } + m.Update(tea.MouseMsg{ + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + X: x, + Y: m.queueStripTop() + row, + }) +} + +func TestQueueStripHiddenWhenEmpty(t *testing.T) { + m := newTestModel() + if m.queueStripVisible() { + t.Fatal("strip must be hidden while the queue is empty") + } + if h := m.queueStripHeight(); h != 0 { + t.Errorf("empty strip height = %d, want 0", h) + } + if v := m.View(); strings.Contains(v, "✕") { + t.Error("empty queue must not render strip controls") + } + // ctrl+q with nothing queued must not latch focus onto a phantom strip. + m.Update(key("ctrl+q")) + if m.qfocus { + t.Error("ctrl+q on an empty queue must not enter focus mode") + } +} + +func TestQueueStripRendersAboveInput(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.queue = []string{"first queued", "second queued"} + m.refresh() + + if !m.queueStripVisible() { + t.Fatal("strip should be visible with queued prompts") + } + rows := queueStripRows(m) + if len(rows) != 2 { + t.Fatalf("strip rows = %d, want 2 (one per queued prompt)", len(rows)) + } + if got := plain(rows[0]); !strings.Contains(got, "first queued") { + t.Errorf("head row = %q, want the queue head text", got) + } + if got := plain(rows[1]); !strings.Contains(got, "second queued") { + t.Errorf("row 1 = %q, want the second queued prompt", got) + } + for _, r := range rows { + p := plain(r) + for _, glyph := range []rune{'▲', '▼', '✕'} { + if !strings.ContainsRune(p, glyph) { + t.Errorf("row %q missing %q control", p, string(glyph)) + } + } + } + + // Placement: directly above the input, below the busy status line. The + // viewport starts at row 2 (header); the status line renders a blank + // separator row plus its own row when visible. + wantTop := 2 + m.vp.Height + 2 + if got := m.queueStripTop(); got != wantTop { + t.Errorf("queueStripTop = %d, want %d", got, wantTop) + } + lines := strings.Split(plain(m.View()), "\n") + if !strings.Contains(lines[wantTop], "first queued") { + t.Errorf("View row %d = %q, want the strip head row", wantTop, lines[wantTop]) + } + + // The layout budget must reserve the strip's rows, or the footer drifts: + // with the queue present, the below-viewport chrome grows by exactly the + // strip's height. + with := m.inputAreaHeight() + m.queue = nil + base := m.inputAreaHeight() + m.queue = []string{"first queued", "second queued"} + if with != base+m.queueStripHeight() { + t.Errorf("inputAreaHeight = %d with strip, %d without; strip rows must be budgeted", + with, base) + } +} + +func TestQueueStripDeleteViaMouse(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.queue = []string{"alpha", "beta", "gamma"} + m.refresh() + + clickQueueControl(t, m, 1, '✕') + if got := strings.Join(m.queue, ","); got != "alpha,gamma" { + t.Errorf("queue = %q, want alpha,gamma (beta deleted)", got) + } + if rows := queueStripRows(m); len(rows) != 2 { + t.Errorf("strip rows = %d, want 2 after delete", len(rows)) + } + + // The head row can be deleted too. + clickQueueControl(t, m, 0, '✕') + if got := strings.Join(m.queue, ","); got != "gamma" { + t.Errorf("queue = %q, want gamma", got) + } +} + +func TestQueueStripReorderViaMouse(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.queue = []string{"alpha", "beta", "gamma"} + m.refresh() + + // Move the tail up one: alpha,gamma,beta. + clickQueueControl(t, m, 2, '▲') + if got := strings.Join(m.queue, ","); got != "alpha,gamma,beta" { + t.Errorf("after ▲ on row 2: %q, want alpha,gamma,beta", got) + } + // Move the head down one: gamma,alpha,beta. + clickQueueControl(t, m, 0, '▼') + if got := strings.Join(m.queue, ","); got != "gamma,alpha,beta" { + t.Errorf("after ▼ on row 0: %q, want gamma,alpha,beta", got) + } + // Edge clamps: ▲ on the head and ▼ on the tail are no-ops. + clickQueueControl(t, m, 0, '▲') + clickQueueControl(t, m, 2, '▼') + if got := strings.Join(m.queue, ","); got != "gamma,alpha,beta" { + t.Errorf("edge clamps moved the queue: %q", got) + } +} + +func TestQueueStripKeyboardFocus(t *testing.T) { + m := newTestModel() + m.ta.Focus() + busyTurn(m) + m.queue = []string{"alpha", "beta"} + m.refresh() + + // While unfocused, typing still reaches the composer. + m.Update(key("x")) + if m.ta.Value() != "x" { + t.Fatalf("strip visible but unfocused: typing must reach the input, got %q", m.ta.Value()) + } + m.ta.Reset() + + m.Update(key("ctrl+q")) + if !m.qfocus { + t.Fatal("ctrl+q should enter queue focus mode") + } + if m.qsel != 0 { + t.Errorf("qsel = %d, want 0 (head selected on entry)", m.qsel) + } + + // j/k select; letters are captured by the strip, not the composer. + m.Update(key("j")) + if m.qsel != 1 { + t.Errorf("qsel = %d, want 1 after j", m.qsel) + } + m.Update(key("x")) + if m.ta.Value() != "" { + t.Errorf("typing in focus mode leaked to the input: %q", m.ta.Value()) + } + + // h moves the selected item toward the head, l back toward the tail. + m.Update(key("h")) + if got := strings.Join(m.queue, ","); got != "beta,alpha" { + t.Errorf("after h: %q, want beta,alpha", got) + } + m.Update(key("l")) + if got := strings.Join(m.queue, ","); got != "alpha,beta" { + t.Errorf("after l: %q, want alpha,beta", got) + } + + // d deletes the selected row. + m.Update(key("d")) + if got := strings.Join(m.queue, ","); got != "alpha" { + t.Errorf("after d: %q, want alpha (beta deleted)", got) + } + if m.qsel != 0 { + t.Errorf("qsel = %d, want clamped 0 after delete", m.qsel) + } + + // esc leaves focus mode; typing reaches the composer again. + m.Update(key("esc")) + if m.qfocus { + t.Fatal("esc should leave queue focus mode") + } + m.Update(key("y")) + if m.ta.Value() != "y" { + t.Errorf("typing after esc must reach the input, got %q", m.ta.Value()) + } + + // ctrl+c still quits while focused — the strip never traps the exit. + m.Update(key("ctrl+q")) + m.Update(key("ctrl+c")) + if !m.quitting { + t.Error("ctrl+c must still quit from queue focus mode") + } +} + +func TestQueueStripDrainStepsDown(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.queue = []string{"next up", "after that"} + m.refresh() + + // Turn end: the head pops and fires (sendQueued pops synchronously when + // the done event is handled; the returned cmd is not executed — the test + // model has no client). + _, cmd := m.handleEvent(client.Event{Type: "done", Latency: 1}) + if cmd == nil { + t.Fatal("done should drain the queued prompt") + } + if got := strings.Join(m.queue, ","); got != "after that" { + t.Fatalf("queue after drain = %q, want [after that]", got) + } + if rows := queueStripRows(m); len(rows) != 1 { + t.Errorf("strip rows after drain = %d, want 1", len(rows)) + } +} + +func TestQueueStripOverflowCap(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.queue = []string{ + "one", "two", "three", "four", "five", + "six", "seven", "eight", "nine", "ten", + } + m.refresh() + + rows := queueStripRows(m) + if len(rows) != queueStripCap+1 { + t.Fatalf("rows = %d, want %d + 1 overflow tail", len(rows), queueStripCap) + } + if got := plain(rows[queueStripCap]); !strings.Contains(got, "and 2 more") { + t.Errorf("overflow tail = %q, want an \"and 2 more\" hint", got) + } + + // Keyboard selection reaches past the visible cap. + m.Update(key("ctrl+q")) + for i := 0; i < 9; i++ { + m.Update(key("j")) + } + if m.qsel != 9 { + t.Fatalf("qsel = %d, want 9", m.qsel) + } + rows = queueStripRows(m) // re-render: the tail now names the hidden selection + if got := plain(rows[queueStripCap]); !strings.Contains(got, "ten") { + t.Errorf("overflow tail = %q, want the selected hidden row (ten)", got) + } + m.Update(key("d")) + if len(m.queue) != 9 || m.queue[8] != "nine" { + t.Errorf("queue after delete = %v, want ten removed", m.queue) + } +} + +func TestQueueStripPlainParity(t *testing.T) { + m := newTestModel() + m.plain = true + m.queue = []string{"linear mode queued"} + m.refresh() + + if v := m.plainView(); !strings.Contains(plain(v), "linear mode queued") { + t.Errorf("plainView missing the queue strip: %q", plain(v)) + } +} + +// TestQueueStripClickUsesCellColumns verifies the ▲▼✕ hit zones in terminal +// CELL columns — the unit bubbletea reports. Byte-offset hit tests drift 2 +// cells per multibyte glyph, making ▼ move up and ✕ move down instead of +// delete; both ASCII and multibyte prompt text must hit their own control. +func TestQueueStripClickUsesCellColumns(t *testing.T) { + for _, text := range []string{"plain ascii prompt", "café ☕ unicode prompt"} { + m := newTestModel() + busyTurn(m) + m.queue = []string{text, "second queued"} + m.refresh() + + // ▼ on the head row swaps the two entries. + clickQueueControl(t, m, 0, '▼') + if len(m.queue) != 2 || m.queue[0] != "second queued" { + t.Errorf("[%s] ▼ click: queue = %v, want the head moved down", text, m.queue) + continue + } + m.refresh() + + // ✕ on the head row deletes it. + clickQueueControl(t, m, 0, '✕') + if len(m.queue) != 1 || m.queue[0] != text { + t.Errorf("[%s] ✕ click: queue = %v, want %q deleted", text, m.queue, "second queued") + continue + } + m.refresh() + + // ▲ on the last row is a clamped no-op, not a crash or misfire. + clickQueueControl(t, m, 0, '▲') + if len(m.queue) != 1 || m.queue[0] != text { + t.Errorf("[%s] ▲ click at the top: queue = %v, want unchanged", text, m.queue) + } + m.refresh() + } +} + +// TestQueueStripHidesControlsWithoutMouse verifies the glyphs disappear when +// mouse tracking is off — dead pixels lie. The strip keeps its rows, adds a +// one-row ^Q hint (the key legend once focus latches), and still collapses +// to zero rows on an empty queue. +func TestQueueStripHidesControlsWithoutMouse(t *testing.T) { + m := newTestModel() + m.mouse = false + busyTurn(m) + m.queue = []string{"held one", "held two"} + m.refresh() + + if !m.queueStripVisible() { + t.Fatal("strip must stay visible without mouse — the queue is real") + } + if v := m.queueStripView(); strings.ContainsAny(plain(v), "▲▼✕") { + t.Errorf("controls must be hidden without --mouse: %q", plain(v)) + } + if v := plain(m.queueStripView()); !strings.Contains(v, "^q") { + t.Errorf("missing the ^Q hint row: %q", v) + } + // Hint row costs a row: two queue rows + the hint. + if h := m.queueStripHeight(); h != 3 { + t.Errorf("strip height = %d, want 3 (2 rows + hint)", h) + } + + // Focused: the hint becomes the key legend. + m.qfocus, m.qsel = true, 0 + m.refresh() + if v := plain(m.queueStripView()); !strings.Contains(v, "delete") { + t.Errorf("focused hint should show the key legend: %q", v) + } + + // Clicks on the hint row are inert: no delete, no move, no focus change. + m.qfocus = false + before := append([]string(nil), m.queue...) + m.Update(tea.MouseMsg{ + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + X: 3, + Y: m.queueStripTop() + 2, // the hint row + }) + if !strings.EqualFold(strings.Join(m.queue, "|"), strings.Join(before, "|")) { + t.Errorf("hint-row click mutated the queue: %v → %v", before, m.queue) + } +} + +// TestHelpCardCoversQueueStrip verifies the in-app F1 card teaches the +// queue strip — the README is not in the terminal. +func TestHelpCardCoversQueueStrip(t *testing.T) { + m := newTestModel() + m.showHelp() + if len(m.msgs) == 0 { + t.Fatal("showHelp appended nothing") + } + card := plain(m.msgs[len(m.msgs)-1].content) + if !strings.Contains(card, "^Q") || !strings.Contains(strings.ToLower(card), "queue") { + t.Errorf("F1 card missing the queue-strip binding: %q", card) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 0ffd7d4..a256028 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -28,6 +28,9 @@ func (m *Model) View() string { if sl := m.statusLine(); sl != "" { parts = append(parts, sl) } + if s := m.queueStripView(); s != "" { + parts = append(parts, s) + } parts = append(parts, m.inputArea(), m.footer()) return strings.Join(parts, "\n") } @@ -45,6 +48,9 @@ func (m *Model) plainView() string { } else if m.popover { parts = append(parts, m.popoverView(m.width, plainPanelMax)) } + if s := m.queueStripView(); s != "" { + parts = append(parts, s) + } parts = append(parts, m.inputArea(), m.footer()) return strings.Join(parts, "\n") }