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") }