Skip to content

Commit 8ff54b2

Browse files
Ubuntuclaude
andcommitted
fix(claude-code,m4): multi-line text input lands as ONE turn via paste-buffer -r (v1.0.658-alpha)
Deferred symptom #5 from v1.0.657's claude-code M4 fix arc: multi-line text input was slicing into N separate turns at claude's TUI. Root cause — claude_code/sendkeys.go:inputText: for _, line := range strings.Split(body, "\n") { runner.Run(... "send-keys", "-l", line ...) runner.Run(... "send-keys", "Enter") // ← submits each line } Each send-keys Enter submits the current TUI input buffer to claude. An N-line message therefore arrived as N separate user turns; ADR-032 envelopes (4-line `[<kind> from <sender>]\\n<text>\\n\\n<reply instruction>`) showed up as header alone → text alone → reply instruction alone, never a coherent directive. Fix — same shape as agy v1.0.652 paste-buffer-`-r`: bufName := "ccinput_" + strings.TrimPrefix(a.PaneID, "%") runner.Run(... "set-buffer", "-b", bufName, body) runner.Run(... "paste-buffer", "-b", bufName, "-d", "-r", "-t", a.PaneID) runner.Run(... "send-keys", "-t", a.PaneID, "Enter") `-r` suppresses tmux's default LF→CR translation so internal LF bytes stay as LF on the wire. claude's input field accepts them as in-field newlines (the `\\<Enter>` newline-without-submit affordance), and only our explicit final Enter triggers submission. Buffer name keys off pane id so concurrent multi-line inputs don't collide. Failure path attempts delete-buffer cleanup. Tests: - TestHandleInput_TextMultilineUsesAtomicPasteBuffer (new shape) - TestHandleInput_TextLongSingleLineUsesPasteBuffer (>512 chars) - TestHandleInput_TextCRLFUsesPasteBuffer (\r\n line endings) - TestHandleInput_TextMultilineCleansBufferOnPasteFailure Replaces TestHandleInput_TextMultilineUsesPerLineSendKeys which had calcified the wrong behaviour into the suite. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fa444c0 commit 8ff54b2

5 files changed

Lines changed: 232 additions & 33 deletions

File tree

docs/changelog.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **Type:** reference
44
> **Status:** Current (2026-05-23)
55
> **Audience:** contributors, operators
6-
> **Last verified vs code:** v1.0.657
6+
> **Last verified vs code:** v1.0.658
77
88
**TL;DR.** Append-only record of what shipped in each tagged release.
99
One section per version, newest first. Format follows
@@ -23,6 +23,76 @@ binding). Seed entries prior to that are in
2323

2424
---
2525

