@pellux/goodvibes-tui 1.7.0 → 1.9.1

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.
Files changed (140) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -9
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/config/credential-availability.ts +122 -0
  12. package/src/config/index.ts +15 -0
  13. package/src/core/composer-state.ts +11 -3
  14. package/src/core/conversation-line-cache.ts +27 -3
  15. package/src/core/conversation-rendering.ts +71 -14
  16. package/src/core/stream-event-wiring.ts +20 -1
  17. package/src/core/system-message-noise.ts +87 -0
  18. package/src/core/system-message-router.ts +68 -1
  19. package/src/core/turn-cancellation.ts +7 -2
  20. package/src/core/turn-event-wiring.ts +10 -2
  21. package/src/daemon/cli.ts +29 -2
  22. package/src/daemon/handlers/register.ts +8 -1
  23. package/src/daemon/service-commands.ts +329 -0
  24. package/src/input/autocomplete.ts +27 -1
  25. package/src/input/command-registry.ts +46 -4
  26. package/src/input/commands/codebase-runtime.ts +46 -6
  27. package/src/input/commands/config.ts +43 -3
  28. package/src/input/commands/health-runtime.ts +9 -1
  29. package/src/input/commands/memory.ts +68 -35
  30. package/src/input/commands/operator-panel-runtime.ts +31 -7
  31. package/src/input/commands/planning-runtime.ts +95 -6
  32. package/src/input/commands/qrcode-runtime.ts +25 -5
  33. package/src/input/commands/remote-runtime-setup.ts +5 -3
  34. package/src/input/commands/session-content.ts +20 -9
  35. package/src/input/commands/settings-sync-runtime.ts +15 -3
  36. package/src/input/commands/shell-core.ts +10 -1
  37. package/src/input/commands/workstream-runtime.ts +168 -18
  38. package/src/input/config-modal-types.ts +15 -1
  39. package/src/input/config-modal.ts +227 -12
  40. package/src/input/feed-context-factory.ts +3 -0
  41. package/src/input/handler-command-route.ts +10 -3
  42. package/src/input/handler-content-actions.ts +17 -2
  43. package/src/input/handler-feed-routes.ts +43 -121
  44. package/src/input/handler-feed.ts +32 -4
  45. package/src/input/handler-modal-routes.ts +78 -13
  46. package/src/input/handler-modal-stack.ts +11 -2
  47. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  48. package/src/input/handler-onboarding.ts +71 -59
  49. package/src/input/handler-picker-routes.ts +15 -8
  50. package/src/input/handler-shortcuts.ts +59 -9
  51. package/src/input/handler.ts +6 -1
  52. package/src/input/keybindings.ts +6 -5
  53. package/src/input/model-picker.ts +19 -2
  54. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  55. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  56. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  57. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  58. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  59. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  60. package/src/input/panel-mouse-geometry.ts +97 -0
  61. package/src/input/panel-paste-flood-guard.ts +86 -0
  62. package/src/input/selection-modal.ts +11 -0
  63. package/src/input/session-picker-modal.ts +44 -4
  64. package/src/input/settings-modal-data.ts +51 -2
  65. package/src/input/settings-modal-types.ts +4 -2
  66. package/src/main.ts +28 -27
  67. package/src/panels/builtin/shared.ts +9 -3
  68. package/src/panels/fleet-deep-link.ts +31 -0
  69. package/src/panels/fleet-panel-format.ts +62 -0
  70. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  71. package/src/panels/fleet-panel.ts +89 -131
  72. package/src/panels/fleet-read-model.ts +43 -0
  73. package/src/panels/fleet-steer.ts +35 -2
  74. package/src/panels/fleet-stop.ts +29 -1
  75. package/src/panels/modals/keybindings-modal.ts +16 -1
  76. package/src/panels/modals/modal-theme.ts +35 -29
  77. package/src/panels/modals/pairing-modal.ts +25 -6
  78. package/src/panels/modals/planning-modal.ts +43 -21
  79. package/src/panels/modals/work-plan-modal.ts +28 -0
  80. package/src/panels/panel-manager.ts +15 -4
  81. package/src/panels/polish-core.ts +38 -25
  82. package/src/panels/project-planning-answer-actions.ts +8 -13
  83. package/src/panels/types.ts +24 -0
  84. package/src/permissions/prompt.ts +160 -13
  85. package/src/renderer/autocomplete-overlay.ts +28 -2
  86. package/src/renderer/compositor.ts +2 -3
  87. package/src/renderer/config-modal.ts +19 -5
  88. package/src/renderer/footer-tips.ts +5 -1
  89. package/src/renderer/fullscreen-primitives.ts +32 -22
  90. package/src/renderer/git-status.ts +3 -1
  91. package/src/renderer/layout.ts +0 -4
  92. package/src/renderer/markdown.ts +7 -3
  93. package/src/renderer/modal-factory.ts +25 -20
  94. package/src/renderer/model-workspace.ts +18 -3
  95. package/src/renderer/overlay-box.ts +21 -17
  96. package/src/renderer/process-indicator.ts +14 -3
  97. package/src/renderer/selection-modal-overlay.ts +6 -1
  98. package/src/renderer/session-picker-modal.ts +196 -3
  99. package/src/renderer/settings-modal-helpers.ts +2 -0
  100. package/src/renderer/settings-modal.ts +7 -0
  101. package/src/renderer/shell-surface.ts +8 -1
  102. package/src/renderer/status-glyphs.ts +14 -15
  103. package/src/renderer/system-message.ts +15 -3
  104. package/src/renderer/terminal-bg-probe.ts +339 -0
  105. package/src/renderer/terminal-escapes.ts +20 -0
  106. package/src/renderer/theme-mode-config.ts +67 -0
  107. package/src/renderer/theme.ts +91 -1
  108. package/src/renderer/thinking.ts +11 -3
  109. package/src/renderer/tool-call.ts +15 -9
  110. package/src/renderer/tool-result-summary.ts +148 -0
  111. package/src/renderer/turn-injection.ts +22 -3
  112. package/src/renderer/ui-factory.ts +154 -85
  113. package/src/renderer/ui-primitives.ts +30 -129
  114. package/src/runtime/bootstrap-command-context.ts +6 -0
  115. package/src/runtime/bootstrap-command-parts.ts +8 -4
  116. package/src/runtime/bootstrap-core.ts +46 -24
  117. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  118. package/src/runtime/bootstrap-shell.ts +19 -1
  119. package/src/runtime/bootstrap.ts +118 -5
  120. package/src/runtime/code-index-services.ts +25 -2
  121. package/src/runtime/legacy-daemon-migration.ts +516 -0
  122. package/src/runtime/memory-fold.ts +55 -0
  123. package/src/runtime/onboarding/derivation.ts +7 -2
  124. package/src/runtime/onboarding/snapshot.ts +27 -1
  125. package/src/runtime/onboarding/types.ts +31 -1
  126. package/src/runtime/operator-token-cleanup.ts +82 -1
  127. package/src/runtime/orchestrator-core-services.ts +10 -0
  128. package/src/runtime/resume-notice.ts +209 -0
  129. package/src/runtime/services.ts +12 -8
  130. package/src/runtime/session-inbound-inputs.ts +252 -0
  131. package/src/runtime/session-spine-transport.ts +64 -0
  132. package/src/runtime/terminal-output-guard.ts +15 -8
  133. package/src/runtime/ui-services.ts +19 -3
  134. package/src/runtime/workstream-services.ts +160 -28
  135. package/src/runtime/wrfc-persistence.ts +124 -17
  136. package/src/shell/blocking-input.ts +46 -3
  137. package/src/shell/recovery-input-helpers.ts +170 -1
  138. package/src/shell/ui-openers.ts +42 -9
  139. package/src/utils/terminal-width.ts +52 -0
  140. package/src/version.ts +1 -1
