Skip to content

Commit d0984fd

Browse files
authored
feat(tui): prompt queue, history recall, reconnect retry, jump-to-latest (#19)
* feat(tui): queue prompts sent mid-turn and drain on turn end Pressing enter while a turn runs used to be a silent no-op. Prompts are now queued (footer shows the count) and auto-sent on done/error; esc hands queued prompts back to the input instead of firing them into a cancelled session. A submit while disconnected keeps the draft and explains why nothing was sent. * feat(tui): recall submitted prompts with arrow keys Up on an empty input (or while already walking) steps back through the session's prompt history; down steps forward and restores the stashed draft past the newest entry. History is an in-memory ring of 100 with consecutive duplicates collapsed. With no history to recall, up/down keep their transcript-scroll behaviour. * feat(tui): manual reconnect retry and draft-safe disconnects The terminal disconnected state used to dead-end at 'press ^C to quit' while typed input was silently swallowed. The footer now offers r to redial (empty input only, so drafts are never hijacked), a successful redial flushes queued prompts, and the give-up notice says how to retry. * feat(tui): new-output indicator and jump-to-latest key Scrolled-up scrollback during a run used to give only a muted scroll percent. The footer now flags new output while busy and advertises G (also End) to jump to the latest — bound only with an empty input so typing a capital G or using End for cursor movement is never hijacked.
1 parent d621518 commit d0984fd

10 files changed

Lines changed: 474 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,19 +124,29 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
124124

125125
| Key | Action |
126126
|-----|--------|
127-
| `⏎` | Send the prompt (or run a `/command`) |
127+
| `⏎` | Send the prompt (queues it while a turn is running) |
128128
| `/` | Open the command palette (see below) |
129129
| `@` | Attach a file (see below) |
130130
| `^R` | Browse & resume saved sessions |
131131
| `^O` | Switch the model |
132132
| `^T` | Toggle extended thinking for the next turn |
133133
| `^J` | Insert a newline in the input |
134134
| `^L` | Clear the conversation |
135-
| `Esc` | Cancel the running turn |
135+
| `Esc` | Cancel the running turn (queued prompts return to the input) |
136+
| `↑` / `↓` (empty input) | Recall previous prompts (prompt history) |
136137
| `↑` / `↓` / `PgUp` / `PgDn` | Scroll the transcript |
138+
| `G` / `End` (empty input) | Jump to the latest output |
137139
| `wheel` (with `--mouse`) | Scroll the transcript |
140+
| `r` (when disconnected) | Retry the connection |
138141
| `^C` | Quit |
139142

143+
Prompts sent while a turn is running are **queued** and sent automatically
144+
when the turn ends — the footer shows how many are waiting. While the
145+
transcript is scrolled up mid-run, the footer flags `↓ new output`; press
146+
`G` to jump to the latest. If the connection drops, bodek retries with
147+
backoff and, after giving up, keeps your draft and offers a manual retry
148+
on `r`.
149+
140150
### Commands (`/`)
141151

142152
Type `/` at the start of the input for a command palette. `↑`/`↓` to choose,

‎internal/tui/commands.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,18 @@ func (m *Model) showHelp() {
167167
b.WriteString("\n" + th.statsLabel.Render("keys"))
168168
const keyW = 4
169169
for _, k := range [][2]string{
170-
{"⏎", "send · run a /command"},
170+
{"⏎", "send · queue mid-turn · run a /command"},
171171
{"^J", "newline in the input"},
172172
{"@", "attach files"},
173+
{"↑↓", "recall prompts · scroll"},
174+
{"G", "jump to the latest output"},
173175
{"^R", "browse & resume sessions"},
174176
{"^O", "switch model"},
175177
{"^T", "toggle extended thinking"},
176178
{"^L", "clear the conversation"},
177179
{"^E", "toggle tool details"},
178180
{"esc", "cancel the running turn"},
181+
{"r", "retry a lost connection"},
179182
{"^C", "quit"},
180183
{"--mouse", "click tool rows to expand"},
181184
} {

‎internal/tui/coverage_test.go‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,16 @@ func TestSubmitGuards(t *testing.T) {
182182
}
183183
m.disconn = true
184184
m.ta.SetValue("hi")
185-
if m.submit() != nil {
186-
t.Error("submit while disconnected should be nil")
185+
cmd := m.submit()
186+
if cmd == nil {
187+
t.Error("submit while disconnected should arm the notice expiry")
188+
}
189+
if m.ta.Value() != "hi" {
190+
t.Error("submit while disconnected must keep the draft")
191+
}
192+
exec(cmd) // fires the notice-expiry tick safely
193+
if len(m.notices) == 0 {
194+
t.Error("submit while disconnected should explain why nothing was sent")
187195
}
188196
}
189197

‎internal/tui/events.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
192192
return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.queueRender())
193193
}
194194
m.refresh()
195-
return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq))
195+
// A turn that just ended (done / error) drains the next queued prompt.
196+
return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.sendQueued())
196197
}
197198

