@pellux/goodvibes-tui 1.19.6 → 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 (84) hide show
  1. package/CHANGELOG.md +6 -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/conversation-line-cache.ts +1 -1
  14. package/src/core/conversation-rendering.ts +70 -24
  15. package/src/core/conversation-tool-groups.ts +23 -10
  16. package/src/core/conversation-types.ts +9 -0
  17. package/src/core/rewind-turn-anchors.ts +12 -13
  18. package/src/core/session-recovery.ts +59 -2
  19. package/src/core/session-resume-core.ts +206 -0
  20. package/src/core/transcript-journal.ts +32 -6
  21. package/src/core/turn-event-wiring.ts +55 -12
  22. package/src/input/command-args-hint.ts +11 -2
  23. package/src/input/command-registry.ts +19 -0
  24. package/src/input/commands/checkpoint-runtime.ts +3 -3
  25. package/src/input/commands/health-runtime.ts +1 -3
  26. package/src/input/commands/local-runtime.ts +1 -1
  27. package/src/input/commands/rewind-runtime.ts +132 -1
  28. package/src/input/commands/runtime-services.ts +11 -1
  29. package/src/input/commands/secret-runtime.ts +1 -1
  30. package/src/input/commands/session-content.ts +9 -1
  31. package/src/input/commands/session-picker-filter.ts +28 -0
  32. package/src/input/commands/session-workflow.ts +62 -73
  33. package/src/input/commands/session.ts +36 -1
  34. package/src/input/commands/shell-core.ts +57 -5
  35. package/src/input/commands/update-runtime.ts +120 -4
  36. package/src/input/commands/websearch-runtime.ts +1 -1
  37. package/src/input/handler-content-actions.ts +63 -39
  38. package/src/input/handler-feed-routes.ts +17 -3
  39. package/src/input/handler-feed.ts +3 -0
  40. package/src/input/handler-interactions.ts +19 -23
  41. package/src/input/handler-modal-token-routes.ts +5 -0
  42. package/src/input/handler-types.ts +4 -1
  43. package/src/input/handler-ui-state.ts +6 -2
  44. package/src/input/handler.ts +19 -9
  45. package/src/input/search.ts +94 -2
  46. package/src/input/selection-modal-queue.ts +176 -0
  47. package/src/main.ts +48 -44
  48. package/src/panels/builtin/operations.ts +2 -1
  49. package/src/panels/provider-health-domains.ts +2 -2
  50. package/src/renderer/block-actions-overlay.ts +59 -0
  51. package/src/renderer/block-actions.ts +1 -3
  52. package/src/renderer/context-status-hint.ts +73 -5
  53. package/src/renderer/conversation-layout.ts +16 -0
  54. package/src/renderer/conversation-overlays.ts +6 -0
  55. package/src/renderer/help-overlay.ts +69 -53
  56. package/src/renderer/selection-modal-overlay.ts +66 -10
  57. package/src/renderer/shell-surface.ts +15 -0
  58. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  59. package/src/renderer/ui-factory.ts +1 -1
  60. package/src/runtime/bootstrap-command-context.ts +5 -1
  61. package/src/runtime/bootstrap-command-parts.ts +5 -2
  62. package/src/runtime/bootstrap-core.ts +3 -3
  63. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  64. package/src/runtime/bootstrap-shell.ts +31 -16
  65. package/src/runtime/bootstrap.ts +13 -18
  66. package/src/runtime/code-index-services.ts +19 -0
  67. package/src/runtime/durability-services.ts +11 -7
  68. package/src/runtime/index.ts +12 -0
  69. package/src/runtime/process-lifecycle.ts +63 -2
  70. package/src/runtime/recovery-autosave.ts +48 -0
  71. package/src/runtime/recovery-offer-wiring.ts +89 -0
  72. package/src/runtime/recovery-prompt.ts +312 -0
  73. package/src/runtime/resume-notice.ts +28 -93
  74. package/src/runtime/services.ts +14 -16
  75. package/src/runtime/session-liveness-marker.ts +120 -0
  76. package/src/runtime/session-pointer-surface.ts +22 -0
  77. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  78. package/src/runtime/session-storage-services.ts +41 -0
  79. package/src/runtime/workspace-checkpointing.ts +10 -2
  80. package/src/shell/recovery-input-helpers.ts +7 -3
  81. package/src/shell/retry-affordance.ts +123 -0
  82. package/src/shell/ui-openers.ts +9 -0
  83. package/src/utils/splash-lines.ts +1 -1
  84. package/src/version.ts +1 -1
