@pellux/goodvibes-tui 1.1.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/CHANGELOG.md +85 -21
- package/README.md +28 -18
- 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/alert-gating.ts +67 -0
- package/src/core/approval-alert.ts +72 -0
- package/src/core/budget-breach-notifier.ts +106 -0
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +90 -14
- package/src/core/conversation.ts +18 -0
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/long-task-notifier.ts +33 -6
- 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 +104 -51
- package/src/core/turn-cancellation.ts +25 -0
- package/src/core/turn-event-wiring.ts +72 -3
- 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/export/cost-utils.ts +36 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +84 -5
- package/src/input/commands/checkpoint-runtime.ts +280 -0
- package/src/input/commands/codebase-runtime.ts +232 -0
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +10 -2
- package/src/input/commands/image-runtime.ts +112 -0
- package/src/input/commands/intelligence-runtime.ts +11 -1
- package/src/input/commands/local-auth-runtime.ts +4 -1
- package/src/input/commands/local-runtime.ts +9 -3
- package/src/input/commands/marketplace-runtime.ts +2 -2
- package/src/input/commands/memory.ts +73 -35
- package/src/input/commands/operator-panel-runtime.ts +71 -31
- package/src/input/commands/planning-runtime.ts +116 -4
- package/src/input/commands/platform-sandbox-runtime.ts +1 -6
- package/src/input/commands/plugin-runtime.ts +2 -2
- package/src/input/commands/policy-dispatch.ts +21 -0
- package/src/input/commands/provider-accounts-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +26 -6
- package/src/input/commands/recall-review.ts +35 -0
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/remote-runtime.ts +3 -3
- package/src/input/commands/runtime-services.ts +54 -13
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/session-workflow.ts +16 -1
- package/src/input/commands/settings-sync-runtime.ts +16 -4
- package/src/input/commands/shell-core.ts +25 -8
- package/src/input/commands/skills-runtime.ts +2 -8
- package/src/input/commands/subscription-runtime.ts +2 -2
- package/src/input/commands/test-runtime.ts +277 -0
- package/src/input/commands/websearch-runtime.ts +101 -0
- package/src/input/commands/work-plan-runtime.ts +36 -10
- package/src/input/commands/workstream-runtime.ts +488 -0
- package/src/input/commands.ts +12 -0
- package/src/input/config-modal-types.ts +175 -0
- package/src/input/config-modal.ts +592 -0
- package/src/input/feed-context-factory.ts +10 -8
- package/src/input/handler-command-route.ts +37 -20
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +74 -114
- package/src/input/handler-feed.ts +79 -27
- package/src/input/handler-interactions.ts +1 -3
- package/src/input/handler-modal-routes.ts +136 -6
- package/src/input/handler-modal-stack.ts +14 -7
- package/src/input/handler-modal-token-routes.ts +16 -49
- 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 +26 -102
- package/src/input/handler-shortcuts.ts +74 -14
- package/src/input/handler-types.ts +2 -6
- package/src/input/handler-ui-state.ts +10 -22
- package/src/input/handler.ts +12 -29
- package/src/input/keybindings.ts +23 -8
- package/src/input/model-picker-types.ts +24 -6
- package/src/input/model-picker.ts +77 -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-integration-actions.ts +9 -170
- 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 +146 -2
- package/src/input/settings-modal-mutations.ts +6 -4
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +49 -51
- package/src/panels/agent-inspector-shared.ts +2 -1
- package/src/panels/base-panel.ts +22 -1
- package/src/panels/builtin/agent.ts +21 -107
- package/src/panels/builtin/development.ts +15 -61
- package/src/panels/builtin/knowledge.ts +8 -32
- package/src/panels/builtin/operations.ts +84 -428
- package/src/panels/builtin/session.ts +21 -112
- package/src/panels/builtin/shared.ts +18 -46
- package/src/panels/builtin-modals.ts +218 -0
- package/src/panels/builtin-panels.ts +5 -0
- package/src/panels/cost-tracker-panel.ts +36 -10
- package/src/panels/diff-panel.ts +12 -43
- package/src/panels/eval-registry.ts +60 -0
- 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 +758 -0
- package/src/panels/fleet-read-model.ts +520 -0
- package/src/panels/fleet-steer.ts +125 -0
- package/src/panels/fleet-stop.ts +153 -0
- package/src/panels/fleet-tabs.ts +230 -0
- package/src/panels/fleet-transcript.ts +356 -0
- package/src/panels/index.ts +7 -31
- package/src/panels/modals/hooks-modal.ts +187 -0
- package/src/panels/modals/keybindings-modal.ts +166 -0
- package/src/panels/modals/knowledge-modal.ts +158 -0
- package/src/panels/modals/local-auth-modal.ts +132 -0
- package/src/panels/modals/marketplace-modal.ts +218 -0
- package/src/panels/modals/memory-modal.ts +180 -0
- package/src/panels/modals/modal-surface-helpers.ts +54 -0
- package/src/panels/modals/modal-theme.ts +42 -0
- package/src/panels/modals/pairing-modal.ts +163 -0
- package/src/panels/modals/planning-modal.ts +304 -0
- package/src/panels/modals/plugins-modal.ts +174 -0
- package/src/panels/modals/policy-modal.ts +256 -0
- package/src/panels/modals/provider-health-modal.ts +136 -0
- package/src/panels/modals/remote-modal.ts +115 -0
- package/src/panels/modals/sandbox-modal.ts +150 -0
- package/src/panels/modals/security-modal.ts +217 -0
- package/src/panels/modals/services-modal.ts +179 -0
- package/src/panels/modals/settings-sync-modal.ts +142 -0
- package/src/panels/modals/skills-modal.ts +189 -0
- package/src/panels/modals/subscription-modal.ts +172 -0
- package/src/panels/modals/work-plan-modal.ts +177 -0
- package/src/panels/panel-confirm-overlay.ts +72 -0
- package/src/panels/panel-manager.ts +123 -9
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -11
- package/src/panels/skills-panel.ts +7 -51
- package/src/panels/types.ts +37 -0
- package/src/permissions/hunk-selection.ts +179 -0
- package/src/permissions/prompt.ts +216 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compaction-history-modal.ts +19 -3
- package/src/renderer/compaction-preview.ts +13 -2
- package/src/renderer/compaction-quality.ts +100 -0
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +95 -0
- package/src/renderer/conversation-overlays.ts +10 -17
- package/src/renderer/fleet-tab-strip.ts +56 -0
- package/src/renderer/footer-tips.ts +15 -3
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +43 -1
- package/src/renderer/help-overlay.ts +2 -2
- 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 +62 -4
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/panel-workspace-bar.ts +8 -2
- 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 +133 -0
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +27 -1
- package/src/runtime/bootstrap-command-parts.ts +41 -10
- package/src/runtime/bootstrap-core.ts +47 -15
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +48 -17
- package/src/runtime/bootstrap.ts +129 -22
- package/src/runtime/code-index-services.ts +135 -0
- 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 +59 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +98 -46
- 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 +27 -3
- package/src/runtime/workstream-services.ts +327 -0
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +68 -4
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +171 -26
- package/src/utils/format-duration.ts +8 -18
- package/src/utils/splash-lines.ts +1 -1
- package/src/utils/terminal-width.ts +52 -0
- package/src/version.ts +1 -1
- package/src/panels/agent-inspector-panel.ts +0 -786
- package/src/panels/approval-panel.ts +0 -252
- package/src/panels/automation-control-panel.ts +0 -479
- package/src/panels/cockpit-panel.ts +0 -481
- package/src/panels/cockpit-read-model.ts +0 -238
- package/src/panels/communication-panel.ts +0 -256
- package/src/panels/control-plane-panel.ts +0 -470
- package/src/panels/debug-panel.ts +0 -615
- package/src/panels/docs-panel.ts +0 -384
- package/src/panels/eval-panel.ts +0 -627
- package/src/panels/file-explorer-panel.ts +0 -673
- package/src/panels/file-preview-panel.ts +0 -517
- package/src/panels/hooks-panel.ts +0 -401
- package/src/panels/incident-review-panel.ts +0 -406
- package/src/panels/intelligence-panel.ts +0 -383
- package/src/panels/knowledge-graph-panel.ts +0 -515
- package/src/panels/marketplace-panel.ts +0 -399
- package/src/panels/memory-panel.ts +0 -558
- package/src/panels/ops-control-panel.ts +0 -329
- package/src/panels/ops-strategy-panel.ts +0 -321
- package/src/panels/orchestration-panel.ts +0 -465
- package/src/panels/panel-list-panel.ts +0 -566
- package/src/panels/plan-dashboard-panel.ts +0 -707
- package/src/panels/policy-panel.ts +0 -517
- package/src/panels/project-planning-panel.ts +0 -731
- package/src/panels/provider-health-panel.ts +0 -678
- package/src/panels/provider-health-tracker.ts +0 -310
- package/src/panels/provider-health-views.ts +0 -567
- package/src/panels/qr-panel.ts +0 -280
- package/src/panels/remote-panel.ts +0 -534
- package/src/panels/routes-panel.ts +0 -241
- package/src/panels/sandbox-panel.ts +0 -456
- package/src/panels/security-panel.ts +0 -447
- package/src/panels/services-panel.ts +0 -329
- package/src/panels/session-browser-panel.ts +0 -496
- package/src/panels/settings-sync-panel.ts +0 -398
- package/src/panels/subscription-panel.ts +0 -342
- package/src/panels/symbol-outline-panel.ts +0 -619
- package/src/panels/system-messages-panel.ts +0 -364
- package/src/panels/tasks-panel.ts +0 -608
- package/src/panels/thinking-panel.ts +0 -333
- package/src/panels/tool-inspector-panel.ts +0 -537
- package/src/panels/work-plan-panel.ts +0 -540
- package/src/panels/worktree-panel.ts +0 -360
- package/src/panels/wrfc-panel.ts +0 -790
- package/src/renderer/agent-detail-modal.ts +0 -466
- package/src/renderer/live-tail-modal.ts +0 -156
- package/src/renderer/process-modal.ts +0 -671
- package/src/renderer/qr-renderer.ts +0 -120
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigModalAction,
|
|
3
|
+
ConfigModalActionContext,
|
|
4
|
+
ConfigModalRow,
|
|
5
|
+
ConfigModalSurface,
|
|
6
|
+
ConfigModalTab,
|
|
7
|
+
ConfigModalView,
|
|
8
|
+
} from './config-modal-types.ts';
|
|
9
|
+
import type { ModalSectionStyle } from '../renderer/modal-factory.ts';
|
|
10
|
+
import { truncateDisplay, wrapText } from '../utils/terminal-width.ts';
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
ConfigModalAction,
|
|
14
|
+
ConfigModalActionContext,
|
|
15
|
+
ConfigModalRow,
|
|
16
|
+
ConfigModalSurface,
|
|
17
|
+
ConfigModalTab,
|
|
18
|
+
ConfigModalView,
|
|
19
|
+
} from './config-modal-types.ts';
|
|
20
|
+
|
|
21
|
+
/** A tab as the renderer sees it (structure frozen, label live). */
|
|
22
|
+
export interface ConfigModalRenderTab {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly active: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A row as the renderer sees it: frozen identity, live label/value overlay. */
|
|
29
|
+
export interface ConfigModalRenderRow {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly label: string;
|
|
32
|
+
readonly style?: ModalSectionStyle;
|
|
33
|
+
readonly selected: boolean;
|
|
34
|
+
readonly selectable: boolean;
|
|
35
|
+
/** True when this frozen row has no live counterpart this tick (value went
|
|
36
|
+
* stale — kept in place, dimmed, until the next interaction boundary). */
|
|
37
|
+
readonly stale: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Everything renderConfigModal needs — computed by overlaying live values onto
|
|
41
|
+
* the frozen structure so layout stays stable between key presses. */
|
|
42
|
+
export interface ConfigModalRenderModel {
|
|
43
|
+
readonly title: string;
|
|
44
|
+
readonly tabs: readonly ConfigModalRenderTab[];
|
|
45
|
+
readonly header: readonly string[];
|
|
46
|
+
readonly rows: readonly ConfigModalRenderRow[];
|
|
47
|
+
readonly emptyText?: string;
|
|
48
|
+
readonly degraded?: string;
|
|
49
|
+
readonly status?: string;
|
|
50
|
+
readonly hints: readonly string[];
|
|
51
|
+
readonly scroll: { readonly offset: number; readonly total: number; readonly visible: number };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_VISIBLE_ROWS = 10;
|
|
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
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ConfigModal — the single, generic, named config-modal host. One instance
|
|
78
|
+
* lives on the InputHandler (like `settingsModal`); it renders whatever
|
|
79
|
+
* `ConfigModalSurface` is currently open. Key routing is a single
|
|
80
|
+
* `handleConfigModalToken` path (handler-modal-routes.ts) — no parallel input
|
|
81
|
+
* system. See config-modal-types.ts for the surface contract and the liveness
|
|
82
|
+
* doctrine this class enforces.
|
|
83
|
+
*/
|
|
84
|
+
export class ConfigModal {
|
|
85
|
+
public active = false;
|
|
86
|
+
|
|
87
|
+
private surface: ConfigModalSurface | null = null;
|
|
88
|
+
private requestRender: () => void = () => {};
|
|
89
|
+
|
|
90
|
+
/** Active tab / selected row tracked by STABLE id (survives value refresh). */
|
|
91
|
+
private activeTabId = '';
|
|
92
|
+
private selectedRowId = '';
|
|
93
|
+
|
|
94
|
+
/** Transient status line (action result, error, or confirm prompt). */
|
|
95
|
+
private statusMessage = '';
|
|
96
|
+
/** Pending destructive confirm: the action key awaiting a second press. */
|
|
97
|
+
private pendingConfirmKey: string | null = null;
|
|
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
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Structure captured at the last interaction boundary (open / key press).
|
|
129
|
+
* Renders overlay live values onto THIS so rows never reflow mid-interaction.
|
|
130
|
+
*/
|
|
131
|
+
private frozenView: ConfigModalView | null = null;
|
|
132
|
+
|
|
133
|
+
private scrollOffset = 0;
|
|
134
|
+
private visibleRows = DEFAULT_VISIBLE_ROWS;
|
|
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
|
+
|
|
145
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
open(surface: ConfigModalSurface, requestRender: () => void = () => {}): void {
|
|
148
|
+
// Re-opening a different surface closes the previous one cleanly.
|
|
149
|
+
if (this.surface && this.surface !== surface) this.surface.onClose?.();
|
|
150
|
+
this.surface = surface;
|
|
151
|
+
this.requestRender = requestRender;
|
|
152
|
+
this.active = true;
|
|
153
|
+
this.statusMessage = '';
|
|
154
|
+
this.pendingConfirmKey = null;
|
|
155
|
+
this.scrollOffset = 0;
|
|
156
|
+
this.filterActive = false;
|
|
157
|
+
this.filterQuery = '';
|
|
158
|
+
this.interactedSinceOpen = false;
|
|
159
|
+
const view = surface.buildView();
|
|
160
|
+
this.frozenView = view;
|
|
161
|
+
this.activeTabId = view.tabs[0]?.id ?? '';
|
|
162
|
+
this.selectedRowId = this._firstSelectableId(this._frozenTab());
|
|
163
|
+
surface.onOpen?.(requestRender);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close(): void {
|
|
167
|
+
if (!this.active) return;
|
|
168
|
+
this.surface?.onClose?.();
|
|
169
|
+
this.active = false;
|
|
170
|
+
this.surface = null;
|
|
171
|
+
this.frozenView = null;
|
|
172
|
+
this.statusMessage = '';
|
|
173
|
+
this.pendingConfirmKey = null;
|
|
174
|
+
this.filterActive = false;
|
|
175
|
+
this.filterQuery = '';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Re-activate the current surface after a nested modal closes (Esc stack). */
|
|
179
|
+
reopen(): void {
|
|
180
|
+
if (!this.surface) return;
|
|
181
|
+
this.active = true;
|
|
182
|
+
this.syncStructure();
|
|
183
|
+
this.surface.onOpen?.(this.requestRender);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
getSurfaceName(): string | null {
|
|
187
|
+
return this.surface?.name ?? null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setViewportRows(rows: number): void {
|
|
191
|
+
this.visibleRows = Math.max(3, rows);
|
|
192
|
+
this._clampScroll();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Interaction boundary ────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/** Token router calls this for every token the open modal receives. */
|
|
198
|
+
noteInteraction(): void {
|
|
199
|
+
this.interactedSinceOpen = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Re-capture the structure from the current live view. Called on every key
|
|
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.
|
|
209
|
+
*/
|
|
210
|
+
syncStructure(): void {
|
|
211
|
+
if (!this.surface) return;
|
|
212
|
+
const view = this._applyFilter(this.surface.buildView());
|
|
213
|
+
this.frozenView = view;
|
|
214
|
+
if (!view.tabs.some((t) => t.id === this.activeTabId)) {
|
|
215
|
+
this.activeTabId = view.tabs[0]?.id ?? '';
|
|
216
|
+
}
|
|
217
|
+
const tab = this._frozenTab();
|
|
218
|
+
if (!this._selectableIds(tab).includes(this.selectedRowId)) {
|
|
219
|
+
this.selectedRowId = this._firstSelectableId(tab);
|
|
220
|
+
}
|
|
221
|
+
this._clampScroll();
|
|
222
|
+
}
|
|
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
|
+
|
|
276
|
+
// ── Navigation (each is an interaction boundary) ────────────────────────────
|
|
277
|
+
|
|
278
|
+
moveDown(): void {
|
|
279
|
+
this._clearConfirm();
|
|
280
|
+
this.syncStructure();
|
|
281
|
+
const ids = this._selectableIds(this._frozenTab());
|
|
282
|
+
if (ids.length === 0) return;
|
|
283
|
+
const i = ids.indexOf(this.selectedRowId);
|
|
284
|
+
this.selectedRowId = ids[(i + 1) % ids.length]!;
|
|
285
|
+
this._clampScroll();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
moveUp(): void {
|
|
289
|
+
this._clearConfirm();
|
|
290
|
+
this.syncStructure();
|
|
291
|
+
const ids = this._selectableIds(this._frozenTab());
|
|
292
|
+
if (ids.length === 0) return;
|
|
293
|
+
const i = ids.indexOf(this.selectedRowId);
|
|
294
|
+
this.selectedRowId = ids[(i - 1 + ids.length) % ids.length]!;
|
|
295
|
+
this._clampScroll();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
nextTab(): void {
|
|
299
|
+
this._switchTab(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
prevTab(): void {
|
|
303
|
+
this._switchTab(-1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private _switchTab(dir: 1 | -1): void {
|
|
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 = '';
|
|
312
|
+
this.syncStructure();
|
|
313
|
+
const tabs = this.frozenView?.tabs ?? [];
|
|
314
|
+
if (tabs.length <= 1) return;
|
|
315
|
+
const i = tabs.findIndex((t) => t.id === this.activeTabId);
|
|
316
|
+
const next = tabs[(i + dir + tabs.length) % tabs.length]!;
|
|
317
|
+
this.activeTabId = next.id;
|
|
318
|
+
this.selectedRowId = this._firstSelectableId(next);
|
|
319
|
+
this.scrollOffset = 0;
|
|
320
|
+
this.statusMessage = '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Actions ─────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/** The action bound to `key` on the active tab/row, if enabled. */
|
|
326
|
+
resolveAction(key: string): ConfigModalAction | null {
|
|
327
|
+
const actions = this.surface?.actions ?? [];
|
|
328
|
+
const row = this.getSelectedRow();
|
|
329
|
+
for (const action of actions) {
|
|
330
|
+
if (action.key !== key) continue;
|
|
331
|
+
if (action.enabledFor && !action.enabledFor(row, this.activeTabId)) return null;
|
|
332
|
+
return action;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Attempt to fire the action bound to `key`. Returns true if a key was an
|
|
339
|
+
* action (consumed). Handles the two-press confirm for destructive actions.
|
|
340
|
+
* This is an interaction boundary (syncs structure first).
|
|
341
|
+
*/
|
|
342
|
+
fireAction(key: string, ctx: Omit<ConfigModalActionContext, 'row' | 'tabId' | 'setStatus' | 'close' | 'requestRender'>): boolean {
|
|
343
|
+
this.syncStructure();
|
|
344
|
+
const action = this.resolveAction(key);
|
|
345
|
+
if (!action) return false;
|
|
346
|
+
if (action.confirm && this.pendingConfirmKey !== key) {
|
|
347
|
+
this.pendingConfirmKey = key;
|
|
348
|
+
this.statusMessage = `Press ${key} again to ${action.label}.`;
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
this._clearConfirm();
|
|
352
|
+
const fullCtx: ConfigModalActionContext = {
|
|
353
|
+
row: this.getSelectedRow(),
|
|
354
|
+
tabId: this.activeTabId,
|
|
355
|
+
print: ctx.print,
|
|
356
|
+
executeCommand: ctx.executeCommand,
|
|
357
|
+
openModal: ctx.openModal,
|
|
358
|
+
submitInput: ctx.submitInput,
|
|
359
|
+
requestRender: this.requestRender,
|
|
360
|
+
setStatus: (m: string) => { this.statusMessage = m; },
|
|
361
|
+
close: () => this.close(),
|
|
362
|
+
};
|
|
363
|
+
this.surface?.onAction?.(action.id, fullCtx);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Clear a pending confirm when the user navigates or presses an unrelated key. */
|
|
368
|
+
clearConfirmOnMiss(): void {
|
|
369
|
+
this._clearConfirm();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Read accessors (for the renderer + tests) ───────────────────────────────
|
|
373
|
+
|
|
374
|
+
getSelectedRow(): ConfigModalRow | null {
|
|
375
|
+
const tab = this._liveTab(this.activeTabId) ?? this._frozenTab();
|
|
376
|
+
return tab?.rows.find((r) => r.id === this.selectedRowId) ?? null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
getActiveTabId(): string {
|
|
380
|
+
return this.activeTabId;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
getSelectedRowId(): string {
|
|
384
|
+
return this.selectedRowId;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
getStatusMessage(): string {
|
|
388
|
+
return this.statusMessage;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Compute the render model: frozen structure + live value overlay. Pure —
|
|
393
|
+
* does NOT sync structure, so calling it repeatedly with only value mutations
|
|
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.
|
|
403
|
+
*/
|
|
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();
|
|
409
|
+
const live = this.surface?.buildView() ?? null;
|
|
410
|
+
const frozen = this.frozenView;
|
|
411
|
+
if (!frozen) {
|
|
412
|
+
return { title: '', tabs: [], header: [], rows: [], hints: [], scroll: { offset: 0, total: 0, visible: this.visibleRows } };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const tabs: ConfigModalRenderTab[] = frozen.tabs.map((ft) => {
|
|
416
|
+
const lt = live?.tabs.find((t) => t.id === ft.id);
|
|
417
|
+
return { id: ft.id, label: lt?.label ?? ft.label, active: ft.id === this.activeTabId };
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const frozenTab = frozen.tabs.find((t) => t.id === this.activeTabId) ?? frozen.tabs[0];
|
|
421
|
+
const liveTab = live?.tabs.find((t) => t.id === this.activeTabId);
|
|
422
|
+
|
|
423
|
+
// Header: all-or-nothing live overlay. Same line count → use live values;
|
|
424
|
+
// a count change is a structural change, deferred (keep frozen header).
|
|
425
|
+
const frozenHeader = frozenTab?.header ?? [];
|
|
426
|
+
const liveHeader = liveTab?.header ?? [];
|
|
427
|
+
const header = liveHeader.length === frozenHeader.length ? liveHeader : frozenHeader;
|
|
428
|
+
|
|
429
|
+
// Rows: iterate the frozen id order; overlay the live row (value refresh) by
|
|
430
|
+
// id, or keep the frozen row marked stale when its live counterpart is gone.
|
|
431
|
+
const frozenRows = frozenTab?.rows ?? [];
|
|
432
|
+
const allRows: ConfigModalRenderRow[] = frozenRows.map((fr) => {
|
|
433
|
+
const lr = liveTab?.rows.find((r) => r.id === fr.id);
|
|
434
|
+
const src = lr ?? fr;
|
|
435
|
+
return {
|
|
436
|
+
id: fr.id,
|
|
437
|
+
label: this._clampRowLabel(fr.label, src.label, labelWrapWidth),
|
|
438
|
+
style: src.style,
|
|
439
|
+
selected: fr.id === this.selectedRowId,
|
|
440
|
+
selectable: fr.selectable !== false,
|
|
441
|
+
stale: lr === undefined,
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const visible = this.visibleRows;
|
|
446
|
+
const windowed = allRows.slice(this.scrollOffset, this.scrollOffset + visible);
|
|
447
|
+
|
|
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
|
+
];
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
title: live?.title ?? frozen.title,
|
|
472
|
+
tabs,
|
|
473
|
+
header,
|
|
474
|
+
rows: windowed,
|
|
475
|
+
emptyText: frozenRows.length === 0 ? (frozenTab?.emptyText ?? 'Nothing to show.') : undefined,
|
|
476
|
+
degraded: live?.degraded ?? frozen.degraded,
|
|
477
|
+
status: this.statusMessage || undefined,
|
|
478
|
+
hints,
|
|
479
|
+
scroll: { offset: this.scrollOffset, total: allRows.length, visible },
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── internals ────────────────────────────────────────────────────────────────
|
|
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
|
+
|
|
546
|
+
private _actionHints(): string[] {
|
|
547
|
+
const actions = this.surface?.actions ?? [];
|
|
548
|
+
const row = this.getSelectedRow();
|
|
549
|
+
const out: string[] = [];
|
|
550
|
+
for (const a of actions) {
|
|
551
|
+
if (a.enabledFor && !a.enabledFor(row, this.activeTabId)) continue;
|
|
552
|
+
out.push(`${a.key} ${a.label}`);
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private _clearConfirm(): void {
|
|
558
|
+
if (this.pendingConfirmKey !== null) {
|
|
559
|
+
this.pendingConfirmKey = null;
|
|
560
|
+
this.statusMessage = '';
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private _frozenTab(): ConfigModalTab | undefined {
|
|
565
|
+
return this.frozenView?.tabs.find((t) => t.id === this.activeTabId) ?? this.frozenView?.tabs[0];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private _liveTab(id: string): ConfigModalTab | undefined {
|
|
569
|
+
return this.surface?.buildView().tabs.find((t) => t.id === id);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private _selectableIds(tab: ConfigModalTab | undefined): string[] {
|
|
573
|
+
return (tab?.rows ?? []).filter((r) => r.selectable !== false).map((r) => r.id);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private _firstSelectableId(tab: ConfigModalTab | undefined): string {
|
|
577
|
+
return this._selectableIds(tab)[0] ?? '';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private _clampScroll(): void {
|
|
581
|
+
const tab = this._frozenTab();
|
|
582
|
+
const rows = tab?.rows ?? [];
|
|
583
|
+
const idx = rows.findIndex((r) => r.id === this.selectedRowId);
|
|
584
|
+
const visible = Math.max(3, this.visibleRows);
|
|
585
|
+
if (idx >= 0) {
|
|
586
|
+
if (idx < this.scrollOffset) this.scrollOffset = idx;
|
|
587
|
+
else if (idx >= this.scrollOffset + visible) this.scrollOffset = idx - visible + 1;
|
|
588
|
+
}
|
|
589
|
+
const maxOffset = Math.max(0, rows.length - visible);
|
|
590
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
@@ -22,15 +22,13 @@ import type { SelectionResult } from './selection-modal.ts';
|
|
|
22
22
|
import type { SearchManager } from './search.ts';
|
|
23
23
|
import type { InputHistory, HistorySearch } from './input-history.ts';
|
|
24
24
|
import type { ConversationManager } from '../core/conversation';
|
|
25
|
-
import type { ProcessModal } from '../renderer/process-modal.ts';
|
|
26
|
-
import type { LiveTailModal } from '../renderer/live-tail-modal.ts';
|
|
27
25
|
import type { BlockActionsMenu } from '../renderer/block-actions.ts';
|
|
28
|
-
import type { AgentDetailModal } from '../renderer/agent-detail-modal.ts';
|
|
29
26
|
import type { ContextInspectorModal } from '../renderer/context-inspector.ts';
|
|
30
27
|
import type { BookmarkModal } from './bookmark-modal.ts';
|
|
31
28
|
import type { SettingsModal } from './settings-modal.ts';
|
|
32
29
|
import type { McpWorkspace } from './mcp-workspace.ts';
|
|
33
30
|
import type { SessionPickerModal } from './session-picker-modal.ts';
|
|
31
|
+
import type { ConfigModal } from './config-modal.ts';
|
|
34
32
|
import type { ProfilePickerModal } from './profile-picker-modal.ts';
|
|
35
33
|
import type { OnboardingWizardController } from './onboarding/onboarding-wizard.ts';
|
|
36
34
|
import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
|
|
@@ -40,6 +38,8 @@ import type { KeybindingsManager } from './keybindings.ts';
|
|
|
40
38
|
import type { ModelPickerTarget } from './model-picker.ts';
|
|
41
39
|
import type { KillRing } from './kill-ring.ts';
|
|
42
40
|
import type { PanelMouseLayout } from './handler-feed-routes.ts';
|
|
41
|
+
import type { FocusTracker } from '../core/focus-tracker.ts';
|
|
42
|
+
import type { PanelBurstGuardState } from './panel-paste-flood-guard.ts';
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Initial mutable scalar values for InputFeedContext.
|
|
@@ -82,8 +82,7 @@ export interface FeedContextMutableInit {
|
|
|
82
82
|
* - `pasteRegistry`, `imageRegistry` — owned Maps, never replaced
|
|
83
83
|
* - `selectionModal`, `bookmarkModal`, `settingsModal`, `sessionPickerModal`,
|
|
84
84
|
* `profilePickerModal` — modal objects constructed once
|
|
85
|
-
* - `filePicker`, `modelPicker`, `
|
|
86
|
-
* `agentDetailModal`, `contextInspectorModal`, `blockActionsMenu`,
|
|
85
|
+
* - `filePicker`, `modelPicker`, `contextInspectorModal`, `blockActionsMenu`,
|
|
87
86
|
* `searchManager`, `historySearch`, `onboardingWizard` — service objects constructed once
|
|
88
87
|
* - `panelManager`, `keybindingsManager` — from uiServices, stable
|
|
89
88
|
* - `modalStack` — reference to the handler's shared array
|
|
@@ -91,6 +90,8 @@ export interface FeedContextMutableInit {
|
|
|
91
90
|
* - `commandRegistry`, `commandContext`, `autocomplete`, `inputHistory`,
|
|
92
91
|
* `conversationManager` — wired after construction; synced at feed() entry only
|
|
93
92
|
* (not per-action) since no in-feed action changes them
|
|
93
|
+
* - `focusTracker` (W2.3) — shared instance from uiServices.platform, mutated
|
|
94
|
+
* directly by feedInputTokens() on 'focus' tokens; never reallocated
|
|
94
95
|
*
|
|
95
96
|
* **Rationale:** per-feed mutation on a single object avoids per-keystroke GC pressure
|
|
96
97
|
* from ~80-field object allocation. Stable references are service handles that never
|
|
@@ -107,6 +108,7 @@ export interface FeedContextStableRefs {
|
|
|
107
108
|
mcpWorkspace: McpWorkspace;
|
|
108
109
|
sessionPickerModal: SessionPickerModal;
|
|
109
110
|
profilePickerModal: ProfilePickerModal;
|
|
111
|
+
configModal: ConfigModal;
|
|
110
112
|
historySearch: HistorySearch;
|
|
111
113
|
commandRegistry: CommandRegistry | null;
|
|
112
114
|
commandContext: CommandContext | undefined;
|
|
@@ -114,9 +116,6 @@ export interface FeedContextStableRefs {
|
|
|
114
116
|
filePicker: FilePickerModal;
|
|
115
117
|
modelPicker: ModelPickerModal;
|
|
116
118
|
onboardingWizard: OnboardingWizardController;
|
|
117
|
-
processModal: ProcessModal;
|
|
118
|
-
liveTailModal: LiveTailModal;
|
|
119
|
-
agentDetailModal: AgentDetailModal;
|
|
120
119
|
contextInspectorModal: ContextInspectorModal;
|
|
121
120
|
blockActionsMenu: BlockActionsMenu;
|
|
122
121
|
searchManager: SearchManager;
|
|
@@ -126,6 +125,9 @@ export interface FeedContextStableRefs {
|
|
|
126
125
|
panelManager: PanelManager;
|
|
127
126
|
keybindingsManager: KeybindingsManager;
|
|
128
127
|
killRing: KillRing;
|
|
128
|
+
focusTracker: FocusTracker;
|
|
129
|
+
/** DEBT-5 item 5 — paste-flood guard state, mutated in place (never reallocated). */
|
|
130
|
+
panelBurstGuard: PanelBurstGuardState;
|
|
129
131
|
getHistory: () => InfiniteBuffer;
|
|
130
132
|
getViewportHeight: () => number;
|
|
131
133
|
getScrollTop: () => number;
|