Skip to content

Commit 668af75

Browse files
committed
fix(serve): split input into literal + Enter keybind
tmux send-keys -l sends LF as a literal character; claude's TUI treats LF as 'insert newline in multi-line input', not 'submit'. Split the send into two calls: literal text via -l, then a bare tmux Enter keybind so the real submit path fires. Preset 'continue' skips SendKeys entirely (Enter-only). Handler test assertions updated to verify sendCalls + enterCalls separately.
1 parent 82268e8 commit 668af75

3 files changed

Lines changed: 54 additions & 12 deletions

File tree

internal/serve/api/input.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type InputSessionSource interface {
2626
// InputTmux is the narrow slice of *tmux.Client the Input handler needs.
2727
type InputTmux interface {
2828
SendKeys(target, keys string) error
29+
SendEnter(target string) error
2930
}
3031

3132
type inputReq struct {
@@ -95,7 +96,18 @@ func Input(src InputSessionSource, tmux InputTmux) http.HandlerFunc {
9596
}
9697

9798
target := fmt.Sprintf("%s:0.0", sess.Name)
98-
if err := tmux.SendKeys(target, keys); err != nil {
99+
// `keys` always ends with "\n" by construction (see expandInput).
100+
// We split: literal text via -l, then a real tmux Enter key so
101+
// claude's TUI treats it as submit rather than "add newline".
102+
literal := strings.TrimRight(keys, "\n")
103+
if literal != "" {
104+
if err := tmux.SendKeys(target, literal); err != nil {
105+
slog.Error("input send_failed", "session", name, "err", err.Error())
106+
writeInputErr(w, http.StatusInternalServerError, "send_failed", err.Error())
107+
return
108+
}
109+
}
110+
if err := tmux.SendEnter(target); err != nil {
99111
slog.Error("input send_failed", "session", name, "err", err.Error())
100112
writeInputErr(w, http.StatusInternalServerError, "send_failed", err.Error())
101113
return

internal/serve/api/input_test.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,23 @@ func (f *fakeInputProj) TmuxAlive(name string) bool {
2929
}
3030

3131
type fakeInputTmux struct {
32-
lastTarget string
33-
lastKeys string
34-
err error
32+
lastTarget string
33+
lastKeys string
34+
sendCalls int
35+
enterCalls int
36+
err error
3537
}
3638

3739
func (f *fakeInputTmux) SendKeys(target, keys string) error {
3840
f.lastTarget = target
3941
f.lastKeys = keys
42+
f.sendCalls++
43+
return f.err
44+
}
45+
46+
func (f *fakeInputTmux) SendEnter(target string) error {
47+
f.lastTarget = target
48+
f.enterCalls++
4049
return f.err
4150
}
4251

@@ -83,8 +92,11 @@ func TestInput_Preset_Yes(t *testing.T) {
8392
if tmux.lastTarget != "alpha:0.0" {
8493
t.Fatalf("tmux target = %q, want %q", tmux.lastTarget, "alpha:0.0")
8594
}
86-
if tmux.lastKeys != "y\n" {
87-
t.Fatalf("tmux keys = %q, want %q", tmux.lastKeys, "y\n")
95+
if tmux.lastKeys != "y" {
96+
t.Fatalf("tmux literal = %q, want %q", tmux.lastKeys, "y")
97+
}
98+
if tmux.enterCalls != 1 {
99+
t.Fatalf("SendEnter called %d times, want 1", tmux.enterCalls)
88100
}
89101
}
90102

@@ -96,8 +108,8 @@ func TestInput_Preset_No(t *testing.T) {
96108
if rec.Code != http.StatusNoContent {
97109
t.Fatalf("status = %d, want 204", rec.Code)
98110
}
99-
if tmux.lastKeys != "n\n" {
100-
t.Fatalf("tmux keys = %q, want %q", tmux.lastKeys, "n\n")
111+
if tmux.lastKeys != "n" || tmux.enterCalls != 1 {
112+
t.Fatalf("tmux literal=%q enters=%d, want %q + 1", tmux.lastKeys, tmux.enterCalls, "n")
101113
}
102114
}
103115

@@ -109,8 +121,11 @@ func TestInput_Preset_Continue(t *testing.T) {
109121
if rec.Code != http.StatusNoContent {
110122
t.Fatalf("status = %d, want 204", rec.Code)
111123
}
112-
if tmux.lastKeys != "\n" {
113-
t.Fatalf("tmux keys = %q, want %q", tmux.lastKeys, "\n")
124+
if tmux.sendCalls != 0 {
125+
t.Fatalf("SendKeys called %d times for 'continue', want 0 (Enter-only)", tmux.sendCalls)
126+
}
127+
if tmux.enterCalls != 1 {
128+
t.Fatalf("SendEnter called %d times, want 1", tmux.enterCalls)
114129
}
115130
}
116131

@@ -122,8 +137,8 @@ func TestInput_FreeText(t *testing.T) {
122137
if rec.Code != http.StatusNoContent {
123138
t.Fatalf("status = %d, want 204", rec.Code)
124139
}
125-
if tmux.lastKeys != "approve\n" {
126-
t.Fatalf("tmux keys = %q, want %q", tmux.lastKeys, "approve\n")
140+
if tmux.lastKeys != "approve" || tmux.enterCalls != 1 {
141+
t.Fatalf("tmux literal=%q enters=%d, want %q + 1", tmux.lastKeys, tmux.enterCalls, "approve")
127142
}
128143
}
129144

internal/tmux/client.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,21 @@ func (c *Client) SendKeys(target, keys string) error {
214214
return nil
215215
}
216216

217+
// SendEnter sends the tmux "Enter" key (without -l) to the given
218+
// target. A literal `\n` via SendKeys is the LF character, which
219+
// TUIs like claude interpret as "insert newline", not "submit".
220+
// Sending the Enter keybind triggers the real submit path.
221+
func (c *Client) SendEnter(target string) error {
222+
if target == "" {
223+
return fmt.Errorf("tmux send-keys Enter: empty target")
224+
}
225+
out, err := c.cmd("tmux", "send-keys", "-t", target, "Enter").CombinedOutput()
226+
if err != nil {
227+
return fmt.Errorf("tmux send-keys Enter -t %q: %w: %s", target, err, string(out))
228+
}
229+
return nil
230+
}
231+
217232
// --- unexported helpers ---
218233

219234
// buildNewSessionArgs constructs args for `tmux new-session`.

0 commit comments

Comments
 (0)