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: 14 additions & 0 deletions docs/changes/unreleased/1926-history-copy-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
kind: fixed
title: History arrows walk multiline prompts and copy mode offers Enter
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."
- "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.
Narrow frames keep the history arrows and Enter copy visible with shorter hints.
87 changes: 87 additions & 0 deletions internal/e2e/historycopy_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//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.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.
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.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)
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")
}
5 changes: 4 additions & 1 deletion internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
22 changes: 15 additions & 7 deletions internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -718,8 +718,12 @@ 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`.
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
Expand Down Expand Up @@ -3201,7 +3205,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 |
Expand All @@ -3211,7 +3215,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.
Expand All @@ -3220,11 +3224,15 @@ 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`.
Narrow terminals keep `enter copy` visible while dropping the other clauses.

## What copy mode copies, and what it refuses

Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions internal/tui3/copymode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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"
Expand Down
96 changes: 96 additions & 0 deletions internal/tui3/historycopy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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"},
)
a.width = 140
a.welcome.open = false
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.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"},
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)
}
}
4 changes: 2 additions & 2 deletions internal/tui3/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions internal/tui3/recall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions internal/tui3/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
Loading
Loading