@pellux/goodvibes-tui 1.19.5 → 1.20.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/bookmark-navigation.ts +44 -0
- package/src/core/conversation-line-cache.ts +87 -19
- package/src/core/conversation-rendering.ts +186 -28
- package/src/core/conversation-tool-groups.ts +152 -0
- package/src/core/conversation-types.ts +30 -3
- package/src/core/conversation.ts +17 -0
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +62 -7
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +19 -2
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +55 -56
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +15 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +1 -1
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -1
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +312 -0
- package/src/runtime/resume-notice.ts +26 -21
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +17 -98
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -22,6 +22,7 @@ import type { CancellationScope, CrossSessionTaskRef } from '@pellux/goodvibes-s
|
|
|
22
22
|
import { VALID_SCOPES } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
23
23
|
import { handleSessionWorkflowCommand } from './session-workflow.ts';
|
|
24
24
|
import { requireSessionOrchestration, requireSessionManager } from './runtime-services.ts';
|
|
25
|
+
import { filterUserFacingSessions } from './session-picker-filter.ts';
|
|
25
26
|
|
|
26
27
|
// ── Argument parsing helpers ──────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -316,6 +317,37 @@ function handleCancel(args: string[], context: CommandContext): void {
|
|
|
316
317
|
|
|
317
318
|
// ── Top-level command definition ───────────────────────────────────────────────
|
|
318
319
|
|
|
320
|
+
/**
|
|
321
|
+
* SESSION_SUBCOMMAND_ARG_HINTS — the single source of truth for each
|
|
322
|
+
* `/session <subcommand>` argument hint, co-located with the switch below so
|
|
323
|
+
* the two can't silently drift apart (command-args-hint.ts imports this
|
|
324
|
+
* directly instead of hand-maintaining its own copy; a drift test —
|
|
325
|
+
* src/test/input/command-args-hint.test.ts — cross-checks every key here
|
|
326
|
+
* against the default usage text's own subcommand list). Aliases (`link`,
|
|
327
|
+
* `ho`, `g`) share their canonical subcommand's hint text.
|
|
328
|
+
*/
|
|
329
|
+
export const SESSION_SUBCOMMAND_ARG_HINTS: Record<string, string> = {
|
|
330
|
+
list: '',
|
|
331
|
+
rename: '<name>',
|
|
332
|
+
resume: '<id|name>',
|
|
333
|
+
fork: '[name]',
|
|
334
|
+
save: '[name]',
|
|
335
|
+
info: '[id]',
|
|
336
|
+
export: '<id|.> [markdown|text]',
|
|
337
|
+
search: '<query>',
|
|
338
|
+
delete: '<id>',
|
|
339
|
+
events: '[kind]',
|
|
340
|
+
groups: '[kind]',
|
|
341
|
+
hotspots: '',
|
|
342
|
+
'link-task': '<taskId> [--session <sid>] [--depends-on <sid:taskId>] [--label <label>]',
|
|
343
|
+
link: '<taskId> [--session <sid>] [--depends-on <sid:taskId>] [--label <label>]',
|
|
344
|
+
handoff: '<taskId> --to <sid> [--session <sid>] [--reason <reason>]',
|
|
345
|
+
ho: '<taskId> --to <sid> [--session <sid>] [--reason <reason>]',
|
|
346
|
+
graph: '[--session <sid>] [--format text|json]',
|
|
347
|
+
g: '[--session <sid>] [--format text|json]',
|
|
348
|
+
cancel: '<taskId> [--scope task|subtree|session] [--session <sid>] [--reason <reason>]',
|
|
349
|
+
};
|
|
350
|
+
|
|
319
351
|
/**
|
|
320
352
|
* sessionCommand — The `/session` slash command.
|
|
321
353
|
*
|
|
@@ -446,7 +478,10 @@ export const resumeCommand: SlashCommand = {
|
|
|
446
478
|
return;
|
|
447
479
|
}
|
|
448
480
|
const sm = requireSessionManager(ctx);
|
|
449
|
-
|
|
481
|
+
// Excludes the current session and subagent-shaped transcripts (see
|
|
482
|
+
// session-picker-filter.ts) — a direct `/resume agent-xxxxxxxx` still
|
|
483
|
+
// reaches them, this only keeps the picker itself readable.
|
|
484
|
+
const sessions = filterUserFacingSessions(sm.list()).filter((s) => s.name !== ctx.session.runtime.sessionId);
|
|
450
485
|
if (sessions.length === 0) {
|
|
451
486
|
ctx.print('No previous sessions to resume yet. /session save [name] stores the current one explicitly.');
|
|
452
487
|
return;
|
|
@@ -71,7 +71,20 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
71
71
|
ctx.openShortcutsOverlay();
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
|
-
ctx.print('Use
|
|
74
|
+
ctx.print('Use /commands for shortcuts');
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
registry.register({
|
|
79
|
+
name: 'find',
|
|
80
|
+
aliases: ['findtext'],
|
|
81
|
+
description: 'Search THIS conversation transcript (same as Ctrl+F) — searching the web instead? use /search',
|
|
82
|
+
handler(_args, ctx) {
|
|
83
|
+
if (ctx.openTranscriptSearch) {
|
|
84
|
+
ctx.openTranscriptSearch();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
ctx.print('Transcript search is not available in this context — try Ctrl+F.');
|
|
75
88
|
},
|
|
76
89
|
});
|
|
77
90
|
|
|
@@ -81,10 +94,18 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
81
94
|
description: 'List current keyboard bindings and their config file path',
|
|
82
95
|
handler(_args, ctx) {
|
|
83
96
|
const km = requireKeybindingsManager(ctx);
|
|
97
|
+
// The full binding table (~45 rows) is presented via the same
|
|
98
|
+
// generated, scrollable overlay /shortcuts opens (see
|
|
99
|
+
// renderShortcutsOverlay) rather than a one-shot transcript dump —
|
|
100
|
+
// the file path (dynamic, per-user) still gets a short transcript
|
|
101
|
+
// line since the overlay itself doesn't show it.
|
|
102
|
+
ctx.print(`Keybindings config: ${km.getConfigPath()} (create it to override any binding: { "action": { "key": "x", "ctrl": true } })`);
|
|
103
|
+
if (ctx.openShortcutsOverlay) {
|
|
104
|
+
ctx.openShortcutsOverlay();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
84
107
|
const all = km.getAll();
|
|
85
108
|
const lines: string[] = [
|
|
86
|
-
`Keybindings config: ${km.getConfigPath()}`,
|
|
87
|
-
'',
|
|
88
109
|
` ${'Action'.padEnd(28)} ${'Binding'.padEnd(20)} Description`,
|
|
89
110
|
` ${'─'.repeat(28)} ${'─'.repeat(20)} ${'─'.repeat(34)}`,
|
|
90
111
|
];
|
|
@@ -92,8 +113,6 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
92
113
|
const label = combos.map((combo) => km.formatCombo(combo)).join(', ');
|
|
93
114
|
lines.push(` ${action.padEnd(28)} ${label.padEnd(20)} ${description}`);
|
|
94
115
|
}
|
|
95
|
-
lines.push('');
|
|
96
|
-
lines.push('To customize: create the config file with { "action": { "key": "x", "ctrl": true } }');
|
|
97
116
|
ctx.print(lines.join('\n'));
|
|
98
117
|
},
|
|
99
118
|
});
|
|
@@ -114,6 +133,39 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
114
133
|
},
|
|
115
134
|
});
|
|
116
135
|
|
|
136
|
+
registry.register({
|
|
137
|
+
name: 'pastes',
|
|
138
|
+
aliases: ['pending-pastes'],
|
|
139
|
+
description: 'Preview the actual content behind each [TEXT: pN, M lines] fold in the composer, before you submit',
|
|
140
|
+
usage: '[pN]',
|
|
141
|
+
argsHint: '[pN]',
|
|
142
|
+
handler(args, ctx) {
|
|
143
|
+
const pending = ctx.getPendingPastes?.();
|
|
144
|
+
if (!pending || pending.size === 0) {
|
|
145
|
+
ctx.print('[Pastes] No folded pastes are currently in the composer (pastes over 8 lines fold to [TEXT: pN, M lines]).');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const requestedId = args[0]?.trim();
|
|
149
|
+
if (requestedId) {
|
|
150
|
+
const content = pending.get(requestedId);
|
|
151
|
+
if (!content) {
|
|
152
|
+
ctx.print(`[Pastes] No pending paste with id "${requestedId}". Pending: ${[...pending.keys()].join(', ')}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
ctx.print(`[Pastes] ${requestedId} (${content.split('\n').length} lines):\n${content}`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const lines = ['[Pastes] Folded in the composer right now:'];
|
|
159
|
+
for (const [id, content] of pending) {
|
|
160
|
+
const contentLines = content.split('\n');
|
|
161
|
+
const preview = contentLines[0].slice(0, 80);
|
|
162
|
+
lines.push(` ${id} — ${contentLines.length} lines — ${preview}${contentLines.length > 1 || preview.length < contentLines[0].length ? '…' : ''}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('', 'Full text: /pastes <id>');
|
|
165
|
+
ctx.print(lines.join('\n'));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
117
169
|
registry.register({
|
|
118
170
|
name: 'help',
|
|
119
171
|
aliases: ['h', '?'],
|
|
@@ -82,6 +82,91 @@ async function downloadText(fetchImpl: UpdateFetchLike, url: string): Promise<st
|
|
|
82
82
|
return await response.text();
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Where cancellation stops being allowed. An update is genuinely abortable up
|
|
87
|
+
* to and including the moment BEFORE the first file is written: every fetch on
|
|
88
|
+
* the way there carries the caller's signal, and every await boundary re-checks
|
|
89
|
+
* it. From the first write onward the swap owns the installed files and always
|
|
90
|
+
* runs to completion — a half-applied swap is the one outcome worse than a slow
|
|
91
|
+
* one.
|
|
92
|
+
*
|
|
93
|
+
* This record makes that boundary readable from outside the call. The launch
|
|
94
|
+
* updater gives `applyUpdate` a budget and abandons the promise when it runs
|
|
95
|
+
* out, so it cannot learn from the return value which side of the line the work
|
|
96
|
+
* was on — it reads these flags instead, and prints the receipt that is
|
|
97
|
+
* actually true (see src/cli/launch-auto-update.ts).
|
|
98
|
+
*/
|
|
99
|
+
export interface UpdateSwapProgress {
|
|
100
|
+
/** True from the first file write of the swap phase; from here the swap is never interrupted. */
|
|
101
|
+
begun: boolean;
|
|
102
|
+
/** True once every target file has been swapped into place and the update is fully installed. */
|
|
103
|
+
committed: boolean;
|
|
104
|
+
/** The release tag being installed, set as soon as the target is resolved (before any download). */
|
|
105
|
+
targetTag: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createUpdateSwapProgress(): UpdateSwapProgress {
|
|
109
|
+
return { begun: false, committed: false, targetTag: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The failure an aborted update ends with — raised only while nothing has been written yet. */
|
|
113
|
+
export const UPDATE_ABORTED_MESSAGE = 'update cancelled before any file was replaced';
|
|
114
|
+
|
|
115
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
116
|
+
if (signal?.aborted) {
|
|
117
|
+
throw new Error(UPDATE_ABORTED_MESSAGE);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The shared UpdateFetchLike init shape predates cancellation, so the signal
|
|
123
|
+
* rides on a widened version of it: the real `fetch` reads it — which is what
|
|
124
|
+
* makes the DOWNLOAD itself cancellable rather than merely abandoned — and a
|
|
125
|
+
* test stub that ignores the extra field behaves exactly as it did before.
|
|
126
|
+
*/
|
|
127
|
+
type AbortableFetchInit = NonNullable<Parameters<UpdateFetchLike>[1]> & { signal?: AbortSignal };
|
|
128
|
+
|
|
129
|
+
function abortableFetch(fetchImpl: UpdateFetchLike, signal: AbortSignal | undefined): UpdateFetchLike {
|
|
130
|
+
if (!signal) return fetchImpl;
|
|
131
|
+
const withSignal = fetchImpl as (url: string, init?: AbortableFetchInit) => ReturnType<UpdateFetchLike>;
|
|
132
|
+
return async (url, init) => {
|
|
133
|
+
throwIfAborted(signal);
|
|
134
|
+
return await withSignal(url, { ...init, signal });
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Wraps the filesystem seam so the first MUTATING call flips `begun`. The swap
|
|
140
|
+
* phase is the only part of the apply path that writes anything, so that first
|
|
141
|
+
* write is exactly the point after which cancellation must no longer be
|
|
142
|
+
* honoured. Reads (the daemon-present probe, the target-exists check inside the
|
|
143
|
+
* swap) leave the flag alone.
|
|
144
|
+
*/
|
|
145
|
+
function trackSwapProgress(io: UpdateFileIo, progress: UpdateSwapProgress): UpdateFileIo {
|
|
146
|
+
const begin = (): void => {
|
|
147
|
+
progress.begun = true;
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
writeFile: (path, data) => {
|
|
151
|
+
begin();
|
|
152
|
+
io.writeFile(path, data);
|
|
153
|
+
},
|
|
154
|
+
rename: (from, to) => {
|
|
155
|
+
begin();
|
|
156
|
+
io.rename(from, to);
|
|
157
|
+
},
|
|
158
|
+
chmod: (path, mode) => {
|
|
159
|
+
begin();
|
|
160
|
+
io.chmod(path, mode);
|
|
161
|
+
},
|
|
162
|
+
mkdir: (path) => {
|
|
163
|
+
begin();
|
|
164
|
+
io.mkdir(path);
|
|
165
|
+
},
|
|
166
|
+
exists: (path) => io.exists(path),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
85
170
|
/**
|
|
86
171
|
* Suffix under which every swap keeps the file it replaced — re-exported
|
|
87
172
|
* from the SDK's canonical update policy module so rollback and swap share
|
|
@@ -153,6 +238,16 @@ export interface ApplyUpdateOptions {
|
|
|
153
238
|
readonly runCommand?: RunCommand;
|
|
154
239
|
/** Injectable filesystem seam (the SDK's UpdateFileIo) so tests observe swaps in memory. */
|
|
155
240
|
readonly io?: UpdateFileIo;
|
|
241
|
+
/**
|
|
242
|
+
* Cancels the update — for real: it is passed to every fetch in the path and
|
|
243
|
+
* re-checked at every await boundary, so an abort stops the download instead
|
|
244
|
+
* of leaving it running unwatched. Honoured only up to the moment before the
|
|
245
|
+
* swap begins; from the first file write onward it is deliberately ignored
|
|
246
|
+
* (see UpdateSwapProgress).
|
|
247
|
+
*/
|
|
248
|
+
readonly signal?: AbortSignal;
|
|
249
|
+
/** Shared record letting the caller tell a cancelled update apart from one whose swap had already started. */
|
|
250
|
+
readonly progress?: UpdateSwapProgress;
|
|
156
251
|
}
|
|
157
252
|
|
|
158
253
|
/**
|
|
@@ -178,7 +273,14 @@ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
|
|
|
178
273
|
return;
|
|
179
274
|
}
|
|
180
275
|
|
|
181
|
-
|
|
276
|
+
// Every network call from here carries the caller's signal, so an abort
|
|
277
|
+
// cancels the request in flight rather than orphaning it.
|
|
278
|
+
const signal = options.signal;
|
|
279
|
+
const progress = options.progress;
|
|
280
|
+
const fetchImpl = abortableFetch(options.fetchImpl, signal);
|
|
281
|
+
|
|
282
|
+
throwIfAborted(signal);
|
|
283
|
+
const latestTag = await resolveLatestReleaseTag(fetchImpl, REPO_RELEASES_LATEST_URL);
|
|
182
284
|
if (compareVersions(options.currentVersion, latestTag) >= 0) {
|
|
183
285
|
options.print(`Already current: running v${normalizeVersion(options.currentVersion)}, latest release is ${latestTag}.`);
|
|
184
286
|
return;
|
|
@@ -190,6 +292,10 @@ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
|
|
|
190
292
|
return;
|
|
191
293
|
}
|
|
192
294
|
|
|
295
|
+
// Named before the first download, so a caller that gives up on a slow apply
|
|
296
|
+
// can still report WHICH version the work was installing.
|
|
297
|
+
if (progress) progress.targetTag = latestTag;
|
|
298
|
+
|
|
193
299
|
options.print(`Update available: ${latestTag} (running v${normalizeVersion(options.currentVersion)}). Downloading and verifying...`);
|
|
194
300
|
|
|
195
301
|
const baseUrl = releaseDownloadBaseUrl(latestTag);
|
|
@@ -209,7 +315,8 @@ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
|
|
|
209
315
|
// file is refreshed for consistency even though the platform blocks extension
|
|
210
316
|
// loading. This inclusion decision needs one manifest pre-read; the swap
|
|
211
317
|
// itself re-verifies every included artifact inside applyVerifiedUpdate.
|
|
212
|
-
|
|
318
|
+
throwIfAborted(signal);
|
|
319
|
+
const checksumText = await downloadText(fetchImpl, `${baseUrl}/${CHECKSUM_MANIFEST_NAME}`);
|
|
213
320
|
const checksums = parseChecksumFile(checksumText);
|
|
214
321
|
const addon = resolveSqliteVecAsset(options.platform, options.arch);
|
|
215
322
|
const addonIncluded = addon !== null && checksums.get(addon.assetName) !== undefined;
|
|
@@ -230,13 +337,22 @@ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
|
|
|
230
337
|
// One mechanism everywhere: downloads + verifies ALL targets before any
|
|
231
338
|
// write, then swaps each atomically with the outgoing file kept at
|
|
232
339
|
// `<path>.previous`.
|
|
340
|
+
//
|
|
341
|
+
// This is the last point at which the update can be called off. The
|
|
342
|
+
// downloads inside applyVerifiedUpdate are still cancellable (the signal
|
|
343
|
+
// rides on every request), but its swap loop is synchronous and runs to
|
|
344
|
+
// completion once its first write lands — which is precisely what
|
|
345
|
+
// `progress.begun` records, and why nothing below this call re-checks the
|
|
346
|
+
// signal.
|
|
347
|
+
throwIfAborted(signal);
|
|
233
348
|
await applyVerifiedUpdate({
|
|
234
|
-
fetchImpl
|
|
349
|
+
fetchImpl,
|
|
235
350
|
downloadBaseUrl: baseUrl,
|
|
236
351
|
targets,
|
|
237
|
-
io,
|
|
352
|
+
io: progress ? trackSwapProgress(io, progress) : io,
|
|
238
353
|
platform: options.platform,
|
|
239
354
|
});
|
|
355
|
+
if (progress) progress.committed = true;
|
|
240
356
|
|
|
241
357
|
const serviceInfo = detectDaemonServiceManaged(options.platform, options.configManager, options.runCommand);
|
|
242
358
|
|
|
@@ -35,7 +35,7 @@ function parseSearchArgs(args: string[]): ParsedSearchArgs {
|
|
|
35
35
|
export function registerWebSearchRuntimeCommands(registry: CommandRegistry): void {
|
|
36
36
|
registry.register({
|
|
37
37
|
name: 'search',
|
|
38
|
-
description: '
|
|
38
|
+
description: 'Web search — ranked results with source labels (searching THIS transcript? use /find)',
|
|
39
39
|
usage: '<query> [--limit <n>]',
|
|
40
40
|
argsHint: '<query> [--limit <n>]',
|
|
41
41
|
async handler(args, ctx) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { copyToClipboard, pasteFromClipboard, pasteImageFromClipboard } from '../utils/clipboard.ts';
|
|
3
3
|
import type { InfiniteBuffer } from '../core/history.ts';
|
|
4
|
-
import type { ConversationManager } from '../core/conversation';
|
|
4
|
+
import type { BlockMeta, ConversationManager } from '../core/conversation';
|
|
5
5
|
import type { PermissionCategory } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
6
6
|
import type { ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
|
|
7
7
|
import type { CommandContext } from './command-registry.ts';
|
|
@@ -243,6 +243,30 @@ export function findMarkerAtPos(prompt: string, pos: number): { start: number; e
|
|
|
243
243
|
return null;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* describeBlockForReceipt - Honest, human-readable name for a block, used by
|
|
248
|
+
* every anchor-based action's receipt (Ctrl+Y/B/S/A, Tab, block-actions menu)
|
|
249
|
+
* so the transcript always says exactly what was acted on — never just an
|
|
250
|
+
* internal collapseKey or a bare "done".
|
|
251
|
+
*/
|
|
252
|
+
export function describeBlockForReceipt(block: BlockMeta): string {
|
|
253
|
+
const lineText = `${block.lineCount} line${block.lineCount === 1 ? '' : 's'}`;
|
|
254
|
+
switch (block.type) {
|
|
255
|
+
case 'tool':
|
|
256
|
+
return block.toolName ? `tool result: ${block.toolName} (${lineText})` : `tool result (${lineText})`;
|
|
257
|
+
case 'tool_group':
|
|
258
|
+
return `tool result group (${lineText})`;
|
|
259
|
+
case 'diff':
|
|
260
|
+
return block.filePath ? `diff: ${block.filePath} (${lineText})` : `diff (${lineText})`;
|
|
261
|
+
case 'code':
|
|
262
|
+
return `code block (${lineText})`;
|
|
263
|
+
case 'thinking':
|
|
264
|
+
return `thinking (${lineText})`;
|
|
265
|
+
default:
|
|
266
|
+
return `${block.type} (${lineText})`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
246
270
|
export function handleCopy(
|
|
247
271
|
selection: SelectionManager,
|
|
248
272
|
getHistory: () => InfiniteBuffer,
|
|
@@ -256,30 +280,42 @@ export function handleCopy(
|
|
|
256
280
|
setTimeout(() => requestRender(), 2005);
|
|
257
281
|
}
|
|
258
282
|
|
|
283
|
+
/**
|
|
284
|
+
* handleBlockCopy - Ctrl+Y: copy the content of the block the user is
|
|
285
|
+
* actually looking at. `getAnchorLine` resolves to the viewport's
|
|
286
|
+
* bottom-most visible line (see getViewportBottomLine in
|
|
287
|
+
* conversation-layout.ts), not the raw scroll position — the top of the
|
|
288
|
+
* viewport is off-screen-above once the transcript is longer than one page.
|
|
289
|
+
*/
|
|
259
290
|
export function handleBlockCopy(
|
|
260
291
|
conversationManager: ConversationManager | null,
|
|
261
|
-
|
|
292
|
+
getAnchorLine: () => number,
|
|
262
293
|
requestRender: () => void,
|
|
263
294
|
onCopied: () => void,
|
|
264
295
|
): void {
|
|
265
296
|
if (!conversationManager) return;
|
|
266
|
-
const lineIndex =
|
|
267
|
-
const
|
|
268
|
-
if (!
|
|
269
|
-
|
|
297
|
+
const lineIndex = getAnchorLine();
|
|
298
|
+
const nearest = conversationManager.findNearestBlock(lineIndex);
|
|
299
|
+
if (!nearest) {
|
|
300
|
+
conversationManager.log('[Ctrl+Y: No block found nearby]', { fg: '240' });
|
|
301
|
+
requestRender();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
copyToClipboard(nearest.rawContent);
|
|
270
305
|
onCopied();
|
|
306
|
+
conversationManager.log(`[Copied ${describeBlockForReceipt(nearest)}]`, { fg: '#22c55e' });
|
|
271
307
|
requestRender();
|
|
272
308
|
setTimeout(() => requestRender(), 2005);
|
|
273
309
|
}
|
|
274
310
|
|
|
275
311
|
export function handleBookmark(
|
|
276
312
|
conversationManager: ConversationManager | null,
|
|
277
|
-
|
|
313
|
+
getAnchorLine: () => number,
|
|
278
314
|
requestRender: () => void,
|
|
279
315
|
bookmarkManager: BookmarkManager,
|
|
280
316
|
): void {
|
|
281
317
|
if (!conversationManager) return;
|
|
282
|
-
const lineIndex =
|
|
318
|
+
const lineIndex = getAnchorLine();
|
|
283
319
|
const nearest = conversationManager.findNearestBlock(lineIndex);
|
|
284
320
|
if (!nearest) {
|
|
285
321
|
conversationManager.log('[Ctrl+B: No block found nearby]', { fg: '240' });
|
|
@@ -288,80 +324,68 @@ export function handleBookmark(
|
|
|
288
324
|
}
|
|
289
325
|
const label = `${nearest.type}: ${nearest.rawContent.slice(0, 40).replace(/\n/g, ' ')}`;
|
|
290
326
|
const added = bookmarkManager.toggle(nearest.collapseKey, label);
|
|
327
|
+
const target = describeBlockForReceipt(nearest);
|
|
291
328
|
const msg = added
|
|
292
|
-
? `[Bookmarked: ${
|
|
293
|
-
: `[Bookmark removed: ${
|
|
329
|
+
? `[Bookmarked: ${target}]`
|
|
330
|
+
: `[Bookmark removed: ${target}]`;
|
|
294
331
|
conversationManager.log(msg, { fg: added ? '#22c55e' : '244' });
|
|
295
332
|
requestRender();
|
|
296
333
|
}
|
|
297
334
|
|
|
298
335
|
export function handleBlockSave(
|
|
299
336
|
conversationManager: ConversationManager | null,
|
|
300
|
-
|
|
337
|
+
getAnchorLine: () => number,
|
|
301
338
|
requestRender: () => void,
|
|
302
339
|
bookmarkManager: BookmarkManager,
|
|
303
340
|
): void {
|
|
304
341
|
if (!conversationManager) return;
|
|
305
|
-
const lineIndex =
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
342
|
+
const lineIndex = getAnchorLine();
|
|
343
|
+
const nearest = conversationManager.findNearestBlock(lineIndex);
|
|
344
|
+
if (!nearest) {
|
|
308
345
|
conversationManager.log('[Ctrl+S: No block found nearby]', { fg: '240' });
|
|
309
346
|
requestRender();
|
|
310
347
|
return;
|
|
311
348
|
}
|
|
312
|
-
const
|
|
313
|
-
const label = nearest?.type ?? 'block';
|
|
349
|
+
const target = describeBlockForReceipt(nearest);
|
|
314
350
|
try {
|
|
315
|
-
const filePath = bookmarkManager.saveToFile(
|
|
351
|
+
const filePath = bookmarkManager.saveToFile(nearest.rawContent, nearest.type);
|
|
316
352
|
const homePath = process.env.HOME || process.env.USERPROFILE || '';
|
|
317
353
|
const displayPath = homePath ? filePath.replace(homePath, '~') : filePath;
|
|
318
|
-
conversationManager.log(`[Saved to: ${displayPath}]`, { fg: '#22c55e' });
|
|
354
|
+
conversationManager.log(`[Saved ${target} to: ${displayPath}]`, { fg: '#22c55e' });
|
|
319
355
|
} catch (err) {
|
|
320
356
|
const msg = summarizeError(err);
|
|
321
|
-
conversationManager.log(`[Save failed: ${msg}]`, { fg: '#ef4444' });
|
|
322
|
-
}
|
|
323
|
-
requestRender();
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
export function handleBlockRerun(
|
|
327
|
-
conversationManager: ConversationManager | null,
|
|
328
|
-
getScrollTop: () => number,
|
|
329
|
-
requestRender: () => void,
|
|
330
|
-
): void {
|
|
331
|
-
if (!conversationManager) return;
|
|
332
|
-
const lineIndex = getScrollTop();
|
|
333
|
-
const nearest = conversationManager.findNearestBlock(lineIndex, 'tool');
|
|
334
|
-
if (!nearest) {
|
|
335
|
-
conversationManager.log('[Re-run: No tool block found nearby]', { fg: '240' });
|
|
336
|
-
requestRender();
|
|
337
|
-
return;
|
|
357
|
+
conversationManager.log(`[Save failed (${target}): ${msg}]`, { fg: '#ef4444' });
|
|
338
358
|
}
|
|
339
359
|
requestRender();
|
|
340
360
|
}
|
|
341
361
|
|
|
342
362
|
export function handleBlockToggle(
|
|
343
363
|
conversationManager: ConversationManager | null,
|
|
344
|
-
|
|
364
|
+
getAnchorLine: () => number,
|
|
345
365
|
requestRender: () => void,
|
|
346
366
|
): void {
|
|
347
367
|
if (!conversationManager) return;
|
|
348
|
-
const lineIndex =
|
|
368
|
+
const lineIndex = getAnchorLine();
|
|
369
|
+
const nearest = conversationManager.findNearestBlock(lineIndex);
|
|
370
|
+
if (!nearest) return;
|
|
371
|
+
const wasCollapsed = conversationManager.isCollapsed(nearest.blockIndex);
|
|
349
372
|
const blockIdx = conversationManager.toggleCollapseAtLine(lineIndex);
|
|
350
373
|
if (blockIdx >= 0) {
|
|
374
|
+
conversationManager.log(`[${wasCollapsed ? 'Expanded' : 'Collapsed'} ${describeBlockForReceipt(nearest)}]`, { fg: '244' });
|
|
351
375
|
requestRender();
|
|
352
376
|
}
|
|
353
377
|
}
|
|
354
378
|
|
|
355
379
|
export function handleDiffApply(
|
|
356
380
|
conversationManager: ConversationManager | null,
|
|
357
|
-
|
|
381
|
+
getAnchorLine: () => number,
|
|
358
382
|
commandContext: CommandContext | undefined,
|
|
359
383
|
requestRender: () => void,
|
|
360
384
|
getCallId: () => string,
|
|
361
385
|
category: PermissionCategory,
|
|
362
386
|
): boolean {
|
|
363
387
|
if (!conversationManager) return false;
|
|
364
|
-
const lineIndex =
|
|
388
|
+
const lineIndex = getAnchorLine();
|
|
365
389
|
const diff = conversationManager.getDiffAtLine(lineIndex);
|
|
366
390
|
if (!diff || !diff.filePath) return false;
|
|
367
391
|
const projectRoot = commandContext?.workspace.shellPaths?.workingDirectory
|
|
@@ -344,6 +344,11 @@ export type KeyRouteState = {
|
|
|
344
344
|
commandRegistry?: CommandRegistry | null;
|
|
345
345
|
autocomplete: AutocompleteEngine | null;
|
|
346
346
|
blockActionsMenu: { open: (block: BlockMeta) => void };
|
|
347
|
+
/** The absolute history line of the bottom-most visible block — the block
|
|
348
|
+
* the user is actually looking at (see getViewportBottomLine's doc).
|
|
349
|
+
* Anchors Enter-on-empty-composer's block-actions menu to that block
|
|
350
|
+
* instead of an arbitrary fixed line. */
|
|
351
|
+
getBlockAnchorLine: () => number;
|
|
347
352
|
/** F2 opens+focuses the Fleet panel (which subsumes the retired process modal). */
|
|
348
353
|
openFleetPanel: () => void;
|
|
349
354
|
modalOpened: (name: string) => void;
|
|
@@ -430,7 +435,9 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
|
|
|
430
435
|
|
|
431
436
|
const text = prompt.trim();
|
|
432
437
|
if (!text && !commandMode) {
|
|
433
|
-
|
|
438
|
+
// Target the block the user is actually reading, not the conversation's
|
|
439
|
+
// oldest block (a fixed lineIndex 0 would always resolve there).
|
|
440
|
+
const lineIndex = state.getBlockAnchorLine();
|
|
434
441
|
const nearest = state.conversationManager?.findNearestBlock(lineIndex);
|
|
435
442
|
if (nearest) {
|
|
436
443
|
state.modalOpened('blockActions');
|
|
@@ -603,6 +610,13 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
|
|
|
603
610
|
}
|
|
604
611
|
|
|
605
612
|
if (token.logicalName === 'down') {
|
|
613
|
+
// Announce the focus move honestly — this handoff is otherwise silent
|
|
614
|
+
// and easy to miss (the next keystrokes look like they're doing nothing,
|
|
615
|
+
// because they're now landing on the indicator, not the composer).
|
|
616
|
+
const announceIndicatorFocus = () => {
|
|
617
|
+
indicatorFocused = true;
|
|
618
|
+
state.commandContext?.print('[Down] Focus moved to the process indicator — Up or Esc returns to the composer.');
|
|
619
|
+
};
|
|
606
620
|
const move = computeCursorVerticalMove(prompt, cursorPos, inputScrollTop, state.contentWidth, state.maxInputRows, 1);
|
|
607
621
|
if (move.moved) {
|
|
608
622
|
cursorPos = move.cursorPos;
|
|
@@ -617,10 +631,10 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
|
|
|
617
631
|
cursorPos = recalled.length;
|
|
618
632
|
ensureLocalInputCursorVisible();
|
|
619
633
|
} else {
|
|
620
|
-
|
|
634
|
+
announceIndicatorFocus();
|
|
621
635
|
}
|
|
622
636
|
} else {
|
|
623
|
-
|
|
637
|
+
announceIndicatorFocus();
|
|
624
638
|
}
|
|
625
639
|
}
|
|
626
640
|
return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from './handler-feed-routes.ts';
|
|
35
35
|
import type { PanelBurstGuardState } from './panel-paste-flood-guard.ts';
|
|
36
36
|
import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
|
|
37
|
+
import { getViewportBottomLine } from '../renderer/conversation-layout.ts';
|
|
37
38
|
import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
|
|
38
39
|
import { handleCommandModeToken } from './handler-command-route.ts';
|
|
39
40
|
import { handleGlobalShortcutToken } from './handler-shortcuts.ts';
|
|
@@ -224,6 +225,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
|
|
|
224
225
|
|
|
225
226
|
const modalRoute = handleModalTokenRoutes({
|
|
226
227
|
history,
|
|
228
|
+
conversationManager: context.conversationManager,
|
|
227
229
|
searchShortcutMatch: token.type === 'key' && keybindings.matches('search', token),
|
|
228
230
|
selectionModal: context.selectionModal,
|
|
229
231
|
selectionCallback: context.selectionCallback,
|
|
@@ -477,6 +479,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
|
|
|
477
479
|
submitConcealedInput: context.submitConcealedInput,
|
|
478
480
|
autocomplete: context.autocomplete,
|
|
479
481
|
blockActionsMenu: { open: (block: BlockMeta) => context.blockActionsMenu.open(block) },
|
|
482
|
+
getBlockAnchorLine: () => getViewportBottomLine(scrollTop, viewportHeight, lineCount),
|
|
480
483
|
openFleetPanel,
|
|
481
484
|
modalOpened: context.modalOpened,
|
|
482
485
|
saveUndoState: context.saveUndoState,
|