@@ -7,6 +7,7 @@ import type {
7
7
  ConfigModalView,
8
8
  } from './config-modal-types.ts';
9
9
  import type { ModalSectionStyle } from '../renderer/modal-factory.ts';
10
+ import { truncateDisplay, wrapText } from '../utils/terminal-width.ts';
10
11
 
11
12
  export type {
12
13
  ConfigModalAction,
@@ -52,6 +53,26 @@ export interface ConfigModalRenderModel {
52
53
 
53
54
  const DEFAULT_VISIBLE_ROWS = 10;
54
55
 
56
+ /**
57
+ * Default label wrap width for getRenderModel() callers that don't pass one
58
+ * (tests, mainly). Matches the renderer's real list-item wrap width at the
59
+ * common terminal sizes this codebase tests against: default box width 76,
60
+ * minus the 4-column text margin and 2-column selection indicator (see
61
+ * ModalFactory._renderListSection's `contentW - 2`). renderConfigModal()
62
+ * itself always computes and passes the ACTUAL width for the current
63
+ * terminal size — this constant only matters when getRenderModel() is
64
+ * called directly without going through the renderer.
65
+ */
66
+ const DEFAULT_LABEL_WRAP_WIDTH = 70;
67
+
68
+ /**
69
+ * Stable id for the synthesized "no matches" row DEBT-5 item 1 injects when a
70
+ * filter query excludes every selectable row in a tab. Non-selectable (info
71
+ * rows are never filtered — see ConfigModal's filter doc) so this id can
72
+ * never collide with a real surface row id.
73
+ */
74
+ const FILTER_NO_MATCH_ROW_ID = '__config_modal_filter_no_match__';
75
+
55
76
  /**
56
77
  * ConfigModal — the single, generic, named config-modal host. One instance
57
78
  * lives on the InputHandler (like `settingsModal`); it renders whatever
@@ -75,6 +96,34 @@ export class ConfigModal {
75
96
  /** Pending destructive confirm: the action key awaiting a second press. */
76
97
  private pendingConfirmKey: string | null = null;
77
98
 
99
+ /**
100
+ * DEBT-5 item 1 — the host's own type-to-filter, armed with '/' (matching
101
+ * the pre-existing "'/' to filter" convention: scrollable-list-panel.ts's
102
+ * opt-in filter, and SettingsModal's own '/'-armed search). `filterActive`
103
+ * gates whether printable keys go to the query instead of nav/actions;
104
+ * `filterQuery` is the TEXT-CAPTURE buffer itself (a multi-char paste token
105
+ * appends in one shot — see handleConfigModalToken). Filtering only ever
106
+ * narrows the ACTIVE tab's rows and is reset on tab switch (a query typed
107
+ * against one tab's rows has no defined meaning against another's).
108
+ *
109
+ * FILTER-CONVENTION RULING (batch integration — where '/'-armed vs instant
110
+ * filtering is decided across the TUI's list UIs):
111
+ * - '/'-ARMED here (config-modal host surfaces): these surfaces bind PLAIN
112
+ * single keys to ACTION HOTKEYS (e.g. 'r' refresh, 'd' delete). A key
113
+ * can't be both an action and a filter character, so filtering must be
114
+ * explicitly armed with '/' first. This is DEBT-5's design.
115
+ * - INSTANT filtering (pure pickers — help overlay, command palette,
116
+ * selection-modal): these have no single-key actions, so every printable
117
+ * key is unambiguously a filter character and narrows the list on the
118
+ * first keystroke. This is UX-C's design (selection-modal-overlay.ts).
119
+ * - Both keep single-Esc-close semantics. The only extra step is here: a
120
+ * two-stage Esc (first Esc clears a NON-EMPTY query, second Esc closes)
121
+ * — see the Esc branch in handleConfigModalToken. With an empty query,
122
+ * one Esc closes, exactly like the instant pickers.
123
+ */
124
+ private filterActive = false;
125
+ private filterQuery = '';
126
+
78
127
  /**
79
128
  * Structure captured at the last interaction boundary (open / key press).
80
129
  * Renders overlay live values onto THIS so rows never reflow mid-interaction.
@@ -84,6 +133,15 @@ export class ConfigModal {
84
133
  private scrollOffset = 0;
85
134
  private visibleRows = DEFAULT_VISIBLE_ROWS;
86
135
 
136
+ /**
137
+ * False until the first token reaches the open modal. While false, renders
138
+ * may re-sync structure so an async onOpen load replaces the "Loading…"
139
+ * placeholder WITHOUT requiring a keypress; the freeze-to-interaction-
140
+ * boundary rule protects a cursor the user has engaged, and before the
141
+ * first interaction there is nothing to protect (refutation finding 3).
142
+ */
143
+ private interactedSinceOpen = false;
144
+
87
145
  // ── Lifecycle ──────────────────────────────────────────────────────────────
88
146
 
89
147
  open(surface: ConfigModalSurface, requestRender: () => void = () => {}): void {
@@ -95,6 +153,9 @@ export class ConfigModal {
95
153
  this.statusMessage = '';
96
154
  this.pendingConfirmKey = null;
97
155
  this.scrollOffset = 0;
156
+ this.filterActive = false;
157
+ this.filterQuery = '';
158
+ this.interactedSinceOpen = false;
98
159
  const view = surface.buildView();
99
160
  this.frozenView = view;
100
161
  this.activeTabId = view.tabs[0]?.id ?? '';
@@ -110,6 +171,8 @@ export class ConfigModal {
110
171
  this.frozenView = null;
111
172
  this.statusMessage = '';
112
173
  this.pendingConfirmKey = null;
174
+ this.filterActive = false;
175
+ this.filterQuery = '';
113
176
  }
114
177
 
115
178
  /** Re-activate the current surface after a nested modal closes (Esc stack). */
@@ -131,15 +194,22 @@ export class ConfigModal {
131
194
 
132
195
  // ── Interaction boundary ────────────────────────────────────────────────────
133
196
 
197
+ /** Token router calls this for every token the open modal receives. */
198
+ noteInteraction(): void {
199
+ this.interactedSinceOpen = true;
200
+ }
201
+
134
202
  /**
135
203
  * Re-capture the structure from the current live view. Called on every key
136
- * press (never during a pure render tick)this is what makes structural
137
- * changes appear only at an interaction boundary. Selection + active tab are
138
- * preserved by id and clamped if their target vanished.
204
+ * press (never during a pure render tick — with one exception: before the
205
+ * FIRST interaction, getRenderModel syncs so async loads paint) this is
206
+ * what makes structural changes appear only at an interaction boundary.
207
+ * Selection + active tab are preserved by id and clamped if their target
208
+ * vanished.
139
209
  */
140
210
  syncStructure(): void {
141
211
  if (!this.surface) return;
142
- const view = this.surface.buildView();
212
+ const view = this._applyFilter(this.surface.buildView());
143
213
  this.frozenView = view;
144
214
  if (!view.tabs.some((t) => t.id === this.activeTabId)) {
145
215
  this.activeTabId = view.tabs[0]?.id ?? '';
@@ -151,6 +221,58 @@ export class ConfigModal {
151
221
  this._clampScroll();
152
222
  }
153
223
 
224
+ // ── Filter (DEBT-5 item 1 — each mutation is an interaction boundary) ──────
225
+
226
+ isFilterActive(): boolean {
227
+ return this.filterActive;
228
+ }
229
+
230
+ getFilterQuery(): string {
231
+ return this.filterQuery;
232
+ }
233
+
234
+ /** Arm the filter (handleConfigModalToken calls this on '/'). Idempotent. */
235
+ activateFilter(): void {
236
+ if (this.filterActive) return;
237
+ this._clearConfirm();
238
+ this.filterActive = true;
239
+ this.syncStructure();
240
+ }
241
+
242
+ /**
243
+ * Append text to the query — the WHOLE token value in one call, so a
244
+ * multi-char paste token lands in the filter atomically rather than being
245
+ * split into per-char nav/action dispatch (the text-capture invariant this
246
+ * item's brief calls out). A no-op when the filter isn't armed.
247
+ */
248
+ appendFilterText(text: string): void {
249
+ if (!this.filterActive || text.length === 0) return;
250
+ this.filterQuery += text;
251
+ this.syncStructure();
252
+ }
253
+
254
+ backspaceFilter(): void {
255
+ if (!this.filterActive || this.filterQuery.length === 0) return;
256
+ this.filterQuery = this.filterQuery.slice(0, -1);
257
+ this.syncStructure();
258
+ }
259
+
260
+ /**
261
+ * Esc while filtering: a non-empty query is CLEARED (stays armed, ready to
262
+ * retype without pressing '/' again) and this returns true so the caller
263
+ * (handleConfigModalToken) stops here instead of closing the modal. An
264
+ * empty query returns false — the caller falls through to the ordinary
265
+ * close path, preserving single-Esc-close for the no-filter case (the one
266
+ * documented exception: two-stage Esc only when there is something to
267
+ * clear).
268
+ */
269
+ clearFilterOrFallthrough(): boolean {
270
+ if (!this.filterActive || this.filterQuery.length === 0) return false;
271
+ this.filterQuery = '';
272
+ this.syncStructure();
273
+ return true;
274
+ }
275
+
154
276
  // ── Navigation (each is an interaction boundary) ────────────────────────────
155
277
 
156
278
  moveDown(): void {
@@ -183,6 +305,10 @@ export class ConfigModal {
183
305
 
184
306
  private _switchTab(dir: 1 | -1): void {
185
307
  this._clearConfirm();
308
+ // A filter query is scoped to the tab it was typed against — switching
309
+ // tabs resets it (DEBT-5 item 1), same as statusMessage below.
310
+ this.filterActive = false;
311
+ this.filterQuery = '';
186
312
  this.syncStructure();
187
313
  const tabs = this.frozenView?.tabs ?? [];
188
314
  if (tabs.length <= 1) return;
@@ -229,6 +355,7 @@ export class ConfigModal {
229
355
  print: ctx.print,
230
356
  executeCommand: ctx.executeCommand,
231
357
  openModal: ctx.openModal,
358
+ submitInput: ctx.submitInput,
232
359
  requestRender: this.requestRender,
233
360
  setStatus: (m: string) => { this.statusMessage = m; },
234
361
  close: () => this.close(),
@@ -265,8 +392,20 @@ export class ConfigModal {
265
392
  * Compute the render model: frozen structure + live value overlay. Pure —
266
393
  * does NOT sync structure, so calling it repeatedly with only value mutations
267
394
  * yields byte-identical layout (the liveness contract).
395
+ *
396
+ * `labelWrapWidth` is the wrap column ModalFactory's list section will use
397
+ * for row labels (renderConfigModal computes and passes the real one for
398
+ * the current terminal size). It exists so the wrap-clamp below (DEBT-5
399
+ * item 2) can pre-empt a live label growing past ModalFactory's wrap width
400
+ * mid-tick — a structural change (an extra visible line) without an
401
+ * interaction — by measuring wrapped line counts here, before the label
402
+ * ever reaches the renderer.
268
403
  */
269
- getRenderModel(): ConfigModalRenderModel {
404
+ getRenderModel(labelWrapWidth: number = DEFAULT_LABEL_WRAP_WIDTH): ConfigModalRenderModel {
405
+ // Pre-first-interaction: async onOpen loads may restructure freely (the
406
+ // user hasn't engaged a cursor yet) — sync so "Loading…" is replaced by
407
+ // real content on the load's own requestRender, not the next keypress.
408
+ if (this.active && !this.interactedSinceOpen) this.syncStructure();
270
409
  const live = this.surface?.buildView() ?? null;
271
410
  const frozen = this.frozenView;
272
411
  if (!frozen) {
@@ -295,7 +434,7 @@ export class ConfigModal {
295
434
  const src = lr ?? fr;
296
435
  return {
297
436
  id: fr.id,
298
- label: src.label,
437
+ label: this._clampRowLabel(fr.label, src.label, labelWrapWidth),
299
438
  style: src.style,
300
439
  selected: fr.id === this.selectedRowId,
301
440
  selectable: fr.selectable !== false,
@@ -306,12 +445,27 @@ export class ConfigModal {
306
445
  const visible = this.visibleRows;
307
446
  const windowed = allRows.slice(this.scrollOffset, this.scrollOffset + visible);
308
447
 
309
- const hints = [
310
- ...(frozen.hints ?? []),
311
- ...(frozenTab?.hints ?? []),
312
- ...this._actionHints(),
313
- 'Esc close',
314
- ];
448
+ // DEBT-5 item 1: while filtering, the surface's own action/tab hints are
449
+ // unreliable (their printable-letter keys are captured by the filter
450
+ // instead of firing — see handleConfigModalToken), so the footer swaps
451
+ // to just the filter status (query + a truthful match count) and the
452
+ // Esc contract for this mode. See getFilterQuery/isFilterActive callers.
453
+ const filtering = this.filterActive;
454
+ const hasQuery = filtering && this.filterQuery.length > 0;
455
+ const hints = filtering
456
+ ? [
457
+ this._filterStatusHint(
458
+ frozenRows.filter((r) => r.selectable !== false).length,
459
+ (liveTab?.rows ?? []).filter((r) => r.selectable !== false).length,
460
+ ),
461
+ hasQuery ? 'Esc clear · Esc close' : 'Esc close',
462
+ ]
463
+ : [
464
+ ...(frozen.hints ?? []),
465
+ ...(frozenTab?.hints ?? []),
466
+ ...this._actionHints(),
467
+ 'Esc close',
468
+ ];
315
469
 
316
470
  return {
317
471
  title: live?.title ?? frozen.title,
@@ -328,6 +482,67 @@ export class ConfigModal {
328
482
 
329
483
  // ── internals ────────────────────────────────────────────────────────────────
330
484
 
485
+ /**
486
+ * DEBT-5 item 2 (wrap-clamp overlay): keep a live label within the FROZEN
487
+ * row's line footprint until the next interaction boundary re-freezes it.
488
+ * `frozenLabel` is what the row wrapped to when the structure was captured
489
+ * (open/nav/filter keystroke); `liveLabel` is this tick's value. If the
490
+ * live label would wrap into MORE lines than the frozen one, that is a
491
+ * structural change (an extra visible row) happening without a keypress —
492
+ * exactly the hazard this closes. Clamp to the frozen line count, ellipsis
493
+ * on the last line to signal truncation; a live label that wraps to the
494
+ * SAME or FEWER lines passes through untouched (not the documented hazard).
495
+ * Identical strings short-circuit (the common per-tick case: unchanged or
496
+ * non-selected/stale rows) without doing any wrap work.
497
+ */
498
+ private _clampRowLabel(frozenLabel: string, liveLabel: string, width: number): string {
499
+ if (liveLabel === frozenLabel) return liveLabel;
500
+ const frozenLineCount = Math.max(1, wrapText(frozenLabel, width).length);
501
+ const liveLines = wrapText(liveLabel, width);
502
+ if (liveLines.length <= frozenLineCount) return liveLabel;
503
+ const clamped = liveLines.slice(0, frozenLineCount);
504
+ const lastIdx = clamped.length - 1;
505
+ clamped[lastIdx] = `${truncateDisplay(clamped[lastIdx]!, Math.max(1, width - 1))}…`;
506
+ return clamped.join('\n');
507
+ }
508
+
509
+ /** Footer text for the active filter: the query + a truthful match count. */
510
+ private _filterStatusHint(matched: number, total: number): string {
511
+ const q = this.filterQuery;
512
+ if (q.length === 0) return `/ type to filter (${total} row${total === 1 ? '' : 's'})`;
513
+ return `/${q} — ${matched} of ${total} match${matched === 1 ? '' : 'es'}`;
514
+ }
515
+
516
+ /** Apply the active filter query to every tab's rows. A no-op passthrough when not filtering. */
517
+ private _applyFilter(view: ConfigModalView): ConfigModalView {
518
+ if (!this.filterActive || this.filterQuery === '') return view;
519
+ const query = this.filterQuery.toLowerCase();
520
+ return { ...view, tabs: view.tabs.map((tab) => this._filterTab(tab, query)) };
521
+ }
522
+
523
+ /**
524
+ * Narrow one tab's rows to those matching `query` (case-insensitive
525
+ * substring on the label). Non-selectable rows (section titles, honest
526
+ * empty-state copy, warning banners) always pass through unfiltered —
527
+ * they're context, not data. When the query excludes every selectable row
528
+ * but the tab genuinely had some, append the honest "no rows match" line
529
+ * (DEBT-5 item 1's empty-result case) instead of silently showing nothing
530
+ * or misleadingly falling back to the surface's own emptyText (which
531
+ * describes "no data at all", not "no data matches your filter").
532
+ */
533
+ private _filterTab(tab: ConfigModalTab, query: string): ConfigModalTab {
534
+ const kept = tab.rows.filter((r) => r.selectable === false || r.label.toLowerCase().includes(query));
535
+ const hadSelectable = tab.rows.some((r) => r.selectable !== false);
536
+ const stillMatched = kept.some((r) => r.selectable !== false);
537
+ if (hadSelectable && !stillMatched) {
538
+ return {
539
+ ...tab,
540
+ rows: [...kept, { id: FILTER_NO_MATCH_ROW_ID, label: `No rows match "${this.filterQuery}".`, selectable: false }],
541
+ };
542
+ }
543
+ return { ...tab, rows: kept };
544
+ }
545
+
331
546
  private _actionHints(): string[] {
332
547
  const actions = this.surface?.actions ?? [];
333
548
  const row = this.getSelectedRow();
@@ -39,6 +39,7 @@ import type { ModelPickerTarget } from './model-picker.ts';
39
39
  import type { KillRing } from './kill-ring.ts';
40
40
  import type { PanelMouseLayout } from './handler-feed-routes.ts';
41
41
  import type { FocusTracker } from '../core/focus-tracker.ts';
42
+ import type { PanelBurstGuardState } from './panel-paste-flood-guard.ts';
42
43
 
43
44
  /**
44
45
  * Initial mutable scalar values for InputFeedContext.
@@ -125,6 +126,8 @@ export interface FeedContextStableRefs {
125
126
  keybindingsManager: KeybindingsManager;
126
127
  killRing: KillRing;
127
128
  focusTracker: FocusTracker;
129
+ /** DEBT-5 item 5 — paste-flood guard state, mutated in place (never reallocated). */
130
+ panelBurstGuard: PanelBurstGuardState;
128
131
  getHistory: () => InfiniteBuffer;
129
132
  getViewportHeight: () => number;
130
133
  getScrollTop: () => number;
@@ -143,9 +143,16 @@ function withPanelFocusSync(context: CommandContext, state: CommandModeRouteStat
143
143
  }
144
144
  : undefined,
145
145
  showPanel: context.showPanel
146
- ? (panelId, pane) => {
147
- context.showPanel?.(panelId, pane);
148
- state.panelFocused = true;
146
+ ? (panelId, pane, target, opts) => {
147
+ context.showPanel?.(panelId, pane, target, opts);
148
+ // UX-C: showPanel no longer transfers focus by default — the command
149
+ // path leaves the composer focused (ui-openers.ts's showPanel only
150
+ // calls focusPanels() when opts.focus is set). Only flip the local
151
+ // panelFocused mirror when the caller explicitly asked for focus;
152
+ // otherwise leave it exactly as it was (the composer, since you can
153
+ // only type a command from there in the first place). `target` is the
154
+ // DEBT-5 deep-link jump target, forwarded verbatim.
155
+ if (opts?.focus) state.panelFocused = true;
149
156
  }
150
157
  : undefined,
151
158
  focusPanels: context.focusPanels
@@ -421,7 +421,7 @@ export function handleCtrlC(
421
421
  saveUndoState: () => void,
422
422
  setPrompt: (value: string) => void,
423
423
  setCursorPos: (value: number) => void,
424
- cancelGeneration: (() => void) | undefined,
424
+ cancelGeneration: (() => boolean | void) | undefined,
425
425
  exitApp: () => void,
426
426
  requestRender: () => void,
427
427
  lastCtrlCTime: number,
@@ -436,7 +436,22 @@ export function handleCtrlC(
436
436
  setCursorPos(0);
437
437
  return;
438
438
  }
439
- cancelGeneration?.();
439
+ // Stopping in-flight speech / aborting the turn must never prevent the quit
440
+ // double-press window from advancing. A throw from the async TTS-stop path
441
+ // (e.g. killing the audio subprocess) would otherwise swallow this press and
442
+ // strand the user unable to quit while TTS is speaking. Decouple the two: the
443
+ // press below is recorded no matter how speech-stop fares. (UX-B item 6b.)
444
+ let stoppedSpeech = false;
445
+ try {
446
+ stoppedSpeech = cancelGeneration?.() === true;
447
+ } catch {
448
+ // Non-fatal to the exit chord; the quit-window bookkeeping still runs.
449
+ }
450
+ // A press that silenced LIVE speech is consumed by that job (batch replay
451
+ // D5) — symmetric with the prompt-clearing press above. The quit chord
452
+ // ("Ctrl+C x2") starts from a quiet state; turn-aborts still count toward
453
+ // the double-press.
454
+ if (stoppedSpeech) return;
440
455
  const now = Date.now();
441
456
  // Clear any hide-timer pending from a prior press before deciding this
442
457
  // one's outcome, whichever branch below runs. Without this, a hide-timer
@@ -13,11 +13,13 @@ import {
13
13
  } from './handler-prompt-buffer.ts';
14
14
  import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, registerPaste } from './handler-content-actions.ts';
15
15
  import type { PanelManager } from '../panels/panel-manager.ts';
16
- import { renderPanelWorkspaceBar } from '../renderer/panel-workspace-bar.ts';
17
- import type { TabHitRegion } from '../renderer/tab-strip.ts';
16
+ import { getPanelUnderMouse, workspaceTabAtMouse } from './panel-mouse-geometry.ts';
17
+ import type { PanelMouseLayout } from './panel-mouse-geometry.ts';
18
+ export type { PanelMouseLayout };
18
19
  import type { KeybindingsManager } from './keybindings.ts';
19
20
  import type { KillRing } from './kill-ring.ts';
20
21
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
22
+ import { trackPanelPasteFloodGuard, type PanelBurstGuardState } from './panel-paste-flood-guard.ts';
21
23
 
22
24
  export type PanelFocusRouteState = {
23
25
  panelManager: PanelManager;
@@ -43,6 +45,9 @@ export type PanelFocusRouteState = {
43
45
  * no text capture (Invariant A: focus must not silently flip to the composer).
44
46
  */
45
47
  onPasteDropped?: (panelName: string) => void;
48
+ /** Wall-clock time (ms) for this token; `burstGuard` is DEBT-5 item 5's flood-guard state, mutated in place across tokens (see panel-paste-flood-guard.ts). */
49
+ now: number;
50
+ burstGuard: PanelBurstGuardState;
46
51
  /**
47
52
  * True while a turn is actively streaming; gates Escape's cancel-first
48
53
  * precedence (I6.1) ahead of the panel's own two-stage escape contract
@@ -87,20 +92,13 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
87
92
  // (e.g. dismiss a confirm dialog or clear search). Only unfocus if the
88
93
  // panel returns false (unconsumed) or there is no active panel.
89
94
  if (token.logicalName === 'escape') {
90
- // I6.1: while a turn is streaming, a focused panel must not be able to
91
- // swallow the only way to cancel it (replay R5) — Escape's first job is
92
- // always cancel-turn. The panel's own two-stage consume-or-unfocus
93
- // contract below only runs once no turn is active, so a *second*
94
- // Escape (now with the turn already cancelled) falls through to it
95
- // normally. panelFocused is left unchanged here (panel stays open and
96
- // focused) the panel close moves to that second Escape, or to
97
- // Ctrl+X (panel-close, keybindings.ts), which is handled earlier in
98
- // handleGlobalShortcutToken and is unaffected by this branch. This
99
- // deliberately calls cancelGeneration() directly instead of routing
100
- // through the shared handleEscape() (handler-modal-stack.ts) — that
101
- // function's fallthrough order (modal-pop -> active-modal-close ->
102
- // clear-nonempty-prompt -> cancelGeneration) is not guaranteed to reach
103
- // cancelGeneration first in every state combination.
95
+ // I6.1/replay R5: while streaming, a focused panel must not swallow the
96
+ // only way to cancel — Escape's first job is always cancel-turn; the
97
+ // panel's own two-stage consume-or-unfocus contract only runs once no
98
+ // turn is active, so a *second* Escape falls through to it normally
99
+ // (panelFocused stays unchanged here). Calls cancelGeneration()
100
+ // directly rather than the shared handleEscape() (handler-modal-stack.ts),
101
+ // whose fallthrough order doesn't guarantee reaching it first.
104
102
  if (state.isTurnActive()) {
105
103
  state.cancelGeneration();
106
104
  state.requestRender();
@@ -119,13 +117,10 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
119
117
  }
120
118
  const kb = state.keybindingsManager;
121
119
  // NOTE: panel-tab-next/prev, panel-close, and panel-close-all are handled
122
- // globally in handleGlobalShortcutToken, which runs earlier in the feed
123
- // loop and consumes those tokens before they ever reach this focused-panel
124
- // route. Their old copies lived here too and had drifted (this route never
125
- // called pm.hide() on close-all); they were unreachable, so they are gone
126
- // and close/close-all semantics now live in exactly one place. Only
127
- // panel-focus-toggle stays here, because it is meaningful only while the
128
- // panel workspace already owns focus (it swaps between the two panes).
120
+ // globally in handleGlobalShortcutToken (runs earlier in the feed loop) —
121
+ // their old, drifted copies here were unreachable and are gone. Only
122
+ // panel-focus-toggle stays, since it's meaningful only once the panel
123
+ // workspace already owns focus (it swaps between the two panes).
129
124
  if (kb.matches('panel-focus-toggle', token)) {
130
125
  // Switch keyboard focus between the top and bottom panes (no-op when
131
126
  // there is no visible, non-empty bottom pane).
@@ -153,20 +148,32 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
153
148
 
154
149
  if (token.type === 'text' && token.value) {
155
150
  const activePanel = state.panelManager.getActive();
156
- // Invariant A/B (W6.2). A paste (isPasteToken: one multi-char text token)
157
- // into a focused text-capturing panel (a `/`-search or steer-draft field —
158
- // isCapturingTextBurst) is forwarded verbatim below; into any other focused
159
- // panel it is DROPPED with a one-shot hint — never exploded into per-char
160
- // hotkeys, and never a silent focus flip to the composer (what the old
161
- // per-feed char-sum burst guard did, which also misfired on two quick nav
162
- // keys in one feed). Focus moves only on an explicit transfer verb, so
163
- // panelFocused is left unchanged. Discrete 1-char keystrokes are not pastes
164
- // and fall through to the per-char dispatch below, one at a time.
151
+ // UX-C: '/' is an explicit transfer verb back to the composer (a capturing panel, isCapturingTextBurst, keeps '/' for itself).
152
+ if (token.value === '/' && !activePanel?.isCapturingTextBurst?.()) {
153
+ panelFocused = false;
154
+ return { handled: false, panelFocused };
155
+ }
156
+ // Invariant A/B (W6.2): a paste (isPasteToken, one multi-char token) into
157
+ // a capturing panel forwards verbatim below; elsewhere it is DROPPED with
158
+ // a one-shot hint (never exploded into hotkeys, never a silent focus
159
+ // flip focus moves only on an explicit verb). Discrete keystrokes fall
160
+ // through to the per-char dispatch below.
165
161
  if (state.isPasteToken && !activePanel?.isCapturingTextBurst?.()) {
166
162
  state.onPasteDropped?.(activePanel?.name ?? 'the panel');
167
163
  state.requestRender();
168
164
  return { handled: true, panelFocused };
169
165
  }
166
+
167
+ // DEBT-5 item 5 — paste flood guard (rate-based; see panel-paste-flood-guard.ts).
168
+ if (!state.isPasteToken && !activePanel?.isCapturingTextBurst?.()) {
169
+ const guard = trackPanelPasteFloodGuard(state.burstGuard, state.now);
170
+ if (!guard.dispatch) {
171
+ if (guard.showHintNow) state.onPasteDropped?.(activePanel?.name ?? 'the panel');
172
+ state.requestRender();
173
+ return { handled: true, panelFocused };
174
+ }
175
+ }
176
+
170
177
  if (activePanel?.handleInput) {
171
178
  for (const ch of token.value) {
172
179
  activePanel.handleInput(ch);
@@ -204,8 +211,7 @@ export function handleIndicatorFocusToken(state: IndicatorFocusRouteState, token
204
211
  }
205
212
  if (token.logicalName === 'enter') {
206
213
  indicatorFocused = false;
207
- // W2.2: repointed from the retired process modal (removed in W6.2) to the
208
- // Fleet panel — F2 also opens the Fleet panel (handlePromptKeyToken f2).
214
+ // W2.2: opens the Fleet panel (see openFleetPanel's own doc, line ~352).
209
215
  state.openFleetPanel();
210
216
  state.requestRender();
211
217
  return { handled: true, indicatorFocused };
@@ -603,12 +609,11 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
603
609
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
604
610
  }
605
611
 
612
+ // UX-C: F2 is now handled globally with toggle semantics (handler-shortcuts.ts)
613
+ // BEFORE it ever reaches this route; this branch is dead in the real
614
+ // pipeline but stays for panel-entry-points-reachable.test.ts's direct test.
606
615
  if (token.logicalName === 'f2') {
607
616
  indicatorFocused = false;
608
- // W6.2 e: F2 opens AND focuses the Fleet panel, which subsumes the deleted
609
- // process modal (ProcessModal/AgentDetailModal/LiveTailModal — removed in
610
- // W6.1 once this repoint made them unreachable). openFleetPanel sets
611
- // panelFocused on the shared context directly, so nothing more is returned.
612
617
  state.openFleetPanel();
613
618
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
614
619
  }
@@ -632,89 +637,6 @@ export type MouseRouteState = {
632
637
  handleCopy: () => void;
633
638
  };
634
639
 
635
- export type PanelMouseLayout = {
636
- x: number;
637
- y: number;
638
- width: number;
639
- height: number;
640
- hasBottomPane: boolean;
641
- verticalSplitRatio: number;
642
- };
643
-
644
- function clampRatio(value: number): number {
645
- return Math.max(0.2, Math.min(0.8, value));
646
- }
647
-
648
- function getActivePanelInPane(panelManager: PanelManager, pane: 'top' | 'bottom') {
649
- const target = pane === 'top' ? panelManager.getTopPane() : panelManager.getBottomPane();
650
- return target.panels[target.activeIndex] ?? null;
651
- }
652
-
653
- function getPanelUnderMouse(
654
- panelManager: PanelManager,
655
- layout: PanelMouseLayout | null,
656
- row: number,
657
- col: number,
658
- ) {
659
- if (
660
- layout === null
661
- || !panelManager.isVisible()
662
- || panelManager.getAllOpen().length === 0
663
- || col < layout.x
664
- || col >= layout.x + layout.width
665
- || row < layout.y
666
- || row >= layout.y + layout.height
667
- ) {
668
- return null;
669
- }
670
-
671
- const panelRow = row - layout.y;
672
- if (!layout.hasBottomPane) {
673
- return getActivePanelInPane(panelManager, 'top');
674
- }
675
-
676
- // Single consolidated workspace bar (row 0) + h-separator; the rest splits
677
- // between the two panes' content.
678
- const panelAreaRows = Math.max(0, layout.height - 1);
679
- const contentRows = Math.max(0, panelAreaRows - 1);
680
- const topContentRows = contentRows <= 1
681
- ? contentRows
682
- : Math.max(1, Math.floor(contentRows * clampRatio(layout.verticalSplitRatio)));
683
- // panelRow 0 = workspace bar; rows 1..topContentRows = top pane; rest = bottom.
684
- return panelRow <= topContentRows
685
- ? getActivePanelInPane(panelManager, 'top')
686
- : getActivePanelInPane(panelManager, 'bottom');
687
- }
688
-
689
- /**
690
- * If the mouse is over the consolidated workspace tab bar (the first panel
691
- * row), return the index of the tab under the cursor, else null. Recomputes the
692
- * tab hit regions by rendering the bar with a layout callback — cheap and keeps
693
- * the click geometry in lockstep with what was drawn.
694
- */
695
- function workspaceTabAtMouse(
696
- panelManager: PanelManager,
697
- layout: PanelMouseLayout | null,
698
- row: number,
699
- col: number,
700
- ): number | null {
701
- if (
702
- layout === null
703
- || !panelManager.isVisible()
704
- || panelManager.getAllOpen().length === 0
705
- || row !== layout.y // workspace bar is the first panel row
706
- || col < layout.x
707
- || col >= layout.x + layout.width
708
- ) {
709
- return null;
710
- }
711
- let regions: readonly TabHitRegion[] = [];
712
- renderPanelWorkspaceBar(panelManager.getWorkspaceTabs(), layout.width, true, (r) => { regions = r; });
713
- const relCol = col - layout.x;
714
- const hit = regions.find((rg) => relCol >= rg.startCol && relCol < rg.endCol);
715
- return hit ? hit.index : null;
716
- }
717
-
718
640
  function scrollPanelUnderMouse(
719
641
  state: MouseRouteState,
720
642
  token: Extract<InputToken, { type: 'mouse' }>,