@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.
- package/CHANGELOG.md +20 -1
- package/README.md +7 -7
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/doctor.ts +7 -1
- package/src/cli/management.ts +1 -1
- package/src/cli/package-verification.ts +92 -7
- package/src/cli/status.ts +16 -0
- package/src/config/read-versioned.ts +115 -2
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-event-navigation.ts +42 -0
- package/src/core/conversation-search-expansion.ts +67 -0
- package/src/core/conversation-splash-state.ts +58 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +43 -40
- package/src/core/model-routing-chip.ts +38 -18
- package/src/core/rewind-turn-anchors.ts +155 -3
- package/src/core/stream-event-wiring.ts +298 -17
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +145 -7
- package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
- package/src/daemon/handlers/inbox/index.ts +14 -1
- package/src/daemon/handlers/sqlite-store.ts +160 -6
- package/src/input/command-registry.ts +8 -0
- package/src/input/commands/local-runtime.ts +6 -1
- package/src/input/commands/schedule-runtime.ts +15 -2
- package/src/input/commands/shell-core.ts +87 -38
- package/src/input/composer-capture.ts +47 -0
- package/src/input/handler-command-route.ts +4 -0
- package/src/input/handler-content-actions.ts +9 -0
- package/src/input/handler-feed-routes.ts +1 -0
- package/src/input/handler-modal-routes.ts +12 -1
- package/src/input/handler-picker-routes.ts +43 -5
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/handler-ui-state.ts +8 -3
- package/src/input/model-picker-items.ts +17 -2
- package/src/input/model-picker.ts +19 -2
- package/src/input/search.ts +183 -89
- package/src/main.ts +31 -38
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/markdown-inline.ts +143 -0
- package/src/renderer/markdown-table.ts +459 -0
- package/src/renderer/markdown.ts +6 -335
- package/src/renderer/model-workspace.ts +48 -5
- package/src/renderer/shell-surface.ts +36 -0
- package/src/renderer/ui-factory.ts +25 -78
- package/src/runtime/bootstrap-command-parts.ts +36 -4
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/durability-housekeeping.ts +183 -0
- package/src/runtime/durability-services.ts +68 -11
- package/src/runtime/legacy-daemon-reconcile.ts +16 -7
- package/src/runtime/provider-fallback.ts +16 -2
- package/src/runtime/recovery-decisions.ts +295 -0
- package/src/runtime/recovery-prompt.ts +66 -6
- package/src/runtime/services.ts +9 -4
- package/src/runtime/session-liveness-marker.ts +142 -5
- package/src/runtime/workstream-draft-store.ts +198 -25
- package/src/runtime/workstream-services.ts +17 -2
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
- 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}
|
|
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
|
+
}
|