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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
4 changes: 4 additions & 0 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 2 additions & 0 deletions internal/tui/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
31 changes: 31 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions internal/tui/model_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
247 changes: 247 additions & 0 deletions internal/tui/queue.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading