number-key + Enter shortcuts for interactive prompt buttons (T-240)

In a ToolPromptCard, number keys pick the matching button/option (labels are
prefixed 1./2./3.…) and Enter confirms the primary action — matching the Claude
CLI. Permission: 1=Allow, 2=Allow&remember (when offered) else Deny, 3=Deny.
AskUserQuestion: 1..N select/toggle the current question's options + Other.

The card autofocuses and the key handler self-guards on hasPrimaryFocus, so once
the user clicks into a note field the digits type normally and never fire a
button. Shared the permission/option actions between the buttons and the keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:27:08 +02:00
co-authored by Claude Opus 4.8
parent 1e8e37c6f9
commit 7d8f58690f
5 changed files with 357 additions and 51 deletions
+124 -41
View File
@@ -17,6 +17,7 @@ import 'package:clide/kernel/src/syntax/language_map.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/services.dart';
import 'package:flutter/widgets.dart';
/// Sentinel option key for the always-present free-text "Other…" choice.
@@ -93,39 +94,116 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final (accent, label, children) = widget.prompt.isQuestion ? _question(tokens) : _permission(tokens);
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(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: accent),
const SizedBox(height: 8),
...children,
],
// Autofocus the card so number keys pick a button/option on appear (T-240).
// _onKey self-guards via hasPrimaryFocus, so once the user clicks a note
// field the digits type normally instead of triggering buttons.
return Focus(
autofocus: true,
onKeyEvent: _onKey,
child: 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(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: accent),
const SizedBox(height: 8),
...children,
],
),
),
);
}
// -- number-key + Enter shortcuts (T-240, CLI muscle memory) ---------------
static const _digitKeys = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
// A note field has focus → let the digits type; only act for the card.
if (!node.hasPrimaryFocus) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) {
return _activatePrimary() ? KeyEventResult.handled : KeyEventResult.ignored;
}
final i = _digitKeys.indexOf(e.logicalKey);
if (i < 0) return KeyEventResult.ignored;
return _activateNumber(i + 1) ? KeyEventResult.handled : KeyEventResult.ignored;
}
/// The question whose options the number keys address, or null (permission is
/// handled separately; the review step has no numbered options).
int? _currentQuestion() {
if (_q.isEmpty) return null;
if (_q.length <= 1) return 0;
return _step < _q.length ? _step : null;
}
bool _activateNumber(int n) {
if (!widget.prompt.isQuestion) {
final canRemember = widget.prompt.permissionSuggestions.isNotEmpty;
if (n == 1) return _then(_permAllow);
if (canRemember && n == 2) return _then(() => _permAllow(remember: true));
if (n == (canRemember ? 3 : 2)) return _then(_permDeny);
return false;
}
final qi = _currentQuestion();
if (qi == null) return false;
final q = _q[qi];
final keys = [...q.options.map((o) => o.label), _kOther];
if (n < 1 || n > keys.length) return false;
_toggleOption(qi, keys[n - 1], q.multiSelect);
return true;
}
/// Enter confirms the primary action: Allow (permission), or Submit / Next /
/// Review for questions — only when the step is answerable.
bool _activatePrimary() {
if (!widget.prompt.isQuestion) return _then(_permAllow);
if (_q.length <= 1) return (_q.isNotEmpty && _answer(0).isNotEmpty) ? _then(_submit) : false;
if (_step >= _q.length) return _allAnswered ? _then(_submit) : false;
return _answer(_step).isNotEmpty ? _then(() => setState(() => _step++)) : false;
}
bool _then(void Function() action) {
action();
return true;
}
// -- permission allow / allow-remember / deny ------------------------------
String? _permNote() {
final t = _note.text.trim();
return t.isEmpty ? null : t;
}
void _permAllow({bool remember = false}) {
final p = widget.prompt;
widget.onResolve(p.promptId, AllowTool(p.input, updatedPermissions: remember ? p.permissionSuggestions : null, followUpNote: _permNote()));
}
void _permDeny() => widget.onResolve(widget.prompt.promptId, DenyTool(_permNote() ?? 'Denied by the user.'));
(Color, String, List<Widget>) _permission(SurfaceTokens tokens) {
final p = widget.prompt;
final canRemember = p.permissionSuggestions.isNotEmpty;
String? note() {
final t = _note.text.trim();
return t.isEmpty ? null : t;
}
void allow({bool remember = false}) => widget.onResolve(
p.promptId,
AllowTool(p.input, updatedPermissions: remember ? p.permissionSuggestions : null, followUpNote: note()),
);
return (
tokens.statusWarning,
'permission · ${p.displayName}',
@@ -149,9 +227,9 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
spacing: 8,
runSpacing: 8,
children: [
ClideButton(label: 'Allow', variant: ClideButtonVariant.primary, onPressed: () => allow()),
if (canRemember) ClideButton(label: "Allow & don't ask again", onPressed: () => allow(remember: true)),
ClideButton(label: 'Deny', onPressed: () => widget.onResolve(p.promptId, DenyTool(note() ?? 'Denied by the user.'))),
ClideButton(label: '1. Allow', variant: ClideButtonVariant.primary, onPressed: () => _permAllow()),
if (canRemember) ClideButton(label: "2. Allow & don't ask again", onPressed: () => _permAllow(remember: true)),
ClideButton(label: '${canRemember ? '3' : '2'}. Deny', onPressed: _permDeny),
],
),
const SizedBox(height: 10),
@@ -277,8 +355,9 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
spacing: 8,
runSpacing: 8,
children: [
for (final opt in q.options) _optButton(qi, opt.label, opt.label, q.multiSelect, opt.description),
_optButton(qi, _kOther, 'Other…', q.multiSelect, ''),
for (var oi = 0; oi < q.options.length; oi++)
_optButton(qi, q.options[oi].label, q.options[oi].label, q.multiSelect, q.options[oi].description, oi + 1),
_optButton(qi, _kOther, 'Other…', q.multiSelect, '', q.options.length + 1),
],
),
if (hasOther) ...[
@@ -291,25 +370,29 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
);
}
Widget _optButton(int qi, String key, String label, bool multi, String description) {
Widget _optButton(int qi, String key, String label, bool multi, String description, int number) {
final sel = _picked[qi].contains(key);
return ClideButton(
label: '${sel ? '●' : '○'} $label',
label: '$number. ${sel ? '●' : '○'} $label',
variant: sel ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
tooltip: description.isNotEmpty ? description : null,
onPressed: () => setState(() {
final s = _picked[qi];
if (multi) {
s.contains(key) ? s.remove(key) : s.add(key);
} else {
s
..clear()
..add(key);
}
}),
onPressed: () => _toggleOption(qi, key, multi),
);
}
void _toggleOption(int qi, String key, bool multi) {
setState(() {
final s = _picked[qi];
if (multi) {
s.contains(key) ? s.remove(key) : s.add(key);
} else {
s
..clear()
..add(key);
}
});
}
Widget _chatInstead(SurfaceTokens tokens) {
return ClideButton(
label: 'chat instead',