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