@@ -0,0 +1,176 @@
1
+ /**
2
+ * selection-modal-queue.ts — Serializes overlapping InputHandler.openSelection() calls.
3
+ *
4
+ * openSelection (handler.ts) writes into a singleton SelectionModal plus a
5
+ * single callback slot. A second openSelection call while one is already
6
+ * showing used to silently overwrite both — the pre-empted caller's callback
7
+ * was never invoked, not even with null, so any caller that awaits it (see
8
+ * the `ask()` helper in runtime/recovery-prompt.ts, which wraps the callback
9
+ * in a Promise) hung forever. This is reachable in production: the
10
+ * `--continue` liveness-confirm modal (session-resume-liveness-confirm.ts)
11
+ * races the startup recovery offer, which is scheduled on a 0ms macrotask
12
+ * (scheduleRecoveryOffer in runtime/recovery-prompt.ts).
13
+ *
14
+ * Fix: queue overlapping requests FIFO and open each automatically once the
15
+ * previous one resolves, through the SAME callback the caller already
16
+ * supplied — openSelection's public signature never changes.
17
+ *
18
+ * Why the drain is deferred to a microtask instead of running inline inside
19
+ * the resolving callback: both resolution paths — the select/enter path in
20
+ * handler-modal-routes.ts's dispatchSelectionAction, and the Escape path in
21
+ * handler-modal-stack.ts's handleEscape/closeSelection — take a snapshot of
22
+ * the current selectionCallback before dispatch and write some form of that
23
+ * snapshot back into handler/context state AFTER invoking the callback (the
24
+ * select path re-reads via getSelectionCallback; the Escape path threads a
25
+ * local variable back out through handleEscapeForHandler in
26
+ * handler-interactions.ts). If this module opened the next queued request
27
+ * synchronously inside the resolving callback, those post-callback
28
+ * write-backs would immediately clobber the freshly-set callback for the
29
+ * newly-opened modal. Deferring past the end of the synchronous resolution
30
+ * (queueMicrotask) lets all of that settle first, then opens the next
31
+ * request cleanly — the same shape as the production race this fixes, which
32
+ * itself resolves via a deferred (macrotask) call.
33
+ */
34
+ import type { SelectionItem, SelectionAction, SelectionResult, SelectionModal } from './selection-modal.ts';
35
+
36
+ export type SelectionModalCallback = (result: SelectionResult | null) => void;
37
+
38
+ export interface SelectionOpenOpts {
39
+ preSelectId?: string;
40
+ allowSearch?: boolean;
41
+ customActions?: Map<string, SelectionAction>;
42
+ primaryVerbLabel?: string;
43
+ }
44
+
45
+ export interface PendingSelectionRequest {
46
+ readonly title: string;
47
+ readonly items: SelectionItem[];
48
+ readonly opts: SelectionOpenOpts | undefined;
49
+ readonly callback: SelectionModalCallback;
50
+ }
51
+
52
+ /**
53
+ * Maximum number of openSelection() calls held while one is already showing.
54
+ * In practice at most two calls ever race in production (the liveness-confirm
55
+ * modal vs. the recovery offer's 0ms macrotask), so 8 is generous headroom
56
+ * for any future chained-picker sequence without letting a misbehaving
57
+ * caller queue without bound. Once full, the overflowing call is resolved
58
+ * with `null` immediately — a bounded queue must still give every caller a
59
+ * definite answer, never silently drop one.
60
+ */
61
+ export const MAX_QUEUED_SELECTIONS = 8;
62
+
63
+ /** Dependencies the queue needs from InputHandler, kept minimal and explicit. */
64
+ export interface SelectionQueueHost {
65
+ readonly selectionModal: Pick<SelectionModal, 'active' | 'open'>;
66
+ /** Assigns InputHandler.selectionCallback (the live resolution slot). */
67
+ setSelectionCallback(callback: SelectionModalCallback | null): void;
68
+ /** Mirrors the callback into the in-flight feed context, when a feed() call is in progress (no-op otherwise). */
69
+ syncFeedSelectionCallback(callback: SelectionModalCallback | null): void;
70
+ modalOpened(name: string): void;
71
+ requestRender(): void;
72
+ }
73
+
74
+ /** Owns the FIFO of pending openSelection() requests for one InputHandler instance. */
75
+ export class SelectionModalQueue {
76
+ private readonly pending: PendingSelectionRequest[] = [];
77
+
78
+ constructor(private readonly host: SelectionQueueHost) {}
79
+
80
+ /** Number of requests currently queued (excludes whichever modal is actively showing, if any). */
81
+ public get size(): number {
82
+ return this.pending.length;
83
+ }
84
+
85
+ /**
86
+ * Open `request` immediately if no selection modal is currently showing;
87
+ * otherwise queue it FIFO. If the queue is already at MAX_QUEUED_SELECTIONS,
88
+ * the request is resolved with `null` right away instead of being queued.
89
+ */
90
+ public request(request: PendingSelectionRequest): void {
91
+ if (this.host.selectionModal.active) {
92
+ if (this.pending.length >= MAX_QUEUED_SELECTIONS) {
93
+ request.callback(null);
94
+ return;
95
+ }
96
+ this.pending.push(request);
97
+ return;
98
+ }
99
+ this.openNow(request);
100
+ }
101
+
102
+ /**
103
+ * Resolve every still-queued request with `null`. Called when the handler
104
+ * is torn down (see the exitApp wrap in handler.ts's constructor) so no
105
+ * queued caller is ever left permanently pending.
106
+ */
107
+ public clear(): void {
108
+ while (this.pending.length > 0) {
109
+ const next = this.pending.shift();
110
+ next?.callback(null);
111
+ }
112
+ }
113
+
114
+ private openNow(request: PendingSelectionRequest): void {
115
+ const wrapped: SelectionModalCallback = (result) => {
116
+ request.callback(result);
117
+ // Only a terminal resolution (select/cancel/escape) actually closes
118
+ // the modal — dispatchSelectionAction's toggle/increment/decrement
119
+ // branch invokes the callback but leaves the modal open for further
120
+ // interaction, so only drain the queue once the modal has gone
121
+ // inactive, never on every callback invocation.
122
+ if (!this.host.selectionModal.active) {
123
+ queueMicrotask(() => this.drain());
124
+ }
125
+ };
126
+ this.host.modalOpened('selection');
127
+ this.host.selectionModal.open(request.title, request.items, request.opts);
128
+ this.host.setSelectionCallback(wrapped);
129
+ this.host.syncFeedSelectionCallback(wrapped);
130
+ this.host.requestRender();
131
+ }
132
+
133
+ private drain(): void {
134
+ // Something else already opened a modal in the meantime (e.g. a
135
+ // synchronously chained openSelection call from inside the resolving
136
+ // callback) — leave the queue alone; that modal's own resolution will
137
+ // drain it in turn.
138
+ if (this.host.selectionModal.active) return;
139
+ const next = this.pending.shift();
140
+ if (!next) return;
141
+ this.openNow(next);
142
+ }
143
+ }
144
+
145
+ /** InputHandler's actual field/method shape — satisfies this structurally, no import needed. */
146
+ export interface SelectionQueueOwner {
147
+ readonly selectionModal: Pick<SelectionModal, 'active' | 'open'>;
148
+ selectionCallback: SelectionModalCallback | null;
149
+ syncFeedSelectionCallback: ((callback: SelectionModalCallback | null) => void) | null;
150
+ modalOpened(name: string): void;
151
+ requestRender(): void;
152
+ exitApp: () => void;
153
+ }
154
+
155
+ /**
156
+ * Builds the queue for one InputHandler and wires teardown: wraps
157
+ * `owner.exitApp` (the one path guaranteed to run when the handler is torn
158
+ * down) so every still-queued selection request resolves with `null`
159
+ * instead of being left pending. Keeps this bookkeeping out of handler.ts,
160
+ * which only needs `this.selectionQueue = attachSelectionModalQueue(this);`.
161
+ */
162
+ export function attachSelectionModalQueue(owner: SelectionQueueOwner): SelectionModalQueue {
163
+ const queue = new SelectionModalQueue({
164
+ selectionModal: owner.selectionModal,
165
+ setSelectionCallback: (callback) => { owner.selectionCallback = callback; },
166
+ syncFeedSelectionCallback: (callback) => { owner.syncFeedSelectionCallback?.(callback); },
167
+ modalOpened: (name) => owner.modalOpened(name),
168
+ requestRender: () => owner.requestRender(),
169
+ });
170
+ const suppliedExitApp = owner.exitApp;
171
+ owner.exitApp = () => {
172
+ queue.clear();
173
+ suppliedExitApp();
174
+ };
175
+ return queue;
176
+ }
package/src/main.ts CHANGED
@@ -37,13 +37,16 @@ import { selfUpdateAtLaunch } from './cli/launch-auto-update.ts';
37
37
  import { buildSharedOrchestratorCoreServices, refreshMemoryRecallSnapshot } from './runtime/orchestrator-core-services.ts';