26+
## v1.0.658-alpha — 2026-05-23
27+
28+
ADR-027 W11 fix-up wedge #2 — the deferred symptom #5 from v1.0.657's
29+
five-bug parallel: **multi-line text input slicing**.
30+
31+
### Root cause
32+
33+
`claude_code/sendkeys.go:inputText` had two paths:
34+
35+
- single-line short body (≤512 chars, no `\n`/`\r`) → `send-keys -l
36+
<body>` + `send-keys Enter`. **Correct.**
37+
- multi-line OR long body → `strings.Split(body, "\n")` → for each
38+
line: `send-keys -l <line>` + `send-keys Enter`. **Wrong.**
39+
40+
Each `send-keys Enter` submits the current input buffer to claude's
41+
TUI. A 5-line message therefore landed as 5 SEPARATE user turns — only
42+
the first line's "/" + slash command (or whatever started the body)
43+
received any meaningful reply, the rest streamed in as bare-text
44+
prompts the agent then tried to address one at a time. ADR-032 envelope
45+
bodies (4-line `[<kind> from <sender>]\n<text>\n\n<reply instruction>`)
46+
were among the worst affected: the agent saw a header alone, then the
47+
text alone, then the reply instruction alone, never a coherent
48+
directive.
49+
50+
### Fix shape — same as agy v1.0.652
51+
52+
`hub/internal/drivers/local_log_tail/claude_code/sendkeys.go`:
53+
54+
```go
55+
if len(body) <= 512 && !strings.ContainsAny(body, "\n\r") {
56+
// cheap path unchanged
57+
} else {
58+
bufName := "ccinput_" + strings.TrimPrefix(a.PaneID, "%")
59+
runner.Run(ctx, "tmux", "set-buffer", "-b", bufName, body)
60+
runner.Run(ctx, "tmux", "paste-buffer", "-b", bufName, "-d", "-r", "-t", a.PaneID)
61+
runner.Run(ctx, "tmux", "send-keys", "-t", a.PaneID, "Enter")
62+
}
63+
```
64+
65+
`-r` is the load-bearing flag: it suppresses tmux's default LF→CR
66+
translation, so internal LF bytes in the buffer stay as LF on the
67+
wire. claude's input field accepts them as in-field newlines (the same
68+
`\<Enter>` newline-without-submit affordance it offers interactively),
69+
and only our explicit final `send-keys Enter` triggers submission.
70+
Buffer name keys off pane id so two concurrent multi-line inputs to
71+
different agents don't collide.
72+
73+
Failure path: if `paste-buffer` errors, best-effort `delete-buffer` so
74+
a stale buffer doesn't survive into the next call.
75+
76+
### Test additions
77+
78+
- `TestHandleInput_TextMultilineUsesAtomicPasteBuffer` — the contract
79+
for the new path (set-buffer + paste-buffer -d -r + Enter, three
80+
calls total, no per-line Enter).
81+
- `TestHandleInput_TextLongSingleLineUsesPasteBuffer` — bodies >512
82+
chars still take the paste-buffer path even with no newlines.
83+
- `TestHandleInput_TextCRLFUsesPasteBuffer``\r\n` line endings also
84+
fall through to paste-buffer (the cheap-path guard tests for BOTH
85+
`\n` AND `\r` via strings.ContainsAny).
86+
- `TestHandleInput_TextMultilineCleansBufferOnPasteFailure` — locks
87+
the failure-path delete-buffer cleanup.
88+
89+
Replaces the pre-v1.0.658 `TestHandleInput_TextMultilineUsesPerLineSendKeys`
90+
which had calcified the wrong behaviour into the test suite.
91+
92+
### Tag
93+
94+
- Tag: `v1.0.658-alpha`
95+
2696
## v1.0.657-alpha — 2026-05-23
2797

2898
ADR-027 W11 fix-up wedge #1 (mirror of the agy v1.0.643–.652 arc) —

hub/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// mobile and hub use the same x.y.z-alpha numbering. Use
1414
// `make bump VERSION=...` from the repo root to update both files
1515
// atomically.
16-
const Version = "1.0.657-alpha"
16+
const Version = "1.0.658-alpha"
1717

