add an interrupt path for a running Claude turn
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 29s

A runaway turn had no escape: Escape was unbound once the slash typeahead
was closed, and there was no Stop affordance. Now the composer interrupts
the in-flight turn — Escape (when no typeahead is open) or a Stop button
shown while busy — over the stream-json control channel.

StreamJsonSession gains interrupt() (writes a {subtype: interrupt}
control_request; claude cancels the turn and ends it with a result) and a
busy/busyStream signal driven true on send and false on the next result.
The pane binds onInterrupt to the session and reflects busy reactively.

D-78.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:08:40 +02:00
co-authored by Claude Opus 4.7
parent e0fa081cb9
commit 3e7b600815
6 changed files with 163 additions and 8 deletions
+4
View File
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- Interrupt a running Claude turn (D-78) — Escape in the composer (when no
typeahead is open) or a Stop button shown while busy cancels the current
turn over the stream-json control channel. The escape hatch from a
runaway turn.
- Native permission & AskUserQuestion prompts (T-166, T-175, T-176, T-179,
D-78) — the composer becomes a prompt: Allow / Allow-and-don't-ask-again /
Deny showing the command, or an AskUserQuestion option picker (single or
+40 -4
View File
@@ -46,6 +46,8 @@ class ClaudeComposer extends StatefulWidget {
this.hint = 'Message Claude… (Enter to send · Shift+Enter for newline)',
this.pasteResolver,
this.slashCommandsResolver,
this.onInterrupt,
this.busy = false,
});
/// Called with the composed message (typed text plus attachment `@path`
@@ -65,6 +67,13 @@ class ClaudeComposer extends StatefulWidget {
/// app-wide [ClaudeConfig]; injected in tests (T-152).
final Iterable<String> Function()? slashCommandsResolver;
/// Interrupt the running turn — fired by the Stop button and by Escape
/// (when the typeahead is closed). The escape hatch for a runaway turn.
final VoidCallback? onInterrupt;
/// Whether a turn is in flight; shows the Stop affordance.
final bool busy;
@override
State<ClaudeComposer> createState() => _ClaudeComposerState();
}
@@ -159,8 +168,21 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (_overlay == null) return KeyEventResult.ignored;
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
// Escape: dismiss the typeahead if open, otherwise interrupt the running
// turn — the escape hatch from a runaway (D-78).
if (e.logicalKey == LogicalKeyboardKey.escape) {
if (_overlay != null) {
_closeTypeahead();
return KeyEventResult.handled;
}
if (widget.onInterrupt != null) {
widget.onInterrupt!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
if (_overlay == null) return KeyEventResult.ignored;
switch (e.logicalKey) {
case LogicalKeyboardKey.arrowDown:
_moveSelection(1);
@@ -168,9 +190,6 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
case LogicalKeyboardKey.arrowUp:
_moveSelection(-1);
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
_closeTypeahead();
return KeyEventResult.handled;
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
case LogicalKeyboardKey.tab:
@@ -312,6 +331,23 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// Running turn → an always-reachable Stop (also bound to Escape).
if (widget.busy && widget.onInterrupt != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
ClideText('running…', muted: true, fontSize: clideFontMeta),
const Spacer(),
ClideButton(
label: 'Stop ⎋',
variant: ClideButtonVariant.primary,
onPressed: widget.onInterrupt,
semanticHint: 'Interrupt the running turn (Escape)',
),
],
),
),
if (_attachments.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
+10 -4
View File
@@ -311,10 +311,16 @@ class _ClaudePaneState extends State<ClaudePane> {
if (prompt != null && _session != null)
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
else
ClaudeComposer(
enabled: _session != null,
onSubmit: _send,
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()),
StreamBuilder<bool>(
stream: _session?.busyStream,
initialData: _session?.busy ?? false,
builder: (context, busySnap) => ClaudeComposer(
enabled: _session != null,
busy: busySnap.data ?? false,
onInterrupt: _session?.interrupt,
onSubmit: _send,
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()),
),
),
],
);
@@ -186,6 +186,19 @@ class StreamJsonSession {
Set<String> get promptedToolUseIds => _promptedToolUses;
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
/// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance.
bool _busy = false;
final _busyCtl = StreamController<bool>.broadcast();
bool get busy => _busy;
Stream<bool> get busyStream => _busyCtl.stream;
void _setBusy(bool value) {
if (_busy == value) return;
_busy = value;
_busyCtl.add(value);
}
/// The prompt currently awaiting a decision (queue head), or null.
ToolPrompt? get pendingPrompt => _queue.isEmpty ? null : _queue.first;
@@ -220,6 +233,8 @@ class StreamJsonSession {
_onControlRequest(ev);
return;
}
// A `result` ends the turn — clear the busy/interruptible state.
if (ev['type'] == 'result') _setBusy(false);
// Items + assistant model/tokens reuse the transcript parser (identical
// message.content shapes).
final parsed = parseTranscriptChunk(trimmed);
@@ -320,6 +335,18 @@ class StreamJsonSession {
isSidechain: false,
text: text,
));
_setBusy(true);
}
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
/// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle.
void interrupt() {
_proc.writeLine(jsonEncode({
'type': 'control_request',
'request_id': 'interrupt-${_localSeq++}',
'request': {'subtype': 'interrupt'},
}));
}
Future<void> dispose() async {
@@ -328,5 +355,6 @@ class StreamJsonSession {
await _items.close();
await _statusCtl.close();
await _pendingCtl.close();
await _busyCtl.close();
}
}
@@ -231,5 +231,66 @@ void main() {
await tester.pumpAndSettle();
expect(submitted, ['just text']);
});
testWidgets('Escape interrupts when the typeahead is closed', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++),
));
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(interrupts, 1);
});
testWidgets('Escape closes the typeahead before it interrupts', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
onInterrupt: () => interrupts++,
slashCommandsResolver: () => ['model'],
),
));
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
expect(find.text('/model'), findsOneWidget);
// First Escape only dismisses the popup; it does not interrupt.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.text('/model'), findsNothing);
expect(interrupts, 0);
// A second Escape, now with the popup closed, interrupts.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(interrupts, 1);
});
testWidgets('the Stop button shows when busy and interrupts on tap', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () => interrupts++),
));
expect(find.text('Stop ⎋'), findsOneWidget);
await tester.tap(find.text('Stop ⎋'));
await tester.pump();
expect(interrupts, 1);
});
testWidgets('no Stop button when idle', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () {}),
));
expect(find.text('Stop ⎋'), findsNothing);
});
});
}
@@ -337,6 +337,26 @@ void main() {
expect(resp['error'], contains('mystery_subtype'));
});
test('interrupt writes an interrupt control_request', () async {
session.interrupt();
final sent = jsonDecode(proc.writes.single) as Map<String, dynamic>;
expect(sent['type'], 'control_request');
expect((sent['request'] as Map)['subtype'], 'interrupt');
expect(sent['request_id'], isNotNull);
});
test('busy goes true on send and false on a result event', () async {
final busy = <bool>[];
session.busyStream.listen(busy.add);
session.send('hi');
expect(session.busy, isTrue);
proc.emit(jsonEncode({'type': 'result', 'subtype': 'success'}));
await Future<void>.delayed(Duration.zero);
expect(session.busy, isFalse);
expect(busy, [true, false]);
});
test('dispose kills the process', () async {
await session.dispose();
expect(proc.killed, isTrue);