From c4cc68ad2e6bbdc321440032df422a1a05498c2d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 13:36:01 +0200 Subject: [PATCH] handle /clear in clide: reset to a fresh session instead of forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's /clear forks the conversation to a new session-id, which clide's transcript reader (pinned to the spawn --session-id) can't follow — so after /clear the pane froze on the old transcript and looked dead. clide now owns /clear: it's intercepted in the composer's send path (never forwarded to tmux), tears the pane's session down, and respawns a fresh empty one. A new session-id is forced even for the primary so it starts empty rather than resuming the old transcript; _spawn's self-heal kills the stale tmux session. The old transcript is left on disk. Known follow-up (T-156): /resume and /compact have the same forking problem but need different handling (a session picker, not a wipe). T-156. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 2 ++ .pql/changelog/tickets/2026-05.sql | 15 +++++++++ CHANGELOG.md | 4 +++ lib/builtin/claude/src/claude_pane.dart | 32 ++++++++++++++++++++ lib/builtin/claude/src/slash_commands.dart | 12 ++++++++ test/builtin/claude/slash_commands_test.dart | 14 +++++++++ 6 files changed, 79 insertions(+) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index bda701ab..ae8c19c2 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2206,3 +2206,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, 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-153', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 10:31:31', '2026-05-23 10:31:31', '2026-05-23 10:31:31', NULL, 'd5a1f02df5c8be27b9dc62b1ce277d67', 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-153', 'status', 'in_progress', 'done', NULL, '2026-05-23 10:35:56', '2026-05-23 10:35:56', '2026-05-23 10:35:56', NULL, 'ddf23a60cdfbfcd79b5d68af10f37928', 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-152', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 10:37:57', '2026-05-23 10:37:57', '2026-05-23 10:37:57', NULL, 'e12bfc4f8896514fb13450e0f83cf6c6', 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-152', 'status', 'in_progress', 'done', NULL, '2026-05-23 10:43:52', '2026-05-23 10:43:52', '2026-05-23 10:43:52', NULL, 'edab8c3daaabe51797afbe70b5f9bfde', 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-156', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 11:30:35', '2026-05-23 11:30:35', '2026-05-23 11:30:35', NULL, '66000ad896a8ccd2ec91d2ae6c539f01', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 560cafc5..a9b209ee 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2592,3 +2592,18 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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-153', 'task', 'T-132', 'Command-aware send: bypass bracketed-paste for valid slash commands', 'Fix the delivery mismatch where slash commands behave differently in clide than the CLI. tmux.sendMessage (lib/builtin/claude/src/tmux_session.dart) always uses paste-buffer -p (bracketed paste), and Claude''s TUI deliberately does not run slash-command parsing on bracketed-pasted content — so /cmd and /skill arrive as literal prompt text instead of invoking. Fix: when the submitted input is a valid slash command (per ClaudeConfig, T-151) — or single-line input generally — deliver it typed via tmux send-keys -l -- then Enter, so the TUI parses it like the CLI; keep paste-buffer -p only for multi-line content (mirrors encodeClaudeInput''s existing single-vs-multiline logic on the PTY fallback path). Acceptance: a typed or typeahead-selected slash command actually invokes the skill/command in a real make-run session (parity with the CLI); multi-line messages still arrive as one block, not a stream of submits; unit tests on the send-encoding branches (single-line vs multi-line vs recognized-command); coverage >= floor. Blocked by T-151.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:27', '2026-05-23 10:31:31', NULL, 'fc5104b0410e9db5da9f85962f7f3ac9', 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-153', 'task', 'T-132', 'Command-aware send: bypass bracketed-paste for valid slash commands', 'Fix the delivery mismatch where slash commands behave differently in clide than the CLI. tmux.sendMessage (lib/builtin/claude/src/tmux_session.dart) always uses paste-buffer -p (bracketed paste), and Claude''s TUI deliberately does not run slash-command parsing on bracketed-pasted content — so /cmd and /skill arrive as literal prompt text instead of invoking. Fix: when the submitted input is a valid slash command (per ClaudeConfig, T-151) — or single-line input generally — deliver it typed via tmux send-keys -l -- then Enter, so the TUI parses it like the CLI; keep paste-buffer -p only for multi-line content (mirrors encodeClaudeInput''s existing single-vs-multiline logic on the PTY fallback path). Acceptance: a typed or typeahead-selected slash command actually invokes the skill/command in a real make-run session (parity with the CLI); multi-line messages still arrive as one block, not a stream of submits; unit tests on the send-encoding branches (single-line vs multi-line vs recognized-command); coverage >= floor. Blocked by T-151.', 'done', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:27', '2026-05-23 10:35:56', NULL, '05aa3d095767c48b831ece6289897483', 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-152', 'task', 'T-132', 'Composer slash-command typeahead (inline detection, CLI-style)', 'Make ClaudeComposer (lib/builtin/claude/src/claude_composer.dart) slash-aware. Detect a /token at the cursor ANYWHERE in the text — inline mid-sentence, not only at position 0 (user requirement) — and show a typeahead popup listing matching slash commands + skills sourced from ClaudeConfig (T-151), filtering as the token grows. Keyboard nav: Up/Down to move, Tab/Enter to complete, Esc to dismiss; reuse ClidePalette interaction patterns. Completing inserts the command token at the cursor. Never re-scans the filesystem — reads from ClaudeConfig and refreshes when it changes. Acceptance: typing / opens the list; an inline / after existing text also opens it; arrow/Tab/Enter/Esc behave; the list reflects ClaudeConfig (skills + custom commands + built-ins); widget tests; a11y (focusable, labelled, contrast). Blocked by T-151.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:20', '2026-05-23 10:37:57', NULL, '470583866efdc3cb6aec47e013519348', 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-152', 'task', 'T-132', 'Composer slash-command typeahead (inline detection, CLI-style)', 'Make ClaudeComposer (lib/builtin/claude/src/claude_composer.dart) slash-aware. Detect a /token at the cursor ANYWHERE in the text — inline mid-sentence, not only at position 0 (user requirement) — and show a typeahead popup listing matching slash commands + skills sourced from ClaudeConfig (T-151), filtering as the token grows. Keyboard nav: Up/Down to move, Tab/Enter to complete, Esc to dismiss; reuse ClidePalette interaction patterns. Completing inserts the command token at the cursor. Never re-scans the filesystem — reads from ClaudeConfig and refreshes when it changes. Acceptance: typing / opens the list; an inline / after existing text also opens it; arrow/Tab/Enter/Esc behave; the list reflects ClaudeConfig (skills + custom commands + built-ins); widget tests; a11y (focusable, labelled, contrast). Blocked by T-151.', 'done', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:20', '2026-05-23 10:43:52', NULL, 'baa4e56a765b8078c23babcbc739e26e', 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-155', 'task', NULL, 'Finish i18n catalog plumbing: auto-load extension namespaces, kill ''namespace not registered'' warnings', 'Five builtins (tickets, decisions, git, pql, problems) declare titleKey: ''tab.title'' + i18nNamespace on their TabContribution but ship NO catalog file (lib/kernel/src/i18n/catalog/ has only the 8 tier-0 namespaces) and aren''t in the hand-maintained _tier0Namespaces preload list (lib/main.dart). Result: every boot logs ''i18n: namespace not registered: builtin.X (key: tab.title)'' for each. Tabs still render correctly via the placeholder fallback, so it''s cosmetic console noise — but it recurs for every new builtin because the preload list is maintained by hand. + +Proper fix: when an extension activates, auto-load the i18nNamespace(s) declared by its contributions via i18n.ensureNamespaceLoaded — removing the manual _tier0Namespaces list and the drift it causes. AssetCatalogLoader.load returns empty for a missing file, so auto-preloading alone would just shift the warning to ''missing key tab.title'' — so the missing catalogs must also be created: builtin.{tickets,decisions,git,pql,problems}_en_us.json with at least tab.title plus any other keys those extensions look up. + +Acceptance: clean boot with all builtins active logs zero i18n warnings; a new builtin that declares a titleKey gets its namespace loaded automatically with no manual list edit; the five catalog files exist; tests cover the auto-load-on-activate path and the missing-catalog case; coverage >= floor.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 11:06:50', '2026-05-23 11:06:50', NULL, 'c41b10fdead4afe4aac9a01af31d0fdd', 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-156', 'bug', 'T-132', 'Claude pane doesn''t follow session switches (/clear, /compact) — interaction wedges', 'Now that T-153 makes slash commands actually fire, session-mutating commands wedge the Claude pane. /clear makes Claude Code start a NEW session-id (a fresh .jsonl). The composer''s keystrokes still reach the same tmux/claude process, but clide''s TranscriptReader is pinned to the spawn --session-id file (claude_pane binds the reader to a fixed transcriptFile; transcript_reader.dart has zero session-change handling). After /clear: the old transcript freezes, Claude writes to the new session file, and clide keeps tailing the dead file — no responses appear and the pane looks completely broken. Affects /clear and likely /compact, /resume, --fork-session. + +Observed (2026-05-23): a new .jsonl (8b21f12b…) appeared at 13:11 while the prior froze at 13:07, matching a /clear at that time. Pre-T-153 this was hidden because slash commands were pasted as literal text (no-op). + +Tension to respect: T-146 deliberately pinned each pane to its own --session-id to stop secondaries rendering the primary''s transcript (the older newest-jsonl approach crossed panes). So the fix must NOT just follow the newest jsonl globally — it must follow THIS pane''s session lineage when Claude forks it. + +Direction (needs a small decision/spike): detect when Claude switches the active session for a pane and re-bind the TranscriptReader to the new session-id without re-introducing the T-146 cross-pane bug — e.g. via the transcript''s session lineage (parentUuid/leaf, or a session-change marker), or by querying the active session for the pane''s tmux session. Near-term safety to weigh: detect session-mutating commands (/clear, /compact, /resume) and handle/warn rather than silently wedging. Immediate user workaround: restart clide (primary re-binds + --resume to the pre-clear session). + +Acceptance: after a /clear in a Claude pane, clide follows to the new session and continues to show prompts + responses; secondaries still bind only to their own session (T-146 not regressed); tests cover a simulated session switch re-binding the reader; coverage >= floor.', 'in_progress', 'high', NULL, NULL, 'D-75', '2026-05-23 11:15:08', '2026-05-23 11:30:35', NULL, '8a1383c7726d30afc1336d47b6a4984c', 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 94c1b3ba..cc445352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. Recognised commands are delivered as typed input so Claude's TUI parses them; other input (and stray leading slashes like a `/tmp` path) stays bracketed-pasted as literal text. +- `/clear` is now handled by clide — it resets the Claude pane to a fresh, + empty session — instead of being forwarded to Claude Code, whose `/clear` + forked to a new session clide couldn't follow and left the pane + unresponsive (T-156). - Claude secondary panes no longer flash a false "session exited" while the session is alive (a transient tmux client exit is now verified against the live session), and the tab and banner agree on the label diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 63f2fae5..51e4bdcb 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -301,6 +301,13 @@ class _ClaudePaneState extends State { // The no-tmux fallback runs claude directly in our PTY, where pane.write // does reach it. void _send(String text) { + // Commands clide owns (T-156) are handled here, never forwarded — Claude + // Code's /clear forks the session to a new id our reader can't follow, so + // we tear this session down and start a fresh one instead. + if (clideOwnedCommand(text) == 'clear') { + unawaited(_clearSession()); + return; + } if (_usingTmux) { final session = _sessionName; if (session == null) return; @@ -321,6 +328,31 @@ class _ClaudePaneState extends State { unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)})); } + /// clide-owned `/clear` (T-156): tear this pane's session down and respawn a + /// brand-new, empty one. A fresh session id is forced — even for the primary, + /// whose id is normally deterministic — so we start empty rather than resume + /// the old transcript; _spawn's self-heal kills the now-stale tmux session + /// because the new id has no transcript yet. The old transcript is left on + /// disk (history preserved, just detached from this pane). + Future _clearSession() async { + _conversation?.dispose(); + _conversation = null; + unawaited(_feed?.dispose()); + _feed = null; + _statusSub?.cancel(); + _statusSub = null; + _eventSub?.cancel(); + _eventSub = null; + _sessionId = freshSessionId(); + if (mounted) { + setState(() { + _status = const SessionStatus(); + _statusLine = 'clearing…'; + }); + } + await _spawn(); + } + void _subscribe() { final kernel = _kernel(); if (kernel == null) return; diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index 9e06a60f..b8f02a74 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -27,6 +27,18 @@ bool isKnownSlashCommand(String text, Iterable known) { return token != null && known.contains(token); } +/// Slash commands clide handles itself instead of forwarding to Claude: +/// Claude Code's own handling forks the session to a new id that clide's +/// transcript reader can't follow, so clide owns the semantics (T-156). +const Set kClideOwnedCommands = {'clear'}; + +/// The clide-owned command in [text] (a single-line leading-slash token in +/// [kClideOwnedCommands]), or null. +String? clideOwnedCommand(String text) { + final token = slashCommandToken(text); + return token != null && kClideOwnedCommands.contains(token) ? token : null; +} + bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n'; /// An in-progress slash query at the cursor — the `/` position and the word diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart index be7d5a30..07c053e0 100644 --- a/test/builtin/claude/slash_commands_test.dart +++ b/test/builtin/claude/slash_commands_test.dart @@ -36,6 +36,20 @@ void main() { }); }); + group('clideOwnedCommand', () { + test('recognises /clear as clide-owned', () { + expect(clideOwnedCommand('/clear'), 'clear'); + expect(clideOwnedCommand('/clear '), 'clear'); + }); + + test('returns null for commands clide forwards to Claude', () { + expect(clideOwnedCommand('/model sonnet'), isNull); + expect(clideOwnedCommand('/compact'), isNull); + expect(clideOwnedCommand('not a command'), isNull); + expect(clideOwnedCommand('/clearairspace'), isNull); // token must be exactly "clear" + }); + }); + group('activeSlashQuery', () { test('matches a slash token at the cursor, including inline', () { expect(activeSlashQuery('/mod', 4), const SlashQuery(start: 0, query: 'mod'));