1818
var (
1919
Commit string

hub/internal/drivers/local_log_tail/claude_code/sendkeys.go

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,36 @@ func (a *Adapter) requirePane() error {
6363
return nil
6464
}
6565

66-
// inputText sends a free-text body followed by Enter. Short bodies
67-
// (no embedded newlines, ≤ 512 chars) go via send-keys -l. Longer or
68-
// multi-line bodies go via load-buffer + paste-buffer so the TUI
69-
// receives the whole block atomically rather than streaming one
70-
// character per send-keys round-trip.
66+
// inputText sends a free-text body as ONE atomic submission, followed
67+
// by Enter. Single-line short bodies (no embedded newlines, ≤ 512
68+
// chars) take the cheap path: `send-keys -l <body>` + `send-keys Enter`.
69+
//
70+
// Multi-line / long bodies go via tmux's named-buffer paste:
71+
//
72+
// tmux set-buffer -b <name> <body>
73+
// tmux paste-buffer -b <name> -d -r -t <pane>
74+
// tmux send-keys -t <pane> Enter
75+
//
76+
// `-d` deletes the buffer after the paste so concurrent inputs don't
77+
// stack. `-r` is LOAD-BEARING: without it tmux translates every LF
78+
// byte in the buffer into a CR (Enter) keystroke on the way to the
79+
// pane, which means each line of a multi-line body arrives as a
80+
// SEPARATE user submission. Pre-v1.0.658 the old path was even worse —
81+
// it explicitly split on `\n` and inserted `send-keys Enter` between
82+
// every line, so a 5-line body landed as 5 turns at claude's TUI input
83+
// (a "/code please run \n curl …" multi-line block became 5 distinct
84+
// prompts, only the last receiving any reply). Same fix shape as the
85+
// agy v1.0.652 paste-buffer-`-r` flag.
86+
//
87+
// With `-r`, LF stays as LF — claude's input field is multi-line
88+
// capable (the same `\<Enter>` newline-without-submit affordance) and
89+
// accepts pasted newlines as in-field newline characters. Only our
90+
// explicit final `send-keys Enter` triggers submission.
91+
//
92+
// Buffer name is derived from the pane id so two concurrent inputs to
93+
// different agents don't clobber each other. Tmux buffer names must be
94+
// `[A-Za-z0-9_-]+`; the pane id form `%NN` is sanitised by stripping
95+
// the `%`.
7196
func (a *Adapter) inputText(ctx context.Context, p map[string]any) error {
7297
body, _ := p["body"].(string)
7398
if body == "" {
@@ -77,28 +102,31 @@ func (a *Adapter) inputText(ctx context.Context, p map[string]any) error {
77102
return err
78103
}
79104
runner := a.cmdRunner()
105+
106+
// Single-line, short → cheap path.
80107
if len(body) <= 512 && !strings.ContainsAny(body, "\n\r") {
81108
if _, err := runner.Run(ctx, "tmux", "send-keys", "-t", a.PaneID, "-l", body); err != nil {
82109
return err
83110
}
84-
} else {
85-
// load-buffer reads from stdin; we can't easily plumb stdin
86-
// through CmdRunner. As an MVP fallback, fold newlines into
87-
// `tmux send-keys -l <line>; tmux send-keys Enter` per line.
88-
// Pasting-via-buffer becomes a W2-plus tightening once the
89-
// CmdRunner interface grows a Stdin field.
90-
for _, line := range strings.Split(body, "\n") {
91-
if _, err := runner.Run(ctx, "tmux", "send-keys", "-t", a.PaneID, "-l", line); err != nil {
92-
return err
93-
}
94-
if _, err := runner.Run(ctx, "tmux", "send-keys", "-t", a.PaneID, "Enter"); err != nil {
95-
return err
96-
}
97-
}
98-
return nil
111+
_, err := runner.Run(ctx, "tmux", "send-keys", "-t", a.PaneID, "Enter")
112+
return err
99113
}
100-
_, err := runner.Run(ctx, "tmux", "send-keys", "-t", a.PaneID, "Enter")
101-
return err
114+
115+
// Multi-line / long → atomic paste-buffer.
116+
bufName := "ccinput_" + strings.TrimPrefix(a.PaneID, "%")
117+
if _, err := runner.Run(ctx, "tmux", "set-buffer", "-b", bufName, body); err != nil {
118+
return fmt.Errorf("set-buffer: %w", err)
119+
}
120+
if _, err := runner.Run(ctx, "tmux", "paste-buffer", "-b", bufName, "-d", "-r", "-t", a.PaneID); err != nil {
121+
// Best-effort buffer cleanup on the failure path so we don't
122+
// leave a stale buffer for the next call to clobber.
123+
_, _ = runner.Run(ctx, "tmux", "delete-buffer", "-b", bufName)
124+
return fmt.Errorf("paste-buffer: %w", err)
125+
}
126+
if _, err := runner.Run(ctx, "tmux", "send-keys", "-t", a.PaneID, "Enter"); err != nil {
127+
return err
128+
}
129+
return nil
102130
}
103131

104132
// inputSendKey sends a single named key. The caller passes the tmux

hub/internal/drivers/local_log_tail/claude_code/sendkeys_test.go

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,24 @@ package claudecode
22

33
import (
44
"context"
5+
"errors"
56
"strings"
67
"sync"
78
"testing"
89
)
910

11+
// errFake is a sentinel for tests that need a Run() to fail.
12+
var errFake = errors.New("fake-runner-failure")
13+
14+
// runnerFunc adapts a function to the CmdRunner interface so tests can
15+
// inject per-call behaviour (e.g. "succeed on set-buffer, fail on
16+
// paste-buffer") without growing recordingRunner.
17+
type runnerFunc func(ctx context.Context, name string, args ...string) ([]byte, error)
18+
19+
func (r runnerFunc) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
20+
return r(ctx, name, args...)
21+
}
22+
1023
// recordingRunner captures the (name, args) pairs Run was called
1124
// with; tests inspect the captured slice instead of stubbing per-
1225
// command outputs (no command we test here produces meaningful
@@ -82,21 +95,109 @@ func TestHandleInput_SlashCommandRoutesAsText(t *testing.T) {
8295
}
8396
}
8497

