diff --git a/CHANGELOG.md b/CHANGELOG.md index 90483019..f031f0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 0f914d45..56a09e4c 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -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 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 createState() => _ClaudeComposerState(); } @@ -159,8 +168,21 @@ class _ClaudeComposerState extends State { } 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 { 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 { 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), diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index cc24aa62..c7cd0e1b 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -311,10 +311,16 @@ class _ClaudePaneState extends State { if (prompt != null && _session != null) ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt) else - ClaudeComposer( - enabled: _session != null, - onSubmit: _send, - pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), + StreamBuilder( + 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()), + ), ), ], ); diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 26e3b2ee..91ca03b3 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -186,6 +186,19 @@ class StreamJsonSession { Set get promptedToolUseIds => _promptedToolUses; Map 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.broadcast(); + bool get busy => _busy; + Stream 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 dispose() async { @@ -328,5 +355,6 @@ class StreamJsonSession { await _items.close(); await _statusCtl.close(); await _pendingCtl.close(); + await _busyCtl.close(); } } diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index d7f5492a..a9213c66 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -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(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); + }); }); } diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 3d3f8f73..5c26e06f 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -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; + 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 = []; + session.busyStream.listen(busy.add); + session.send('hi'); + expect(session.busy, isTrue); + + proc.emit(jsonEncode({'type': 'result', 'subtype': 'success'})); + await Future.delayed(Duration.zero); + expect(session.busy, isFalse); + expect(busy, [true, false]); + }); + test('dispose kills the process', () async { await session.dispose(); expect(proc.killed, isTrue);