@adhdev/daemon-core 0.9.82-rc.355 → 0.9.82-rc.357

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,7 @@ import {
32
32
  import { evaluateFsm, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
33
33
  import {
34
34
  type CliSpecV4, type FsmState, type FsmTransition,
35
- initialState, stateById, statusForState, outgoingTransitions,
35
+ initialState, stateById, statusForState, modalKindForState, outgoingTransitions,
36
36
  } from './fsm-types.js';
37
37
  import { loadFsmSpec } from './fsm-loader.js';
38
38
  import { applyPreLaunchTrust } from './pre-launch-trust.js';
@@ -44,7 +44,7 @@ import { LOG } from '../../logging/logger.js';
44
44
  export type DashboardEvent =
45
45
  | { kind: 'pty_data'; chunk: string }
46
46
  | { kind: 'state_changed'; state: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' };
47
- modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
47
+ modal: { title: string | null; buttons: { index: number; label: string }[]; kind: 'approval' | 'picker' | 'confirm' | null } | null;
48
48
  controls: { id: string; label: string; action_type: string }[] }
49
49
  | { kind: 'notification'; id: string; title: string; body: string }
50
50
  | { kind: 'delegate'; id: string; task: string }
@@ -221,7 +221,7 @@ export function guessExt(mime: string): string {
221
221
 
222
222
  interface ModalSnapshot {
223
223
  title: string | null;
224
- buttons: { index: number; label: string; key: string }[];
224
+ buttons: { index: number; label: string; key: string; current: boolean }[];
225
225
  }
226
226
 
227
227
  interface VisibleControl {
@@ -694,7 +694,12 @@ export class FsmDriver implements ISpecDriver {
694
694
  this.emit({
695
695
  kind: 'state_changed',
696
696
  state: next.state,
697
- modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })) } : null,
697
+ // kind is the SEMANTIC modal class (approval vs picker/confirm)
698
+ // derived from the FSM state, NOT from the parsed buttons — the
699
+ // status field already collapsed it to 'approval' so the modal is
700
+ // surfaced. The auto-approve worker needs the distinction back to
701
+ // avoid answering a /model picker on the user's behalf.
702
+ modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })), kind: modalKindForState(state) } : null,
698
703
  controls: next.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
699
704
  });
700
705
  this.fireNotifications(state.id, title);
@@ -1079,6 +1084,28 @@ export class FsmDriver implements ISpecDriver {
1079
1084
  if (!m) return;
1080
1085
  const btn = m.buttons.find(b => b.index === index);
1081
1086
  if (!btn) return;
1087
+
1088
+ const rule = stateById(this.spec, this.currentStateId)?.extract?.buttons;
1089
+ if (rule?.select_mode === 'arrow_keys') {
1090
+ // Cursor-list approval modal (claude-cli new TUI): number keys are
1091
+ // IGNORED — sending `btn.key` ("1\r") types a literal "1" into the
1092
+ // composer and the trailing CR submits it as a chat message. Drive
1093
+ // the cursor from its current row to the target row with arrows,
1094
+ // then confirm. The cursor opens on the first option, so when the
1095
+ // marker isn't detected we step down from row 1 (index - 1).
1096
+ const from = m.buttons.find(b => b.current)?.index ?? 1;
1097
+ const up = rule.cursor_keys?.up ?? '\x1b[A';
1098
+ const down = rule.cursor_keys?.down ?? '\x1b[B';
1099
+ const delta = btn.index - from;
1100
+ const step = delta >= 0 ? down : up;
1101
+ const nav = step.repeat(Math.abs(delta));
1102
+ // Confirm = key_for_index with the (now unused) {index} stripped:
1103
+ // `{index}\r` → `\r`.
1104
+ const confirm = (rule.key_for_index || '\r').replace(/\{index\}/g, '') || '\r';
1105
+ if (nav) this.adapter.send_keys(nav);
1106
+ this.adapter.send_keys(confirm);
1107
+ return;
1108
+ }
1082
1109
  this.adapter.send_keys(btn.key);
1083
1110
  }
1084
1111
 
