From 6910c79e5be87dfde1d93227a43443b1ae0ba7c4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 00:01:43 +0200 Subject: [PATCH] submit composer input via tmux server, not the detached client PTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer sent input with pane.write, which writes to the PTY of the tmux client the app spawned. That client detaches (we no longer render or drain its PTY since T-137), leaving the session alive on the server with no client — so keystrokes written to the dead PTY vanished and Claude never saw the message. Submit now goes through the tmux server: load the text into a named paste buffer, paste it bracketed (multi-line and special chars arrive as one block, not a stream of submits), then send Enter. Verified against a live session — paste-buffer -p reaches Claude's input with no client attached. The no-tmux fallback still uses pane.write (claude runs directly in our PTY there). Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 1 + CHANGELOG.md | 7 ++++--- lib/builtin/claude/src/claude_pane.dart | 16 +++++++++++++--- lib/builtin/claude/src/tmux_session.dart | 18 ++++++++++++++++++ test/builtin/claude/tmux_session_test.dart | 11 +++++++++++ 6 files changed, 48 insertions(+), 6 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 5a392bf1..85b19c53 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2142,3 +2142,4 @@ Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks an INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 17:58:07', '2026-05-22 17:58:07', '2026-05-22 17:58:07', NULL, '4e67262d4b04f8541e71fe8f03106fbf', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'status', 'in_progress', 'done', NULL, '2026-05-22 18:14:32', '2026-05-22 18:14:32', '2026-05-22 18:14:32', NULL, 'a284c7249dda3c63a52a202bad07f105', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 21:08:05', '2026-05-22 21:08:05', '2026-05-22 21:08:05', NULL, 'c3dfefe24d912081cd89ac0be066b641', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'status', 'in_progress', 'done', NULL, '2026-05-22 21:49:09', '2026-05-22 21:49:09', '2026-05-22 21:49:09', NULL, 'e4be02e339d638da70a4e7ce0f850691', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 86924ae7..713a33ff 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2487,3 +2487,4 @@ Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks an INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:58:07', NULL, 'e8244b90386314574545f0c1a7bd012c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 18:14:32', NULL, '6651a2c3c9099f7d38553f6cf9e41e98', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:08:05', NULL, '2e6b932ad78ac78d83c86c5e08290ed3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:49:09', NULL, '8b247ce6f9fc63c6f56030303aece2e8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index b5932f0d..dabfc824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added - Native composer in the Claude pane (T-138) — type below the - conversation and press Enter to send (Shift+Enter for a newline); - input reaches Claude over `pane.write`. Multi-line text is sent as a - bracketed paste so it submits as one message. + conversation and press Enter to send (Shift+Enter for a newline). + Submits via the tmux server (bracketed paste + Enter), so input + reaches Claude even when no tmux client is attached; multi-line goes + as one message. - File and image paste in the composer (T-138) — Ctrl/Cmd+V of a copied file or a clipboard image inserts an `@path` reference (images are saved to a cache dir first); plain text pastes inline. Backed by a diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 450a88d5..0333ef81 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -49,6 +49,7 @@ class _ClaudePaneState extends State { String _statusLine = 'attaching…'; bool _spawned = false; + bool _usingTmux = false; @override void didChangeDependencies() { @@ -193,8 +194,10 @@ class _ClaudePaneState extends State { setState(() => _error = resp.error?.message ?? 'spawn failed'); return; } + _usingTmux = false; setState(() => _statusLine = 'no-tmux · fresh every launch'); } else { + _usingTmux = true; setState(() => _statusLine = 'tmux · $_sessionName'); } @@ -213,10 +216,17 @@ class _ClaudePaneState extends State { setState(() {}); } - // Send composed text to Claude's tmux session. pane.write delivers it - // to the PTY (the attached tmux client), which forwards to claude — the - // same input verb the terminal pane uses, so D-6 parity holds. + // Send composed text to Claude. On the tmux path, submit via the tmux + // server (paste-buffer + Enter) — it reaches Claude even with no client + // attached, unlike pane.write to the (now-detached) spawned client PTY. + // The no-tmux fallback runs claude directly in our PTY, where pane.write + // does reach it. void _send(String text) { + if (_usingTmux) { + final session = _sessionName; + if (session != null) unawaited(tmux.sendMessage(session, text)); + return; + } final id = _paneId; final ipc = _ipc(); if (id == null || ipc == null) return; diff --git a/lib/builtin/claude/src/tmux_session.dart b/lib/builtin/claude/src/tmux_session.dart index 96ac8f67..f2ac1586 100644 --- a/lib/builtin/claude/src/tmux_session.dart +++ b/lib/builtin/claude/src/tmux_session.dart @@ -54,3 +54,21 @@ Future killAllForRepo(String primaryName) async { } } } + +/// Named tmux paste buffer clide loads composed messages into. +const _composeBuffer = 'clide-compose'; + +/// Submit [text] to [session] as a single message: load it into a named +/// paste buffer, paste it in bracketed mode (so multi-line content and +/// special characters arrive as one block rather than a stream of +/// submits), then press Enter. +/// +/// This goes through the tmux *server*, so it reaches Claude whether or +/// not a client is attached. Writing to the app's spawned client PTY +/// (`pane.write`) does not — once that client detaches, the PTY is dead +/// and keystrokes vanish (the bug this replaces). +Future sendMessage(String session, String text) async { + await tmuxRunner([..._socket, 'set-buffer', '-b', _composeBuffer, '--', text]); + await tmuxRunner([..._socket, 'paste-buffer', '-p', '-d', '-b', _composeBuffer, '-t', session]); + await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']); +} diff --git a/test/builtin/claude/tmux_session_test.dart b/test/builtin/claude/tmux_session_test.dart index 75bb154b..d66a308b 100644 --- a/test/builtin/claude/tmux_session_test.dart +++ b/test/builtin/claude/tmux_session_test.dart @@ -103,4 +103,15 @@ void main() { ]); }); }); + + group('sendMessage', () { + test('loads a bracketed paste buffer then submits with Enter', () async { + await tmux.sendMessage('clide-claude-foo', 'hello\nworld'); + expect(runner.calls, [ + ['-L', 'clide', 'set-buffer', '-b', 'clide-compose', '--', 'hello\nworld'], + ['-L', 'clide', 'paste-buffer', '-p', '-d', '-b', 'clide-compose', '-t', 'clide-claude-foo'], + ['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', 'Enter'], + ]); + }); + }); }