From c80f07383d7377c0cdb9c46ad1fa6f4f67de3e17 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 7 Aug 2026 14:46:08 +0200 Subject: [PATCH] fix(tui): free arrow keys for transcript scroll, move recall to ^P/^N MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With prompt history present, bare ↑ with an empty input always recalled a previous prompt and never scrolled the transcript — combined with the opt-in mouse wheel, the conversation history was practically unreachable while the input holds focus. - ↑/↓ at the input's edge lines now always scroll the transcript (PgUp/PgDn/^U/^D unchanged) - prompt-history recall moves to the dedicated readline-style ^P/^N - discoverability: ^P hint in the input placeholder, a keys section in /help (scroll/page/recall rows), and a 'PgUp more' hint in the footer's off-bottom badge; README key table updated Regression test: ↑ scrolls even with prompt history present and leaves histNav/input untouched; ^P still recalls. --- README.md | 4 +-- internal/tui/commands.go | 6 ++-- internal/tui/model.go | 38 +++++++++++++------------ internal/tui/promptflow_test.go | 50 ++++++++++++++++++++++++--------- internal/tui/view.go | 4 ++- 5 files changed, 66 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 2d3f29e..2830555 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | `^J` | Insert a newline in the input | | `^L` | Clear the conversation | | `Esc` | Cancel the running turn (queued prompts return to the input) | -| `↑` / `↓` (empty input) | Recall previous prompts (prompt history) | -| `↑` / `↓` / `PgUp` / `PgDn` | Scroll the transcript | +| `↑` / `↓` / `PgUp` / `PgDn` / `^U` / `^D` | Scroll the transcript (arrows at the input's edge lines) | +| `^P` / `^N` | Recall previous prompts (prompt history) | | `G` / `End` (empty input) | Jump to the latest output | | `wheel` (with `--mouse`) | Scroll the transcript | | `r` (when disconnected) | Retry the connection | diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 7d8d895..01dca5b 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -170,7 +170,9 @@ func (m *Model) showHelp() { {"⏎", "send · queue mid-turn · run a /command"}, {"^J", "newline in the input"}, {"@", "attach files"}, - {"↑↓", "recall prompts · scroll"}, + {"↑↓", "scroll the transcript"}, + {"Pg↑↓", "page the transcript"}, + {"^P^N", "recall prompts"}, {"G", "jump to the latest output"}, {"^R", "browse & resume sessions"}, {"^O", "switch model"}, @@ -180,7 +182,7 @@ func (m *Model) showHelp() { {"esc", "cancel the running turn"}, {"r", "retry a lost connection"}, {"^C", "quit"}, - {"--mouse", "click tool rows to expand"}, + {"--mouse", "wheel scroll · click tool rows"}, } { b.WriteString("\n" + th.tipKey.Render(padRight(k[0], keyW)) + " " + th.tipText.Render(k[1])) } diff --git a/internal/tui/model.go b/internal/tui/model.go index cb080d4..4bfcb72 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -124,7 +124,7 @@ type Model struct { 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 + histNav bool // true while ^P/^N is walking the history histIdx int // index into history while navigating histDraft string // input stashed while navigating history @@ -180,7 +180,7 @@ func New(cl *client.Client, opts Options) *Model { th := newTheme() ta := textarea.New() - ta.Placeholder = "Ask odek to build, fix, explore… (⏎ send · ^J newline)" + ta.Placeholder = "Ask odek to build, fix, explore… (⏎ send · ^J newline · ↑ scroll · ^P history)" ta.Prompt = th.asstLabel.Render("┃ ") ta.ShowLineNumbers = false ta.CharLimit = 0 @@ -389,26 +389,28 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.convCount = -1 // re-render the cached transcript prefix too m.refresh() return m, nil - case "up", "ctrl+p": - // 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 - } + case "ctrl+p": + // Prompt-history recall lives on this dedicated readline-style + // binding so bare ↑/↓ are free to scroll the transcript — the far + // more frequent intent while the input holds focus. + if m.ta.Line() == 0 && (m.histNav || m.ta.Value() == "") { + m.historyPrev() + return m, nil + } + case "ctrl+n": + if m.ta.Line() == m.ta.LineCount()-1 && m.histNav { + m.historyNext() + return m, nil + } + case "up", "down": + // At the input's edge lines, scroll the transcript; inside a + // multi-line input the textarea moves the cursor instead. + if msg.String() == "up" && m.ta.Line() == 0 { var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return m, cmd } - case "down", "ctrl+n": - // 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 - } + if msg.String() == "down" && m.ta.Line() == m.ta.LineCount()-1 { var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return m, cmd diff --git a/internal/tui/promptflow_test.go b/internal/tui/promptflow_test.go index 9786332..14b6b60 100644 --- a/internal/tui/promptflow_test.go +++ b/internal/tui/promptflow_test.go @@ -186,7 +186,7 @@ func tallTranscript(m *Model) { } } -// TestHistoryRecall verifies ↑/↓ walks submitted prompts and restores the +// TestHistoryRecall verifies ^P/^N walks submitted prompts and restores the // stashed draft past the newest entry. func TestHistoryRecall(t *testing.T) { m := newTestModel() @@ -199,33 +199,33 @@ func TestHistoryRecall(t *testing.T) { t.Fatalf("history = %v, want [first second]", m.history) } - m.Update(key("up")) + m.Update(key("ctrl+p")) if got := m.ta.Value(); got != "second" { - t.Errorf("first up = %q, want %q", got, "second") + t.Errorf("first ^P = %q, want %q", got, "second") } - m.Update(key("up")) + m.Update(key("ctrl+p")) if got := m.ta.Value(); got != "first" { - t.Errorf("second up = %q, want %q", got, "first") + t.Errorf("second ^P = %q, want %q", got, "first") } - m.Update(key("up")) // at the oldest entry: consumed, no movement + m.Update(key("ctrl+p")) // at the oldest entry: consumed, no movement if got := m.ta.Value(); got != "first" { - t.Errorf("up past oldest = %q, want %q", got, "first") + t.Errorf("^P past oldest = %q, want %q", got, "first") } - m.Update(key("down")) + m.Update(key("ctrl+n")) if got := m.ta.Value(); got != "second" { - t.Errorf("down = %q, want %q", got, "second") + t.Errorf("^N = %q, want %q", got, "second") } - m.Update(key("down")) // past newest: restore the (empty) draft + m.Update(key("ctrl+n")) // past newest: restore the (empty) draft if got := m.ta.Value(); got != "" { - t.Errorf("down past newest = %q, want empty draft", got) + t.Errorf("^N 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). +// TestHistoryScrollFallback verifies ↑ scrolls the transcript with an empty +// input (no history recorded yet, tall transcript). func TestHistoryScrollFallback(t *testing.T) { m := newTestModel() tallTranscript(m) @@ -237,6 +237,30 @@ func TestHistoryScrollFallback(t *testing.T) { } } +// TestUpScrollsEvenWithHistory is the regression test for the scroll-vs-recall +// conflict: bare ↑/↓ must scroll the transcript even when prompt history +// exists — recall moved to the dedicated ^P/^N binding. +func TestUpScrollsEvenWithHistory(t *testing.T) { + m := newTestModel() + m.recordHistory("earlier prompt") + tallTranscript(m) + bottom := m.vp.YOffset + + m.Update(key("up")) + if m.vp.YOffset >= bottom { + t.Error("up should scroll the transcript even with prompt history present") + } + if m.histNav || m.ta.Value() != "" { + t.Errorf("up must not touch prompt history: histNav=%v input=%q", m.histNav, m.ta.Value()) + } + + // ^P still recalls. + m.Update(key("ctrl+p")) + if got := m.ta.Value(); got != "earlier prompt" { + t.Errorf("^P = %q, want %q", got, "earlier prompt") + } +} + // TestHistoryEdgeCases covers the history ring cap, the no-navigation guard, // and cancelRun's draft-prepend branch. func TestHistoryEdgeCases(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index fb68170..9e42f2d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -752,7 +752,9 @@ func (m *Model) footer() string { if m.busy { seg = th.scroll.Render("↓ new output") + th.footerSep.Render(" · ") } - seg += th.footerKey.Render("G") + th.footer.Render(" latest") + + seg += th.footerKey.Render("PgUp") + th.footer.Render(" more") + + th.footerSep.Render(" · ") + + 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)