38
38
  import { createSessionContinuityHintsBuilder } from './runtime/session-continuity-hints.ts';
39
39
  import { resolveWebSurfaceUrl } from '@pellux/goodvibes-sdk/platform/runtime/feature-announcements';
40
- import { readLastSessionPointer, writeRecoveryFile } from '@/runtime/index.ts';
40
+ import { readLastSessionPointer } from '@/runtime/index.ts';
41
+ import { startRecoveryAutosave } from './runtime/recovery-autosave.ts';
42
+ import { scheduleRecoveryOffer } from './runtime/recovery-prompt.ts';
43
+ import { buildRecoveryOfferWiring } from './runtime/recovery-offer-wiring.ts';
41
44
  import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
42
45
  import { handleErrorAffordanceKey } from './shell/recovery-input-helpers.ts';
46
+ import { createRetryAffordanceState, disarmRetryAffordance, retryAffordanceHint, wireRetryAffordanceOnError } from './shell/retry-affordance.ts';
43
47
  import { wireShellUiOpeners } from './shell/ui-openers.ts';
44
48
  import { deriveComposerState } from './core/composer-state.ts';
45
49
  import { resolveFoldedBookmarkLine } from './core/bookmark-navigation.ts';
46
- import { buildPersistedSessionContext, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
47
50
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
48
51
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
49
52
  import { applyInitialTuiCliState, reportFatalStartupError } from './cli/tui-startup.ts';
@@ -60,7 +63,7 @@ import { footerFleetCost } from './panels/fleet-read-model.ts';
60
63
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
61
64
  import { wireStreamEventMetrics, createStreamMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
62
65
  import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
63
- import { buildContextStatusHint } from './renderer/context-status-hint.ts';
66
+ import { resolveContextStatusHint } from './renderer/context-status-hint.ts';
64
67
  import { isEffectiveDangerMode } from './config/index.ts';
65
68
  import { createScriptableStatusline } from './core/scriptable-statusline.ts';
66
69
  import { applyComposerCapture } from './input/composer-capture.ts';
@@ -77,6 +80,7 @@ import { setPanelFrameRequester } from './panels/base-panel.ts';
77
80
 
78
81
  import { ALT_SCREEN_ENTER, ALT_SCREEN_EXIT, MOUSE_ENABLE, MOUSE_DISABLE, CURSOR_HIDE, CURSOR_SHOW, CLEAR_SCREEN, KEYBOARD_EXT_ENABLE, KEYBOARD_EXT_DISABLE, PASTE_ENABLE, PASTE_DISABLE, FOCUS_ENABLE, FOCUS_DISABLE } from './renderer/terminal-escapes.ts';
79
82
  import { installBackgroundThemeProbe } from './renderer/terminal-bg-probe.ts';
83
+ import { VERSION } from './version.ts';
80
84
 
81
85
  async function main() {
82
86
  const stdout = process.stdout;
@@ -86,6 +90,12 @@ async function main() {
86
90
  homeDirectory: homedir(),
87
91
  }, 'goodvibes');
88
92
 
93
+ // Between binary start and the first frame, boot used to be silent (config
94
+ // load, trust manager, memory init, hooks). One honest pre-alt-screen line,
95
+ // placed after the help/version/completion early-exits above so those stay
96
+ // byte-clean.
97
+ stdout.write(`goodvibes v${VERSION} starting…\n`);
98
+
89
99
  // Launch-time self-update, before any bootstrap or terminal mode change; on
90
100
  // an installed update this restarts onto the swapped binary and never returns.
91
101
  const launchUpdateLines = await selfUpdateAtLaunch({ configManager, stdout });
@@ -135,8 +145,11 @@ async function main() {
135
145
  applyConfiguredHitlMode(configManager, modeManager);
136
146
  applyTuiRuntimeConfigDefaults(configManager);
137
147
 
138
- // Re-surface pre-TUI launch-update lines in-session (the alt screen wipes stdout).
139
- for (const line of launchUpdateLines) systemMessageRouter.high(`[Update] ${line}`);
148
+ // Re-surface pre-TUI launch-update lines in-session (the alt screen wipes
149
+ // stdout). Launch-time update mechanics are routine, not urgent — low
150
+ // priority, not high (a skipped/deferred check is not the kind of thing
151
+ // that should compete with real session alerts for attention).
152
+ for (const line of launchUpdateLines) systemMessageRouter.low(`[Update] ${line}`);
140
153
 
141
154
  const panelManager = ctx.services.panelManager;
142
155
  const buildSessionContinuityHints = createSessionContinuityHintsBuilder({ readModels: uiServices.readModels, panelManager });
@@ -324,6 +337,9 @@ async function main() {
324
337
  commandContext.submitInput = submitInput;
325
338
  commandContext.submitSpokenInput = (text, content) => submitInput(text, content, { spokenOutput: true });
326
339
  commandContext.stopSpokenOutput = () => spokenTurns.stop(); commandContext.pasteFromClipboard = () => input.handlePaste();
340
+ // Read-only view of pending [TEXT: pN, M lines] fold markers, so /pastes
341
+ // can preview a folded paste's actual content before the user submits it.
342
+ commandContext.getPendingPastes = () => input.pasteRegistry;
327
343
  commandContext.executeCommand = (name, args) => commandRegistry.execute(name, args, commandContext);
328
344
  // Late-patched: bootstrap.ts populates uiServices.platform.externalServices AFTER commandContext is built.
329
345
  commandContext.platform.externalServices = uiServices.platform.externalServices;
@@ -423,7 +439,7 @@ async function main() {
423
439
  model: runtime.model,
424
440
  provider: runtime.provider,
425
441
  toolCount,
426
- lastSessionId: readLastSessionPointer({ workingDirectory: workingDir, homeDirectory, surfaceRoot: 'tui' }) ?? undefined,
442
+ lastSessionId: readLastSessionPointer({ surface: ctx.services.surface }) ?? undefined,
427
443
  };
428
444
 
429
445
  const renderNow = () => {
@@ -457,16 +473,10 @@ async function main() {
457
473
  hasAttachments: input.getImageAttachments().size > 0,
458
474
  turnState: sessionSnapshot.turnState,
459
475
  });
460
- const maintenanceStatus = evaluateSessionMaintenance({
461
- configManager,
476
+ const contextStatusHint = resolveContextStatusHint({
477
+ evaluate: (args) => evaluateSessionMaintenance({ configManager, ...args, sessionMemoryCount: ctx.services.sessionMemoryStore.list().length }),
462
478
  currentTokens: orchestrator.lastInputTokens,
463
479
  contextWindow,
464
- sessionMemoryCount: ctx.services.sessionMemoryStore.list().length,
465
- });
466
- const contextStatusHint = buildContextStatusHint({
467
- level: maintenanceStatus.level,
468
- autoCompactEnabled: maintenanceStatus.autoCompactEnabled,
469
- usagePct: maintenanceStatus.usagePct,
470
480
  });
471
481
  const footerLines = buildShellFooter({
472
482
  width,
@@ -486,6 +496,7 @@ async function main() {
486
496
  provider: runtime.provider,
487
497
  contextWindow,
488
498
  contextStatusHint,
499
+ retryHint: retryAffordanceHint(retryAffordance),
489
500
  scriptableStatusLine: scriptableStatusline.current(),
490
501
  // Compact footer posture on short terminals so the shell stays usable.
491
502
  compact: height < 30,
@@ -680,9 +691,7 @@ async function main() {
680
691
  providerRegistry,
681
692
  systemMessageRouter,
682
693
  hookDispatcher,
683
- workingDir,
684
- homeDirectory,
685
- sessionManager: ctx.services.sessionManager,
694
+ surface: ctx.services.surface,
686
695
  gitStatusProvider,
687
696
  lastGitInfoRef,
688
697
  buildSessionContinuityHints,
@@ -696,7 +705,10 @@ async function main() {
696
705
  let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
697
706
  // One-key retry affordance, active right after a user-visible TURN_ERROR: 'r' re-submits on the
698
707
  // current provider, 'm' opens the model picker, any other character clears it and routes normally.
699
- let errorAffordanceActive = false;
708
+ // Surfaced as a transient FOOTER hint (see retryAffordanceHint below), not a transcript message.
709
+ // Time-bounded: onExpire repaints once the 60s disarm timer fires, so a stray keypress hours
710
+ // later can never trigger a real retry — see retry-affordance.ts.
711
+ const retryAffordance = createRetryAffordanceState({ onExpire: render });
700
712
  const retryTurn = (): void => {
701
713
  if (!retryCtx) return;
702
714
  const { count, text, content: rContent, opts: rOpts } = retryCtx;
@@ -712,13 +724,7 @@ async function main() {
712
724
  });
713
725
  unsubs.push(...streamResult.unsubs);
714
726
  // Activate one-key retry affordance when a user-visible error surfaces.
715
- streamResult.onErrorSurfaced((exhausted) => {
716
- if (retryCtx) {
717
- errorAffordanceActive = true;
718
- systemMessageRouter.low(exhausted ? '[Retry] r retry same provider · m switch model' : '[Retry] r retry · m switch model');
719
- render();
720
- }
721
- });
727
+ wireRetryAffordanceOnError(streamResult.onErrorSurfaced, retryAffordance, () => retryCtx !== null, render);
722
728
 
723
729
  // Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a throw
724
730
  // during setup or the initial render still restores the terminal; 'exit' is the final safety net.
@@ -736,7 +742,8 @@ async function main() {
736
742
  // OSC 11 probe and repaints once if light wins. filterInput strips the reply from stdin.
737
743
  const themeProbe = installBackgroundThemeProbe({ configManager, isTTY: Boolean(stdout.isTTY), env: process.env, writeQuery: (b) => allowTerminalWrite(() => stdout.write(b)), requestRepaint: () => { compositor.resetDiff(); render(); } });
738
744
 
739
- applyInitialTuiCliState({ cli, input, commandRegistry, commandContext, shellPaths: ctx.services.shellPaths, render });
745
+ // continueRecovery lets --continue/bare --resume check the target session for a live crash snapshot newer than its store before resuming (see tui-startup.ts).
746
+ applyInitialTuiCliState({ cli, input, commandRegistry, commandContext, shellPaths: ctx.services.shellPaths, surface: ctx.services.surface, render, continueRecovery: { sessionManager: ctx.services.sessionManager, runtime, conversation, writeLastSessionPointer, receipt: (line) => systemMessageRouter.high(line) } });
740
747
 
741
748
  stdin.on('data', (raw: string) => {
742
749
  const data = themeProbe.filterInput(raw); if (data.length === 0) return;
@@ -749,9 +756,10 @@ async function main() {
749
756
  return;
750
757
  }
751
758
  // One-key retry affordance: armed after a user-visible TURN_ERROR; any key other than r/m dismisses it and routes normally.
752
- if (errorAffordanceActive) {
753
- errorAffordanceActive = false;
759
+ if (retryAffordance.armed) {
760
+ disarmRetryAffordance(retryAffordance);
754
761
  if (handleErrorAffordanceKey(data, { retryArmed: retryCtx !== null, retry: retryTurn, openModelPicker: () => commandContext.openModelPicker?.(), render })) return;
762
+ render(); // disarm must repaint immediately so the footer hint clears even when the key fell through
755
763
  }
756
764
  // One-key jump to a spawned CI fix-session: 'j' attaches, any other key dismisses and routes normally.
757
765
  if (fixSessionAttachArmed !== null) {
@@ -768,28 +776,24 @@ async function main() {
768
776
 
769
777
  // State restores happen ONLY when the user explicitly asks — a CLI flag
770
778
  // (--continue/--resume/--fork), a slash command (/session resume,
771
- // /checkpoints, /rewind), or the boot resume notice's advertised command.
772
- // There is deliberately no unconditional auto-restore here: a bare launch
773
- // never loads a saved conversation on its own (owner ruling). Any live
774
- // recovery snapshot is surfaced (never applied) by the boot resume
775
- // notice — see announceResumeState in runtime/resume-notice.ts.
779
+ // /checkpoints, /rewind), or the startup recovery offer's modal. There is
780
+ // deliberately no unconditional auto-restore here: a bare launch never
781
+ // loads a saved conversation on its own (owner ruling).
776
782
 
777
783
  // Initial render
778
784
  conversation.rebuildHistory();
779
785
  render();
780
786
 
781
- // --- Auto-save to recovery file every 60s ---
782
- recoveryInterval = setInterval(() => {
783
- const snapshot = conversation.toJSON() as { messages: Array<import('./core/conversation.ts').ConversationMessageSnapshot> };
784
- const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
785
- writeRecoveryFile(
786
- { ...snapshot, ...persisted },
787
- runtime.sessionId,
788
- conversation.title ?? '',
789
- { workingDirectory: workingDir, homeDirectory },
790
- );
791
- }, 60_000);
787
+ // Crash-recovery snapshot: asked, never assumed. Scheduled after the first
788
+ // frame so the question is drawn rather than posed at a blank terminal.
789
+ scheduleRecoveryOffer(buildRecoveryOfferWiring({
790
+ surface: ctx.services.surface, sessionManager: ctx.services.sessionManager, runtime, conversation, commandContext,
791
+ writeLastSessionPointer, receipt: (line) => systemMessageRouter.high(line), render,
792
+ }));
792
793
 
794
+ // Auto-save to recovery file every 60s + multi-instance liveness-marker
795
+ // refresh — see runtime/recovery-autosave.ts.
796
+ recoveryInterval = startRecoveryAutosave({ conversation, runtime, surface: ctx.services.surface, buildSessionContinuityHints });
793
797
  }
794
798
 
795
799
  main().catch(reportFatalStartupError);
@@ -9,6 +9,7 @@ import { LocalAuthPanel } from '../local-auth-panel.ts';
9
9
  import { NotificationsPanel } from '../notifications-panel.ts';
10
10
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
11
11
  import { requireUiServices } from './shared.ts';
12
+ import { GOODVIBES_TUI_SURFACE_ROOT } from '../../config/surface.ts';
12
13
 
13
14
  // the former single 'monitoring' category (33 panels pre-merge) is
14
15
  // split into five operator domains, applied per-registration below. Kept in
@@ -113,7 +114,7 @@ export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBu
113
114
  // (AgentManager's bounded retention ring), empty once evicted (the
114
115
  // panel degrades to the on-disk ledger fallback in that case).
115
116
  getConversationSnapshot: (agentId: string) => ui.agents.agentManager.getConversationSnapshot(agentId),
116
- resolveSessionLogPath: (agentId: string) => ui.environment.shellPaths.resolveProjectPath('tui', 'sessions', `${agentId}.jsonl`),
117
+ resolveSessionLogPath: (agentId: string) => ui.environment.shellPaths.resolveProjectPath(GOODVIBES_TUI_SURFACE_ROOT, 'sessions', `${agentId}.jsonl`),
117
118
  // Queue a message for a live in-process agent/wrfc-subtask member.
118
119
  steer: (id: string, text: string) => fleetReadModel.steer(id, text),
119
120
  }, deps.configManager, fleetActs, fleetSpawn),
@@ -181,14 +181,14 @@ export function buildProviderHealthDomainSummaries(
181
181
  summary: continuity.recoveryFilePresent
182
182
  ? `recovery file present for ${continuity.sessionId || '(unknown session)'}`
183
183
  : continuity.lastSessionPointer ? `last session pointer ${continuity.lastSessionPointer}` : 'no last-session pointer',
184
- next: continuity.recoveryFilePresent ? '/session resume <id>' : '/session list',
184
+ next: continuity.recoveryFilePresent ? '/resume <id>' : '/session list',
185
185
  details: [
186
186
  continuity.returnContext ? `last activity: ${continuity.returnContext.activityLabel}` : '',
187
187
  continuity.returnContext ? `resume posture: ${continuity.returnContext.statusLabel}` : '',
188
188
  !continuity.lastSessionPointer ? 'no persisted last-session pointer recorded' : '',
189
189
  ].filter(Boolean),
190
190
  nextSteps: continuity.recoveryFilePresent
191
- ? ['/session list', '/session resume <id>', '/health continuity']
191
+ ? ['/session list', '/resume <id>', '/health continuity']
192
192
  : ['/session list'],
193
193
  });
194
194
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * renderBlockActionsMenu — renders the BlockActionsMenu (src/renderer/block-actions.ts)
3
+ * as a docked overlay, so opening it via Enter-on-an-empty-composer actually
4
+ * shows something. Follows the established docked-overlay pattern (see
5
+ * selection-modal-overlay.ts / bookmark-modal.ts): ModalFactory sizes the box
6
+ * to its content, and every text section wraps rather than clips, so the
7
+ * block summary and action list are always shown in full.
8
+ */
9
+
10
+ import { type Line } from '../types/grid.ts';
11
+ import { ModalFactory, type ModalSection } from './modal-factory.ts';
12
+ import type { BlockActionsMenu } from './block-actions.ts';
13
+ import { describeBlockForReceipt } from '../input/handler-content-actions.ts';
14
+ import { getOverlaySurfaceMetrics } from './overlay-viewport.ts';
15
+ import { formatHints } from './hint-grammar.ts';
16
+
17
+ export function renderBlockActionsMenu(
18
+ menu: BlockActionsMenu,
19
+ width: number,
20
+ viewportHeight = 24,
21
+ ): Line[] {
22
+ if (!menu.active || !menu.block) return [];
23
+
24
+ const summary = describeBlockForReceipt(menu.block);
25
+ const metrics = getOverlaySurfaceMetrics(width, viewportHeight, {
26
+ margin: 4,
27
+ maxWidth: 64,
28
+ chromeRows: 5,
29
+ minContentRows: 3,
30
+ maxContentRows: Math.max(6, viewportHeight - 8),
31
+ });
32
+
33
+ const sections: ModalSection[] = [
34
+ { type: 'text', content: `Target: ${summary}`, style: { dim: true } },
35
+ { type: 'separator' },
36
+ {
37
+ type: 'list',
38
+ items: menu.actions.map((action, i) => ({
39
+ label: `[${action.key}] ${action.label}`,
40
+ selected: i === menu.selectedIndex,
41
+ })),
42
+ },
43
+ ];
44
+
45
+ return ModalFactory.createModal(
46
+ {
47
+ title: 'Block Actions',
48
+ width: metrics.boxWidth,
49
+ margin: metrics.margin,
50
+ sections,
51
+ hints: [formatHints([
52
+ { key: 'Up/Down', verb: 'Navigate' },
53
+ { key: 'Enter', verb: 'Select' },
54
+ { key: 'Esc', verb: 'Close' },
55
+ ])],
56
+ },
57
+ width,
58
+ );
59
+ }
@@ -1,6 +1,6 @@
1
1
  import type { BlockMeta } from '../core/conversation';
2
2
 
3
- export type BlockActionId = 'copy' | 'bookmark' | 'toggle' | 'apply' | 'rerun';
3
+ export type BlockActionId = 'copy' | 'bookmark' | 'toggle' | 'apply';
4
4
 
5
5
  export interface BlockAction {
6
6
  id: BlockActionId;
@@ -13,7 +13,6 @@ const ALL_ACTIONS: BlockAction[] = [
13
13
  { id: 'bookmark', label: 'Bookmark', key: 'b' },
14
14
  { id: 'toggle', label: 'Collapse/Expand',key: 'Tab' },
15
15
  { id: 'apply', label: 'Apply diff', key: 'a' },
16
- { id: 'rerun', label: 'Re-run tool', key: 'r' },
17
16
  ];
18
17
 
19
18
  /**
@@ -37,7 +36,6 @@ export class BlockActionsMenu {
37
36
  this.block = block;
38
37
  this.actions = ALL_ACTIONS.filter(a => {
39
38
  if (a.id === 'apply') return block.type === 'diff';
40
- if (a.id === 'rerun') return block.type === 'tool';
41
39
  return true;
42
40
  });
43
41
  this.selectedIndex = 0;
@@ -12,34 +12,77 @@
12
12
  * - compacting → shows in-progress text so the user knows something is running
13
13
  * - watch → no hint (not yet actionable)
14
14
  * - stable/unknown → no hint
15
+ *
16
+ * Evidence policy (why the guards below exist)
17
+ * ────────────────────────────────────────────
18
+ * A fresh boot printed "Context high (0% used) — auto-compact will run before
19
+ * the next turn." before a single token had been counted. The evaluator was
20
+ * not at fault: `evaluateSessionMaintenance` reaches 'suggest-compact' when
21
+ * `remainingTokens <= 15_000`, and at boot the resolved window is still the
22
+ * SDK's DEFAULT_CONTEXT_WINDOW fallback (8,192 — the "8.2k" the footer meter
23
+ * shows before the real window arrives), so 8,192 free tokens tripped that
24
+ * rule instantly. Arithmetically consistent; factually false. No context had
25
+ * been consumed and no auto-compact was imminent.
26
+ *
27
+ * So a pressure claim here has to be backed by evidence, not merely by a
28
+ * level:
29
+ * 1. Usage has actually been measured. At zero tokens consumed there is no
30
+ * pressure to report, whatever the window says.
31
+ * 2. The window is one the provider vouched for, not the fallback constant.
32
+ * A model whose real window happens to equal the fallback is
33
+ * indistinguishable from "not resolved yet", and in that ambiguity
34
+ * staying quiet is the honest choice — the Tokens panel and /context
35
+ * still show the real numbers either way.
36
+ * Neither guard touches 'compacting', which reports work that is provably
37
+ * running rather than a prediction about headroom.
15
38
  */
16
39
 
40
+ import { DEFAULT_CONTEXT_WINDOW } from '@pellux/goodvibes-sdk/platform/providers';
17
41
  import type { PanelSessionMaintenanceLevel } from '../panels/session-maintenance.ts';
18
42
 
19
- export interface ContextStatusHintOptions {
43
+ interface ContextStatusHintOptions {
20
44
  /** Maintenance level from evaluateSessionMaintenance. */
21
45
  readonly level: PanelSessionMaintenanceLevel;
22
46
  /** Whether auto-compaction is active (threshold > 0 in config). */
23
47
  readonly autoCompactEnabled: boolean;
24
48
  /** Current usage percent 0–100. */
25
49
  readonly usagePct: number;
50
+ /** Tokens actually counted so far this session (orchestrator.lastInputTokens). */
51
+ readonly currentTokens: number;
52
+ /** The resolved context window the usage percent was computed against. */
53
+ readonly contextWindow: number;
54
+ }
55
+
56
+ /**
57
+ * Whether the numbers backing a pressure claim are real yet: usage has been
58
+ * counted, and the window is not the provider fallback the app boots with.
59
+ */
60
+ function hasRealContextNumbers(
61
+ options: Pick<ContextStatusHintOptions, 'currentTokens' | 'contextWindow' | 'usagePct'>,
62
+ ): boolean {
63
+ if (options.currentTokens <= 0 || options.usagePct <= 0) return false;
64
+ if (options.contextWindow <= 0 || options.contextWindow === DEFAULT_CONTEXT_WINDOW) return false;
65
+ return true;
26
66
  }
27
67
 
28
68
  /**
29
69
  * Build the passive status-line hint text for context pressure.
30
70
  *
31
- * Returns null when no hint is warranted (stable / watch / unknown).
32
- * The caller renders this as a dim informational line — no prompts, no
33
- * blocking, no confirmation required.
71
+ * Returns null when no hint is warranted (stable / watch / unknown), and also
72
+ * when a pressure level is not yet backed by real numbers — see the evidence
73
+ * policy above. The caller renders this as a dim informational line — no
74
+ * prompts, no blocking, no confirmation required.
34
75
  */
35
- export function buildContextStatusHint(options: ContextStatusHintOptions): string | null {
76
+ function buildContextStatusHint(options: ContextStatusHintOptions): string | null {
36
77
  const { level, autoCompactEnabled, usagePct } = options;
37
78
 
38
79
  switch (level) {
39
80
  case 'needs-repair':
81
+ if (!hasRealContextNumbers(options)) return null;
40
82
  return ` Context pressure critical (${usagePct}% used) — compaction needs attention. Run /compact or /health review.`;
41
83
 
42
84
  case 'suggest-compact':
85
+ if (!hasRealContextNumbers(options)) return null;
43
86
  if (autoCompactEnabled) {
44
87
  return ` Context high (${usagePct}% used) — auto-compact will run before the next turn.`;
45
88
  }
@@ -52,3 +95,28 @@ export function buildContextStatusHint(options: ContextStatusHintOptions): strin
52
95
  return null;
53
96
  }
54
97
  }
98
+
99
+ /**
100
+ * Evaluate session maintenance and turn it into the footer hint in one call.
101
+ * The ONE public seam of this module, deliberately: the guards above need the
102
+ * exact `currentTokens` / `contextWindow` the evaluator was handed, and a
103
+ * caller free to pass different values to each half is how they drift apart.
104
+ */
105
+ export function resolveContextStatusHint(input: {
106
+ readonly evaluate: (args: { readonly currentTokens: number; readonly contextWindow: number }) => {
107
+ readonly level: PanelSessionMaintenanceLevel;
108
+ readonly autoCompactEnabled: boolean;
109
+ readonly usagePct: number;
110
+ };
111
+ readonly currentTokens: number;
112
+ readonly contextWindow: number;
113
+ }): string | null {
114
+ const status = input.evaluate({ currentTokens: input.currentTokens, contextWindow: input.contextWindow });
115
+ return buildContextStatusHint({
116
+ level: status.level,
117
+ autoCompactEnabled: status.autoCompactEnabled,
118
+ usagePct: status.usagePct,
119
+ currentTokens: input.currentTokens,
120
+ contextWindow: input.contextWindow,
121
+ });
122
+ }
@@ -38,6 +38,22 @@ export function buildConversationViewport(
38
38
  };
39
39
  }
40
40
 
41
+ /**
42
+ * Absolute history-line index of the bottom-most visible row of the
43
+ * conversation viewport. The buffer bottom-aligns content shorter than the
44
+ * viewport (InfiniteBuffer.getSnapshot pads with blank lines at the TOP, not
45
+ * the bottom — see history.ts), so the naive `scrollTop + viewportHeight - 1`
46
+ * overshoots past the real last line whenever the whole transcript already
47
+ * fits on screen. Clamping to `lineCount - 1` handles both cases with one
48
+ * formula: when scrollLocked at the true bottom (the common case), this is
49
+ * exactly the last rendered content line — the block the user is actually
50
+ * looking at, not whatever sits at the TOP of the viewport (scrollTop alone).
51
+ */
52
+ export function getViewportBottomLine(scrollTop: number, viewportHeight: number, lineCount: number): number {
53
+ if (lineCount <= 0) return 0;
54
+ return Math.max(0, Math.min(scrollTop + viewportHeight - 1, lineCount - 1));
55
+ }
56
+
41
57
  export function overlayViewportBottom(
42
58
  viewport: readonly Line[],
43
59
  overlay: readonly Line[],
@@ -16,6 +16,7 @@ import { renderSessionPickerModal } from './session-picker-modal.ts';
16
16
  import { renderProfilePickerModal } from './profile-picker-modal.ts';
17
17
  import { renderBookmarkModal } from './bookmark-modal.ts';
18
18
  import { renderHelpOverlay, renderShortcutsOverlay } from './help-overlay.ts';
19
+ import { renderBlockActionsMenu } from './block-actions-overlay.ts';
19
20
  import { renderAutocompleteOverlay } from './autocomplete-overlay.ts';
20
21
  import { renderOnboardingWizard } from './onboarding/onboarding-wizard.ts';
21
22
  import { overlayViewportBottom, replaceViewportWithOverlay } from './conversation-layout.ts';
@@ -118,6 +119,11 @@ export function applyConversationOverlays(
118
119
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
119
120
  }
120
121
 
122
+ if (!fullscreenClaimed && input.blockActionsMenu.active) {
123
+ const lines = renderBlockActionsMenu(input.blockActionsMenu, conversationWidth, viewportHeight);
124
+ next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
125
+ }
126
+
121
127
  if (input.helpOverlayActive) {
122
128
  const lines = renderHelpOverlay(conversationWidth, keybindingsManager, commandRegistry.getAll(), input.helpScrollOffset, viewportHeight);
123
129
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);