From a67d6718166d400940ed3bad311c484ea1ec7b26 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 24 Oct 2026 14:43:44 -0400 Subject: [PATCH 1/3] fix: make multiline history navigation and keyboard copy discoverable --- .../unreleased/1-history-copy-keyboard.md | 13 +++ internal/e2e/historycopy_e2e_test.go | 81 +++++++++++++++++++ internal/manual/chat/commands.md | 5 +- internal/manual/chat/keys.md | 20 +++-- internal/tui3/commands.go | 2 +- internal/tui3/copymode.go | 14 +++- internal/tui3/historycopy_test.go | 79 ++++++++++++++++++ internal/tui3/input.go | 4 +- internal/tui3/render.go | 2 + internal/tui3/room.go | 4 +- 10 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 docs/changes/unreleased/1-history-copy-keyboard.md create mode 100644 internal/e2e/historycopy_e2e_test.go create mode 100644 internal/tui3/historycopy_test.go diff --git a/docs/changes/unreleased/1-history-copy-keyboard.md b/docs/changes/unreleased/1-history-copy-keyboard.md new file mode 100644 index 0000000000..0284970697 --- /dev/null +++ b/docs/changes/unreleased/1-history-copy-keyboard.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: History arrows walk multiline prompts and copy mode offers Enter +pr: 1 +surface: [chat, docs] +invalidates: + - "After recalling a multiline prompt, Up moved the caret instead of reaching the older prompt. While browsing history, Up and Down now move whole entries in conversations and task rooms, and Down past the newest restores the draft." + - "Copy mode only offered the unfamiliar y yank action. It now displays Enter copy, retains y as an alias, and shows clipboard sent after emitting the terminal clipboard write." +--- + +History keeps its existing ordering: cleared drafts, project history, then other +projects. Terminal clipboard permissions still apply; clipboard sent confirms +the write was emitted rather than claiming the terminal accepted it. diff --git a/internal/e2e/historycopy_e2e_test.go b/internal/e2e/historycopy_e2e_test.go new file mode 100644 index 0000000000..c3e209d2ad --- /dev/null +++ b/internal/e2e/historycopy_e2e_test.go @@ -0,0 +1,81 @@ +//go:build e2e + +package e2e + +import ( + "encoding/base64" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +func TestHistoryCopyKeyboard(t *testing.T) { + requireTmuxAndKey(t) + const model = "deepseek/deepseek-v4.1-flash" + if e2eModel != model { + t.Fatal("pin every live role to " + model) + } + home := newHome(t, map[string]any{"model.talk": model, "models.fallbacks": "", "model_pool": "off", "daily_budget_usd": 0}) + ws := newWorkspace(t, "history-copy", false) + r := start(t, "history_copy", home, ws, 140, 42, "chat", "--model", model, "--one-model", "--no-host") + stream := filepath.Join(t.TempDir(), "terminal.raw") + if out, err := exec.Command("tmux", "pipe-pane", "-O", "-t", r.name, + "cat > '"+strings.ReplaceAll(stream, "'", "'\\''")+"'").CombinedOutput(); err != nil { + t.Fatalf("record terminal output: %v %s", err, out) + } + r.lit("Reply only with FIRST_HISTORY_REPLY. No tools.") + r.keys("Enter") + cleanChatWaitAnswer(t, r, "FIRST_HISTORY_REPLY", 2*time.Minute) + r.lit("Reply only with SECOND_HISTORY_REPLY.") + r.keys("M-Enter") + r.lit("No tools. This is the second line.") + r.keys("Enter") + cleanChatWaitAnswer(t, r, "SECOND_HISTORY_REPLY", 2*time.Minute) + r.lit("UNSENT_DRAFT_7319") + r.keys("Up") + r.waitFor(5*time.Second, "No tools. This is the second line.") + r.keys("Up") + screen := r.capture() + // Only the draft region is relevant: the transcript retains both prompts. + lines := strings.Split(screen, "\n") + draft := strings.Join(lines[max(0, len(lines)-8):], "\n") + if !strings.Contains(draft, "FIRST_HISTORY_REPLY") || strings.Contains(draft, "second line") { + t.Fatalf("second up did not reach the previous prompt:\n%s", screen) + } + r.keys("Down", "Down") + r.waitFor(5*time.Second, "UNSENT_DRAFT_7319") + r.keys("C-u") + r.lit("/copy") + r.keys("Enter") + r.waitFor(5*time.Second, "enter copy") + r.keys("End", "a", "Enter") + r.waitFor(5*time.Second, "clipboard sent") + deadline := time.Now().Add(5 * time.Second) + var payload []byte + for time.Now().Before(deadline) { + raw, err := os.ReadFile(stream) + if err != nil { + t.Fatal(err) + } + matches := regexp.MustCompile("\\x1b\\]52;c;([A-Za-z0-9+/=]*)\\a").FindAllSubmatch(raw, -1) + if len(matches) > 0 { + payload, err = base64.StdEncoding.DecodeString(string(matches[len(matches)-1][1])) + if err != nil { + t.Fatal(err) + } + break + } + time.Sleep(pollEvery) + } + if strings.TrimSpace(string(payload)) != "SECOND_HISTORY_REPLY" { + t.Fatalf("terminal clipboard payload = %q, want the selected answer", payload) + } + t.Log("live terminal clipboard payload: SECOND_HISTORY_REPLY") + r.keys("Escape") + r.quit() + cleanChatAuditModels(t, home, model, "history-copy") +} diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 41aee7c5b4..8cbe05b057 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -585,7 +585,10 @@ nothing to rewind ## /copy — read the conversation back and copy from it `/copy` freezes the visible conversation and enters copy mode. It is the same thing -ctrl+b does. In copy mode ↑↓ move, `v` marks, `a` takes the block, `y` yanks. +ctrl+b does. In copy mode ↑↓ move, `v` marks, `a` takes the block, and `enter` +copies the selection (`y` also works). The status says `COPY · clipboard sent` +after the clipboard write is sent to your terminal; terminal clipboard permissions +still apply. `esc` leaves copy mode. **`/copy` silently does nothing in two states**, with no message either way: diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index eb7e613513..0578c61a1c 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -696,7 +696,7 @@ cannot send it delivers plain enter on many keyboards, or a newline on some. | Chord | What it does | |---|---| -| `up` | Four meanings, tried in this order: move the caret up inside a multi-line message; walk back through history; over an empty box, select the previous tool row; scroll up one row | +| `up` | While browsing history, recall the previous whole entry. Otherwise move the caret up inside a multi-line message; at the first line, walk back through history; over an empty box with no history, select the previous tool row; otherwise scroll up one row | | `down` | The mirror of `up` | | `left` | Empty box: step back a level — close the room, else clear the selection. Two `left` presses inside about 600ms go home to the live edge. Non-empty box: move the caret left | | `right` | Empty box: go into the next running task's room. Non-empty box: move the caret right | @@ -718,8 +718,11 @@ cannot send it delivers plain enter on many keyboards, or a newline on some. | any printing key | Types the character — over a selection it replaces it | `home`, `end`, `up` and `down` work on the logical line — the run between newlines — -not on the row your terminal wrapped it onto. `up` only reaches history when the -caret is on the first logical line, and `down` only when it is on the last. +not on the row your terminal wrapped it onto. `up` enters history when the +caret is on the first logical line. Once browsing, `up` and `down` move whole +entries, including multiline prompts, in both the conversation and task rooms. +`down` past the newest entry restores your draft and caret; `esc` restores them +immediately. The conversation hint says `↑ older · ↓ newer · esc restore draft · enter send`. A word jump crosses the same boundary the word kill deletes: spaces first, then the run of non-spaces, so `alt+left` then `alt+backspace` always deletes exactly @@ -3201,7 +3204,7 @@ own screen; it was taken out the same day.) Freezing looks like nothing when nothing is moving — the rows stay where they are on purpose. What tells you the freeze is on is the keys row, which reads exactly -`v select · a block · y yank · esc`, the highlighted cursor row, and the status word +`↑↓ move · v select · a block · enter copy · esc`, the highlighted cursor row, and the status word `COPY`. `esc` always leaves. | Chord | What it does | @@ -3211,7 +3214,7 @@ purpose. What tells you the freeze is on is the keys row, which reads exactly | `home`, `end` | Jump to top or bottom | | `v` | Drop or lift the mark | | `a` | Take the block under the cursor | -| `y` | Yank the selection | +| `enter`, `y` | Copy the selection, or the cursor's line when no range is marked | | `esc`, `ctrl+b`, `q` | Leave | Copy mode takes **every other key too**, and does nothing with them. @@ -3220,11 +3223,14 @@ Copy mode takes **every other key too**, and does nothing with them. to widen to the whole answer around it. A blank row belongs to nothing, and `a` there does nothing. -`y` **stays** in copy mode and lifts the mark, so a second `y` cannot copy the same +Copying **stays** in copy mode and lifts the mark, so a second copy cannot copy the same span by accident. The status line reads exactly `COPY`, or `COPY · N lines` when more than one line is -selected. The row under the box reads `v select · a block · y yank · esc`. +selected. After copying it reads `COPY · clipboard sent` until you move or change +the selection. This confirms that codeaf sent the clipboard write; your terminal +must permit clipboard access. The row under the box reads +`↑↓ move · v select · a block · enter copy · esc`. ## What copy mode copies, and what it refuses diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 006e638153..43f5d1253b 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -1103,7 +1103,7 @@ func helpText(file string, chords chordSpelling) string { // rows at the foot of this list already use. helpKeyRow(spellOutKey, "over a draft: spell it out · what it means · enter adds it to yours"), "ctrl+o expand this turn's tool calls · click one to open it · in a task, scroll up does too", - "ctrl+b copy mode · ↑↓ move · v marks · a takes the block · y yanks", + "ctrl+b copy mode · ↑↓ move · v marks · a takes the block · enter copies (or y)", "ctrl+s drag to select with your mouse · any key ends it", "enter mid-answer: stops the current reply and steers these words in", // AND THE THIRD THING TO DO WITH A SENTENCE TYPED OVER A RUNNING ANSWER diff --git a/internal/tui3/copymode.go b/internal/tui3/copymode.go index 41bb80d7ef..44bfc008ac 100644 --- a/internal/tui3/copymode.go +++ b/internal/tui3/copymode.go @@ -129,12 +129,13 @@ func (a *app) takeMouseBack() bool { // copyKeysWord is the keys row while the viewport is frozen, under either box: // the reader's keys are the only keys that work, so they are the only keys the // row may name. -const copyKeysWord = "v select · a block · y yank · esc" +const copyKeysWord = "↑↓ move · v select · a block · enter copy · esc" // copyMode is the frozen viewport's whole state. The zero value is off, except // for mark, which [newApp] sets to -1 — nothing is marked. type copyMode struct { - on bool + on bool + clipboardSent bool // The reading gutter belongs to the snapshot, even after a resize. gutter int // rows is the snapshot as it is drawn, and text the same rows stripped of @@ -236,7 +237,7 @@ func (a *app) copyKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { a.copyMark() case "a": a.copyBlock() - case "y": + case "enter", "y": return a.copyYank(), true } return nil, true @@ -247,6 +248,7 @@ func (a *app) copyKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // in step, so there is nothing for the two to disagree about. func (a *app) copyScroll(delta int) { c := &a.copy + c.clipboardSent = false c.at = clampInt(c.at+delta, 0, len(c.rows)-1) height := a.viewHeight() if height < 1 { @@ -266,6 +268,7 @@ func (a *app) copyScroll(delta int) { // the NEAR end: v then ↓↓↓ grows the span downward, exactly as it does in every // other reader that has this key. func (a *app) copyMark() { + a.copy.clipboardSent = false if a.copy.mark >= 0 { a.copy.mark = -1 } else { @@ -288,6 +291,7 @@ func (a *app) copyMark() { // which neighbour was meant. func (a *app) copyBlock() { c := &a.copy + c.clipboardSent = false if c.at < 0 || c.at >= len(c.text) { return } @@ -382,6 +386,7 @@ func (a *app) copyYank() tea.Cmd { lines = append(lines, copyClean(line, a.copy.gutter)) } a.copy.mark = -1 + a.copy.clipboardSent = true a.touch() return tea.Raw(osc52(strings.Join(lines, "\n"), a.tmux)) } @@ -463,6 +468,9 @@ func (a *app) copyRows(width, height int) ([]row, int) { // as well as the mode, because a marked span longer than the screen is a span a // person cannot otherwise measure. func (a *app) copyWord() string { + if a.copy.clipboardSent { + return "COPY · clipboard sent" + } from, to := a.copySpan() if n := to - from + 1; n > 1 { return "COPY · " + itoa(n) + " lines" diff --git a/internal/tui3/historycopy_test.go b/internal/tui3/historycopy_test.go new file mode 100644 index 0000000000..1d39edd8b5 --- /dev/null +++ b/internal/tui3/historycopy_test.go @@ -0,0 +1,79 @@ +package tui3 + +import ( + "encoding/base64" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/history" +) + +func TestHistoryArrowsWalkWholeMultilineEntries(t *testing.T) { + a, _ := recallApp(t, + history.Entry{Text: "older prompt", Cwd: "/tmp/lab"}, + history.Entry{Text: "first line\nsecond line", Cwd: "/tmp/lab"}, + ) + typeInto(t, a, "my draft") + for _, step := range []struct{ key, want string }{ + {"up", "first line\nsecond line"}, + {"up", "older prompt"}, + {"down", "first line\nsecond line"}, + {"down", "my draft"}, + } { + drive(t, a, key(step.key)) + if got := a.input.String(); got != step.want { + t.Fatalf("%s shows %q, want %q", step.key, got, step.want) + } + if step.want != "my draft" && !strings.Contains(plain(frame(a)), "↑ older · ↓ newer") { + t.Fatal("history does not show its navigation keys") + } + } +} + +func TestRoomHistoryArrowsWalkWholeMultilineEntries(t *testing.T) { + a, _ := roomRecallApp(t, + history.Entry{Text: "older prompt", Cwd: "/tmp/lab"}, + history.Entry{Text: "first line\nsecond line", Cwd: "/tmp/lab"}, + ) + typeInto(t, a, "room draft") + for _, step := range []struct{ key, want string }{ + {"up", "first line\nsecond line"}, + {"up", "older prompt"}, + {"down", "first line\nsecond line"}, + {"down", "room draft"}, + } { + drive(t, a, key(step.key)) + if got := a.input.String(); got != step.want { + t.Fatalf("%s shows %q, want %q", step.key, got, step.want) + } + } +} + +func TestCopyModeOffersEnterToCopy(t *testing.T) { + a := copyApp(t) + if screen := plain(frame(a)); !strings.Contains(screen, "enter copy") { + t.Fatalf("copy mode hides the copy action:\n%s", screen) + } + a.copy.at = rowWith(t, a, "charlie") + cmd, taken := a.copyKey(key("enter")) + if !taken || cmd == nil { + t.Fatal("enter did not copy the selected line") + } + raw, ok := cmd().(tea.RawMsg) + if !ok { + t.Fatal("copy did not produce a terminal clipboard write") + } + seq, _ := raw.Msg.(string) + payload, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(seq, "\x1b]52;c;"), "\a")) + if err != nil || string(payload) != " · charlie" { + t.Fatalf("clipboard contains %q, error %v", payload, err) + } + if screen := plain(frame(a)); !strings.Contains(screen, "clipboard sent") { + t.Fatalf("copy has no visible feedback:\n%s", screen) + } + drive(t, a, key("up")) + if screen := plain(frame(a)); strings.Contains(screen, "clipboard sent") { + t.Fatalf("moving to a new selection kept stale copy feedback:\n%s", screen) + } +} diff --git a/internal/tui3/input.go b/internal/tui3/input.go index f0a18a4698..4d905a4196 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1088,7 +1088,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // of the draft it walks history, after taking back a parked message; // with nothing typed and calls on screen it selects one; and past all // of those it scrolls. - if !a.input.onFirstLine() { + if !a.recalling() && !a.input.onFirstLine() { a.input.up() a.touch() return a.syncLists() @@ -1118,7 +1118,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return a.scroll(-1) case "down": - if !a.input.onLastLine() { + if !a.recalling() && !a.input.onLastLine() { a.input.down() a.touch() return a.syncLists() diff --git a/internal/tui3/render.go b/internal/tui3/render.go index a59e859965..d65b789898 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -4141,6 +4141,8 @@ func (a *app) hintWord() string { // room is open, esc leaves the page and does not touch the conversation's // turn, so "esc interrupt" would be naming a key that is spoken for. return a.roomHint() + case a.recalling(): + return "↑ older · ↓ newer · esc restore draft · enter send" case a.state == stateWorking: // ONE RUNNING STATE, ONE COMPOSED LINE (steer.go's [app.runHint]). Its // clauses ask their keys' own guards and remain in one fixed order, so the diff --git a/internal/tui3/room.go b/internal/tui3/room.go index e55b0a03d8..3dca49a100 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -2216,7 +2216,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // session with no history at all — which is the same bargain the // conversation struck, where ↑ has not been a one-row scroll since the day // there was anything to recall. - if !a.input.onFirstLine() { + if !a.recalling() && !a.input.onFirstLine() { return nil, false // the caret's, in input.go } if !a.recallBack() { @@ -2225,7 +2225,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, true case "down": - if !a.input.onLastLine() { + if !a.recalling() && !a.input.onLastLine() { return nil, false } if !a.recallForward() { From a2aab1c1f77fe8c6da56a7240aa5d903a52f50a0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 24 Oct 2026 14:52:06 -0400 Subject: [PATCH 2/3] fix: retain history and copy controls in narrow terminals --- .../unreleased/1-history-copy-keyboard.md | 1 + internal/e2e/historycopy_e2e_test.go | 6 ++++++ internal/manual/chat/keys.md | 2 ++ internal/tui3/historycopy_test.go | 19 ++++++++++++++++++- internal/tui3/recall.go | 2 ++ internal/tui3/render.go | 2 +- internal/tui3/slotfilter_test.go | 2 +- internal/tui3/steer.go | 10 ++++++++++ 8 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/changes/unreleased/1-history-copy-keyboard.md b/docs/changes/unreleased/1-history-copy-keyboard.md index 0284970697..e18863f163 100644 --- a/docs/changes/unreleased/1-history-copy-keyboard.md +++ b/docs/changes/unreleased/1-history-copy-keyboard.md @@ -11,3 +11,4 @@ invalidates: History keeps its existing ordering: cleared drafts, project history, then other projects. Terminal clipboard permissions still apply; clipboard sent confirms the write was emitted rather than claiming the terminal accepted it. +Narrow frames keep the history arrows and Enter copy visible with shorter hints. diff --git a/internal/e2e/historycopy_e2e_test.go b/internal/e2e/historycopy_e2e_test.go index c3e209d2ad..163a750c06 100644 --- a/internal/e2e/historycopy_e2e_test.go +++ b/internal/e2e/historycopy_e2e_test.go @@ -38,6 +38,9 @@ func TestHistoryCopyKeyboard(t *testing.T) { r.lit("UNSENT_DRAFT_7319") r.keys("Up") r.waitFor(5*time.Second, "No tools. This is the second line.") + r.resize(40, 42) + r.waitFor(5*time.Second, "↑↓ history") + r.resize(140, 42) r.keys("Up") screen := r.capture() // Only the draft region is relevant: the transcript retains both prompts. @@ -52,6 +55,9 @@ func TestHistoryCopyKeyboard(t *testing.T) { r.lit("/copy") r.keys("Enter") r.waitFor(5*time.Second, "enter copy") + r.resize(40, 42) + r.waitFor(5*time.Second, "enter copy") + r.resize(140, 42) r.keys("End", "a", "Enter") r.waitFor(5*time.Second, "clipboard sent") deadline := time.Now().Add(5 * time.Second) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 0578c61a1c..a43120df32 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -723,6 +723,7 @@ caret is on the first logical line. Once browsing, `up` and `down` move whole entries, including multiline prompts, in both the conversation and task rooms. `down` past the newest entry restores your draft and caret; `esc` restores them immediately. The conversation hint says `↑ older · ↓ newer · esc restore draft · enter send`. +Narrow terminals shorten this to `↑↓ history · esc draft` or `↑↓ history`. A word jump crosses the same boundary the word kill deletes: spaces first, then the run of non-spaces, so `alt+left` then `alt+backspace` always deletes exactly @@ -3231,6 +3232,7 @@ selected. After copying it reads `COPY · clipboard sent` until you move or chan the selection. This confirms that codeaf sent the clipboard write; your terminal must permit clipboard access. The row under the box reads `↑↓ move · v select · a block · enter copy · esc`. +Narrow terminals keep `enter copy` visible while dropping the other clauses. ## What copy mode copies, and what it refuses diff --git a/internal/tui3/historycopy_test.go b/internal/tui3/historycopy_test.go index 1d39edd8b5..ba55f35a54 100644 --- a/internal/tui3/historycopy_test.go +++ b/internal/tui3/historycopy_test.go @@ -14,6 +14,8 @@ func TestHistoryArrowsWalkWholeMultilineEntries(t *testing.T) { history.Entry{Text: "older prompt", Cwd: "/tmp/lab"}, history.Entry{Text: "first line\nsecond line", Cwd: "/tmp/lab"}, ) + a.width = 140 + a.welcome.open = false typeInto(t, a, "my draft") for _, step := range []struct{ key, want string }{ {"up", "first line\nsecond line"}, @@ -26,11 +28,26 @@ func TestHistoryArrowsWalkWholeMultilineEntries(t *testing.T) { t.Fatalf("%s shows %q, want %q", step.key, got, step.want) } if step.want != "my draft" && !strings.Contains(plain(frame(a)), "↑ older · ↓ newer") { - t.Fatal("history does not show its navigation keys") + t.Fatalf("history does not show its navigation keys:\n%s", plain(frame(a))) } } } +func TestHistoryAndCopyKeepTheirActionsOnNarrowFrames(t *testing.T) { + a, _ := recallApp(t, history.Entry{Text: "an old prompt", Cwd: "/tmp/lab"}) + a.welcome.open = false + a.width = 40 + drive(t, a, key("up")) + if screen := plain(frame(a)); !strings.Contains(screen, "↑↓ history") { + t.Fatalf("narrow history hides its arrows:\n%s", screen) + } + a = copyApp(t) + a.width = 40 + if screen := plain(frame(a)); !strings.Contains(screen, "enter copy") { + t.Fatalf("narrow copy mode hides its copy action:\n%s", screen) + } +} + func TestRoomHistoryArrowsWalkWholeMultilineEntries(t *testing.T) { a, _ := roomRecallApp(t, history.Entry{Text: "older prompt", Cwd: "/tmp/lab"}, diff --git a/internal/tui3/recall.go b/internal/tui3/recall.go index 37467ba12b..4ba72f081c 100644 --- a/internal/tui3/recall.go +++ b/internal/tui3/recall.go @@ -19,6 +19,8 @@ import "github.com/Agent-Field/codeaf/internal/history" // is further than any ↑ walk anybody has ever done, and it bounds the copy. const recallDepth = 200 +const recallKeysWord = "↑ older · ↓ newer · esc restore draft · enter send" + // History is the slice of *history.Store this surface uses. It is an interface // for the same reason [Agent] is: the surface is driven in a test without a // file, and a store that is nil is simply a surface with no recall. diff --git a/internal/tui3/render.go b/internal/tui3/render.go index d65b789898..a8e7f3223e 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -4142,7 +4142,7 @@ func (a *app) hintWord() string { // turn, so "esc interrupt" would be naming a key that is spoken for. return a.roomHint() case a.recalling(): - return "↑ older · ↓ newer · esc restore draft · enter send" + return recallKeysWord case a.state == stateWorking: // ONE RUNNING STATE, ONE COMPOSED LINE (steer.go's [app.runHint]). Its // clauses ask their keys' own guards and remain in one fixed order, so the diff --git a/internal/tui3/slotfilter_test.go b/internal/tui3/slotfilter_test.go index 8589618d66..77ab2aa0d8 100644 --- a/internal/tui3/slotfilter_test.go +++ b/internal/tui3/slotfilter_test.go @@ -177,7 +177,7 @@ func TestTheHintSlotFollowsTheKeyboard(t *testing.T) { a := newTestApp(&fakeAgent{model: "openai/gpt-4.1-mini"}) a.copy.on = true - if got := a.hintWord(); got != "v select · a block · y yank · esc" { + if got := a.hintWord(); got != "↑↓ move · v select · a block · enter copy · esc" { t.Fatalf("copy mode offered %q", got) } // And the handed-over pointer leads even copy mode, because the key that diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index 9ac4361e00..e7b3bc5304 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -492,6 +492,16 @@ func (a *app) runHint() string { // Idle controls yield from the left to commands and home; running hints keep // their existing priority from the left. Every step removes at least one clause. func (a *app) hintShorter(slot string) string { + switch slot { + case recallKeysWord: + return "↑↓ history · esc draft" + case "↑↓ history · esc draft": + return "↑↓ history" + case copyKeysWord: + return "v select · enter copy · esc" + case "v select · enter copy · esc": + return "enter copy · esc" + } // THE OFFER'S OWN CLAUSE SURVIVES THE TURN'S. It is appended to a running // turn's keys, and the generic rule below cannot shorten a line that extends // the run hint without deleting the whole row. So while the grace is open the From ec5d4a9c93dbd2becde16bcc501687bb8e1b85ac Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 24 Oct 2026 14:55:25 -0400 Subject: [PATCH 3/3] docs: associate history and copy fix with PR 1926 --- ...1-history-copy-keyboard.md => 1926-history-copy-keyboard.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1-history-copy-keyboard.md => 1926-history-copy-keyboard.md} (98%) diff --git a/docs/changes/unreleased/1-history-copy-keyboard.md b/docs/changes/unreleased/1926-history-copy-keyboard.md similarity index 98% rename from docs/changes/unreleased/1-history-copy-keyboard.md rename to docs/changes/unreleased/1926-history-copy-keyboard.md index e18863f163..8e854f68f4 100644 --- a/docs/changes/unreleased/1-history-copy-keyboard.md +++ b/docs/changes/unreleased/1926-history-copy-keyboard.md @@ -1,7 +1,7 @@ --- kind: fixed title: History arrows walk multiline prompts and copy mode offers Enter -pr: 1 +pr: 1926 surface: [chat, docs] invalidates: - "After recalling a multiline prompt, Up moved the caret instead of reaching the older prompt. While browsing history, Up and Down now move whole entries in conversations and task rooms, and Down past the newest restores the draft."