85-
func TestHandleInput_TextMultilineUsesPerLineSendKeys(t *testing.T) {
98+
// Multi-line bodies MUST land as ONE atomic submission via tmux's
99+
// named-buffer paste path — set-buffer / paste-buffer -d -r / Enter —
100+
// not as N per-line `send-keys -l + Enter` pairs. The pre-v1.0.658
101+
// per-line path explicitly inserted `send-keys Enter` between every
102+
// line, so an N-line message arrived as N separate turns at claude's
103+
// TUI input. The new path keeps LF as LF on the wire (`-r` flag) and
104+
// only triggers submission with our explicit final Enter. Same fix
105+
// shape as the agy v1.0.652 paste-buffer-`-r` flag.
106+
func TestHandleInput_TextMultilineUsesAtomicPasteBuffer(t *testing.T) {
86107
a, r := sendkeysAdapter(t, "%42")
87-
body := "line one\nline two"
108+
body := "line one\nline two\nline three"
88109
if err := a.HandleInput(context.Background(), "text", map[string]any{"body": body}); err != nil {
89110
t.Fatalf("HandleInput: %v", err)
90111
}
91112
calls := r.snapshot()
92-
if len(calls) != 4 {
93-
t.Fatalf("multiline calls = %d, want 4 (2 lines × (literal + Enter))", len(calls))
113+
if len(calls) != 3 {
114+
t.Fatalf("multiline calls = %d, want 3 (set-buffer + paste-buffer + Enter); got %+v",
115+
len(calls), calls)
94116
}
95-
if !equalArgs(calls[0], "tmux", "send-keys", "-t", "%42", "-l", "line one") {
96-
t.Errorf("call 0 = %+v", calls[0])
117+
if !equalArgs(calls[0], "tmux", "set-buffer", "-b", "ccinput_42", body) {
118+
t.Errorf("call 0 = %+v; want set-buffer with full body", calls[0])
97119
}
98-
if !equalArgs(calls[2], "tmux", "send-keys", "-t", "%42", "-l", "line two") {
99-
t.Errorf("call 2 = %+v", calls[2])
120+
// paste-buffer MUST carry -r so tmux doesn't translate the
121+
// body's internal LF bytes into CR (Enter) keystrokes.
122+
if !equalArgs(calls[1], "tmux", "paste-buffer", "-b", "ccinput_42", "-d", "-r", "-t", "%42") {
123+
t.Errorf("call 1 = %+v; want paste-buffer -d -r", calls[1])
124+
}
125+
if !equalArgs(calls[2], "tmux", "send-keys", "-t", "%42", "Enter") {
126+
t.Errorf("call 2 = %+v; want a single trailing Enter", calls[2])
127+
}
128+
}
129+
130+
// Long single-line bodies (>512 chars, no newlines) take the same
131+
// atomic paste-buffer path. The 512-char cutoff exists because very
132+
// long send-keys -l argv strings hit tmux's max argument length on
133+
// some shells; paste-buffer side-steps that.
134+
func TestHandleInput_TextLongSingleLineUsesPasteBuffer(t *testing.T) {
135+
a, r := sendkeysAdapter(t, "%42")
136+
body := strings.Repeat("x", 600)
137+
if err := a.HandleInput(context.Background(), "text", map[string]any{"body": body}); err != nil {
138+
t.Fatalf("HandleInput: %v", err)
139+
}
140+
calls := r.snapshot()
141+
if len(calls) != 3 {
142+
t.Fatalf("long-single-line calls = %d, want 3 (set-buffer + paste-buffer + Enter)", len(calls))
143+
}
144+
if calls[0].name != "tmux" || calls[0].args[0] != "set-buffer" {
145+
t.Errorf("call 0 = %+v; want set-buffer", calls[0])
146+
}
147+
if !equalArgs(calls[1], "tmux", "paste-buffer", "-b", "ccinput_42", "-d", "-r", "-t", "%42") {
148+
t.Errorf("call 1 = %+v; want paste-buffer -d -r", calls[1])
149+
}
150+
}
151+
152+
// CRLF bodies (an editor that wrote `\r\n` line endings) MUST also
153+
// take the paste-buffer path — the cheap-path guard tests for both
154+
// `\n` AND `\r` via strings.ContainsAny. Pre-v1.0.658 a CRLF body
155+
// would have fallen through Split(\n) and inserted stray CR bytes
156+
// into each line; the new path leaves them untouched in the buffer.
157+
func TestHandleInput_TextCRLFUsesPasteBuffer(t *testing.T) {
158+
a, r := sendkeysAdapter(t, "%42")
159+
if err := a.HandleInput(context.Background(), "text", map[string]any{"body": "alpha\r\nbeta"}); err != nil {
160+
t.Fatalf("HandleInput: %v", err)
161+
}
162+
calls := r.snapshot()
163+
if len(calls) != 3 {
164+
t.Fatalf("crlf calls = %d, want 3", len(calls))
165+
}
166+
if calls[0].args[0] != "set-buffer" {
167+
t.Errorf("call 0 verb = %q; want set-buffer", calls[0].args[0])
168+
}
169+
}
170+
171+
// On paste-buffer failure the adapter MUST attempt buffer cleanup so a
172+
// stale buffer doesn't survive to the next call (where it would be
173+
// silently overwritten with `-b` collision but at least we tried).
174+
func TestHandleInput_TextMultilineCleansBufferOnPasteFailure(t *testing.T) {
175+
a, _ := sendkeysAdapter(t, "%42")
176+
// Custom runner: succeed on set-buffer, fail on paste-buffer,
177+
// capture every call.
178+
calls := []recordedCall{}
179+
a.CmdRunner = runnerFunc(func(_ context.Context, name string, args ...string) ([]byte, error) {
180+
cp := make([]string, len(args))
181+
copy(cp, args)
182+
calls = append(calls, recordedCall{name: name, args: cp})
183+
if len(args) > 0 && args[0] == "paste-buffer" {
184+
return nil, errFake
185+
}
186+
return nil, nil
187+
})
188+
189+
err := a.HandleInput(context.Background(), "text", map[string]any{"body": "a\nb"})
190+
if err == nil {
191+
t.Fatal("expected paste-buffer failure to surface as error")
192+
}
193+
if !strings.Contains(err.Error(), "paste-buffer") {
194+
t.Errorf("err = %v; want mention of paste-buffer", err)
195+
}
196+
if len(calls) != 3 {
197+
t.Fatalf("calls = %d, want 3 (set-buffer + paste-buffer + delete-buffer cleanup)", len(calls))
198+
}
199+
if calls[2].args[0] != "delete-buffer" {
200+
t.Errorf("call 2 = %+v; want delete-buffer cleanup", calls[2])
100201
}
101202
}
102203

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
1616
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
1717
# In Windows, build-name is used as the major, minor, and patch parts
1818
# of the product and file versions while build-number is used as the build suffix.
19-
version: 1.0.657-alpha+10657
19+
version: 1.0.658-alpha+10658
2020

2121
environment:
2222
sdk: ^3.10.7

0 commit comments

Comments
 (0)