diff --git a/README.md b/README.md index c17a525..2d3f29e 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | Key | Action | |-----|--------| -| `⏎` | Send the prompt (or run a `/command`) | +| `⏎` | Send the prompt (queues it while a turn is running) | | `/` | Open the command palette (see below) | | `@` | Attach a file (see below) | | `^R` | Browse & resume saved sessions | @@ -132,11 +132,21 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | `^T` | Toggle extended thinking for the next turn | | `^J` | Insert a newline in the input | | `^L` | Clear the conversation | -| `Esc` | Cancel the running turn | +| `Esc` | Cancel the running turn (queued prompts return to the input) | +| `↑` / `↓` (empty input) | Recall previous prompts (prompt history) | | `↑` / `↓` / `PgUp` / `PgDn` | Scroll the transcript | +| `G` / `End` (empty input) | Jump to the latest output | | `wheel` (with `--mouse`) | Scroll the transcript | +| `r` (when disconnected) | Retry the connection | | `^C` | Quit | +Prompts sent while a turn is running are **queued** and sent automatically +when the turn ends — the footer shows how many are waiting. 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 +on `r`. + ### Commands (`/`) Type `/` at the start of the input for a command palette. `↑`/`↓` to choose, diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5e6a9ef..7d8d895 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -167,15 +167,18 @@ func (m *Model) showHelp() { b.WriteString("\n" + th.statsLabel.Render("keys")) const keyW = 4 for _, k := range [][2]string{ - {"⏎", "send · run a /command"}, + {"⏎", "send · queue mid-turn · run a /command"}, {"^J", "newline in the input"}, {"@", "attach files"}, + {"↑↓", "recall prompts · scroll"}, + {"G", "jump to the latest output"}, {"^R", "browse & resume sessions"}, {"^O", "switch model"}, {"^T", "toggle extended thinking"}, {"^L", "clear the conversation"}, {"^E", "toggle tool details"}, {"esc", "cancel the running turn"}, + {"r", "retry a lost connection"}, {"^C", "quit"}, {"--mouse", "click tool rows to expand"}, } { diff --git a/internal/tui/coverage_test.go b/internal/tui/coverage_test.go index f338c5e..9646e81 100644 --- a/internal/tui/coverage_test.go +++ b/internal/tui/coverage_test.go @@ -182,8 +182,16 @@ func TestSubmitGuards(t *testing.T) { } m.disconn = true m.ta.SetValue("hi") - if m.submit() != nil { - t.Error("submit while disconnected should be nil") + cmd := m.submit() + if cmd == nil { + t.Error("submit while disconnected should arm the notice expiry") + } + if m.ta.Value() != "hi" { + t.Error("submit while disconnected must keep the draft") + } + exec(cmd) // fires the notice-expiry tick safely + if len(m.notices) == 0 { + t.Error("submit while disconnected should explain why nothing was sent") } } diff --git a/internal/tui/events.go b/internal/tui/events.go index 85c470c..b8f0211 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -192,7 +192,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.queueRender()) } m.refresh() - return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq)) + // A turn that just ended (done / error) drains the next queued prompt. + return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.sendQueued()) } // stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in diff --git a/internal/tui/input.go b/internal/tui/input.go index 6988c71..2117649 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -98,9 +98,31 @@ func (m *Model) submit() tea.Cmd { if strings.HasPrefix(text, "/") { return m.runCommandLine(text) } - if m.busy || m.disconn { + if m.disconn { + // Keep the draft — swallowing it silently reads as a lost message. + prev := m.noticeSeq + m.addTransientNote("disconnected — press r to retry, your draft is kept") + m.refresh() + return m.noticeTimer(prev) + } + if m.busy { + // Queue mid-turn prompts instead of dropping them; the queue drains + // automatically when the running turn ends. + m.queue = append(m.queue, text) + m.ta.Reset() + m.closeAC() + m.refresh() return nil } + m.ta.Reset() + m.closeAC() + return m.sendPrompt(text) +} + +// sendPrompt appends the user/assistant pair to the transcript, records the +// prompt in the history ring, and dispatches it to the server. +func (m *Model) sendPrompt(text string) tea.Cmd { + m.recordHistory(text) m.msgs = append(m.msgs, message{role: roleUser, content: text}) m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) m.curIdx = len(m.msgs) - 1 @@ -134,6 +156,72 @@ func (m *Model) submit() tea.Cmd { } } +// sendQueued pops the oldest queued prompt and sends it when the model is +// idle and connected; otherwise it is a no-op (nil cmd). +func (m *Model) sendQueued() tea.Cmd { + if m.busy || m.disconn || len(m.queue) == 0 { + return nil + } + text := m.queue[0] + m.queue = m.queue[1:] + return m.sendPrompt(text) +} + +// maxHistory bounds the in-memory prompt history ring. +const maxHistory = 100 + +// recordHistory appends a submitted prompt to the history ring (deduping +// consecutive repeats) and resets any active history navigation. +func (m *Model) recordHistory(text string) { + m.histNav = false + m.histDraft = "" + if n := len(m.history); n > 0 && m.history[n-1] == text { + return + } + m.history = append(m.history, text) + if len(m.history) > maxHistory { + m.history = m.history[len(m.history)-maxHistory:] + } +} + +// historyPrev steps back through the prompt history, stashing the current +// input on the first step. Returns false when there is nothing to recall, so +// the caller can fall back to scrolling. At the oldest entry the key is +// consumed without moving. +func (m *Model) historyPrev() bool { + if len(m.history) == 0 { + return false + } + switch { + case !m.histNav: + m.histDraft = m.ta.Value() + m.histIdx = len(m.history) - 1 + m.histNav = true + case m.histIdx > 0: + m.histIdx-- + } + m.ta.SetValue(m.history[m.histIdx]) + m.ta.CursorEnd() + return true +} + +// historyNext steps forward through the history; past the newest entry it +// restores the stashed draft and leaves navigation mode. +func (m *Model) historyNext() { + if !m.histNav { + return + } + if m.histIdx < len(m.history)-1 { + m.histIdx++ + m.ta.SetValue(m.history[m.histIdx]) + } else { + m.histNav = false + m.ta.SetValue(m.histDraft) + m.histDraft = "" + } + m.ta.CursorEnd() +} + // ── @-reference autocomplete ──────────────────────────────────────────────── // refRe matches a trailing @-reference token at the end of the input. diff --git a/internal/tui/model.go b/internal/tui/model.go index ec58548..cb080d4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -121,6 +121,12 @@ type Model struct { approval *client.Event // pending approval, nil when none ac autocomplete // @-reference completion state + queue []string // prompts typed mid-turn, sent when the turn ends + + history []string // submitted prompts, newest last (recalled with ↑) + histNav bool // true while ↑/↓ is walking the history + histIdx int // index into history while navigating + histDraft string // input stashed while navigating history model string sandbox bool @@ -240,7 +246,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = "error" m.addNote("error: " + msg.err.Error()) m.refresh() - return m, nil + return m, m.sendQueued() case acResultMsg: if msg.seq != m.ac.seq || m.ac.mode != acRef { @@ -339,6 +345,15 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleACKey(msg) } + // A dead connection offers a manual retry on r — only with an empty + // input, so a drafted prompt is never disturbed. + if m.disconn && m.opts.Reconnect != nil && msg.String() == "r" && m.ta.Value() == "" { + m.status = "reconnecting" + m.addNote("retrying connection…") + m.refresh() + return m, m.scheduleReconnect(0) + } + switch msg.String() { case "ctrl+c": m.quitting = true @@ -375,20 +390,36 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() return m, nil case "up", "ctrl+p": - // Scroll the transcript when the cursor is already at the top line of - // the input; otherwise let the textarea move the cursor up. + // At the top input line: an empty input (or an active history walk) + // recalls older prompts; otherwise scroll the transcript. Below the + // top line the textarea moves the cursor up instead. if m.ta.Line() == 0 { + if (m.histNav || m.ta.Value() == "") && m.historyPrev() { + return m, nil + } var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return m, cmd } case "down", "ctrl+n": - // Likewise, scroll down when the cursor is on the bottom input line. + // Likewise at the bottom line: walk forward through the history when + // navigating it, else scroll the transcript down. if m.ta.Line() == m.ta.LineCount()-1 { + if m.histNav { + m.historyNext() + return m, nil + } var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return m, cmd } + case "G", "end": + // Jump to the latest output — only with an empty input, so typing a + // capital G (or using End for cursor movement) is never hijacked. + if m.ta.Value() == "" { + m.vp.GotoBottom() + return m, nil + } case "pgup", "pgdown", "ctrl+u", "ctrl+d": var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 53f0996..8aa9e56 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -169,11 +169,22 @@ func (m *Model) deleteSelected() tea.Cmd { } } -// cancelRun aborts the in-flight prompt via the cancel API. +// cancelRun aborts the in-flight prompt via the cancel API. Queued prompts +// belong to the user, not the cancelled turn: hand them back to the input +// for editing instead of firing them into a cancelled session. func (m *Model) cancelRun() tea.Cmd { if !m.busy || m.sessionID == "" { return nil } + if len(m.queue) > 0 { + draft := strings.Join(m.queue, "\n") + if cur := m.ta.Value(); cur != "" { + draft = cur + "\n" + draft + } + m.ta.SetValue(draft) + m.ta.CursorEnd() + m.queue = nil + } m.status = "cancelling" m.refresh() cl := m.cl diff --git a/internal/tui/promptflow_test.go b/internal/tui/promptflow_test.go new file mode 100644 index 0000000..9786332 --- /dev/null +++ b/internal/tui/promptflow_test.go @@ -0,0 +1,291 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// busyTurn puts the model mid-turn with an open streaming assistant message. +func busyTurn(m *Model) { + m.msgs = append(m.msgs, + message{role: roleUser, content: "first"}, + message{role: roleAsst, streaming: true}, + ) + m.curIdx = 1 + m.busy = true +} + +// TestJumpToLatest verifies G/End jump to the bottom with an empty input and +// never hijack typing. +func TestJumpToLatest(t *testing.T) { + m := newTestModel() + m.ta.Focus() + tallTranscript(m) + m.vp.GotoTop() + if m.vp.AtBottom() { + t.Fatal("precondition: scrolled away from the bottom") + } + + // Footer advertises the jump while off-bottom. + if foot := plain(m.footer()); !strings.Contains(foot, "G") || !strings.Contains(foot, "latest") { + t.Errorf("footer missing jump hint: %q", foot) + } + + m.Update(key("G")) + if !m.vp.AtBottom() { + t.Error("G should jump to the latest output") + } + + // End behaves the same (real terminals send KeyEnd, not runes). + m.vp.GotoTop() + m.Update(tea.KeyMsg{Type: tea.KeyEnd}) + if !m.vp.AtBottom() { + t.Error("End should jump to the latest output") + } + + // With a draft, G types instead of jumping. + m.vp.GotoTop() + m.ta.SetValue("draft") + m.Update(key("G")) + if m.vp.AtBottom() { + t.Error("G with a draft must not jump") + } + if m.ta.Value() != "draftG" { + t.Errorf("G with a draft should type, got %q", m.ta.Value()) + } +} + +// TestNewOutputIndicator verifies the footer calls out fresh output while +// scrolled up mid-run. +func TestNewOutputIndicator(t *testing.T) { + m := newTestModel() + tallTranscript(m) + m.vp.GotoTop() + m.busy = true + + if foot := plain(m.footer()); !strings.Contains(foot, "new output") { + t.Errorf("busy off-bottom footer missing new-output indicator: %q", foot) + } +} + +// TestDisconnectedRetry verifies the dead-connection state offers a manual +// redial on r, and that typing r into a draft is never hijacked. +func TestDisconnectedRetry(t *testing.T) { + m := newTestModel() + m.ta.Focus() + m.opts.Reconnect = func() (*client.Client, error) { return nil, errors.New("down") } + m.disconn = true + m.status = "disconnected" + + if foot := plain(m.footer()); !strings.Contains(foot, "r") || !strings.Contains(foot, "retry") { + t.Errorf("disconnected footer missing retry hint: %q", foot) + } + + _, cmd := m.Update(key("r")) + if cmd == nil { + t.Fatal("r while disconnected should schedule a redial") + } + if m.status != "reconnecting" { + t.Errorf("status = %q, want reconnecting", m.status) + } + + // A non-empty draft keeps r as plain typing. + m.status = "disconnected" + m.ta.SetValue("draft") + m.Update(key("r")) + if m.ta.Value() != "draftr" { + t.Errorf("r with a draft should type, got %q", m.ta.Value()) + } +} + +// TestReconnectDrainsQueue verifies a successful redial flushes prompts +// queued while the socket was down. +func TestReconnectDrainsQueue(t *testing.T) { + m := wired(t) // live stand-in: m.cl is a real connected client + m.disconn = true + m.queue = []string{"held"} + + _, cmd := m.handleReconnect(reconnectMsg{attempt: 0, cl: m.cl}) + if m.disconn { + t.Fatal("reconnect with a client should clear the disconnected state") + } + if len(m.queue) != 0 { + t.Errorf("queue should drain on reconnect, got %v", m.queue) + } + if !m.busy { + t.Error("drained prompt should start a new turn") + } + if cmd == nil { + t.Error("reconnect should return the listen/send batch") + } +} + +// TestSubmitWhileBusyQueues verifies that ⏎ mid-turn queues the prompt +// instead of silently dropping it, and that the footer says so. +func TestSubmitWhileBusyQueues(t *testing.T) { + m := newTestModel() + busyTurn(m) + + m.ta.SetValue("follow up") + if cmd := m.submit(); cmd != nil { + t.Error("queueing a prompt should not send anything yet") + } + if len(m.queue) != 1 || m.queue[0] != "follow up" { + t.Fatalf("queue = %v, want [follow up]", m.queue) + } + if m.ta.Value() != "" { + t.Error("input should reset after queueing") + } + if len(m.msgs) != 2 { + t.Error("queued prompt must not enter the transcript before it is sent") + } + if foot := plain(m.footer()); !strings.Contains(foot, "1 queued") { + t.Errorf("footer missing queued indicator: %q", foot) + } +} + +// TestQueuedPromptSendsOnDone verifies the queue drains automatically when +// the running turn ends. +func TestQueuedPromptSendsOnDone(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.ta.SetValue("follow up") + m.submit() + + _, cmd := m.handleEvent(client.Event{Type: "done", Latency: 1}) + if cmd == nil { + t.Fatal("done should drain the queued prompt") + } + // The returned cmd wraps SendPrompt; newTestModel has no client, so it is + // deliberately not executed — state assertions suffice. + if len(m.queue) != 0 { + t.Errorf("queue not drained: %v", m.queue) + } + if !m.busy { + t.Error("model should be busy again with the queued turn") + } + if len(m.msgs) != 4 || m.msgs[2].content != "follow up" { + t.Fatalf("queued turn not appended: %+v", m.msgs) + } +} + +// tallTranscript loads a scrollable assistant message into the transcript. +// (A markdown list survives glamour as one rendered line per item; a plain +// "x\n" repeat would collapse into a single wrapped paragraph.) +func tallTranscript(m *Model) { + md := strings.Repeat("- item\n", 60) + m.msgs = append(m.msgs, message{role: roleAsst, content: md, rendered: m.render(md)}) + m.refresh() + if m.vp.TotalLineCount() <= m.vp.Height { + panic("tallTranscript: content should exceed the viewport") + } +} + +// TestHistoryRecall verifies ↑/↓ walks submitted prompts and restores the +// stashed draft past the newest entry. +func TestHistoryRecall(t *testing.T) { + m := newTestModel() + m.sendPrompt("first") + m.handleEvent(client.Event{Type: "done", Latency: 1}) + m.sendPrompt("second") + m.handleEvent(client.Event{Type: "done", Latency: 1}) + m.sendPrompt("second") // consecutive dup — must not double-record + if len(m.history) != 2 { + t.Fatalf("history = %v, want [first second]", m.history) + } + + m.Update(key("up")) + if got := m.ta.Value(); got != "second" { + t.Errorf("first up = %q, want %q", got, "second") + } + m.Update(key("up")) + if got := m.ta.Value(); got != "first" { + t.Errorf("second up = %q, want %q", got, "first") + } + m.Update(key("up")) // at the oldest entry: consumed, no movement + if got := m.ta.Value(); got != "first" { + t.Errorf("up past oldest = %q, want %q", got, "first") + } + m.Update(key("down")) + if got := m.ta.Value(); got != "second" { + t.Errorf("down = %q, want %q", got, "second") + } + m.Update(key("down")) // past newest: restore the (empty) draft + if got := m.ta.Value(); got != "" { + t.Errorf("down past newest = %q, want empty draft", got) + } + if m.histNav { + t.Error("history navigation should end past the newest entry") + } +} + +// TestHistoryScrollFallback verifies ↑ still scrolls the transcript when +// there is no history to recall (empty input, tall transcript). +func TestHistoryScrollFallback(t *testing.T) { + m := newTestModel() + tallTranscript(m) + bottom := m.vp.YOffset + + m.Update(key("up")) + if m.vp.YOffset >= bottom { + t.Error("up with empty history should scroll the transcript") + } +} + +// TestHistoryEdgeCases covers the history ring cap, the no-navigation guard, +// and cancelRun's draft-prepend branch. +func TestHistoryEdgeCases(t *testing.T) { + m := newTestModel() + for i := 0; i < maxHistory+10; i++ { + m.recordHistory("prompt") + m.recordHistory("unique") + } + if len(m.history) != maxHistory { + t.Errorf("history should cap at %d, got %d", maxHistory, len(m.history)) + } + + // historyNext outside navigation is a safe no-op. + m.ta.SetValue("keep") + m.historyNext() + if m.ta.Value() != "keep" { + t.Error("historyNext without navigation must not touch the input") + } + + // Cancel with both a draft and a queue prepends the draft. + busyTurn(m) + m.sessionID = "s1" + m.ta.SetValue("draft") + m.queue = []string{"held"} + m.cancelRun() + if got := m.ta.Value(); got != "draft\nheld" { + t.Errorf("cancel restore = %q, want draft prepended to queue", got) + } +} + +// TestCancelRestoresQueue verifies that esc hands queued prompts back to the +// input instead of firing them into a cancelled session. +func TestCancelRestoresQueue(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.sessionID = "s1" + m.queue = []string{"one", "two"} + + m.Update(key("esc")) + if len(m.queue) != 0 { + t.Errorf("queue should be handed back, got %v", m.queue) + } + if got := m.ta.Value(); got != "one\ntwo" { + t.Errorf("input = %q, want queued drafts restored", got) + } + + // The done that follows the cancel must not fire anything. + m.handleEvent(client.Event{Type: "done", Latency: 1}) + if len(m.msgs) != 2 { + t.Error("no turn should start after cancel restored the queue") + } +} diff --git a/internal/tui/reconnect.go b/internal/tui/reconnect.go index df4e09b..a8594d9 100644 --- a/internal/tui/reconnect.go +++ b/internal/tui/reconnect.go @@ -58,13 +58,13 @@ func (m *Model) handleReconnect(msg reconnectMsg) (tea.Model, tea.Cmd) { // (including the server-side memory buffer) transparently. m.addNote("reconnected to odek serve — the session resumes on your next prompt") m.refresh() - return m, listen(m.events) + return m, tea.Batch(listen(m.events), m.sendQueued()) } if msg.attempt+1 < maxReconnectAttempts { return m, m.scheduleReconnect(msg.attempt + 1) } m.status = "disconnected" - m.addNote("reconnect failed — " + msg.err.Error()) + m.addNote("reconnect failed — " + msg.err.Error() + " · press r to retry") if m.opts.LogPath != "" { m.addNote("server log · " + m.opts.LogPath) } diff --git a/internal/tui/view.go b/internal/tui/view.go index fc014c2..fb68170 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -705,7 +705,12 @@ func (m *Model) footer() string { return th.footer.Render(" answer the approval prompt to continue") } if m.disconn { - return th.footer.Render(" connection closed · press ^C to quit") + hints := []string{th.footer.Render("connection closed")} + if m.opts.Reconnect != nil { + hints = append(hints, th.footerKey.Render("r")+th.footer.Render(" retry")) + } + hints = append(hints, th.footer.Render("^C to quit")) + return " " + strings.Join(hints, th.footerSep.Render(" · ")) } if m.panel == panelSessions { return m.panelFooter( @@ -728,6 +733,9 @@ func (m *Model) footer() string { left := "" if m.busy { left = " " + th.footerKey.Render("esc") + th.footer.Render(" cancel") + if n := len(m.queue); n > 0 { + left += th.footerSep.Render(" · ") + th.scroll.Render(fmt.Sprintf("▸ %d queued", n)) + } } var segs []string @@ -740,7 +748,14 @@ func (m *Model) footer() string { segs = append(segs, seg) } if !m.vp.AtBottom() { - segs = append(segs, th.scroll.Render(fmt.Sprintf("↕ %d%%", int(m.vp.ScrollPercent()*100)))) + seg := "" + if m.busy { + seg = th.scroll.Render("↓ new output") + th.footerSep.Render(" · ") + } + seg += th.footerKey.Render("G") + th.footer.Render(" latest") + + th.footerSep.Render(" · ") + + th.scroll.Render(fmt.Sprintf("↕ %d%%", int(m.vp.ScrollPercent()*100))) + segs = append(segs, seg) } right := "" if len(segs) > 0 {