@pellux/goodvibes-tui 1.7.0 → 1.9.0
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.
- package/README.md +10 -9
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +71 -14
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +68 -1
- package/src/core/turn-cancellation.ts +7 -2
- package/src/core/turn-event-wiring.ts +10 -2
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +46 -4
- package/src/input/commands/codebase-runtime.ts +46 -6
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/health-runtime.ts +9 -1
- package/src/input/commands/memory.ts +68 -35
- package/src/input/commands/operator-panel-runtime.ts +31 -7
- package/src/input/commands/planning-runtime.ts +95 -6
- package/src/input/commands/qrcode-runtime.ts +25 -5
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/settings-sync-runtime.ts +15 -3
- package/src/input/commands/shell-core.ts +10 -1
- package/src/input/commands/workstream-runtime.ts +168 -18
- package/src/input/config-modal-types.ts +15 -1
- package/src/input/config-modal.ts +227 -12
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +10 -3
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +43 -121
- package/src/input/handler-feed.ts +32 -4
- package/src/input/handler-modal-routes.ts +78 -13
- package/src/input/handler-modal-stack.ts +11 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +15 -8
- package/src/input/handler-shortcuts.ts +59 -9
- package/src/input/handler.ts +6 -1
- package/src/input/keybindings.ts +6 -5
- package/src/input/model-picker.ts +19 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +51 -2
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +28 -27
- package/src/panels/builtin/shared.ts +9 -3
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +89 -131
- package/src/panels/fleet-read-model.ts +43 -0
- package/src/panels/fleet-steer.ts +35 -2
- package/src/panels/fleet-stop.ts +29 -1
- package/src/panels/modals/keybindings-modal.ts +16 -1
- package/src/panels/modals/modal-theme.ts +35 -29
- package/src/panels/modals/pairing-modal.ts +25 -6
- package/src/panels/modals/planning-modal.ts +43 -21
- package/src/panels/modals/work-plan-modal.ts +28 -0
- package/src/panels/panel-manager.ts +15 -4
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -13
- package/src/panels/types.ts +24 -0
- package/src/permissions/prompt.ts +160 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +19 -5
- package/src/renderer/footer-tips.ts +5 -1
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +3 -1
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +18 -3
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +22 -3
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +8 -4
- package/src/runtime/bootstrap-core.ts +33 -11
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +19 -1
- package/src/runtime/bootstrap.ts +118 -5
- package/src/runtime/code-index-services.ts +25 -2
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +26 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +10 -0
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +12 -8
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +19 -3
- package/src/runtime/workstream-services.ts +160 -28
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +46 -3
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +42 -9
- package/src/utils/terminal-width.ts +52 -0
- 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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
17
|
-
import type {
|
|
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
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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:
|
|
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' }>,
|