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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,29 @@ 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 |
| `^O` | Switch the model |
| `^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,
Expand Down
5 changes: 4 additions & 1 deletion internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
} {
Expand Down
12 changes: 10 additions & 2 deletions internal/tui/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
3 changes: 2 additions & 1 deletion internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 89 additions & 1 deletion internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
39 changes: 35 additions & 4 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading