From ba133e9dbdc3e7f6e13128db6b54d908ce0d2fdf Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 8 Aug 2026 18:17:13 +0200 Subject: [PATCH] fix(tui): free capital G for typing, move jump-to-latest to ^G MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The G/End binding was guarded by an empty input, which protected typing G mid-draft but swallowed the keystroke when the input was empty — a prompt could never start with a capital G. G now always types into the input. Jump-to-latest moves to ^G (works even with a draft); End keeps the jump only with an empty input so its cursor-movement meaning inside a draft is preserved. --- README.md | 4 ++-- internal/tui/commands.go | 2 +- internal/tui/model.go | 11 ++++++++--- internal/tui/promptflow_test.go | 33 ++++++++++++++++++++++----------- internal/tui/view.go | 2 +- 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2830555..2d1badb 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | `Esc` | Cancel the running turn (queued prompts return to the input) | | `↑` / `↓` / `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 | +| `^G` / `End` (empty input) | Jump to the latest output | | `wheel` (with `--mouse`) | Scroll the transcript | | `r` (when disconnected) | Retry the connection | | `^C` | Quit | @@ -143,7 +143,7 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` 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 +`^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`. diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 01dca5b..abc4aa0 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -173,7 +173,7 @@ func (m *Model) showHelp() { {"↑↓", "scroll the transcript"}, {"Pg↑↓", "page the transcript"}, {"^P^N", "recall prompts"}, - {"G", "jump to the latest output"}, + {"^G", "jump to the latest output"}, {"^R", "browse & resume sessions"}, {"^O", "switch model"}, {"^T", "toggle extended thinking"}, diff --git a/internal/tui/model.go b/internal/tui/model.go index 4bfcb72..b655c11 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -415,9 +415,14 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, 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. + case "ctrl+g": + // Jump to the latest output. A ctrl binding, so typing a capital G + // (even as the first character of a prompt) is never hijacked. + m.vp.GotoBottom() + return m, nil + case "end": + // End doubles as jump-to-latest — only with an empty input, so its + // cursor-movement meaning inside a draft keeps working. if m.ta.Value() == "" { m.vp.GotoBottom() return m, nil diff --git a/internal/tui/promptflow_test.go b/internal/tui/promptflow_test.go index 14b6b60..389eb21 100644 --- a/internal/tui/promptflow_test.go +++ b/internal/tui/promptflow_test.go @@ -20,8 +20,8 @@ func busyTurn(m *Model) { m.busy = true } -// TestJumpToLatest verifies G/End jump to the bottom with an empty input and -// never hijack typing. +// TestJumpToLatest verifies ^G/End jump to the bottom and that a capital G +// always types — even as the first character of a prompt. func TestJumpToLatest(t *testing.T) { m := newTestModel() m.ta.Focus() @@ -32,31 +32,42 @@ func TestJumpToLatest(t *testing.T) { } // Footer advertises the jump while off-bottom. - if foot := plain(m.footer()); !strings.Contains(foot, "G") || !strings.Contains(foot, "latest") { + if foot := plain(m.footer()); !strings.Contains(foot, "^G") || !strings.Contains(foot, "latest") { t.Errorf("footer missing jump hint: %q", foot) } + // A capital G types into the empty input instead of jumping. m.Update(key("G")) + if m.vp.AtBottom() { + t.Error("G with an empty input must not jump") + } + if m.ta.Value() != "G" { + t.Errorf("G with an empty input should type, got %q", m.ta.Value()) + } + + // ^G jumps even with a draft (no textarea conflict). + m.Update(tea.KeyMsg{Type: tea.KeyCtrlG}) if !m.vp.AtBottom() { - t.Error("G should jump to the latest output") + t.Error("^G should jump to the latest output") + } + if m.ta.Value() != "G" { + t.Errorf("^G must not disturb the draft, got %q", m.ta.Value()) } - // End behaves the same (real terminals send KeyEnd, not runes). + // End jumps only with an empty input (real terminals send KeyEnd, not runes). + m.ta.SetValue("") 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. + // With a draft, End keeps its cursor-movement meaning. m.vp.GotoTop() m.ta.SetValue("draft") - m.Update(key("G")) + m.Update(tea.KeyMsg{Type: tea.KeyEnd}) 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()) + t.Error("End with a draft must not jump") } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 9e42f2d..9bc5e3d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -754,7 +754,7 @@ func (m *Model) footer() string { } seg += th.footerKey.Render("PgUp") + th.footer.Render(" more") + th.footerSep.Render(" · ") + - th.footerKey.Render("G") + th.footer.Render(" latest") + + 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)