drive the Claude pane over stream-json with native prompts

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 08:40:18 +02:00
co-authored by Claude Opus 4.7
parent 8310730506
commit 39f9ed018c
8 changed files with 635 additions and 261 deletions
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added ### 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 - 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 renders through one card template with a copy button on hover and a
collapse/expand caret for tool calls, results, and thinking. collapse/expand caret for tool calls, results, and thinking.
+65 -245
View File
@@ -1,10 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'claude_banner.dart'; import 'claude_banner.dart';
@@ -14,14 +12,19 @@ import 'claude_status.dart';
import 'clipboard_paste.dart'; import 'clipboard_paste.dart';
import 'conversation_controller.dart'; import 'conversation_controller.dart';
import 'conversation_view.dart'; import 'conversation_view.dart';
import 'prompt_card.dart';
import 'session_index.dart'; import 'session_index.dart';
import 'session_naming.dart'; import 'session_naming.dart';
import 'session_picker.dart'; import 'session_picker.dart';
import 'slash_commands.dart'; import 'slash_commands.dart';
import 'tmux_session.dart' as tmux; import 'stream_json_session.dart';
import 'transcript_publisher.dart';
import 'transcript_reader.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 { class ClaudePane extends StatefulWidget {
const ClaudePane({ const ClaudePane({
super.key, super.key,
@@ -50,27 +53,16 @@ class ClaudePane extends StatefulWidget {
} }
class _ClaudePaneState extends State<ClaudePane> { class _ClaudePaneState extends State<ClaudePane> {
// 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<DaemonEvent>? _eventSub;
StreamSubscription<SessionStatus>? _statusSub; StreamSubscription<SessionStatus>? _statusSub;
ConversationController? _conversation; ConversationController? _conversation;
TranscriptPublisher? _feed; StreamJsonSession? _session;
SessionStatus _status = const SessionStatus(); SessionStatus _status = const SessionStatus();
String? _paneId;
String? _sessionName;
String? _sessionId; String? _sessionId;
String? _repoRoot; String? _repoRoot;
String? _error; String? _error;
String _statusLine = 'attaching…'; String _statusLine = 'starting…';
bool _spawned = false; bool _spawned = false;
bool _usingTmux = false;
// The status line surfaced to the bottom status bar via ClidePane — the // The status line surfaced to the bottom status bar via ClidePane — the
// live session fields (model/mode/context, T-150) plus the configured // live session fields (model/mode/context, T-150) plus the configured
@@ -100,8 +92,7 @@ class _ClaudePaneState extends State<ClaudePane> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// Spawn once, after the kernel is available. The conversation renders // Spawn once, after the kernel is available.
// from the transcript, so we no longer wait on a terminal resize.
if (!_spawned) { if (!_spawned) {
_spawned = true; _spawned = true;
unawaited(_spawnWhenReady()); unawaited(_spawnWhenReady());
@@ -116,52 +107,15 @@ class _ClaudePaneState extends State<ClaudePane> {
@override @override
void dispose() { void dispose() {
activeClaudeConfig?.removeListener(_onConfigChanged); activeClaudeConfig?.removeListener(_onConfigChanged);
_conversation?.dispose();
_conversation = null;
unawaited(_feed?.dispose());
_feed = null;
_statusSub?.cancel(); _statusSub?.cancel();
_statusSub = null; _statusSub = null;
_eventSub?.cancel(); // The controller's onDispose kills the session (process + streams).
_eventSub = null; _conversation?.dispose();
final id = _paneId; _conversation = null;
final sessionName = _sessionName; _session = null;
_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));
}
}
super.dispose(); super.dispose();
} }
// -- tmux config extraction -----------------------------------------------
static Future<String?> _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 ---------------------------------------------------------------- // -- spawn ----------------------------------------------------------------
Future<void> _spawnWhenReady() async { Future<void> _spawnWhenReady() async {
@@ -197,133 +151,44 @@ class _ClaudePaneState extends State<ClaudePane> {
} }
_repoRoot = repoRoot; _repoRoot = repoRoot;
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); // Bind this pane to a specific session id (T-146). Primary: deterministic
// Bind this pane to a specific Claude session id so concurrent // → resumes across restarts. Secondary: fresh → a clean session.
// sessions in one workspace don't collide on the newest transcript
// (T-146). Primary: deterministic → resumes across restarts.
// Secondary: fresh → always a clean session.
_sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId(); _sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId();
// A transcript already on disk means the session existed before, so resume
// it; `claude --session-id <id>` refuses an existing id (T-161/D-77).
final home = Platform.environment['HOME'] ?? ''; final home = Platform.environment['HOME'] ?? '';
final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl'; 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 final StreamJsonSession session;
// resume it; otherwise it's new. This drives both the self-heal kill and try {
// the launch flag — `claude --session-id <id>` REFUSES an existing id final proc = await ClaudeStreamJsonProcess.start(sessionArgs: sessionArgs, cwd: repoRoot);
// ("already in use"), so an existing session must launch with `--resume` session = StreamJsonSession(proc)..start();
// (T-161). } catch (e) {
final transcriptExists = await File(transcriptFile).exists(); if (mounted) setState(() => _error = 'Could not start claude: $e');
return;
// 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 if (!mounted) {
// binding, or otherwise unconnectable) and `new-session -A` would await session.dispose();
// attach to it and leave the pane stuck waiting forever. Kill it so a return;
// clean session is created. Safe by construction: only ever kills clide's
// OWN `clide-claude-<slug>` 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 launch = claudeLaunchArgs(_sessionId!, resume: transcriptExists); _session = session;
final tmuxConf = await _ensureTmuxConf(); _conversation = ConversationController(stream: session.items, onDispose: session.dispose);
const cols = _cols; _statusSub = session.statusStream.listen((s) {
const rows = _rows;
var argv = <String>[
'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 (<munged-cwd>/<sessionId>.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) {
if (!mounted) return; if (!mounted) return;
setState(() => _status = s); setState(() => _status = s);
}); });
_subscribe(); setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId');
setState(() {});
} }
// Send composed text to Claude. On the tmux path, submit via the tmux // Send composed text to Claude over the stream-json channel. Commands clide
// server (paste-buffer + Enter) — it reaches Claude even with no client // owns (T-156) are handled here, never forwarded — /clear and /resume fork
// attached, unlike pane.write to the (now-detached) spawned client PTY. // the session to a new id, so clide drives them: /clear starts fresh,
// The no-tmux fallback runs claude directly in our PTY, where pane.write // /resume picks a past session and re-binds to it.
// does reach it.
void _send(String text) { 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)) { switch (clideOwnedCommand(text)) {
case 'clear': case 'clear':
unawaited(_clearSession()); unawaited(_clearSession());
@@ -332,37 +197,17 @@ class _ClaudePaneState extends State<ClaudePane> {
unawaited(_resumeFlow()); unawaited(_resumeFlow());
return; return;
} }
if (_usingTmux) { _session?.send(text);
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)}));
} }
/// clide-owned `/clear` (T-156): respawn this pane on a brand-new, empty /// clide-owned `/clear` (T-156): respawn on a brand-new, empty session.
/// 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.
Future<void> _clearSession() async { Future<void> _clearSession() async {
if (mounted) setState(() => _statusLine = 'clearing…'); if (mounted) setState(() => _statusLine = 'clearing…');
await _respawnWithSession(freshSessionId()); await _respawnWithSession(freshSessionId());
} }
/// clide-owned `/resume` (T-156): pick a past session for this workspace and /// 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 /// re-bind the pane to it.
/// reader can't follow, so clide drives the switch.
Future<void> _resumeFlow() async { Future<void> _resumeFlow() async {
final root = _repoRoot; final root = _repoRoot;
final dialog = _kernel()?.dialog; final dialog = _kernel()?.dialog;
@@ -383,49 +228,19 @@ class _ClaudePaneState extends State<ClaudePane> {
await _respawnWithSession(picked); await _respawnWithSession(picked);
} }
/// Tear the current session down and respawn the pane bound to [sessionId]. /// Tear the current session down and respawn bound to [sessionId]. The old
/// The tmux session is killed first so `new-session` starts a fresh client /// process is killed; its transcript stays on disk (history preserved).
/// on the new id rather than re-attaching the still-running old claude; the
/// old transcript is left on disk (history preserved, detached).
Future<void> _respawnWithSession(String sessionId) async { Future<void> _respawnWithSession(String sessionId) async {
_conversation?.dispose();
_conversation = null;
unawaited(_feed?.dispose());
_feed = null;
_statusSub?.cancel(); _statusSub?.cancel();
_statusSub = null; _statusSub = null;
_eventSub?.cancel(); _conversation?.dispose(); // onDispose kills the old session
_eventSub = null; _conversation = null;
final old = _sessionName; _session = null;
if (old != null) await tmux.killSession(old);
_sessionId = sessionId; _sessionId = sessionId;
if (mounted) setState(() => _status = const SessionStatus()); if (mounted) setState(() => _status = const SessionStatus());
await _spawn(); 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<DaemonEvent>().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 -------------------------------------------------------------- // -- helpers --------------------------------------------------------------
DaemonClient? _ipc() => _kernel()?.ipc; DaemonClient? _ipc() => _kernel()?.ipc;
@@ -464,29 +279,34 @@ class _ClaudePaneState extends State<ClaudePane> {
), ),
), ),
), ),
ClaudeComposer( // The composer zone: an open prompt (permission / AskUserQuestion)
enabled: _paneId != null, // takes this space and hides the text input until it's answered, so
onSubmit: _send, // interaction stays out of the conversation stream (D-78).
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), StreamBuilder<ToolPrompt?>(
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 { } else {
body = const Center(child: ClideText('attaching…', muted: true)); body = const Center(child: ClideText('starting…', muted: true));
} }
final content = widget.showChrome final content = widget.showChrome
? ClidePaneChrome( ? ClidePaneChrome(
title: title, title: title,
subtitle: _error ?? _statusLine, subtitle: _error ?? _statusLine,
onClose: widget.isPrimary
? null
: () {
final id = _paneId;
if (id != null) {
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
}
},
child: body, child: body,
) )
: body; : body;
+201
View File
@@ -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<ToolPromptCard> createState() => _ToolPromptCardState();
}
class _ToolPromptCardState extends State<ToolPromptCard> {
// AskUserQuestion: per-question chosen option labels (set = multi-select).
late List<Set<String>> _picked = List.generate(_questions.length, (_) => <String>{});
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, (_) => <String>{});
}
}
@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<Widget> _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<Widget> _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 = <String, String>{};
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<String, dynamic> 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? ?? ''),
],
),
];
}
+6 -5
View File
@@ -68,11 +68,12 @@ String _hash(String s) {
/// workspace re-binds the same `<uuid>.jsonl` across restarts (resume). /// workspace re-binds the same `<uuid>.jsonl` across restarts (resume).
String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot)); String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot));
/// The `claude` argv to launch [sessionId]: `--resume` an existing session, /// The session-selection args for launching [sessionId]: `--resume` an
/// or `--session-id` to create a new one. `--session-id` REFUSES an id that /// existing session, or `--session-id` to create a new one. `--session-id`
/// already exists ("Session ID … is already in use") — so resuming a pane /// REFUSES an id that already exists ("Session ID … is already in use") — so
/// whose transcript already exists must use `--resume` (T-161). /// resuming a pane whose transcript already exists must use `--resume`
List<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['claude', '--resume', sessionId] : ['claude', '--session-id', sessionId]; /// (T-161). Appended after the stream-json flags by [ClaudeStreamJsonProcess].
List<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['--resume', sessionId] : ['--session-id', sessionId];
/// A fresh random session id for a secondary pane — secondaries are /// A fresh random session id for a secondary pane — secondaries are
/// always clean sessions, never resumed. /// always clean sessions, never resumed.
+134 -9
View File
@@ -53,6 +53,11 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
'--output-format', '--output-format',
'stream-json', 'stream-json',
'--verbose', '--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, ...sessionArgs,
], ],
workingDirectory: cwd, 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> updatedInput;
@override
Map<String, dynamic> 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<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
}
/// Parses a [StreamJsonProcess]'s events into conversation items + status, /// Parses a [StreamJsonProcess]'s events into conversation items + status,
/// and sends user messages. /// answers control-channel prompts, and sends user messages.
class StreamJsonSession { class StreamJsonSession {
StreamJsonSession(this._proc); StreamJsonSession(this._proc);
@@ -85,6 +151,18 @@ class StreamJsonSession {
SessionStatus _status = const SessionStatus(); SessionStatus _status = const SessionStatus();
int _localSeq = 0; int _localSeq = 0;
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
/// is the one currently shown in the composer zone.
final _queue = <ToolPrompt>[];
final _pendingCtl = StreamController<ToolPrompt?>.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<ToolPrompt?> get pendingPromptStream => _pendingCtl.stream;
/// Conversation items, in arrival order (assistant turns + the local echo /// Conversation items, in arrival order (assistant turns + the local echo
/// of the user's own messages). /// of the user's own messages).
Stream<ConversationItem> get items => _items.stream; Stream<ConversationItem> get items => _items.stream;
@@ -100,6 +178,18 @@ class StreamJsonSession {
void _onLine(String line) { void _onLine(String line) {
final trimmed = line.trim(); final trimmed = line.trim();
if (trimmed.isEmpty || !trimmed.startsWith('{')) return; if (trimmed.isEmpty || !trimmed.startsWith('{')) return;
final Map<String, dynamic> ev;
try {
ev = (jsonDecode(trimmed) as Map).cast<String, dynamic>();
} 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 // Items + assistant model/tokens reuse the transcript parser (identical
// message.content shapes). // message.content shapes).
final parsed = parseTranscriptChunk(trimmed); final parsed = parseTranscriptChunk(trimmed);
@@ -108,17 +198,51 @@ class StreamJsonSession {
} }
// The `init` event carries permission mode (no `permission-mode` record // The `init` event carries permission mode (no `permission-mode` record
// exists in stream-json); fold it in alongside the parsed deltas. // 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) { /// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
Object? j; /// item the UI resolves; every other subtype is answered with an error so
try { /// the turn never hangs waiting on us (D-78).
j = jsonDecode(line); void _onControlRequest(Map<String, dynamic> ev) {
} catch (_) { final rid = ev['request_id'] as String?;
return const SessionStatus(); 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<String, dynamic>() ?? <String, dynamic>{};
_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<String, dynamic> j) {
if (j['type'] == 'system' && j['subtype'] == 'init') { if (j['type'] == 'system' && j['subtype'] == 'init') {
return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?); return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?);
} }
@@ -155,5 +279,6 @@ class StreamJsonSession {
await _proc.kill(); await _proc.kill();
await _items.close(); await _items.close();
await _statusCtl.close(); await _statusCtl.close();
await _pendingCtl.close();
} }
} }
+125
View File
@@ -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<AllowTool>());
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<DenyTool>());
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<AllowTool>());
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');
});
}
+2 -2
View File
@@ -81,11 +81,11 @@ void main() {
test('resumes an existing session with --resume, not --session-id', () { test('resumes an existing session with --resume, not --session-id', () {
// --session-id refuses an existing id ("already in use"), so resuming // --session-id refuses an existing id ("already in use"), so resuming
// (transcript on disk) must use --resume. // (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', () { 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']);
}); });
}); });
} }
@@ -56,6 +56,19 @@ String initEvent() => jsonEncode({
'permissionMode': 'default', 'permissionMode': 'default',
}); });
String canUseTool(String rid, {String tool = 'Write', Map<String, dynamic>? 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() { void main() {
late _FakeProc proc; late _FakeProc proc;
late StreamJsonSession session; late StreamJsonSession session;
@@ -129,6 +142,90 @@ void main() {
expect(echoed.single.text, 'do the thing'); 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 = <ToolPrompt?>[];
session.pendingPromptStream.listen(emitted.add);
proc.emit(canUseTool('req-1'));
await Future<void>.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<void>.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<String, dynamic>;
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<void>.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<void>.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<void>.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<void>.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 { test('dispose kills the process', () async {
await session.dispose(); await session.dispose();
expect(proc.killed, isTrue); expect(proc.killed, isTrue);