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
@@ -56,6 +56,19 @@ String initEvent() => jsonEncode({
'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() {
late _FakeProc proc;
late StreamJsonSession session;
@@ -129,6 +142,90 @@ void main() {
expect(echoed.single.text, 'do the thing');
});
test('a can_use_tool control_request becomes a pending prompt (not a conversation item)', () async {
final emitted = <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 {
await session.dispose();
expect(proc.killed, isTrue);