From 39f9ed018ca4529d7a23e3832ba429593d52e36c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 25 May 2026 08:40:18 +0200 Subject: [PATCH] drive the Claude pane over stream-json with native prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Claude pane's tmux-TUI + transcript-tail backend with Claude Code's stream-json control protocol (D-77/D-78). A StreamJsonSession owns the `claude` process: its event stream feeds the existing ConversationController, and permission / AskUserQuestion prompts arrive as can_use_tool control_requests. Those surface as a ToolPrompt in the composer zone — the pane swaps the text input for an Allow/Deny card or an option picker while a prompt is open, so interaction stays out of the conversation stream and the prompt buttons don't fight the message-card hover chrome. The decision is written back as a control_response (allow echoes updatedInput; AskUserQuestion answers go in updatedInput.answers). Unsupported control subtypes are answered with an error so a turn never hangs. Session continuity is --resume (existing transcript) vs --session-id (new); /clear and /resume respawn the process. The transcript reader still backs the sidebar/status/team surfaces. T-165, T-166. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 + lib/builtin/claude/src/claude_pane.dart | 310 ++++-------------- lib/builtin/claude/src/prompt_card.dart | 201 ++++++++++++ lib/builtin/claude/src/session_naming.dart | 11 +- .../claude/src/stream_json_session.dart | 143 +++++++- test/builtin/claude/prompt_card_test.dart | 125 +++++++ test/builtin/claude/session_naming_test.dart | 4 +- .../claude/stream_json_session_test.dart | 97 ++++++ 8 files changed, 635 insertions(+), 261 deletions(-) create mode 100644 lib/builtin/claude/src/prompt_card.dart create mode 100644 test/builtin/claude/prompt_card_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7ec34d..1043dde0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Native permission & AskUserQuestion prompts (T-166, D-78) — when Claude + needs tool approval or asks a question, an inline card appears in the + conversation with Allow/Deny or selectable options; the decision is + returned over the stream-json control channel. Closes the prompt gap + the tmux model couldn't surface. - Conversation message cards (T-173) — every turn in the Claude pane now renders through one card template with a copy button on hover and a collapse/expand caret for tool calls, results, and thinking. diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index f24a5059..8c3cf3c2 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -1,10 +1,8 @@ import 'dart:async'; import 'dart:io'; -import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; -import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; import 'claude_banner.dart'; @@ -14,14 +12,19 @@ import 'claude_status.dart'; import 'clipboard_paste.dart'; import 'conversation_controller.dart'; import 'conversation_view.dart'; +import 'prompt_card.dart'; import 'session_index.dart'; import 'session_naming.dart'; import 'session_picker.dart'; import 'slash_commands.dart'; -import 'tmux_session.dart' as tmux; -import 'transcript_publisher.dart'; +import 'stream_json_session.dart'; import 'transcript_reader.dart'; +/// The Claude conversation pane. Drives `claude` over the stream-json control +/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events +/// feed the [ConversationController], permission / AskUserQuestion prompts come +/// back as [ToolPrompt] cards the user answers, and input is written to the +/// process stdin. No tmux — `--resume` (D-77) provides session continuity. class ClaudePane extends StatefulWidget { const ClaudePane({ super.key, @@ -50,27 +53,16 @@ class ClaudePane extends StatefulWidget { } class _ClaudePaneState extends State { - // Fixed tmux window size — Claude's TUI is no longer rendered (we read - // its transcript instead, T-137/D-75), so a sane default is enough to - // keep claude's layout happy inside the headless tmux session. - static const _cols = 120; - static const _rows = 40; - static String? _tmuxConfPath; - - StreamSubscription? _eventSub; StreamSubscription? _statusSub; ConversationController? _conversation; - TranscriptPublisher? _feed; + StreamJsonSession? _session; SessionStatus _status = const SessionStatus(); - String? _paneId; - String? _sessionName; String? _sessionId; String? _repoRoot; String? _error; - String _statusLine = 'attaching…'; + String _statusLine = 'starting…'; bool _spawned = false; - bool _usingTmux = false; // The status line surfaced to the bottom status bar via ClidePane — the // live session fields (model/mode/context, T-150) plus the configured @@ -100,8 +92,7 @@ class _ClaudePaneState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - // Spawn once, after the kernel is available. The conversation renders - // from the transcript, so we no longer wait on a terminal resize. + // Spawn once, after the kernel is available. if (!_spawned) { _spawned = true; unawaited(_spawnWhenReady()); @@ -116,52 +107,15 @@ class _ClaudePaneState extends State { @override void dispose() { activeClaudeConfig?.removeListener(_onConfigChanged); - _conversation?.dispose(); - _conversation = null; - unawaited(_feed?.dispose()); - _feed = null; _statusSub?.cancel(); _statusSub = null; - _eventSub?.cancel(); - _eventSub = null; - final id = _paneId; - final sessionName = _sessionName; - _paneId = null; - // Secondary panes own their tmux session — close on dispose. - // Primary panes leave the tmux session alive so the next launch - // re-attaches via `tmux new-session -A` (D-41). - // - // pane.close kills the PTY-spawned tmux *client*; the tmux server - // keeps the session alive. We need an explicit kill-session for - // secondaries to actually disappear (D-41 close semantics). - if (id != null && !widget.isPrimary) { - unawaited(_ipc()?.request('pane.close', args: {'id': id})); - if (sessionName != null) { - unawaited(tmux.killSession(sessionName)); - } - } + // The controller's onDispose kills the session (process + streams). + _conversation?.dispose(); + _conversation = null; + _session = null; super.dispose(); } - // -- tmux config extraction ----------------------------------------------- - - static Future _ensureTmuxConf() async { - if (_tmuxConfPath != null) return _tmuxConfPath; - try { - final content = await rootBundle.loadString('assets/clide.tmux.conf'); - final dir = Directory( - '${Platform.environment['HOME'] ?? '/tmp'}/.config/clide', - ); - if (!dir.existsSync()) dir.createSync(recursive: true); - final file = File('${dir.path}/tmux.conf'); - file.writeAsStringSync(content); - _tmuxConfPath = file.path; - return _tmuxConfPath; - } catch (_) { - return null; - } - } - // -- spawn ---------------------------------------------------------------- Future _spawnWhenReady() async { @@ -197,133 +151,44 @@ class _ClaudePaneState extends State { } _repoRoot = repoRoot; - _sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); - // Bind this pane to a specific Claude session id so concurrent - // sessions in one workspace don't collide on the newest transcript - // (T-146). Primary: deterministic → resumes across restarts. - // Secondary: fresh → always a clean session. + // Bind this pane to a specific session id (T-146). Primary: deterministic + // → resumes across restarts. Secondary: fresh → a clean session. _sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId(); + // A transcript already on disk means the session existed before, so resume + // it; `claude --session-id ` refuses an existing id (T-161/D-77). final home = Platform.environment['HOME'] ?? ''; final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl'; + final resume = await File(transcriptFile).exists(); + final sessionArgs = claudeLaunchArgs(_sessionId!, resume: resume); - // A transcript already on disk means this session existed before, so we - // resume it; otherwise it's new. This drives both the self-heal kill and - // the launch flag — `claude --session-id ` REFUSES an existing id - // ("already in use"), so an existing session must launch with `--resume` - // (T-161). - final transcriptExists = await File(transcriptFile).exists(); - - // Self-heal (T-147): if no transcript is bound to our session id, any - // clide tmux session of this name is stale (created before session-id - // binding, or otherwise unconnectable) and `new-session -A` would - // attach to it and leave the pane stuck waiting forever. Kill it so a - // clean session is created. Safe by construction: only ever kills clide's - // OWN `clide-claude-` session on the private `-L clide` socket, and - // never deletes any transcript. A healthy session's transcript exists, so - // re-attach (D-41 continuity) is preserved. - if (!transcriptExists) { - await tmux.killSession(_sessionName!); + final StreamJsonSession session; + try { + final proc = await ClaudeStreamJsonProcess.start(sessionArgs: sessionArgs, cwd: repoRoot); + session = StreamJsonSession(proc)..start(); + } catch (e) { + if (mounted) setState(() => _error = 'Could not start claude: $e'); + return; + } + if (!mounted) { + await session.dispose(); + return; } - final launch = claudeLaunchArgs(_sessionId!, resume: transcriptExists); - final tmuxConf = await _ensureTmuxConf(); - const cols = _cols; - const rows = _rows; - - var argv = [ - 'tmux', - '-L', - 'clide', - if (tmuxConf != null) ...['-f', tmuxConf], - 'new-session', - '-A', - '-s', - _sessionName!, - '-x', - '$cols', - '-y', - '$rows', - ...launch, - ]; - - // CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode: - // input box pinned to the bottom of the alt-screen, claude owns - // its own scrollback. Removes the need for tmux scroll forwarding. - final env = {'CLAUDE_CODE_NO_FLICKER': '1'}; - - var resp = await ipc.request('pane.spawn', args: { - 'argv': argv, - 'kind': PaneKind.claude.wire, - 'cwd': repoRoot, - 'cols': cols, - 'rows': rows, - 'title': _sessionName, - 'env': env, - }); - - if (!resp.ok) { - argv = launch; - resp = await ipc.request('pane.spawn', args: { - 'argv': argv, - 'kind': PaneKind.claude.wire, - 'cwd': repoRoot, - 'cols': cols, - 'rows': rows, - 'title': _sessionName, - 'env': env, - }); - if (!resp.ok) { - setState(() => _error = resp.error?.message ?? 'spawn failed'); - return; - } - _usingTmux = false; - setState(() => _statusLine = 'no-tmux · fresh every launch'); - } else { - _usingTmux = true; - setState(() => _statusLine = 'tmux · $_sessionName'); - } - - if (!mounted) return; - _paneId = resp.data['id'] as String?; - // Render the conversation natively from the transcript (T-137/D-75) - // rather than the PTY's TUI output. claude runs in tmux; a reader - // tails its transcript JSONL and a publisher fans the items onto the - // kernel MessageBus, which the view's controller subscribes to. The - // subscription is wired before the reader's first poll so the initial - // tail is never missed. - // Tail this session's own transcript (/.jsonl), - // not just the newest in the workspace — that's what kept secondaries - // showing the primary's conversation (T-146). Each pane gets its own - // bus channel so their controllers don't cross-talk. - final messages = _kernel()!.messages; - final channel = ClaudeConversation.sessionChannel(_sessionId!); - _feed = TranscriptPublisher( - messages: messages, - reader: TranscriptReader(repoRoot, file: transcriptFile), - channel: channel, - ); - _conversation = ConversationController.fromBus(messages: messages, channel: channel); - // On status change, rebuild — ClidePane re-conveys the new statusWidget - // to the bar while this pane is focused (T-150). - _statusSub = _feed!.statusStream.listen((s) { + _session = session; + _conversation = ConversationController(stream: session.items, onDispose: session.dispose); + _statusSub = session.statusStream.listen((s) { if (!mounted) return; setState(() => _status = s); }); - _subscribe(); - setState(() {}); + setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId'); } - // 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. + // Send composed text to Claude over the stream-json channel. Commands clide + // owns (T-156) are handled here, never forwarded — /clear and /resume fork + // the session to a new id, so clide drives them: /clear starts fresh, + // /resume picks a past session and re-binds to it. void _send(String text) { - // Commands clide owns (T-156) are handled here, never forwarded — Claude - // Code's /clear and /resume fork the session to a new id our reader can't - // follow, so clide drives them: /clear starts fresh, /resume picks a past - // session and re-binds to it. switch (clideOwnedCommand(text)) { case 'clear': unawaited(_clearSession()); @@ -332,37 +197,17 @@ class _ClaudePaneState extends State { unawaited(_resumeFlow()); return; } - if (_usingTmux) { - final session = _sessionName; - if (session == null) return; - // Recognised slash commands go typed (so the TUI fires them); anything - // else is bracketed-pasted, keeping multi-line text and stray leading - // slashes literal (T-153). - final known = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands; - if (isKnownSlashCommand(text, known)) { - unawaited(tmux.sendCommand(session, text)); - } else { - unawaited(tmux.sendMessage(session, text)); - } - return; - } - final id = _paneId; - final ipc = _ipc(); - if (id == null || ipc == null) return; - unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)})); + _session?.send(text); } - /// clide-owned `/clear` (T-156): respawn this pane on a brand-new, empty - /// session. A fresh id is forced — even for the primary, whose id is normally - /// deterministic — so we start empty rather than resume the old transcript. + /// clide-owned `/clear` (T-156): respawn on a brand-new, empty session. Future _clearSession() async { if (mounted) setState(() => _statusLine = 'clearing…'); await _respawnWithSession(freshSessionId()); } /// clide-owned `/resume` (T-156): pick a past session for this workspace and - /// re-bind the pane to it. Claude Code's own /resume forks to a session our - /// reader can't follow, so clide drives the switch. + /// re-bind the pane to it. Future _resumeFlow() async { final root = _repoRoot; final dialog = _kernel()?.dialog; @@ -383,49 +228,19 @@ class _ClaudePaneState extends State { await _respawnWithSession(picked); } - /// Tear the current session down and respawn the pane bound to [sessionId]. - /// The tmux session is killed first so `new-session` starts a fresh client - /// on the new id rather than re-attaching the still-running old claude; the - /// old transcript is left on disk (history preserved, detached). + /// Tear the current session down and respawn bound to [sessionId]. The old + /// process is killed; its transcript stays on disk (history preserved). Future _respawnWithSession(String sessionId) async { - _conversation?.dispose(); - _conversation = null; - unawaited(_feed?.dispose()); - _feed = null; _statusSub?.cancel(); _statusSub = null; - _eventSub?.cancel(); - _eventSub = null; - final old = _sessionName; - if (old != null) await tmux.killSession(old); + _conversation?.dispose(); // onDispose kills the old session + _conversation = null; + _session = null; _sessionId = sessionId; if (mounted) setState(() => _status = const SessionStatus()); await _spawn(); } - void _subscribe() { - final kernel = _kernel(); - if (kernel == null) return; - // Lifecycle only — content comes from the transcript, not pane.output. - _eventSub = kernel.events.on().listen((e) async { - if (e.subsystem != 'pane' || e.data['id'] != _paneId) return; - switch (e.kind) { - case 'pane.exit': - // A transient tmux client can exit (e.g. during spawn/respawn) - // while the session — and Claude — stay alive. Don't report - // "exited" then; only when the tmux session is actually gone. - // (The no-tmux fallback has no session, so the exit is real.) - if (_usingTmux && _sessionName != null && await tmux.hasSession(_sessionName!)) { - return; - } - if (!mounted) return; - setState(() => _statusLine = widget.isPrimary ? 'session exited — restart clide to retry' : 'session exited'); - case 'pane.closed': - _paneId = null; - } - }); - } - // -- helpers -------------------------------------------------------------- DaemonClient? _ipc() => _kernel()?.ipc; @@ -464,29 +279,34 @@ class _ClaudePaneState extends State { ), ), ), - ClaudeComposer( - enabled: _paneId != null, - onSubmit: _send, - pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), + // The composer zone: an open prompt (permission / AskUserQuestion) + // takes this space and hides the text input until it's answered, so + // interaction stays out of the conversation stream (D-78). + StreamBuilder( + stream: _session?.pendingPromptStream, + initialData: _session?.pendingPrompt, + builder: (context, snap) { + final prompt = snap.data; + if (prompt != null && _session != null) { + return ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt); + } + return ClaudeComposer( + enabled: _session != null, + onSubmit: _send, + pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), + ); + }, ), ], ); } else { - body = const Center(child: ClideText('attaching…', muted: true)); + body = const Center(child: ClideText('starting…', muted: true)); } final content = widget.showChrome ? ClidePaneChrome( title: title, subtitle: _error ?? _statusLine, - onClose: widget.isPrimary - ? null - : () { - final id = _paneId; - if (id != null) { - unawaited(_ipc()?.request('pane.close', args: {'id': id})); - } - }, child: body, ) : body; diff --git a/lib/builtin/claude/src/prompt_card.dart b/lib/builtin/claude/src/prompt_card.dart new file mode 100644 index 00000000..ba87f6ad --- /dev/null +++ b/lib/builtin/claude/src/prompt_card.dart @@ -0,0 +1,201 @@ +/// The interactive prompt surface for the stream-json control channel +/// (T-166, D-78): a permission Allow/Deny for a gated tool, or an +/// `AskUserQuestion` option picker. Rendered in the composer zone (not inline +/// in the conversation) so interaction and conversation widgets don't mix — +/// the pane swaps it in for the text input while a prompt is open. The decision +/// is returned via [onResolve]; the pane then removes the card. +/// +/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no +/// hover-revealed chrome that would fight the buttons. +library; + +import 'package:clide/builtin/claude/src/stream_json_session.dart'; +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class ToolPromptCard extends StatefulWidget { + const ToolPromptCard({super.key, required this.prompt, required this.onResolve}); + + final ToolPrompt prompt; + + /// Called once with the user's decision; the card binds the prompt id. + final void Function(String promptId, ToolDecision decision) onResolve; + + @override + State createState() => _ToolPromptCardState(); +} + +class _ToolPromptCardState extends State { + // AskUserQuestion: per-question chosen option labels (set = multi-select). + late List> _picked = List.generate(_questions.length, (_) => {}); + + List<_Question> get _questions => _parseQuestions(widget.prompt.input); + + @override + void didUpdateWidget(ToolPromptCard old) { + super.didUpdateWidget(old); + // A different prompt rotated into the same slot — reset selections. + if (old.prompt.promptId != widget.prompt.promptId) { + _picked = List.generate(_questions.length, (_) => {}); + } + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final isQuestion = widget.prompt.isQuestion; + final accent = isQuestion ? tokens.statusInfo : tokens.statusWarning; + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border(top: BorderSide(color: accent, width: 2)), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClideText( + isQuestion ? 'question' : 'permission · ${widget.prompt.displayName}', + fontSize: clideFontSmall, + fontFamily: clideMonoFamily, + color: accent, + ), + const SizedBox(height: 8), + if (isQuestion) ..._questionBody(tokens) else ..._permissionBody(tokens), + ], + ), + ); + } + + // -- permission allow/deny ------------------------------------------------- + + List _permissionBody(SurfaceTokens tokens) { + final desc = widget.prompt.description; + return [ + if (desc != null && desc.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClideText(desc, fontSize: clideFontMeta, color: tokens.globalForeground), + ), + Row( + children: [ + ClideButton( + label: 'Allow', + variant: ClideButtonVariant.primary, + onPressed: () => widget.onResolve(widget.prompt.promptId, AllowTool(widget.prompt.input)), + ), + const SizedBox(width: 8), + ClideButton( + label: 'Deny', + onPressed: () => widget.onResolve(widget.prompt.promptId, const DenyTool('Denied by the user.')), + ), + ], + ), + ]; + } + + // -- AskUserQuestion option picker ---------------------------------------- + + List _questionBody(SurfaceTokens tokens) { + final questions = _questions; + final answered = questions.asMap().entries.every((e) => _picked[e.key].isNotEmpty); + return [ + for (final (qi, q) in questions.indexed) _questionBlock(tokens, qi, q), + const SizedBox(height: 6), + ClideButton( + label: 'Submit', + variant: ClideButtonVariant.primary, + onPressed: answered ? () => _submitAnswers(questions) : null, + ), + ]; + } + + Widget _questionBlock(SurfaceTokens tokens, int qi, _Question q) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (q.header.isNotEmpty) ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted), + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 6), + child: ClideText(q.question, color: tokens.globalForeground), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final opt in q.options) + ClideButton( + label: _picked[qi].contains(opt.label) ? '● ${opt.label}' : '○ ${opt.label}', + variant: _picked[qi].contains(opt.label) ? ClideButtonVariant.primary : ClideButtonVariant.subtle, + tooltip: opt.description.isNotEmpty ? opt.description : null, + onPressed: () => _toggle(qi, q, opt.label), + ), + ], + ), + ], + ), + ); + } + + void _toggle(int qi, _Question q, String label) { + setState(() { + final sel = _picked[qi]; + if (q.multiSelect) { + sel.contains(label) ? sel.remove(label) : sel.add(label); + } else { + sel + ..clear() + ..add(label); + } + }); + } + + void _submitAnswers(List<_Question> questions) { + // answers: question text → chosen label(s), comma-separated for multi (D-78). + final answers = {}; + for (final (qi, q) in questions.indexed) { + answers[q.question] = _picked[qi].join(', '); + } + widget.onResolve(widget.prompt.promptId, AllowTool({...widget.prompt.input, 'answers': answers})); + } +} + +// -- AskUserQuestion input parsing ------------------------------------------- + +class _Question { + _Question(this.question, this.header, this.multiSelect, this.options); + final String question; + final String header; + final bool multiSelect; + final List<_Option> options; +} + +class _Option { + _Option(this.label, this.description); + final String label; + final String description; +} + +List<_Question> _parseQuestions(Map input) { + final raw = input['questions']; + if (raw is! List) return const []; + return [ + for (final q in raw) + if (q is Map) + _Question( + q['question'] as String? ?? '', + q['header'] as String? ?? '', + q['multiSelect'] as bool? ?? false, + [ + for (final o in (q['options'] as List? ?? const [])) + if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''), + ], + ), + ]; +} diff --git a/lib/builtin/claude/src/session_naming.dart b/lib/builtin/claude/src/session_naming.dart index 8c280ca1..3116cd7e 100644 --- a/lib/builtin/claude/src/session_naming.dart +++ b/lib/builtin/claude/src/session_naming.dart @@ -68,11 +68,12 @@ String _hash(String s) { /// workspace re-binds the same `.jsonl` across restarts (resume). String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot)); -/// The `claude` argv to launch [sessionId]: `--resume` an existing session, -/// or `--session-id` to create a new one. `--session-id` REFUSES an id that -/// already exists ("Session ID … is already in use") — so resuming a pane -/// whose transcript already exists must use `--resume` (T-161). -List claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['claude', '--resume', sessionId] : ['claude', '--session-id', sessionId]; +/// The session-selection args for launching [sessionId]: `--resume` an +/// existing session, or `--session-id` to create a new one. `--session-id` +/// REFUSES an id that already exists ("Session ID … is already in use") — so +/// resuming a pane whose transcript already exists must use `--resume` +/// (T-161). Appended after the stream-json flags by [ClaudeStreamJsonProcess]. +List claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['--resume', sessionId] : ['--session-id', sessionId]; /// A fresh random session id for a secondary pane — secondaries are /// always clean sessions, never resumed. diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 6e1fe9b6..12055825 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -53,6 +53,11 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess { '--output-format', 'stream-json', '--verbose', + // Route permission asks + AskUserQuestion to us over the control + // channel as `can_use_tool` requests. Without `stdio` the CLI silently + // auto-denies anything needing approval (D-78). + '--permission-prompt-tool', + 'stdio', ...sessionArgs, ], workingDirectory: cwd, @@ -73,8 +78,69 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess { } } +/// An interactive prompt Claude is blocked on, from the stream-json control +/// channel (a `can_use_tool` control_request) — a tool needing permission, or +/// an `AskUserQuestion`. Pure data; the decision goes back via +/// [StreamJsonSession.resolvePrompt]. Not a conversation item — prompts are +/// *interaction*, surfaced in the composer zone, not the transcript (D-78). +class ToolPrompt { + const ToolPrompt({ + required this.promptId, + required this.toolName, + required this.displayName, + required this.input, + this.description, + this.toolUseId = '', + }); + + /// The control_request `request_id` — the key passed to [StreamJsonSession.resolvePrompt]. + final String promptId; + + /// The gated `tool_use_id` (matches the preceding assistant tool_use). + final String toolUseId; + + /// Tool being requested, e.g. `Write` or `AskUserQuestion`. + final String toolName; + + /// Human label (`display_name`), falls back to [toolName]. + final String displayName; + + /// Optional one-line summary (`description`). + final String? description; + + /// The tool's proposed input — echoed back (possibly modified) on allow. + final Map input; + + /// AskUserQuestion is answered through the same channel (D-78). + bool get isQuestion => toolName == 'AskUserQuestion'; +} + +/// A decision returned for a [ToolPrompt] over the control channel (D-78). +sealed class ToolDecision { + const ToolDecision(); + Map toJson(); +} + +/// Allow the tool. [updatedInput] is REQUIRED by the protocol — pass the +/// request's input unchanged to allow as-is, or modified to alter the call. +/// For AskUserQuestion, include the `answers` map (question text → label). +final class AllowTool extends ToolDecision { + const AllowTool(this.updatedInput); + final Map updatedInput; + @override + Map toJson() => {'behavior': 'allow', 'updatedInput': updatedInput}; +} + +/// Deny the tool with a user-facing [message] (required by the protocol). +final class DenyTool extends ToolDecision { + const DenyTool(this.message); + final String message; + @override + Map toJson() => {'behavior': 'deny', 'message': message}; +} + /// Parses a [StreamJsonProcess]'s events into conversation items + status, -/// and sends user messages. +/// answers control-channel prompts, and sends user messages. class StreamJsonSession { StreamJsonSession(this._proc); @@ -85,6 +151,18 @@ class StreamJsonSession { SessionStatus _status = const SessionStatus(); int _localSeq = 0; + /// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head + /// is the one currently shown in the composer zone. + final _queue = []; + final _pendingCtl = StreamController.broadcast(); + + /// The prompt currently awaiting a decision (queue head), or null. + ToolPrompt? get pendingPrompt => _queue.isEmpty ? null : _queue.first; + + /// Emits the current pending prompt (or null) whenever it changes — the + /// composer zone swaps between the prompt UI and the text input on this. + Stream get pendingPromptStream => _pendingCtl.stream; + /// Conversation items, in arrival order (assistant turns + the local echo /// of the user's own messages). Stream get items => _items.stream; @@ -100,6 +178,18 @@ class StreamJsonSession { void _onLine(String line) { final trimmed = line.trim(); if (trimmed.isEmpty || !trimmed.startsWith('{')) return; + final Map ev; + try { + ev = (jsonDecode(trimmed) as Map).cast(); + } catch (_) { + return; + } + // Control-channel requests (permission asks, AskUserQuestion) must be + // routed out of the normal event stream and answered (D-78). + if (ev['type'] == 'control_request') { + _onControlRequest(ev); + return; + } // Items + assistant model/tokens reuse the transcript parser (identical // message.content shapes). final parsed = parseTranscriptChunk(trimmed); @@ -108,17 +198,51 @@ class StreamJsonSession { } // The `init` event carries permission mode (no `permission-mode` record // exists in stream-json); fold it in alongside the parsed deltas. - _mergeStatus(parsed.status.merge(_statusFromEvent(trimmed))); + _mergeStatus(parsed.status.merge(_statusFromEvent(ev))); } - SessionStatus _statusFromEvent(String line) { - Object? j; - try { - j = jsonDecode(line); - } catch (_) { - return const SessionStatus(); + /// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt] + /// item the UI resolves; every other subtype is answered with an error so + /// the turn never hangs waiting on us (D-78). + void _onControlRequest(Map ev) { + final rid = ev['request_id'] as String?; + final request = ev['request']; + if (rid == null || request is! Map) return; + if (request['subtype'] == 'can_use_tool') { + final toolName = request['tool_name'] as String? ?? ''; + final input = (request['input'] as Map?)?.cast() ?? {}; + _queue.add(ToolPrompt( + promptId: rid, + toolName: toolName, + displayName: request['display_name'] as String? ?? toolName, + description: request['description'] as String?, + toolUseId: request['tool_use_id'] as String? ?? '', + input: input, + )); + _pendingCtl.add(pendingPrompt); + return; // awaits resolvePrompt } - if (j is! Map) return const SessionStatus(); + _proc.writeLine(jsonEncode({ + 'type': 'control_response', + 'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'}, + })); + } + + /// Answer a [ToolPrompt] over the control channel, by its + /// [ToolPrompt.promptId]. No-op if unknown or already resolved. Advances the + /// queue so the next pending prompt (if any) surfaces. + void resolvePrompt(String promptId, ToolDecision decision) { + final before = _queue.length; + _queue.removeWhere((p) => p.promptId == promptId); + if (_queue.length == before) return; // unknown / already resolved + _proc.writeLine(jsonEncode({ + 'type': 'control_response', + 'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()}, + })); + _pendingCtl.add(pendingPrompt); + } + + SessionStatus _statusFromEvent(Map j) { if (j['type'] == 'system' && j['subtype'] == 'init') { return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?); } @@ -155,5 +279,6 @@ class StreamJsonSession { await _proc.kill(); await _items.close(); await _statusCtl.close(); + await _pendingCtl.close(); } } diff --git a/test/builtin/claude/prompt_card_test.dart b/test/builtin/claude/prompt_card_test.dart new file mode 100644 index 00000000..0f3da171 --- /dev/null +++ b/test/builtin/claude/prompt_card_test.dart @@ -0,0 +1,125 @@ +import 'package:clide/builtin/claude/src/prompt_card.dart'; +import 'package:clide/builtin/claude/src/stream_json_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +ToolPrompt permissionPrompt() => const ToolPrompt( + promptId: 'req-1', + toolName: 'Write', + displayName: 'Write', + description: 'banana.txt', + input: {'file_path': '/tmp/banana.txt', 'content': 'banana'}, + ); + +ToolPrompt questionPrompt({bool multi = false}) => ToolPrompt( + promptId: 'req-q', + toolName: 'AskUserQuestion', + displayName: 'AskUserQuestion', + input: { + 'questions': [ + { + 'question': 'Do you prefer cats or dogs?', + 'header': 'Pet', + 'multiSelect': multi, + 'options': [ + {'label': 'Cats', 'description': 'cat person'}, + {'label': 'Dogs', 'description': 'dog person'}, + ], + }, + ], + }, + ); + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('permission card: Allow returns AllowTool echoing the input', (tester) async { + ToolDecision? decision; + String? id; + await tester.pumpWidget(harness( + f, + ToolPromptCard( + prompt: permissionPrompt(), + onResolve: (p, d) { + id = p; + decision = d; + }, + ), + )); + await tester.pump(); + + expect(find.text('permission · Write'), findsOneWidget); + expect(find.text('Allow'), findsOneWidget); + expect(find.text('Deny'), findsOneWidget); + + await tester.tap(find.text('Allow')); + await tester.pump(); + + expect(id, 'req-1'); + expect(decision, isA()); + expect((decision as AllowTool).updatedInput['content'], 'banana'); + }); + + testWidgets('permission card: Deny returns DenyTool with a message', (tester) async { + ToolDecision? decision; + await tester.pumpWidget(harness( + f, + ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d), + )); + await tester.pump(); + + await tester.tap(find.text('Deny')); + await tester.pump(); + + expect(decision, isA()); + expect((decision as DenyTool).message, isNotEmpty); + }); + + testWidgets('question card: Submit is gated until an option is picked, then returns answers', (tester) async { + ToolDecision? decision; + await tester.pumpWidget(harness( + f, + ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d), + )); + await tester.pump(); + + expect(find.text('Do you prefer cats or dogs?'), findsOneWidget); + + // Submit before choosing → no-op (disabled). + await tester.tap(find.text('Submit')); + await tester.pump(); + expect(decision, isNull); + + await tester.tap(find.textContaining('Dogs')); + await tester.pump(); + await tester.tap(find.text('Submit')); + await tester.pump(); + + expect(decision, isA()); + final answers = (decision as AllowTool).updatedInput['answers'] as Map; + expect(answers['Do you prefer cats or dogs?'], 'Dogs'); + }); + + testWidgets('question card: multi-select joins chosen labels comma-separated', (tester) async { + ToolDecision? decision; + await tester.pumpWidget(harness( + f, + ToolPromptCard(prompt: questionPrompt(multi: true), onResolve: (_, d) => decision = d), + )); + await tester.pump(); + + await tester.tap(find.textContaining('Cats')); + await tester.pump(); + await tester.tap(find.textContaining('Dogs')); + await tester.pump(); + await tester.tap(find.text('Submit')); + await tester.pump(); + + final answers = (decision as AllowTool).updatedInput['answers'] as Map; + expect(answers['Do you prefer cats or dogs?'], 'Cats, Dogs'); + }); +} diff --git a/test/builtin/claude/session_naming_test.dart b/test/builtin/claude/session_naming_test.dart index 45e989d8..23db350a 100644 --- a/test/builtin/claude/session_naming_test.dart +++ b/test/builtin/claude/session_naming_test.dart @@ -81,11 +81,11 @@ void main() { test('resumes an existing session with --resume, not --session-id', () { // --session-id refuses an existing id ("already in use"), so resuming // (transcript on disk) must use --resume. - expect(claudeLaunchArgs('abc', resume: true), ['claude', '--resume', 'abc']); + expect(claudeLaunchArgs('abc', resume: true), ['--resume', 'abc']); }); test('creates a new session with --session-id', () { - expect(claudeLaunchArgs('abc', resume: false), ['claude', '--session-id', 'abc']); + expect(claudeLaunchArgs('abc', resume: false), ['--session-id', 'abc']); }); }); } diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 862e9984..b69541a2 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -56,6 +56,19 @@ String initEvent() => jsonEncode({ 'permissionMode': 'default', }); +String canUseTool(String rid, {String tool = 'Write', Map? input}) => jsonEncode({ + 'type': 'control_request', + 'request_id': rid, + 'request': { + 'subtype': 'can_use_tool', + 'tool_name': tool, + 'display_name': tool, + 'description': 'banana.txt', + 'input': input ?? {'file_path': '/tmp/banana.txt', 'content': 'banana'}, + 'tool_use_id': 'toolu_1', + }, + }); + void main() { late _FakeProc proc; late StreamJsonSession session; @@ -129,6 +142,90 @@ void main() { expect(echoed.single.text, 'do the thing'); }); + test('a can_use_tool control_request becomes a pending prompt (not a conversation item)', () async { + final emitted = []; + session.pendingPromptStream.listen(emitted.add); + proc.emit(canUseTool('req-1')); + await Future.delayed(Duration.zero); + + final p = session.pendingPrompt; + expect(p, isNotNull); + expect(p!.promptId, 'req-1'); + expect(p.toolName, 'Write'); + expect(p.displayName, 'Write'); + expect(p.description, 'banana.txt'); + expect(p.toolUseId, 'toolu_1'); + expect(p.input['content'], 'banana'); + expect(emitted.last, isNotNull); // surfaced on the stream + expect(items, isEmpty); // prompts are not conversation items + expect(proc.writes, isEmpty); // no response until resolved + }); + + test('resolvePrompt(allow) writes success+updatedInput and clears the pending prompt', () async { + proc.emit(canUseTool('req-2')); + await Future.delayed(Duration.zero); + + final p = session.pendingPrompt!; + session.resolvePrompt(p.promptId, AllowTool(p.input)); + expect(session.pendingPrompt, isNull); + + final sent = jsonDecode(proc.writes.single) as Map; + expect(sent['type'], 'control_response'); + final resp = sent['response'] as Map; + expect(resp['subtype'], 'success'); + expect(resp['request_id'], 'req-2'); + final decision = resp['response'] as Map; + expect(decision['behavior'], 'allow'); + expect((decision['updatedInput'] as Map)['content'], 'banana'); + }); + + test('resolvePrompt(deny) writes a deny decision with a message', () async { + proc.emit(canUseTool('req-3')); + await Future.delayed(Duration.zero); + + session.resolvePrompt('req-3', const DenyTool('nope')); + final decision = ((jsonDecode(proc.writes.single) as Map)['response'] as Map)['response'] as Map; + expect(decision['behavior'], 'deny'); + expect(decision['message'], 'nope'); + }); + + test('prompts queue: resolving the head surfaces the next', () async { + proc.emit(canUseTool('q1')); + proc.emit(canUseTool('q2')); + await Future.delayed(Duration.zero); + + expect(session.pendingPrompt!.promptId, 'q1'); + session.resolvePrompt('q1', AllowTool(const {})); + expect(session.pendingPrompt!.promptId, 'q2'); + session.resolvePrompt('q2', AllowTool(const {})); + expect(session.pendingPrompt, isNull); + }); + + test('resolvePrompt is a no-op for an unknown / already-resolved id', () async { + proc.emit(canUseTool('req-4')); + await Future.delayed(Duration.zero); + + session.resolvePrompt('req-4', AllowTool(const {})); // resolves + session.resolvePrompt('req-4', AllowTool(const {})); // already gone + session.resolvePrompt('does-not-exist', AllowTool(const {})); + expect(proc.writes, hasLength(1)); + }); + + test('an unsupported control_request is answered with an error (no hang)', () async { + proc.emit(jsonEncode({ + 'type': 'control_request', + 'request_id': 'req-5', + 'request': {'subtype': 'mystery_subtype'}, + })); + await Future.delayed(Duration.zero); + + expect(items, isEmpty); + final resp = (jsonDecode(proc.writes.single) as Map)['response'] as Map; + expect(resp['subtype'], 'error'); + expect(resp['request_id'], 'req-5'); + expect(resp['error'], contains('mystery_subtype')); + }); + test('dispose kills the process', () async { await session.dispose(); expect(proc.killed, isTrue);