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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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]))
}
Expand Down
38 changes: 20 additions & 18 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 37 additions & 13 deletions internal/tui/promptflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down