@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -1613
  4. package/docs/README.md +4 -4
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +7 -7
  7. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  8. package/src/cli/entrypoint.ts +5 -1
  9. package/src/cli/launch-auto-update.ts +63 -4
  10. package/src/cli/tui-startup.ts +131 -15
  11. package/src/config/surface.ts +21 -0
  12. package/src/config/tui-extension-settings.ts +9 -1
  13. package/src/core/bookmark-navigation.ts +44 -0
  14. package/src/core/conversation-line-cache.ts +87 -19
  15. package/src/core/conversation-rendering.ts +186 -28
  16. package/src/core/conversation-tool-groups.ts +152 -0
  17. package/src/core/conversation-types.ts +30 -3
  18. package/src/core/conversation.ts +17 -0
  19. package/src/core/rewind-turn-anchors.ts +12 -13
  20. package/src/core/session-recovery.ts +62 -7
  21. package/src/core/session-resume-core.ts +206 -0
  22. package/src/core/transcript-journal.ts +32 -6
  23. package/src/core/turn-event-wiring.ts +55 -12
  24. package/src/input/command-args-hint.ts +11 -2
  25. package/src/input/command-registry.ts +19 -0
  26. package/src/input/commands/checkpoint-runtime.ts +3 -3
  27. package/src/input/commands/health-runtime.ts +1 -3
  28. package/src/input/commands/local-runtime.ts +19 -2
  29. package/src/input/commands/rewind-runtime.ts +132 -1
  30. package/src/input/commands/runtime-services.ts +11 -1
  31. package/src/input/commands/secret-runtime.ts +1 -1
  32. package/src/input/commands/session-content.ts +9 -1
  33. package/src/input/commands/session-picker-filter.ts +28 -0
  34. package/src/input/commands/session-workflow.ts +62 -73
  35. package/src/input/commands/session.ts +36 -1
  36. package/src/input/commands/shell-core.ts +57 -5
  37. package/src/input/commands/update-runtime.ts +120 -4
  38. package/src/input/commands/websearch-runtime.ts +1 -1
  39. package/src/input/handler-content-actions.ts +63 -39
  40. package/src/input/handler-feed-routes.ts +17 -3
  41. package/src/input/handler-feed.ts +3 -0
  42. package/src/input/handler-interactions.ts +19 -23
  43. package/src/input/handler-modal-token-routes.ts +5 -0
  44. package/src/input/handler-types.ts +4 -1
  45. package/src/input/handler-ui-state.ts +6 -2
  46. package/src/input/handler.ts +19 -9
  47. package/src/input/search.ts +94 -2
  48. package/src/input/selection-modal-queue.ts +176 -0
  49. package/src/main.ts +55 -56
  50. package/src/panels/builtin/operations.ts +2 -1
  51. package/src/panels/provider-health-domains.ts +2 -2
  52. package/src/renderer/block-actions-overlay.ts +59 -0
  53. package/src/renderer/block-actions.ts +1 -3
  54. package/src/renderer/context-status-hint.ts +73 -5
  55. package/src/renderer/conversation-layout.ts +16 -0
  56. package/src/renderer/conversation-overlays.ts +6 -0
  57. package/src/renderer/help-overlay.ts +69 -53
  58. package/src/renderer/selection-modal-overlay.ts +66 -10
  59. package/src/renderer/shell-surface.ts +15 -0
  60. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  61. package/src/renderer/ui-factory.ts +1 -1
  62. package/src/runtime/bootstrap-command-context.ts +5 -1
  63. package/src/runtime/bootstrap-command-parts.ts +5 -2
  64. package/src/runtime/bootstrap-core.ts +3 -3
  65. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  66. package/src/runtime/bootstrap-shell.ts +31 -16
  67. package/src/runtime/bootstrap.ts +13 -18
  68. package/src/runtime/code-index-services.ts +19 -0
  69. package/src/runtime/durability-services.ts +11 -7
  70. package/src/runtime/index.ts +12 -1
  71. package/src/runtime/process-lifecycle.ts +63 -2
  72. package/src/runtime/recovery-autosave.ts +48 -0
  73. package/src/runtime/recovery-offer-wiring.ts +89 -0
  74. package/src/runtime/recovery-prompt.ts +312 -0
  75. package/src/runtime/resume-notice.ts +26 -21
  76. package/src/runtime/services.ts +14 -16
  77. package/src/runtime/session-liveness-marker.ts +120 -0
  78. package/src/runtime/session-pointer-surface.ts +22 -0
  79. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  80. package/src/runtime/session-storage-services.ts +41 -0
  81. package/src/runtime/workspace-checkpointing.ts +10 -2
  82. package/src/shell/recovery-input-helpers.ts +17 -98
  83. package/src/shell/retry-affordance.ts +123 -0
  84. package/src/shell/ui-openers.ts +9 -0
  85. package/src/utils/splash-lines.ts +1 -1
  86. 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
- const sessions = sm.list().filter((s) => s.name !== ctx.session.runtime.sessionId);
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 ? key or /help for shortcuts');
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
- const latestTag = await resolveLatestReleaseTag(options.fetchImpl, REPO_RELEASES_LATEST_URL);
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
- const checksumText = await downloadText(options.fetchImpl, `${baseUrl}/${CHECKSUM_MANIFEST_NAME}`);
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: options.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: 'Search the web and render ranked results with source labels',
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
- getScrollTop: () => number,
292
+ getAnchorLine: () => number,
262
293
  requestRender: () => void,
263
294
  onCopied: () => void,
264
295
  ): void {
265
296
  if (!conversationManager) return;
266
- const lineIndex = getScrollTop();
267
- const content = conversationManager.getBlockContentAtLine(lineIndex);
268
- if (!content) return;
269
- copyToClipboard(content);
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
- getScrollTop: () => number,
313
+ getAnchorLine: () => number,
278
314
  requestRender: () => void,
279
315
  bookmarkManager: BookmarkManager,
280
316
  ): void {
281
317
  if (!conversationManager) return;
282
- const lineIndex = getScrollTop();
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: ${nearest.collapseKey}]`
293
- : `[Bookmark removed: ${nearest.collapseKey}]`;
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
- getScrollTop: () => number,
337
+ getAnchorLine: () => number,
301
338
  requestRender: () => void,
302
339
  bookmarkManager: BookmarkManager,
303
340
  ): void {
304
341
  if (!conversationManager) return;
305
- const lineIndex = getScrollTop();
306
- const content = conversationManager.getBlockContentAtLine(lineIndex);
307
- if (!content) {
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 nearest = conversationManager.findNearestBlock(lineIndex);
313
- const label = nearest?.type ?? 'block';
349
+ const target = describeBlockForReceipt(nearest);
314
350
  try {
315
- const filePath = bookmarkManager.saveToFile(content, label);
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
- getScrollTop: () => number,
364
+ getAnchorLine: () => number,
345
365
  requestRender: () => void,
346
366
  ): void {
347
367
  if (!conversationManager) return;
348
- const lineIndex = getScrollTop();
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
- getScrollTop: () => number,
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 = getScrollTop();
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
- const lineIndex = 0;
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
- indicatorFocused = true;
634
+ announceIndicatorFocus();
621
635
  }
622
636
  } else {
623
- indicatorFocused = true;
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,