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:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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? ?? ''),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user