@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
|
@@ -5,8 +5,15 @@
|
|
|
5
5
|
* architecture line ceiling; main() wires these with its live services.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
8
9
|
import type { ConversationMessageSnapshot } from '../core/conversation.ts';
|
|
9
|
-
import type { SessionSnapshot } from '@/runtime/index.ts';
|
|
10
|
+
import type { SessionReturnContextSummary, SessionSnapshot } from '@/runtime/index.ts';
|
|
11
|
+
import {
|
|
12
|
+
checkRecoveryFile,
|
|
13
|
+
deleteRecoveryFile as deleteLiveRecoveryFile,
|
|
14
|
+
getRecoveryFilePath,
|
|
15
|
+
loadRecoveryConversation as loadLiveRecoveryConversation,
|
|
16
|
+
} from '@/runtime/index.ts';
|
|
10
17
|
|
|
11
18
|
export interface PersistRecoveryDeps {
|
|
12
19
|
readonly sessionManager: {
|
|
@@ -41,6 +48,168 @@ export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: S
|
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// W3 Finding 3: preserve the dismissed recovery snapshot.
|
|
53
|
+
//
|
|
54
|
+
// main.ts's 60s autosave (writeRecoveryFile) overwrites the single shared
|
|
55
|
+
// recovery.jsonl with the CURRENT session's live state within a minute — so
|
|
56
|
+
// the dismiss banner's promise ("still on disk; you will be asked again next
|
|
57
|
+
// time") went false silently well before the next launch. Fix: the moment
|
|
58
|
+
// the user dismisses (blocking-input.ts), copy recovery.jsonl aside to a
|
|
59
|
+
// preserved sibling file (recovery.jsonl.preserved) so it survives this
|
|
60
|
+
// session's later autosaves. Startup then checks BOTH the live file and the
|
|
61
|
+
// preserved sibling and offers whichever is newer (pickNewestRecoveryInfo),
|
|
62
|
+
// bounded to exactly one preserved file — a later dismiss replaces it, and
|
|
63
|
+
// that replacement is reported honestly (PreserveRecoveryResult.replacedPrevious)
|
|
64
|
+
// rather than silently dropping the older snapshot.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface RecoveryFileDeps {
|
|
68
|
+
readonly homeDirectory: string;
|
|
69
|
+
readonly surfaceRoot?: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PreservedRecoveryInfo {
|
|
73
|
+
readonly title: string;
|
|
74
|
+
readonly timestamp: number;
|
|
75
|
+
readonly sessionId: string;
|
|
76
|
+
readonly returnContext?: SessionReturnContextSummary | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PreserveRecoveryResult {
|
|
80
|
+
/** True if a live recovery.jsonl existed and was copied to the preserved sibling. */
|
|
81
|
+
readonly preserved: boolean;
|
|
82
|
+
/** True if an existing preserved sibling (from an earlier dismiss) was overwritten. */
|
|
83
|
+
readonly replacedPrevious: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function preservedRecoveryPath(deps: RecoveryFileDeps): string {
|
|
87
|
+
return `${getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot)}.preserved`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readRecoveryMetaLine(path: string): { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary } | null {
|
|
91
|
+
try {
|
|
92
|
+
if (!existsSync(path)) return null;
|
|
93
|
+
const firstLine = readFileSync(path, 'utf-8').split('\n')[0];
|
|
94
|
+
if (!firstLine) return null;
|
|
95
|
+
return JSON.parse(firstLine) as { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary };
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Copy the live recovery.jsonl aside to the preserved sibling. Called once, at dismiss. */
|
|
102
|
+
export function createPreserveRecoveryFile(deps: RecoveryFileDeps): () => PreserveRecoveryResult {
|
|
103
|
+
return () => {
|
|
104
|
+
const live = getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot);
|
|
105
|
+
const preserved = preservedRecoveryPath(deps);
|
|
106
|
+
if (!existsSync(live)) return { preserved: false, replacedPrevious: false };
|
|
107
|
+
const replacedPrevious = existsSync(preserved);
|
|
108
|
+
try {
|
|
109
|
+
copyFileSync(live, preserved);
|
|
110
|
+
return { preserved: true, replacedPrevious };
|
|
111
|
+
} catch {
|
|
112
|
+
return { preserved: false, replacedPrevious: false };
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Startup check for the preserved sibling — mirrors the SDK's
|
|
119
|
+
* checkRecoveryFile meta read, but for the fixed preserved path. No
|
|
120
|
+
* mtime-vs-last-clean-save gate: the preserved file is inert (never
|
|
121
|
+
* autosaved-over) until explicitly replaced or cleared, so its mere
|
|
122
|
+
* existence is actionable.
|
|
123
|
+
*/
|
|
124
|
+
export function checkPreservedRecoveryFile(deps: RecoveryFileDeps): PreservedRecoveryInfo | null {
|
|
125
|
+
const meta = readRecoveryMetaLine(preservedRecoveryPath(deps));
|
|
126
|
+
if (!meta) return null;
|
|
127
|
+
return { title: meta.title ?? '', timestamp: meta.timestamp ?? 0, sessionId: meta.sessionId ?? '', returnContext: meta.returnContext };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Load the preserved sibling's full snapshot — same JSONL shape as recovery.jsonl since it's a byte-identical copy. */
|
|
131
|
+
export function loadPreservedRecoveryConversation(deps: RecoveryFileDeps): SessionSnapshot | null {
|
|
132
|
+
const path = preservedRecoveryPath(deps);
|
|
133
|
+
try {
|
|
134
|
+
if (!existsSync(path)) return null;
|
|
135
|
+
const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
|
|
136
|
+
if (lines.length < 2) return { messages: [] };
|
|
137
|
+
const metaLine = JSON.parse(lines[0]!) as { title?: string; titleSource?: SessionSnapshot['titleSource']; returnContext?: SessionSnapshot['returnContext'] };
|
|
138
|
+
return {
|
|
139
|
+
title: metaLine.title,
|
|
140
|
+
titleSource: metaLine.titleSource,
|
|
141
|
+
returnContext: metaLine.returnContext,
|
|
142
|
+
messages: lines.slice(1).map((line) => {
|
|
143
|
+
const { type: _type, ...rest } = JSON.parse(line) as { type: string } & Record<string, unknown>;
|
|
144
|
+
return rest;
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Remove the preserved sibling — called after it has been restored or discarded. */
|
|
153
|
+
export function deletePreservedRecoveryFile(deps: RecoveryFileDeps): void {
|
|
154
|
+
try {
|
|
155
|
+
unlinkSync(preservedRecoveryPath(deps));
|
|
156
|
+
} catch {
|
|
157
|
+
// missing file is fine
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pick the newer of the live vs. preserved recovery info for the startup
|
|
163
|
+
* prompt, tagging which source it came from so the caller knows which
|
|
164
|
+
* load/delete pair to use. Null when neither exists.
|
|
165
|
+
*/
|
|
166
|
+
export function pickNewestRecoveryInfo<T extends { readonly timestamp: number }>(
|
|
167
|
+
live: T | null,
|
|
168
|
+
preserved: T | null,
|
|
169
|
+
): (T & { readonly source: 'live' | 'preserved' }) | null {
|
|
170
|
+
if (!live && !preserved) return null;
|
|
171
|
+
if (!live) return { ...(preserved as T), source: 'preserved' };
|
|
172
|
+
if (!preserved) return { ...live, source: 'live' };
|
|
173
|
+
return preserved.timestamp > live.timestamp
|
|
174
|
+
? { ...(preserved as T), source: 'preserved' }
|
|
175
|
+
: { ...live, source: 'live' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Startup entry point: resolve which recovery prompt (if any) main.ts should
|
|
180
|
+
* show — the live recovery.jsonl, or an earlier dismiss's preserved sibling,
|
|
181
|
+
* whichever is newer. Bundles checkRecoveryFile + checkPreservedRecoveryFile
|
|
182
|
+
* + pickNewestRecoveryInfo so main.ts's entrypoint stays a one-liner.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveStartupRecoveryInfo(
|
|
185
|
+
deps: RecoveryFileDeps & { readonly workingDirectory: string },
|
|
186
|
+
): (PreservedRecoveryInfo & { readonly source: 'live' | 'preserved' }) | null {
|
|
187
|
+
const live = checkRecoveryFile({ workingDirectory: deps.workingDirectory, homeDirectory: deps.homeDirectory });
|
|
188
|
+
const preserved = checkPreservedRecoveryFile(deps);
|
|
189
|
+
return pickNewestRecoveryInfo(live, preserved);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface RecoveryFileOps {
|
|
193
|
+
readonly loadRecoveryConversation: () => SessionSnapshot | null;
|
|
194
|
+
readonly deleteRecoveryFile: () => void;
|
|
195
|
+
readonly preserveRecoveryFile?: () => PreserveRecoveryResult;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Build the three source-aware recovery callbacks main.ts's stdin handler
|
|
200
|
+
* wires into handleBlockingShellInput. `getSource` is read fresh by
|
|
201
|
+
* load/delete on every call (main.ts's recoverySource can change between
|
|
202
|
+
* events); preserveRecoveryFile is only ever wired when the CURRENT prompt
|
|
203
|
+
* came from the live file (see createPreserveRecoveryFile's doc comment).
|
|
204
|
+
*/
|
|
205
|
+
export function createRecoveryFileOps(getSource: () => 'live' | 'preserved', deps: RecoveryFileDeps): RecoveryFileOps {
|
|
206
|
+
return {
|
|
207
|
+
loadRecoveryConversation: () => (getSource() === 'preserved' ? loadPreservedRecoveryConversation(deps) : loadLiveRecoveryConversation(deps)),
|
|
208
|
+
deleteRecoveryFile: () => (getSource() === 'preserved' ? deletePreservedRecoveryFile(deps) : deleteLiveRecoveryFile(deps)),
|
|
209
|
+
preserveRecoveryFile: getSource() === 'live' ? createPreserveRecoveryFile(deps) : undefined,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
44
213
|
export interface ErrorAffordanceDeps {
|
|
45
214
|
/** True when the failover retry context is armed (a retry is actually possible). */
|
|
46
215
|
readonly retryArmed: boolean;
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
|
|
|
15
15
|
import type { EmbeddingProviderPickerEntry, ModelPickerTargetInfo } from '../input/model-picker.ts';
|
|
16
16
|
import type { SelectionItem } from '../input/selection-modal.ts';
|
|
17
17
|
import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
|
|
18
|
+
import { setActiveThemeMode } from '../renderer/theme.ts';
|
|
19
|
+
import { THEME_MODE_CONFIG_KEY, coerceThemeModeSetting } from '../renderer/theme-mode-config.ts';
|
|
18
20
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
19
21
|
|
|
20
22
|
type WireShellUiOpenersOptions = {
|
|
@@ -229,7 +231,13 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
229
231
|
|
|
230
232
|
commandContext.openModelPicker = () => {
|
|
231
233
|
void (async () => {
|
|
232
|
-
|
|
234
|
+
// D5: getSelectableModels() is catalog-driven and can list models whose
|
|
235
|
+
// `provider` id (e.g. 'google', sourced from the pricing catalog) was never
|
|
236
|
+
// handed to providerRegistry.register()/registerRuntimeProvider(). Selecting
|
|
237
|
+
// such a model fails hard at turn time with ProviderNotFoundError
|
|
238
|
+
// ("Provider 'X' is not registered."). Filter to runtime-registered
|
|
239
|
+
// providers only, so the picker never offers a model that cannot work.
|
|
240
|
+
const models = providerRegistry.getSelectableModels().filter((m) => providerRegistry.has(m.provider));
|
|
233
241
|
const configuredIds = new Set(getConfiguredProviderIds());
|
|
234
242
|
input.modelPicker.configuredProviders = configuredIds;
|
|
235
243
|
const providerIds = [...new Set(models.map((m) => m.provider))];
|
|
@@ -256,7 +264,13 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
256
264
|
|
|
257
265
|
commandContext.openProviderPicker = () => {
|
|
258
266
|
void (async () => {
|
|
259
|
-
|
|
267
|
+
// D5: listModels() surfaces every catalog provider id, not just the ones
|
|
268
|
+
// actually registered on this runtime (see openModelPicker above for the
|
|
269
|
+
// same class of bug). Intersect against providerRegistry.has() so the
|
|
270
|
+
// provider picker never offers a provider that will fail with
|
|
271
|
+
// "Provider not registered" once a model under it is selected.
|
|
272
|
+
const providers = [...new Set(providerRegistry.listModels().map((model) => model.provider))]
|
|
273
|
+
.filter((providerId) => providerRegistry.has(providerId));
|
|
260
274
|
const configuredIds = new Set(getConfiguredProviderIds());
|
|
261
275
|
input.modelPicker.configuredProviders = configuredIds;
|
|
262
276
|
const secretProviderIds = await resolveSecretProviderIds();
|
|
@@ -325,10 +339,23 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
325
339
|
commandContext.openSettingsModal = (target?: string) => {
|
|
326
340
|
input.modalOpened('settings');
|
|
327
341
|
input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager, {
|
|
328
|
-
onSettingApplied: (change) =>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
342
|
+
onSettingApplied: (change) => {
|
|
343
|
+
// DEBT-2: forced dark/light applies immediately (rebuild palettes + full
|
|
344
|
+
// repaint); auto only re-probes at startup, so it takes effect next launch.
|
|
345
|
+
if (String(change.key) === THEME_MODE_CONFIG_KEY) {
|
|
346
|
+
const next = coerceThemeModeSetting(change.value);
|
|
347
|
+
if (next === 'dark' || next === 'light') {
|
|
348
|
+
setActiveThemeMode(next);
|
|
349
|
+
commandContext.requestFullRepaint?.();
|
|
350
|
+
return { message: `Theme mode: ${next} (applied now)` };
|
|
351
|
+
}
|
|
352
|
+
return { message: 'Theme mode: auto (probes terminal on next startup)' };
|
|
353
|
+
}
|
|
354
|
+
return syncServiceSettingToPlatform(
|
|
355
|
+
{ configManager, workingDirectory, homeDirectory },
|
|
356
|
+
change,
|
|
357
|
+
);
|
|
358
|
+
},
|
|
332
359
|
});
|
|
333
360
|
input.settingsModal.selectTarget(target);
|
|
334
361
|
render();
|
|
@@ -441,10 +468,16 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
441
468
|
render();
|
|
442
469
|
};
|
|
443
470
|
|
|
444
|
-
commandContext.showPanel = (panelId, pane) => {
|
|
445
|
-
|
|
471
|
+
commandContext.showPanel = (panelId, pane, target, opts) => {
|
|
472
|
+
// DEBT-5: forward the deep-link target so a jumped-to panel lands on the
|
|
473
|
+
// right row (fleet --target); undefined for ordinary opens.
|
|
474
|
+
panelManager.open(panelId, pane, target);
|
|
446
475
|
panelManager.show();
|
|
447
|
-
|
|
476
|
+
// UX-C focus rule 1a: every registered caller of showPanel is a slash
|
|
477
|
+
// command (/panel open, /routes, /approval, /tasks, /ops-control, ...) —
|
|
478
|
+
// the command path leaves focus in the composer ("the user is
|
|
479
|
+
// mid-command-flow") unless the caller explicitly asks to grab it.
|
|
480
|
+
if (opts?.focus) panelManager.focusPanels();
|
|
448
481
|
conversation.setSplashSuppressed(true);
|
|
449
482
|
conversation.rebuildHistory();
|
|
450
483
|
render();
|
|
@@ -86,6 +86,13 @@ export function getDisplayWidth(text: string): number {
|
|
|
86
86
|
code === 0x2717 ||
|
|
87
87
|
code === 0x2714 ||
|
|
88
88
|
code === 0x2718 ||
|
|
89
|
+
// ✕ (0x2715) and ✖ (0x2716) — the multiplication-X cross family used for
|
|
90
|
+
// the error-line prefix. Terminals draw them one cell wide, but they sit
|
|
91
|
+
// inside the 0x2600–0x27bf emoji block below and would otherwise be counted
|
|
92
|
+
// as width 2, desyncing the styled cell grid from the physical glyph and
|
|
93
|
+
// corrupting the following text (the "✕t" glitch on the steer error line).
|
|
94
|
+
code === 0x2715 ||
|
|
95
|
+
code === 0x2716 ||
|
|
89
96
|
code === 0x2022 ||
|
|
90
97
|
code === 0x258d ||
|
|
91
98
|
(code >= 0x2500 && code <= 0x257f)
|
|
@@ -159,6 +166,51 @@ export function truncateDisplay(text: string, width: number, ellipsis = '…'):
|
|
|
159
166
|
return result + ellipsis;
|
|
160
167
|
}
|
|
161
168
|
|
|
169
|
+
/** A single footer/status-line segment plus its survival priority. */
|
|
170
|
+
export interface PrioritizedSegment {
|
|
171
|
+
readonly text: string;
|
|
172
|
+
/** Lower number = higher priority = dropped LAST under width pressure. */
|
|
173
|
+
readonly priority: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Join segments left-to-right with `separator`, but when the joined line
|
|
178
|
+
* would exceed `width`, drop whole low-priority segments (highest `priority`
|
|
179
|
+
* number first) one at a time until it fits — rather than character-truncating
|
|
180
|
+
* the joined string, which can mangle a high-value segment mid-word (e.g. a
|
|
181
|
+
* `spine:online` daemon-liveness marker clipped to `spi…`).
|
|
182
|
+
*
|
|
183
|
+
* Only falls back to character truncation (via truncateDisplay) if the
|
|
184
|
+
* remaining highest-priority segments still don't fit at width — a rare,
|
|
185
|
+
* very-narrow-terminal case.
|
|
186
|
+
*/
|
|
187
|
+
export function joinPrioritizedSegments(
|
|
188
|
+
segments: readonly PrioritizedSegment[],
|
|
189
|
+
separator: string,
|
|
190
|
+
width: number,
|
|
191
|
+
): string {
|
|
192
|
+
const join = (list: readonly PrioritizedSegment[]) => list.map(s => s.text).join(separator);
|
|
193
|
+
let kept = segments;
|
|
194
|
+
// Keep dropping whole segments while more than one remains; once a single
|
|
195
|
+
// segment is left, stop — an empty result would be a worse outcome than
|
|
196
|
+
// falling through to character truncation on that last segment below.
|
|
197
|
+
while (kept.length > 1 && getDisplayWidth(join(kept)) > width) {
|
|
198
|
+
// Drop the single lowest-priority (highest `priority` number) segment;
|
|
199
|
+
// ties broken toward the leftmost (earlier-declared) segment surviving —
|
|
200
|
+
// `>=` (not `>`) so that on equal priority the LATER index keeps winning
|
|
201
|
+
// as the drop candidate, leaving the earliest-declared segment of that
|
|
202
|
+
// priority tier intact (e.g. cwd survives over model when both are
|
|
203
|
+
// priority 0, since cwd is declared first).
|
|
204
|
+
let dropIdx = 0;
|
|
205
|
+
for (let i = 1; i < kept.length; i++) {
|
|
206
|
+
if (kept[i].priority >= kept[dropIdx].priority) dropIdx = i;
|
|
207
|
+
}
|
|
208
|
+
kept = kept.slice(0, dropIdx).concat(kept.slice(dropIdx + 1));
|
|
209
|
+
}
|
|
210
|
+
const joined = join(kept);
|
|
211
|
+
return getDisplayWidth(joined) > width ? truncateDisplay(joined, width) : joined;
|
|
212
|
+
}
|
|
213
|
+
|
|
162
214
|
export function padDisplayEnd(text: string, width: number): string {
|
|
163
215
|
const currentWidth = getDisplayWidth(text);
|
|
164
216
|
if (currentWidth >= width) return text;
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '1.
|
|
9
|
+
let _version = '1.9.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|