@pellux/goodvibes-tui 1.20.1 → 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 +13 -1
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/doctor.ts +7 -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/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.ts +23 -23
- 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 +141 -20
- 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 +12 -20
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- 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/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 +180 -11
- package/src/runtime/recovery-prompt.ts +11 -1
- 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
|
@@ -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,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
|
+
}
|
package/src/core/conversation.ts
CHANGED
|
@@ -5,8 +5,7 @@ import type { ToolCall, ToolResult } from '@pellux/goodvibes-sdk/platform/types'
|
|
|
5
5
|
import type { ProviderMessage, ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
|
|
6
6
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
7
7
|
import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
|
|
8
|
-
// SystemMessageKind imported from runtime directly to avoid cycle:
|
|
9
|
-
// conversation.ts → system-message-router.ts → conversation.ts
|
|
8
|
+
// SystemMessageKind imported from runtime directly to avoid cycle: conversation.ts → system-message-router.ts → conversation.ts
|
|
10
9
|
import type { SystemMessageKind } from '@/runtime/index.ts';
|
|
11
10
|
import {
|
|
12
11
|
ConversationManager as SdkConversationManager,
|
|
@@ -15,6 +14,9 @@ import {
|
|
|
15
14
|
import type { BlockMeta } from './conversation-types.ts';
|
|
16
15
|
import { MessageLineCache } from './conversation-line-cache.ts';
|
|
17
16
|
import { UserReceiptIndices } from './conversation-user-receipts.ts';
|
|
17
|
+
import { SplashGateState } from './conversation-splash-state.ts';
|
|
18
|
+
import { resolveTranscriptEventLine } from './conversation-event-navigation.ts';
|
|
19
|
+
import { SearchExpansionTracker } from './conversation-search-expansion.ts';
|
|
18
20
|
import {
|
|
19
21
|
addConversationSplashScreen,
|
|
20
22
|
conversationTextToLines,
|
|
@@ -111,6 +113,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
111
113
|
private _configManager: ConfigManager | null = null;
|
|
112
114
|
/** Collapse state: stable key (msg_N) -> collapsed (true = collapsed). */
|
|
113
115
|
private collapseState: Map<string, boolean> = new Map();
|
|
116
|
+
public readonly searchExpansion = new SearchExpansionTracker();
|
|
114
117
|
/** Block registry: track rendered blocks for copy/apply. */
|
|
115
118
|
protected blockRegistry: BlockMeta[] = [];
|
|
116
119
|
/**
|
|
@@ -167,6 +170,8 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
167
170
|
|
|
168
171
|
public suppressSplash: boolean = false;
|
|
169
172
|
public splashOptions: SplashOptions = {};
|
|
173
|
+
/** Run-sticky dismissal + the splash→transcript edge (conversation-splash-state.ts). */
|
|
174
|
+
private readonly splashGate = new SplashGateState();
|
|
170
175
|
|
|
171
176
|
constructor(
|
|
172
177
|
getWidth: () => number = () => process.stdout.columns || 80,
|
|
@@ -488,10 +493,12 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
488
493
|
return true;
|
|
489
494
|
});
|
|
490
495
|
|
|
491
|
-
if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash) {
|
|
496
|
+
if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash && !this.splashGate.dismissed) {
|
|
492
497
|
this.addSplashScreen(width);
|
|
498
|
+
this.splashGate.enter();
|
|
493
499
|
return;
|
|
494
500
|
}
|
|
501
|
+
this.splashGate.leave(); // latches the splash→transcript edge for consumeSplashTransition()
|
|
495
502
|
|
|
496
503
|
// The in-progress streaming placeholder (rendered EMPTY above via
|
|
497
504
|
// renderSnapshot) is left uncached: its content mutates in place per delta and
|
|
@@ -643,6 +650,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
643
650
|
if (!nearest) return -1;
|
|
644
651
|
const current = this.collapseState.get(nearest.collapseKey) ?? false;
|
|
645
652
|
this.collapseState.set(nearest.collapseKey, !current);
|
|
653
|
+
this.searchExpansion.noteUserTouch(nearest.collapseKey);
|
|
646
654
|
this.markDirty();
|
|
647
655
|
return nearest.blockIndex;
|
|
648
656
|
}
|
|
@@ -704,30 +712,12 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
704
712
|
|
|
705
713
|
public nextTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
|
|
706
714
|
this.flushHistory();
|
|
707
|
-
|
|
708
|
-
const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
|
|
709
|
-
if (events.length === 0) return -1;
|
|
710
|
-
const lines = events
|
|
711
|
-
.map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
|
|
712
|
-
.filter((line) => line >= 0)
|
|
713
|
-
.sort((a, b) => a - b);
|
|
714
|
-
if (lines.length === 0) return -1;
|
|
715
|
-
const after = lines.find((line) => line > currentLine);
|
|
716
|
-
return after ?? lines[0]!;
|
|
715
|
+
return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'next');
|
|
717
716
|
}
|
|
718
717
|
|
|
719
718
|
public prevTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
|
|
720
719
|
this.flushHistory();
|
|
721
|
-
|
|
722
|
-
const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
|
|
723
|
-
if (events.length === 0) return -1;
|
|
724
|
-
const lines = events
|
|
725
|
-
.map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
|
|
726
|
-
.filter((line) => line >= 0)
|
|
727
|
-
.sort((a, b) => a - b);
|
|
728
|
-
if (lines.length === 0) return -1;
|
|
729
|
-
const before = [...lines].reverse().find((line) => line < currentLine);
|
|
730
|
-
return before ?? lines[lines.length - 1]!;
|
|
720
|
+
return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'prev');
|
|
731
721
|
}
|
|
732
722
|
|
|
733
723
|
public setSplashSuppressed(suppressed: boolean): void {
|
|
@@ -736,6 +726,16 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
736
726
|
this.markDirty();
|
|
737
727
|
}
|
|
738
728
|
|
|
729
|
+
/** Retire the splash for the run — any submission does it (SplashGateState). */
|
|
730
|
+
public dismissSplash(): void {
|
|
731
|
+
if (this.splashGate.dismiss()) this.markDirty();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** True once, on the frame the splash gives way to transcript content. */
|
|
735
|
+
public consumeSplashTransition(): boolean {
|
|
736
|
+
return this.splashGate.consumeTransition();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
739
|
private addSplashScreen(width: number): void {
|
|
740
740
|
addConversationSplashScreen(this.renderingContext(), width);
|
|
741
741
|
}
|
|
@@ -9,18 +9,22 @@
|
|
|
9
9
|
* narration of their own.
|
|
10
10
|
*
|
|
11
11
|
* Division of labour with the failover path: the optimizer-gated failover in
|
|
12
|
-
* stream-event-wiring.ts
|
|
13
|
-
* (reason)(+cost)` line
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* [Failover] line already narrated that exact change with its real reason.
|
|
17
|
-
* Every other change is surfaced here.
|
|
12
|
+
* stream-event-wiring.ts renders a richer `[Failover] from → to
|
|
13
|
+
* (reason)(+cost)` line for the switches it makes itself, and tells this
|
|
14
|
+
* listener so directly (see the self-narrated guard there) rather than relying
|
|
15
|
+
* on this module to infer it. Those changes never reach buildRoutingChip.
|
|
18
16
|
*
|
|
19
|
-
* Reason honesty
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
17
|
+
* Reason honesty, in three tiers:
|
|
18
|
+
* 1. a change the failover path already narrated → not offered here at all;
|
|
19
|
+
* 2. a change that correlates to a transition in the optimizer's fallback
|
|
20
|
+
* log → the log's own reason is quoted, because the reason IS known. The
|
|
21
|
+
* log is shared: the TUI's failover records provider ids ("abacusai"),
|
|
22
|
+
* the SDK's agent-orchestrator fallback records registry keys
|
|
23
|
+
* ("abacusai:route-llm"), and a match on either form counts. Reading only
|
|
24
|
+
* one form is what made a real, explained failover print
|
|
25
|
+
* "(reason unknown)";
|
|
26
|
+
* 3. no correlation → "(reason unknown)", because a change with no
|
|
27
|
+
* explanation is still reported, just honestly marked as unexplained.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
/** The subset of a FallbackTransition this module reads (structural, SDK-agnostic). */
|
|
@@ -44,9 +48,22 @@ export const FALLBACK_CORRELATION_WINDOW_MS = 4_000;
|
|
|
44
48
|
/** The forced conversation prefix so the notice is never gated as noise or routed away. */
|
|
45
49
|
export const ROUTING_CHIP_PREFIX = '[Routing]';
|
|
46
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The provider id of a fallback-log endpoint, which may be written either as a
|
|
53
|
+
* bare provider id or as a `provider:model` registry key depending on which
|
|
54
|
+
* writer recorded it. Everything before the first ':' is the provider in both
|
|
55
|
+
* forms, so one rule reads both.
|
|
56
|
+
*/
|
|
57
|
+
function providerOf(endpoint: string): string {
|
|
58
|
+
const separator = endpoint.indexOf(':');
|
|
59
|
+
return separator === -1 ? endpoint : endpoint.slice(0, separator);
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
/**
|
|
48
63
|
* Most recent fallback-log reason for a from→to provider transition within the
|
|
49
64
|
* correlation window, or null. Scans newest-first so the freshest transition wins.
|
|
65
|
+
* Endpoints are compared by provider id, so entries recorded as registry keys
|
|
66
|
+
* correlate exactly like entries recorded as bare provider ids.
|
|
50
67
|
*/
|
|
51
68
|
export function findRecentFallbackReason(
|
|
52
69
|
fallbackLog: readonly FallbackTransitionLike[],
|
|
@@ -57,17 +74,20 @@ export function findRecentFallbackReason(
|
|
|
57
74
|
): string | null {
|
|
58
75
|
for (let i = fallbackLog.length - 1; i >= 0; i--) {
|
|
59
76
|
const t = fallbackLog[i]!;
|
|
60
|
-
if (t.from === fromProvider
|
|
77
|
+
if (providerOf(t.from) === providerOf(fromProvider)
|
|
78
|
+
&& providerOf(t.to) === providerOf(toProvider)
|
|
79
|
+
&& now - t.ts <= windowMs) return t.reason;
|
|
61
80
|
}
|
|
62
81
|
return null;
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/**
|
|
66
85
|
* The routing-chip line to emit for a MODEL_CHANGED event, or null to stay
|
|
67
|
-
* silent. Null only when there is no real change (no/same previous model)
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
86
|
+
* silent. Null only when there is no real change (no/same previous model) —
|
|
87
|
+
* changes the failover path narrates itself never reach this function.
|
|
88
|
+
*
|
|
89
|
+
* A change that correlates to a logged fallback transition is reported WITH
|
|
90
|
+
* that transition's reason; anything else is reported as "reason unknown".
|
|
71
91
|
*/
|
|
72
92
|
export function buildRoutingChip(
|
|
73
93
|
change: ModelChangedLike,
|
|
@@ -77,6 +97,6 @@ export function buildRoutingChip(
|
|
|
77
97
|
const prev = change.previous;
|
|
78
98
|
if (!prev || prev.registryKey === change.registryKey) return null;
|
|
79
99
|
const failoverReason = findRecentFallbackReason(fallbackLog, prev.provider, change.provider, now);
|
|
80
|
-
|
|
81
|
-
return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (reason
|
|
100
|
+
const reason = failoverReason === null ? 'reason unknown' : `failover: ${failoverReason}`;
|
|
101
|
+
return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (${reason})`;
|
|
82
102
|
}
|