198199
// stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in

‎internal/tui/input.go‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,31 @@ func (m *Model) submit() tea.Cmd {
9898
if strings.HasPrefix(text, "/") {
9999
return m.runCommandLine(text)
100100
}
101-
if m.busy || m.disconn {
101+
if m.disconn {
102+
// Keep the draft — swallowing it silently reads as a lost message.
103+
prev := m.noticeSeq
104+
m.addTransientNote("disconnected — press r to retry, your draft is kept")
105+
m.refresh()
106+
return m.noticeTimer(prev)
107+
}
108+
if m.busy {
109+
// Queue mid-turn prompts instead of dropping them; the queue drains
110+
// automatically when the running turn ends.
111+
m.queue = append(m.queue, text)
112+
m.ta.Reset()
113+
m.closeAC()
114+
m.refresh()
102115
return nil
103116
}
117+
m.ta.Reset()
118+
m.closeAC()
119+
return m.sendPrompt(text)
120+
}
121+
122+
// sendPrompt appends the user/assistant pair to the transcript, records the
123+
// prompt in the history ring, and dispatches it to the server.
124+
func (m *Model) sendPrompt(text string) tea.Cmd {
125+
m.recordHistory(text)
104126
m.msgs = append(m.msgs, message{role: roleUser, content: text})
105127
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
106128
m.curIdx = len(m.msgs) - 1
@@ -134,6 +156,72 @@ func (m *Model) submit() tea.Cmd {
134156
}
135157
}
136158

159+
// sendQueued pops the oldest queued prompt and sends it when the model is
160+
// idle and connected; otherwise it is a no-op (nil cmd).
161+
func (m *Model) sendQueued() tea.Cmd {
162+
if m.busy || m.disconn || len(m.queue) == 0 {
163+
return nil
164+
}
165+
text := m.queue[0]
166+
m.queue = m.queue[1:]
167+
return m.sendPrompt(text)
168+
}
169+
170+
// maxHistory bounds the in-memory prompt history ring.
171+
const maxHistory = 100
172+
173+
// recordHistory appends a submitted prompt to the history ring (deduping
174+
// consecutive repeats) and resets any active history navigation.
175+
func (m *Model) recordHistory(text string) {
176+
m.histNav = false
177+
m.histDraft = ""
178+
if n := len(m.history); n > 0 && m.history[n-1] == text {
179+
return
180+
}
181+
m.history = append(m.history, text)
182+
if len(m.history) > maxHistory {
183+
m.history = m.history[len(m.history)-maxHistory:]
184+
}
185+
}
186+
187+
// historyPrev steps back through the prompt history, stashing the current
188+
// input on the first step. Returns false when there is nothing to recall, so
189+
// the caller can fall back to scrolling. At the oldest entry the key is
190+
// consumed without moving.
191+
func (m *Model) historyPrev() bool {
192+
if len(m.history) == 0 {
193+
return false
194+
}
195+
switch {
196+
case !m.histNav:
197+
m.histDraft = m.ta.Value()
198+
m.histIdx = len(m.history) - 1
199+
m.histNav = true
200+
case m.histIdx > 0:
201+
m.histIdx--
202+
}
203+
m.ta.SetValue(m.history[m.histIdx])
204+
m.ta.CursorEnd()
205+
return true
206+
}
207+
208+
// historyNext steps forward through the history; past the newest entry it
209+
// restores the stashed draft and leaves navigation mode.
210+
func (m *Model) historyNext() {
211+
if !m.histNav {
212+
return
213+
}
214+
if m.histIdx < len(m.history)-1 {
215+
m.histIdx++
216+
m.ta.SetValue(m.history[m.histIdx])
217+
} else {
218+
m.histNav = false
219+
m.ta.SetValue(m.histDraft)
220+
m.histDraft = ""
221+
}
222+
m.ta.CursorEnd()
223+
}
224+
137225
// ── @-reference autocomplete ────────────────────────────────────────────────
138226

139227
// refRe matches a trailing @-reference token at the end of the input.

‎internal/tui/model.go‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ type Model struct {
121121

122122
approval *client.Event // pending approval, nil when none
123123
ac autocomplete // @-reference completion state
124+
queue []string // prompts typed mid-turn, sent when the turn ends
125+
126+
history []string // submitted prompts, newest last (recalled with ↑)
127+
histNav bool // true while ↑/↓ is walking the history
128+
histIdx int // index into history while navigating
129+
histDraft string // input stashed while navigating history
124130

125131
model string
126132
sandbox bool
@@ -240,7 +246,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
240246
m.status = "error"
241247
m.addNote("error: " + msg.err.Error())
242248
m.refresh()
243-
return m, nil
249+
return m, m.sendQueued()
244250

245251
case acResultMsg:
246252
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) {
339345
return m.handleACKey(msg)
340346
}
341347

348+
// A dead connection offers a manual retry on r — only with an empty
349+
// input, so a drafted prompt is never disturbed.
350+
if m.disconn && m.opts.Reconnect != nil && msg.String() == "r" && m.ta.Value() == "" {
351+
m.status = "reconnecting"
352+
m.addNote("retrying connection…")
353+
m.refresh()
354+
return m, m.scheduleReconnect(0)
355+
}
356+
342357
switch msg.String() {
343358
case "ctrl+c":
344359
m.quitting = true
@@ -375,20 +390,36 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
375390
m.refresh()
376391
return m, nil
377392
case "up", "ctrl+p":
378-
// Scroll the transcript when the cursor is already at the top line of
379-
// the input; otherwise let the textarea move the cursor up.
393+
// At the top input line: an empty input (or an active history walk)
394+
// recalls older prompts; otherwise scroll the transcript. Below the
395+
// top line the textarea moves the cursor up instead.
380396
if m.ta.Line() == 0 {
397+
if (m.histNav || m.ta.Value() == "") && m.historyPrev() {
398+
return m, nil
399+
}
381400
var cmd tea.Cmd
382401
m.vp, cmd = m.vp.Update(msg)
383402
return m, cmd
384403
}
385404
case "down", "ctrl+n":
386-
// Likewise, scroll down when the cursor is on the bottom input line.
405+
// Likewise at the bottom line: walk forward through the history when
406+
// navigating it, else scroll the transcript down.
387407
if m.ta.Line() == m.ta.LineCount()-1 {
408+
if m.histNav {
409+
m.historyNext()
410+
return m, nil
411+
}
388412
var cmd tea.Cmd
389413
m.vp, cmd = m.vp.Update(msg)
390414
return m, cmd
391415
}
416+
case "G", "end":
417+
// Jump to the latest output — only with an empty input, so typing a
418+
// capital G (or using End for cursor movement) is never hijacked.
419+
if m.ta.Value() == "" {
420+
m.vp.GotoBottom()
421+
return m, nil
422+
}
392423
case "pgup", "pgdown", "ctrl+u", "ctrl+d":
393424
var cmd tea.Cmd
394425
m.vp, cmd = m.vp.Update(msg)

‎internal/tui/panels.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,22 @@ func (m *Model) deleteSelected() tea.Cmd {
169169
}
170170
}
171171

172-
// cancelRun aborts the in-flight prompt via the cancel API.
172+
// cancelRun aborts the in-flight prompt via the cancel API. Queued prompts
173+
// belong to the user, not the cancelled turn: hand them back to the input
174+
// for editing instead of firing them into a cancelled session.
173175
func (m *Model) cancelRun() tea.Cmd {
174176
if !m.busy || m.sessionID == "" {
175177
return nil
176178
}
179+
if len(m.queue) > 0 {
180+
draft := strings.Join(m.queue, "\n")
181+
if cur := m.ta.Value(); cur != "" {
182+
draft = cur + "\n" + draft
183+
}
184+
m.ta.SetValue(draft)
185+
m.ta.CursorEnd()
186+
m.queue = nil
187+
}
177188
m.status = "cancelling"
178189
m.refresh()
179190
cl := m.cl

0 commit comments

Comments
 (0)