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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ own front-end settings are separate; see [Configuration](#configuration).
| `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 d` two-step delete · `esc`/`⏎` back to the input) |
| `^Q` | Focus the queue strip (`↑↓`/`jk` select · `←→`/`hl` move · `d d` two-step delete · `esc`/`⏎` back to the input; full manager: `/queue`) |
| `^S` | Stop the running sub-agent (two-step confirm: `y` stops, any other key continues) |
| `^T` | Toggle extended thinking for the next turn |
| `^J` | Insert a newline in the input |
Expand Down Expand Up @@ -348,9 +348,12 @@ 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
row per prompt (long prompts collapse to a single line) with per-row `▲ ▼ ✕`
controls (`--mouse`) to reorder or delete, and a `^Q` keyboard focus mode for
the same actions (`↑↓` select, `←→` move, `d` delete). For full management,
`/queue` opens a dedicated window: `↑↓` select, `←→`/`hl` change priority,
`d`→`y` delete, and `⏎` sends the selected prompt ahead of the queue when
idle. 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.

Expand All @@ -368,6 +371,7 @@ full command and press `⏎`.
| `/copy` | Copy the last reply to the clipboard |
| `/export` | Save the session transcript next to you — `/export [md|json]` (markdown by default, never overwrites) |
| `/retry` | Re-send the last prompt (queues it if a turn is running) |
| `/queue` | Manage the prompt queue — priority, delete, send now (the full manager over the `^Q` strip) |
| `/theme [name]` | Switch the color theme at runtime and persist it (`ember-dark` · `ember-light` · `high-contrast` · `classic`) |
| `/stats` | Session metrics card (cost, cache, context gauge) |
| `/server` | Cockpit — server, link, budget & session in one card (or click the header) |
Expand Down
7 changes: 5 additions & 2 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func slashCommands() []command {
return m.transientNoteCmd("answer the pending approval first — it dies with the connection")
}
if len(m.queue) > 0 {
return m.transientNoteCmd("drain the prompt queue first (ctrl+q)")
return m.transientNoteCmd("drain the prompt queue first (/queue or ctrl+q)")
}
return m.startFreshSession()
}},
Expand All @@ -58,6 +58,9 @@ func slashCommands() []command {
{"retry", "re-send the last prompt (alt+r)", func(m *Model, _ string) tea.Cmd {
return m.retryLast()
}},
{"queue", "manage queued prompts — priority, delete, send now", func(m *Model, _ string) tea.Cmd {
return m.openQueue()
}},
{"stats", "session metrics & context gauge", func(m *Model, _ string) tea.Cmd {
m.showStats()
return nil
Expand Down Expand Up @@ -294,7 +297,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)"},
{"^Q", "quick-manage the queue strip (full manager: /queue)"},
{"^O", "switch model"},
{"^K", "command palette"},
{"^T", "toggle extended thinking"},
Expand Down
13 changes: 13 additions & 0 deletions internal/tui/commands_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,17 @@ func TestE2EAllCommands(t *testing.T) {
t.Fatal("freshStart not armed for the reconnect note")
}
},
"/queue": func(t *testing.T, m *Model) {
if m.panel != panelQueue {
t.Fatalf("/queue opened panel %d", m.panel)
}
if m.panelLen() != 2 {
t.Fatalf("/queue panelLen = %d, want 2", m.panelLen())
}
if m.queueStripVisible() {
t.Fatal("the strip must stand down while the /queue panel is open")
}
},
"/plan": func(t *testing.T, m *Model) {
if m.panel != panelPlan {
t.Fatalf("/plan opened panel %d", m.panel)
Expand Down Expand Up @@ -360,6 +371,8 @@ func TestE2EAllCommands(t *testing.T) {
case "/new":
m.sessionID, m.authToken = "s1", "a1"
m.msgs = append(m.msgs, message{role: roleUser, content: "x"})
case "/queue":
m.queue = []string{"one", "two"}
case "/attach":
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ func (m *Model) sendQueued() tea.Cmd {
text := m.queue[0]
m.queue = m.queue[1:]
m.qsel = clampSel(m.qsel, len(m.queue))
if m.panel == panelQueue {
m.panelSel = clampSel(m.panelSel, len(m.queue)) // the drain races the open panel too
}
m.qarm = -1 // the head just drained — never mis-aim the armed confirm
if len(m.queue) == 0 {
m.qfocus = false
Expand Down
32 changes: 32 additions & 0 deletions internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (
panelConfig
panelAgents
panelJobs
panelQueue
)

// panelEditMode is the text-entry submode a panel can capture: `/` search in
Expand All @@ -55,6 +56,7 @@ const (
confirmCancel
confirmStopAgent
confirmStopJob
confirmQueueDelete
confirmQuit
)

Expand Down Expand Up @@ -88,6 +90,11 @@ func (m *Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// here, so a job that finished while the gate sat armed just
// reports its terminal state from the server.
return m, m.fireJobStop()
case confirmQueueDelete:
// queueDeleteAt self-guards: a queue that drained while the
// gate sat armed turns this into a no-op.
m.queueDeleteAt(m.panelSel)
return m, nil
case confirmQuit:
m.quitting = true
return m, tea.Quit
Expand Down Expand Up @@ -359,6 +366,17 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.refresh()
}
return m, nil
case "h", "left":
// The queue sends head-first, so ←/h raises the prompt's priority.
if m.panel == panelQueue {
m.queueMove(m.panelSel, -1)
}
return m, nil
case "l", "right":
if m.panel == panelQueue {
m.queueMove(m.panelSel, 1)
}
return m, nil
case "enter":
return m, m.panelSelect()
case "a":
Expand Down Expand Up @@ -388,6 +406,12 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// No deletes here — x doubles as the filter-clear key.
return m, m.clearEventFilters()
}
if m.panel == panelQueue {
if m.panelSel < len(m.queue) {
return m, m.armConfirm(confirmQueueDelete, "queued prompt")
}
return m, nil
}
if m.panel == panelSessions {
if m.panelSel < len(m.sessions) {
return m, m.armConfirm(confirmSessionDelete, "session "+shortID(m.sessions[m.panelSel].ID))
Expand Down Expand Up @@ -588,6 +612,8 @@ func (m *Model) panelLen() int {
return len(m.agentsReg)
case panelJobs:
return len(m.jobs)
case panelQueue:
return len(m.queue)
}
return 0
}
Expand Down Expand Up @@ -645,6 +671,9 @@ func (m *Model) modelEntries() []modelEntry {

func (m *Model) panelSelect() tea.Cmd {
switch m.panel {
case panelQueue:
// ⏎ sends the selected prompt now — the "this one next" verb.
return m.queueSendSelected()
case panelSessions:
if m.panelSel < len(m.sessions) {
return m.resumeSession(m.sessions[m.panelSel].ID)
Expand Down Expand Up @@ -1213,6 +1242,9 @@ func (m *Model) renderPanel(w, h int) string {
case panelJobs:
title = "◍ jobs"
rows = m.jobRowsRender(w - 6)
case panelQueue:
title = "≡ queue"
rows = m.queuePanelRows(w - 6)
}

header := th.acTitle.Render(title)
Expand Down
84 changes: 80 additions & 4 deletions internal/tui/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import (
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.
// prompts and stays out of the way while an approval owns the input area or
// the /queue panel manages the queue.
func (m *Model) queueStripVisible() bool {
return len(m.queue) > 0 && m.curApproval() == nil
return len(m.queue) > 0 && m.curApproval() == nil && m.panel != panelQueue
}

// queueStripHeight is the number of rows the strip claims above the input,
Expand Down Expand Up @@ -78,13 +79,15 @@ func (m *Model) queueStripView() string {
} else if m.qfocus && m.qsel == i {
marker = "▸ "
}
text := th.footer.Render(truncate(m.queue[i], max(1, m.width-cw-5)))
// collapse first: a queued prompt may span lines, but the strip's
// row budget counts one row per prompt — never more.
text := th.footer.Render(truncate(collapse(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))
s += " · ▸ " + truncate(collapse(m.queue[m.qsel]), max(1, m.width-lipgloss.Width(s)-2))
}
rows = append(rows, th.acDetail.Render(s))
}
Expand Down Expand Up @@ -119,6 +122,9 @@ func (m *Model) queueDeleteAt(i int) {
m.qarm = -1 // the armed row is gone — never leave a stale confirm
m.queue = append(m.queue[:i], m.queue[i+1:]...)
m.qsel = clampSel(m.qsel, len(m.queue))
if m.panel == panelQueue {
m.panelSel = clampSel(m.panelSel, len(m.queue))
}
if len(m.queue) == 0 {
m.qfocus = false
}
Expand All @@ -136,6 +142,9 @@ func (m *Model) queueMove(i, delta int) {
if m.qsel == i {
m.qsel = j
}
if m.panel == panelQueue && m.panelSel == i {
m.panelSel = j // the panel selection rides the moved item too
}
m.qarm = -1 // the armed row moved — disarm rather than mis-aim the confirm
m.refresh()
}
Expand Down Expand Up @@ -274,3 +283,70 @@ func (m *Model) queueStripKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.refresh()
return m, nil
}

// ── /queue management panel ──────────────────────────────────────────────
//
// The full-area counterpart of the strip: same queue state, room to breathe
// — arrow selection, ←/→ priority moves, a gated delete, and ⏎ to send the
// selected prompt ahead of the queue when idle. Index 0 ships first.

// openQueue shows the queue manager. The strip stands down while the panel
// is open — one queue surface at a time.
func (m *Model) openQueue() tea.Cmd {
m.panel = panelQueue
m.panelSel = 0
m.panelMsg = ""
m.panelEdit = panelEditNone
m.panelDetail = false // a detail view never survives the tab change
m.detailScroll = 0
m.qfocus = false // the panel replaces the strip's keyboard mode
m.qarm = -1
m.relayout()
m.refresh()
return nil
}

// queuePanelRows renders one row per queued prompt in send order. Every
// prompt collapses to a single line — the panel manages, it does not preview.
func (m *Model) queuePanelRows(w int) []string {
th := m.th
rows := make([]string, 0, len(m.queue))
for i, q := range m.queue {
num := th.footer.Render(fmt.Sprintf("%d. ", i+1))
lab := truncate(collapse(q), max(1, w-2-lipgloss.Width(num)))
prefix, body := " ", th.acItem.Render(lab)
if i == m.panelSel {
prefix, body = "› ", th.acSel.Render(lab)
}
rows = append(rows, prefix+num+body)
}
if len(rows) == 0 {
rows = append(rows, th.acDim.Render("queue is empty — ⏎ mid-turn queues a prompt here"))
}
return rows
}

// queueSendSelected fires the selected prompt now. Mid-turn it refuses and
// names the escape hatch: ← raises the prompt's priority instead.
func (m *Model) queueSendSelected() tea.Cmd {
if m.panelSel >= len(m.queue) {
return nil
}
if m.disconn {
return m.transientNoteCmd("disconnected — reconnect before sending")
}
if m.busy {
return m.transientNoteCmd("a turn is running — this sends when it ends · ← raises its priority")
}
i := m.panelSel
text := m.queue[i]
m.queue = append(m.queue[:i], m.queue[i+1:]...)
m.qsel = clampSel(m.qsel, len(m.queue))
m.panelSel = clampSel(m.panelSel, len(m.queue))
m.qarm = -1
if len(m.queue) == 0 {
m.qfocus = false
}
m.closePanel() // the turn is the point — the transcript gets its screen back
return m.sendPrompt(text)
}
Loading