@pellux/goodvibes-tui 1.20.0 → 1.21.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 (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -7
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +1 -1
  6. package/src/cli/doctor.ts +7 -1
  7. package/src/cli/management.ts +1 -1
  8. package/src/cli/package-verification.ts +92 -7
  9. package/src/cli/status.ts +16 -0
  10. package/src/config/read-versioned.ts +115 -2
  11. package/src/core/active-model-identity.ts +153 -0
  12. package/src/core/conversation-event-navigation.ts +42 -0
  13. package/src/core/conversation-search-expansion.ts +67 -0
  14. package/src/core/conversation-splash-state.ts +58 -0
  15. package/src/core/conversation-user-receipts.ts +42 -0
  16. package/src/core/conversation.ts +43 -40
  17. package/src/core/model-routing-chip.ts +38 -18
  18. package/src/core/rewind-turn-anchors.ts +155 -3
  19. package/src/core/stream-event-wiring.ts +298 -17
  20. package/src/core/system-message-router.ts +22 -0
  21. package/src/core/transcript-journal.ts +145 -7
  22. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  23. package/src/daemon/handlers/inbox/index.ts +14 -1
  24. package/src/daemon/handlers/sqlite-store.ts +160 -6
  25. package/src/input/command-registry.ts +8 -0
  26. package/src/input/commands/local-runtime.ts +6 -1
  27. package/src/input/commands/schedule-runtime.ts +15 -2
  28. package/src/input/commands/shell-core.ts +87 -38
  29. package/src/input/composer-capture.ts +47 -0
  30. package/src/input/handler-command-route.ts +4 -0
  31. package/src/input/handler-content-actions.ts +9 -0
  32. package/src/input/handler-feed-routes.ts +1 -0
  33. package/src/input/handler-modal-routes.ts +12 -1
  34. package/src/input/handler-picker-routes.ts +43 -5
  35. package/src/input/handler-shortcuts.ts +1 -1
  36. package/src/input/handler-ui-state.ts +8 -3
  37. package/src/input/model-picker-items.ts +17 -2
  38. package/src/input/model-picker.ts +19 -2
  39. package/src/input/search.ts +183 -89
  40. package/src/main.ts +31 -38
  41. package/src/{cli → providers}/provider-classification.ts +12 -0
  42. package/src/providers/reasoning-effort-surface.ts +399 -0
  43. package/src/renderer/compositor.ts +68 -18
  44. package/src/renderer/header-line.ts +146 -0
  45. package/src/renderer/markdown-inline.ts +143 -0
  46. package/src/renderer/markdown-table.ts +459 -0
  47. package/src/renderer/markdown.ts +6 -335
  48. package/src/renderer/model-workspace.ts +48 -5
  49. package/src/renderer/shell-surface.ts +36 -0
  50. package/src/renderer/ui-factory.ts +25 -78
  51. package/src/runtime/bootstrap-command-parts.ts +36 -4
  52. package/src/runtime/bootstrap-core.ts +1 -1
  53. package/src/runtime/durability-housekeeping.ts +183 -0
  54. package/src/runtime/durability-services.ts +68 -11
  55. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  56. package/src/runtime/provider-fallback.ts +16 -2
  57. package/src/runtime/recovery-decisions.ts +295 -0
  58. package/src/runtime/recovery-prompt.ts +66 -6
  59. package/src/runtime/services.ts +9 -4
  60. package/src/runtime/session-liveness-marker.ts +142 -5
  61. package/src/runtime/workstream-draft-store.ts +198 -25
  62. package/src/runtime/workstream-services.ts +17 -2
  63. package/src/shell/ui-openers.ts +1 -1
  64. package/src/version.ts +1 -1
  65. package/src/work-plans/work-plan-store.ts +334 -16
package/src/cli/status.ts CHANGED
@@ -8,6 +8,7 @@ import type { GoodVibesCliOutputFormat } from './types.ts';
8
8
  import type { CliServicePosture } from './service-posture.ts';
9
9
  import type { InstallSelfCheckFinding } from '../runtime/install-self-check.ts';
10
10
  import { getProviderIdFromModel } from '../config/provider-model.ts';
11
+ import { describeConfiguredEffort } from '../providers/reasoning-effort-surface.ts';
11
12
 
12
13
  export interface CliStatusOptions {
13
14
  readonly configManager: Pick<ConfigManager, 'get'>;
@@ -96,6 +97,13 @@ export interface CliStatusSnapshot {
96
97
  readonly provider: string;
97
98
  readonly model: string;
98
99
  readonly reasoning: string;
100
+ /**
101
+ * What the configured reasoning level actually becomes on the configured
102
+ * model. The level alone is not the whole truth: a model that does not
103
+ * offer it gets the next level down, and a model with no configurable
104
+ * reasoning gets nothing sent at all.
105
+ */
106
+ readonly reasoningResolved: string;
99
107
  };
100
108
  readonly auth: {
101
109
  readonly permissionMode: unknown;
@@ -551,6 +559,10 @@ export function buildCliStatusSnapshot(options: CliStatusOptions): CliStatusSnap
551
559
  provider: getProviderIdFromModel(config.get('provider.model')),
552
560
  model: String(config.get('provider.model')),
553
561
  reasoning: String(config.get('provider.reasoningEffort')),
562
+ reasoningResolved: describeConfiguredEffort(
563
+ String(config.get('provider.model') ?? ''),
564
+ String(config.get('provider.reasoningEffort') ?? ''),
565
+ ),
554
566
  },
555
567
  auth: {
556
568
  permissionMode: config.get('permissions.mode'),
@@ -609,6 +621,10 @@ export function renderCliStatus(options: CliStatusOptions): string {
609
621
  ` provider: ${getProviderIdFromModel(config.get('provider.model'))}`,
610
622
  ` model: ${String(config.get('provider.model'))}`,
611
623
  ` reasoning: ${String(config.get('provider.reasoningEffort'))}`,
624
+ ` reasoning (on this model): ${describeConfiguredEffort(
625
+ String(config.get('provider.model') ?? ''),
626
+ String(config.get('provider.reasoningEffort') ?? ''),
627
+ )}`,
612
628
  '',
613
629
  'Auth:',
614
630
  ` permissions: ${permissionModeLabel(config.get('permissions.mode'))} (${String(config.get('permissions.mode'))})`,
@@ -1,4 +1,12 @@
1
- import { existsSync, readFileSync, renameSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The suffix every quarantined file in this codebase gets. Exported so the
6
+ * other producer of these files (the transcript journal's corrupt-tail
7
+ * quarantine) and the reclaim sweep below all agree on one spelling.
8
+ */
9
+ export const UNRECOGNIZED_SUFFIX = '.unrecognized';
2
10
 
3
11
  /**
4
12
  * A migration function that transforms data from version N to N+1.
@@ -108,8 +116,113 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
108
116
 
109
117
  function quarantine(path: string): void {
110
118
  try {
111
- renameSync(path, `${path}.unrecognized`);
119
+ renameSync(path, `${path}${UNRECOGNIZED_SUFFIX}`);
112
120
  } catch {
113
121
  // Best-effort — if rename fails (e.g. race), proceed silently.
114
122
  }
115
123
  }
124
+
125
+ // ─── Quarantine reclaim ───────────────────────────────────────────────────────
126
+ //
127
+ // Quarantining renames a bad file out of the way so a human can inspect it,
128
+ // and until now nothing ever removed the result — every corrupt config file
129
+ // and every torn journal tail left a `.unrecognized` file behind permanently.
130
+ // Forensic value is real, so the retention window below is deliberately long,
131
+ // but "keep forever" is a leak.
132
+
133
+ /**
134
+ * How long a quarantined file is kept before it is reclaimed: 30 days
135
+ * (2_592_000_000 ms). These files exist so a person can look at what went
136
+ * wrong, so the window is a month rather than the hours-to-days retention the
137
+ * live durability artefacts get — long enough to survive a holiday, short
138
+ * enough that a recurring corruption cannot fill a disk.
139
+ */
140
+ export const QUARANTINE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
141
+
142
+ /**
143
+ * Hard ceiling on quarantined files kept per swept directory, newest kept.
144
+ * The age rule alone cannot bound a fast repeating corruption (a boot loop
145
+ * quarantining the same file every restart), so a count cap runs alongside it.
146
+ */
147
+ export const QUARANTINE_MAX_FILES_PER_DIR = 50;
148
+
149
+ export interface QuarantineReapResult {
150
+ /** Quarantined files examined across every directory. */
151
+ readonly scanned: number;
152
+ /** Quarantined files deleted. */
153
+ readonly reaped: number;
154
+ }
155
+
156
+ export interface QuarantineReapOptions {
157
+ readonly now?: () => number;
158
+ /** Override the age window (tests). */
159
+ readonly maxAgeMs?: number;
160
+ /** Override the per-directory count cap (tests). */
161
+ readonly maxFilesPerDir?: number;
162
+ }
163
+
164
+ /**
165
+ * Delete `.unrecognized` quarantine files that are past the retention window,
166
+ * plus any beyond the per-directory count cap (newest kept).
167
+ *
168
+ * Each directory is scanned non-recursively; a missing or unreadable directory
169
+ * contributes nothing and is not an error. Idempotent, and safe to run
170
+ * concurrently from several processes — a file another sweeper already
171
+ * unlinked (ENOENT) counts as reclaimed rather than failing the sweep.
172
+ */
173
+ export function reapQuarantinedFiles(
174
+ directories: readonly string[],
175
+ options: QuarantineReapOptions = {},
176
+ ): QuarantineReapResult {
177
+ const now = options.now?.() ?? Date.now();
178
+ const maxAgeMs = options.maxAgeMs ?? QUARANTINE_RETENTION_MS;
179
+ const maxFilesPerDir = options.maxFilesPerDir ?? QUARANTINE_MAX_FILES_PER_DIR;
180
+
181
+ let scanned = 0;
182
+ let reaped = 0;
183
+
184
+ for (const dir of new Set(directories)) {
185
+ let names: string[];
186
+ try {
187
+ names = readdirSync(dir).filter((name) => name.endsWith(UNRECOGNIZED_SUFFIX));
188
+ } catch {
189
+ continue;
190
+ }
191
+ scanned += names.length;
192
+
193
+ const survivors: { readonly path: string; readonly mtimeMs: number }[] = [];
194
+ for (const name of names) {
195
+ const path = join(dir, name);
196
+ let mtimeMs: number;
197
+ try {
198
+ mtimeMs = statSync(path).mtimeMs;
199
+ } catch {
200
+ // Vanished under us (another sweeper) — nothing left to reclaim.
201
+ continue;
202
+ }
203
+ if (now - mtimeMs > maxAgeMs) {
204
+ if (unlinkQuarantined(path)) reaped++;
205
+ continue;
206
+ }
207
+ survivors.push({ path, mtimeMs });
208
+ }
209
+
210
+ if (survivors.length > maxFilesPerDir) {
211
+ survivors.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
212
+ for (const victim of survivors.slice(0, survivors.length - maxFilesPerDir)) {
213
+ if (unlinkQuarantined(victim.path)) reaped++;
214
+ }
215
+ }
216
+ }
217
+
218
+ return { scanned, reaped };
219
+ }
220
+
221
+ function unlinkQuarantined(path: string): boolean {
222
+ try {
223
+ unlinkSync(path);
224
+ return true;
225
+ } catch (err) {
226
+ return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
227
+ }
228
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * active-model-identity.ts — the single answer to "which backend is serving
3
+ * this session right now, and is that what the user chose?"
4
+ *
5
+ * Two shell surfaces used to answer that question independently and could
6
+ * therefore contradict each other:
7
+ * - the top-right header read `providerRegistry.getCurrentModel()` live
8
+ * every frame, so it followed an automatic failover switch immediately;
9
+ * - the footer read `runtime.model` / `runtime.provider`, which only
10
+ * bootstrap and explicit user model switches ever wrote, so it kept
11
+ * naming the configured backend after failover had moved serving (and
12
+ * billing) somewhere else.
13
+ *
14
+ * Both surfaces now resolve through resolveActiveModelDisplay() so they are
15
+ * computed from one comparison of serving-vs-configured. Divergence is
16
+ * detected structurally — by comparing the live registry key against the
17
+ * configured selection — not by trusting a flag, so ANY path that moves the
18
+ * registry off the user's choice is surfaced, not just the failover path that
19
+ * sets the record below.
20
+ *
21
+ * The failover record adds the REASON for a divergence ("failover from …");
22
+ * it never decides whether one exists.
23
+ */
24
+
25
+ /**
26
+ * The live per-turn record of an automatic provider failover.
27
+ *
28
+ * `configuredRegistryKey` is sticky across repeated failovers within one turn
29
+ * (a second hop still reports divergence from what the USER chose, not from
30
+ * the first fallback). `servingRegistryKey` tracks the most recent switch.
31
+ */
32
+ export interface FailoverTurnRecord {
33
+ /** The user's configured selection (`provider.model`) when failover began. */
34
+ readonly configuredRegistryKey: string;
35
+ /** The registry key failover switched serving to. */
36
+ readonly servingRegistryKey: string;
37
+ }
38
+
39
+ /**
40
+ * Mutable holder for the failover record, owned by the shell composition root
41
+ * (main.ts) and shared between the stream-event wiring that sets it and the
42
+ * render frame that reads it.
43
+ */
44
+ export interface FailoverTurnState {
45
+ /** The active record, or null when no failover is in effect. */
46
+ current(): FailoverTurnRecord | null;
47
+ /**
48
+ * Record a failover switch. On a second switch within the same turn the
49
+ * originally-configured key is preserved — divergence is always reported
50
+ * against the user's own selection.
51
+ */
52
+ begin(record: FailoverTurnRecord): void;
53
+ /** Drop the record — called only after serving is actually back on the configured selection. */
54
+ clear(): void;
55
+ }
56
+
57
+ /** Create an empty FailoverTurnState. */
58
+ export function createFailoverTurnState(): FailoverTurnState {
59
+ let active: FailoverTurnRecord | null = null;
60
+ return {
61
+ current: () => active,
62
+ begin(record: FailoverTurnRecord): void {
63
+ active = active === null
64
+ ? record
65
+ : { configuredRegistryKey: active.configuredRegistryKey, servingRegistryKey: record.servingRegistryKey };
66
+ },
67
+ clear(): void { active = null; },
68
+ };
69
+ }
70
+
71
+ /** What the live provider registry reports as serving this frame. */
72
+ export interface ServingModelIdentity {
73
+ readonly id: string;
74
+ readonly provider: string;
75
+ readonly registryKey?: string | undefined;
76
+ }
77
+
78
+ export interface ActiveModelInputs {
79
+ /** Live registry answer — who is actually serving right now. */
80
+ readonly serving: ServingModelIdentity;
81
+ /** The user's configured selection as a registry key (config `provider.model`). */
82
+ readonly configuredRegistryKey: string | undefined;
83
+ /** The configured selection's footer label (session `runtime.model`). */
84
+ readonly configuredLabel: string | undefined;
85
+ /** The configured selection's provider id (session `runtime.provider`). */
86
+ readonly configuredProvider: string | undefined;
87
+ /** The live failover record, when one is in effect. */
88
+ readonly failover: FailoverTurnRecord | null;
89
+ }
90
+
91
+ export interface ActiveModelDisplay {
92
+ /** Model text for the header's right-hand segment. */
93
+ readonly headerModel: string;
94
+ /** Provider text for the header's right-hand segment. */
95
+ readonly headerProvider: string;
96
+ /** Model text for the footer's context-info segment. */
97
+ readonly footerModel: string;
98
+ /** Provider text for the footer's context-info segment. */
99
+ readonly footerProvider: string;
100
+ /**
101
+ * Divergence marker naming the configured selection, e.g.
102
+ * `failover from abacusai:route-llm`. Empty string when serving is the
103
+ * configured selection — in that case both surfaces render exactly as they
104
+ * did before this resolver existed.
105
+ */
106
+ readonly divergenceNote: string;
107
+ /** True when the serving backend is not the user's configured selection. */
108
+ readonly diverged: boolean;
109
+ }
110
+
111
+ /**
112
+ * Decide what the header and footer say about the active model this frame.
113
+ *
114
+ * Divergence requires BOTH a known live registry key and a known configured
115
+ * selection; if either is missing the resolver reports no divergence rather
116
+ * than guessing, because a false "failover" marker would be its own lie. The
117
+ * configured selection is matched against the serving registry key AND the
118
+ * bare serving model id, so a config value stored as a bare model id (rather
119
+ * than the `provider:model` registry key) does not read as permanent divergence.
120
+ */
121
+ export function resolveActiveModelDisplay(input: ActiveModelInputs): ActiveModelDisplay {
122
+ const servingKey = input.serving.registryKey;
123
+ const configuredKey = input.configuredRegistryKey;
124
+ const diverged = Boolean(
125
+ servingKey && configuredKey && configuredKey !== servingKey && configuredKey !== input.serving.id,
126
+ );
127
+ if (!diverged) {
128
+ return {
129
+ headerModel: input.serving.id,
130
+ headerProvider: input.serving.provider,
131
+ // Footer keeps its own established label form (runtime.model) when there
132
+ // is nothing to correct, so the unchanged case renders byte-identically.
133
+ footerModel: input.configuredLabel ?? input.serving.id,
134
+ footerProvider: input.configuredProvider ?? input.serving.provider,
135
+ divergenceNote: '',
136
+ diverged: false,
137
+ };
138
+ }
139
+ // Serving != configured. Both surfaces now name the SERVING backend, plus a
140
+ // marker naming the configured selection it departed from. The failover
141
+ // record supplies the reason only when it matches the backend actually
142
+ // serving; any other divergence is described without claiming a cause.
143
+ const causedByFailover = input.failover !== null && input.failover.servingRegistryKey === servingKey;
144
+ const reason = causedByFailover ? 'failover from' : 'not the configured';
145
+ return {
146
+ headerModel: input.serving.id,
147
+ headerProvider: input.serving.provider,
148
+ footerModel: input.serving.id,
149
+ footerProvider: input.serving.provider,
150
+ divergenceNote: `${reason} ${configuredKey}`,
151
+ diverged: true,
152
+ };
153
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * conversation-event-navigation.ts — resolving "jump to the next/previous
3
+ * transcript event" into a history-buffer line number.
4
+ *
5
+ * Extracted from ConversationManager (core/conversation.ts), which is at its
6
+ * line-count gate, and kept structural (no SDK imports beyond the event kind)
7
+ * so the ordering rule can be tested without building a conversation.
8
+ *
9
+ * The rule both directions share: take the events of the requested kind, map
10
+ * each to the line its message was rendered at, drop the ones that were never
11
+ * rendered (a message with no registry entry), sort ascending, then wrap —
12
+ * searching forward past `currentLine` falls back to the first line, searching
13
+ * backward falls back to the last. -1 means there is nowhere to go.
14
+ */
15
+
16
+ import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
17
+
18
+ /** The subset of a transcript-event-index entry this module reads. */
19
+ export interface TranscriptEventLike {
20
+ readonly kind: TranscriptEventKind;
21
+ readonly messageIndex: number;
22
+ }
23
+
24
+ export function resolveTranscriptEventLine(
25
+ events: readonly TranscriptEventLike[],
26
+ kind: TranscriptEventKind | 'all',
27
+ messageLineRegistry: readonly (number | undefined)[],
28
+ currentLine: number,
29
+ direction: 'next' | 'prev',
30
+ ): number {
31
+ const selected = kind === 'all' ? events : events.filter((event) => event.kind === kind);
32
+ if (selected.length === 0) return -1;
33
+ const lines = selected
34
+ .map((event) => messageLineRegistry[event.messageIndex] ?? -1)
35
+ .filter((line) => line >= 0)
36
+ .sort((a, b) => a - b);
37
+ if (lines.length === 0) return -1;
38
+ if (direction === 'next') {
39
+ return lines.find((line) => line > currentLine) ?? lines[0]!;
40
+ }
41
+ return [...lines].reverse().find((line) => line < currentLine) ?? lines[lines.length - 1]!;
42
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * conversation-search-expansion.ts — tracks which collapse keys transcript
3
+ * search auto-expanded so they can be restored on search close, unless the
4
+ * user explicitly acted on the block while it was open.
5
+ *
6
+ * Extracted from conversation.ts to keep that file under the architecture
7
+ * line-count gate. restoreOnto() applies restoration through a structural
8
+ * `{ setCollapsed }` shape (ConversationManager's own public setCollapsed())
9
+ * rather than importing ConversationManager itself, so this module has no
10
+ * import-cycle back to conversation.ts.
11
+ *
12
+ * The contract (see SearchManager.revealCurrentMatch and .close in
13
+ * search.ts): a single keystroke in the search field must never expand
14
+ * collapsed content — only navigating TO a match hidden inside it does, and
15
+ * that expansion is undone when search closes UNLESS the user separately
16
+ * touched the block (toggled, copied, bookmarked, saved, or bulk-expanded
17
+ * via /expand) while it sat auto-expanded. An explicit user action always
18
+ * wins over search's own bookkeeping, regardless of which happened first.
19
+ */
20
+
21
+ /** Structural shape restoreOnto() needs — matches ConversationManager's own
22
+ * public setCollapsed() without importing the class (avoids a cycle). */
23
+ export interface CollapseSettable {
24
+ setCollapsed(collapseKey: string, collapsed: boolean): void;
25
+ }
26
+
27
+ export class SearchExpansionTracker {
28
+ /**
29
+ * Collapse keys currently expanded because the user navigated to a search
30
+ * match hidden inside them. Never contains a key the user has touched
31
+ * explicitly — noteUserTouch() removes it on the way in.
32
+ */
33
+ private searchExpandedKeys = new Set<string>();
34
+
35
+ /**
36
+ * Collapse keys the user has explicitly acted on at least once. Growth is
37
+ * unbounded for the life of the conversation — membership only ever gates
38
+ * one decision (whether restoreSearchExpansions() may re-collapse a key),
39
+ * so a stale positive is harmless and pruning would add complexity for no
40
+ * behavioral gain.
41
+ */
42
+ private userTouchedKeys = new Set<string>();
43
+
44
+ /** Record that `collapseKey` was expanded because the user navigated to a
45
+ * search match hidden inside it. No-op for a key the user already
46
+ * touched explicitly, since that ownership always wins. */
47
+ markSearchExpanded(collapseKey: string): void {
48
+ if (!this.userTouchedKeys.has(collapseKey)) this.searchExpandedKeys.add(collapseKey);
49
+ }
50
+
51
+ /** Record an explicit user action on `collapseKey` (toggle/copy/bookmark/
52
+ * save/bulk-expand). Exempts it from restoreSearchExpansions()'s
53
+ * auto-re-collapse for the rest of the session. */
54
+ noteUserTouch(collapseKey: string): void {
55
+ this.userTouchedKeys.add(collapseKey);
56
+ this.searchExpandedKeys.delete(collapseKey);
57
+ }
58
+
59
+ /** Re-collapse every key search auto-expanded and not since user-touched,
60
+ * through `conversationManager`'s public setCollapsed() — called from
61
+ * SearchManager.close() to restore pre-search collapse state. */
62
+ restoreOnto(conversationManager: CollapseSettable): void {
63
+ const keys = [...this.searchExpandedKeys];
64
+ this.searchExpandedKeys.clear();
65
+ for (const key of keys) conversationManager.setCollapsed(key, true);
66
+ }
67
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * conversation-splash-state.ts — when the splash owns the conversation area,
3
+ * and what has to happen the moment it stops.
4
+ *
5
+ * Two pieces of state, kept out of ConversationManager (core/conversation.ts,
6
+ * already at its line-count gate) and out of `suppressSplash`, which is a
7
+ * per-frame posture the panel workspace sets both ways on every render:
8
+ *
9
+ * - `dismissed` — sticky for the run. Owner rule: the splash yields to ANY
10
+ * submission, a chat message or a slash command alike. A slash command
11
+ * whose whole output is a modal (or nothing) still means the session has
12
+ * started, so the splash must not reappear underneath it, and must not
13
+ * come back in the gap before the first reply arrives.
14
+ *
15
+ * - the splash→transcript EDGE — latched when a rebuild that would have
16
+ * drawn the splash draws transcript content instead. The shell consumes
17
+ * it to repaint the whole viewport for that one frame. Without that, the
18
+ * differential renderer only rewrites rows it knows changed, so every row
19
+ * the incoming transcript does not cover keeps showing splash art: rule
20
+ * lines, the hint line, and half-overwritten rows where a short transcript
21
+ * line collides with the tail of a longer splash line.
22
+ */
23
+ export class SplashGateState {
24
+ private dismissedForRun = false;
25
+ private onScreen = false;
26
+ private yielded = false;
27
+
28
+ /** True once the run's first submission has retired the splash. */
29
+ get dismissed(): boolean {
30
+ return this.dismissedForRun;
31
+ }
32
+
33
+ /** Retire the splash for the run. Returns true when this call changed anything. */
34
+ dismiss(): boolean {
35
+ if (this.dismissedForRun) return false;
36
+ this.dismissedForRun = true;
37
+ return true;
38
+ }
39
+
40
+ /** Record that this rebuild drew the splash. */
41
+ enter(): void {
42
+ this.onScreen = true;
43
+ }
44
+
45
+ /** Record that this rebuild drew transcript content; latches the edge. */
46
+ leave(): void {
47
+ if (!this.onScreen) return;
48
+ this.onScreen = false;
49
+ this.yielded = true;
50
+ }
51
+
52
+ /** True exactly once per splash→transcript transition. */
53
+ consumeTransition(): boolean {
54
+ const transition = this.yielded;
55
+ this.yielded = false;
56
+ return transition;
57
+ }
58
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * conversation-user-receipts.ts — tracks which system-role message indices
3
+ * are a direct receipt for an explicit user action (e.g. answering the
4
+ * startup recovery modal's Resume/Keep/Remove choice), as opposed to ambient
5
+ * boot chatter (provider registration, the resume notice, etc.). Kept in its
6
+ * own file so ConversationManager (core/conversation.ts) — already near its
7
+ * line-count gate — doesn't have to carry the bookkeeping inline.
8
+ *
9
+ * ConversationManager.rebuildHistory()'s splash-vs-transcript check consults
10
+ * this via ConversationManager.addUserActionReceipt(): a receipt the user is
11
+ * actively waiting on must displace the splash the way a user message does,
12
+ * while quiet startup plumbing keeps rendering underneath it. Indices mirror
13
+ * messageKindRegistry's own lifecycle — cleared on resetAll(), purged for
14
+ * indices an undo() frees for reuse, deleted when a later add overwrites the
15
+ * same recycled index with a non-receipt message.
16
+ */
17
+ export class UserReceiptIndices {
18
+ private readonly indices = new Set<number>();
19
+
20
+ add(index: number): void {
21
+ this.indices.add(index);
22
+ }
23
+
24
+ delete(index: number): void {
25
+ this.indices.delete(index);
26
+ }
27
+
28
+ has(index: number): boolean {
29
+ return this.indices.has(index);
30
+ }
31
+
32
+ clear(): void {
33
+ this.indices.clear();
34
+ }
35
+
36
+ /** Drop every tracked index >= minIndex — mirrors undo()'s tail-splice. */
37
+ purgeFrom(minIndex: number): void {
38
+ for (const key of this.indices) {
39
+ if (key >= minIndex) this.indices.delete(key);
40
+ }
41
+ }
42
+ }