@@ -88,6 +88,27 @@ export interface FsmState {
88
88
  /** Modal states (approval/picker) expose modal buttons in the UI and are
89
89
  * treated as "interesting" — the dashboard surfaces them distinctly. */
90
90
  modal?: boolean;
91
+ /**
92
+ * For a modal state, what KIND of modal it is — the semantic distinction the
93
+ * status field (always 'approval' for any modal, so the dashboard surfaces it)
94
+ * deliberately loses. The auto-approve worker uses this to decide whether it
95
+ * may answer the modal on the user's behalf:
96
+ *
97
+ * - 'approval' — a tool/command/trust consent prompt ("Allow Bash?",
98
+ * "Trust this folder?"). Auto-approve MAY fire (a background mesh worker
99
+ * should not stall on these).
100
+ * - 'picker' — a selection menu the user opened (/model, /mode, …). There
101
+ * is no "correct" answer to auto-pick; blindly selecting the first option
102
+ * silently changes the model/mode. Auto-approve must NOT fire — the user
103
+ * chooses.
104
+ * - 'confirm' — a non-consent yes/no the user must decide. Left to the user.
105
+ *
106
+ * Defaults to 'approval' for a modal state that omits it (preserves the
107
+ * pre-existing "auto-approve any modal" behaviour for un-migrated specs;
108
+ * picker/confirm states declare their kind explicitly). Non-modal states
109
+ * have no modal_kind.
110
+ */
111
+ modal_kind?: 'approval' | 'picker' | 'confirm';
91
112
  /** Status this state maps to for the dashboard/cli-adapter status field.
92
113
  * One of: idle | generating | approval. Defaults: modal→approval,
93
114
  * initial→idle, id==='busy'→generating, else idle. Explicit wins. */
@@ -236,3 +257,17 @@ export function statusForState(state: FsmState): 'idle' | 'generating' | 'approv
236
257
  if (state.id === 'busy' || state.id === 'generating') return 'generating';
237
258
  return 'idle';
238
259
  }
260
+
261
+ /**
262
+ * The modal kind for a state, or null when the state is not modal. A modal state
263
+ * that omits `modal_kind` defaults to 'approval' so the established
264
+ * auto-approve-any-modal behaviour is preserved for specs that have not yet
265
+ * declared a kind; picker/confirm states must opt out by declaring their kind.
266
+ * This is the value the cli-adapter carries on `activeModal.kind` and the
267
+ * auto-approve gate reads — see cli-provider-instance.maybeAutoApproveStatus.
268
+ */
269
+ export function modalKindForState(state: FsmState): 'approval' | 'picker' | 'confirm' | null {
270
+ if (state.modal_kind) return state.modal_kind;
271
+ if (state.modal) return 'approval';
272
+ return null;
273
+ }
@@ -34,6 +34,21 @@ export type ControlAction =
34
34
  wait_for: WaitForCondition;
35
35
  extract_choices: SectionPattern;
36
36
  submit_key: string;
37
+ /**
38
+ * How a parsed choice is committed once the picker is open.
39
+ * 'index' (default) — type the on-screen number then `submit_key`
40
+ * (`{index}\r`). Correct for CLIs whose picker is number-selectable
41
+ * (codex-cli, hermes-cli).
42
+ * 'arrow_keys' — the picker is a cursor list that ignores number keys
43
+ * (claude-cli /model): move the cursor from its current row to the
44
+ * target row with up/down arrows, then confirm with the `submit_key`
45
+ * tail (the `\r` left after stripping `{index}`). Requires the
46
+ * extracted choices to flag the current cursor row.
47
+ */
48
+ select_mode?: 'index' | 'arrow_keys';
49
+ /** Arrow byte sequences for `select_mode: 'arrow_keys'`. Defaults to
50
+ * ANSI cursor up/down (`` / ``) when omitted. */
51
+ cursor_keys?: { up: string; down: string };
37
52
  }
38
53
  | {
39
54
  type: 'attach_image';
@@ -227,4 +242,21 @@ export interface ExtractButtons {
227
242
  key_for_index: string;
228
243
  min_count?: number;
229
244
  continuation_lines?: boolean;
245
+ /**
246
+ * How a button is committed when its modal is resolved (auto-approve or an
247
+ * explicit dashboard click).
248
+ * 'index' (default) — send `key_for_index` with `{index}` filled in
249
+ * (`{index}\r` → `1\r`). Correct for modals whose buttons are
250
+ * number-selectable (codex, hermes, antigravity number rows).
251
+ * 'arrow_keys' — the modal is a cursor list that IGNORES number keys
252
+ * (claude-cli's new TUI approval modal): a typed `1` leaks into the
253
+ * composer as literal text and `\r` submits it. Instead move the cursor
254
+ * from its current row to the target row with up/down arrows, then
255
+ * confirm with the `key_for_index` tail (the `\r` left after stripping
256
+ * `{index}`). Mirrors the `open_picker` `select_mode` of the same name.
257
+ */
258
+ select_mode?: 'index' | 'arrow_keys';
259
+ /** Arrow byte sequences for `select_mode: 'arrow_keys'`. Defaults to ANSI
260
+ * cursor up/down (`[A` / `[B`) when omitted. */
261
+ cursor_keys?: { up: string; down: string };
230
262
  }