handle /clear in clide: reset to a fresh session instead of forwarding

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 13:36:01 +02:00
co-authored by Claude Opus 4.7
parent f6a5d72ccd
commit c4cc68ad2e
6 changed files with 79 additions and 0 deletions
@@ -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;
+15
View File
@@ -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 -- <text> 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 -- <text> 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 <uuid>.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 <uuid>.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);
+4
View File
@@ -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
+32
View File
@@ -301,6 +301,13 @@ class _ClaudePaneState extends State<ClaudePane> {
// 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<ClaudePane> {
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<void> _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;
@@ -27,6 +27,18 @@ bool isKnownSlashCommand(String text, Iterable<String> 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<String> 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
@@ -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'));