@pellux/goodvibes-tui 0.29.0 → 1.1.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 +69 -98
- package/README.md +16 -7
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
- package/src/cli/entrypoint.ts +2 -0
- package/src/core/conversation-line-cache.ts +432 -0
- package/src/core/conversation-rendering.ts +11 -3
- package/src/core/conversation.ts +90 -4
- package/src/core/stream-event-wiring.ts +104 -2
- package/src/core/stream-stall-watchdog.ts +41 -17
- package/src/export/cost-utils.ts +90 -8
- package/src/input/command-registry.ts +16 -0
- package/src/input/commands/diff-runtime.ts +61 -30
- package/src/input/commands/session-content.ts +12 -0
- package/src/input/commands/session-workflow.ts +3 -0
- package/src/input/commands/share-runtime.ts +9 -2
- package/src/input/handler-content-actions.ts +30 -11
- package/src/input/handler-feed-routes.ts +63 -1
- package/src/input/handler-feed.ts +12 -0
- package/src/input/handler-interactions.ts +2 -0
- package/src/input/handler-types.ts +1 -0
- package/src/input/handler.ts +4 -0
- package/src/input/input-history.ts +17 -4
- package/src/main.ts +27 -23
- package/src/panels/agent-inspector-shared.ts +33 -10
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-read-model.ts +16 -11
- package/src/panels/cost-tracker-panel.ts +28 -5
- package/src/panels/debug-panel.ts +11 -5
- package/src/panels/diff-panel.ts +122 -22
- package/src/panels/docs-panel.ts +9 -0
- package/src/panels/file-explorer-panel.ts +9 -0
- package/src/panels/git-panel.ts +11 -11
- package/src/panels/knowledge-graph-panel.ts +9 -0
- package/src/panels/local-auth-panel.ts +10 -0
- package/src/panels/panel-list-panel.ts +9 -0
- package/src/panels/project-planning-panel.ts +10 -0
- package/src/panels/provider-health-tracker.ts +5 -1
- package/src/panels/provider-health-views.ts +8 -1
- package/src/panels/scrollable-list-panel.ts +9 -0
- package/src/panels/session-browser-panel.ts +9 -0
- package/src/panels/token-budget-panel.ts +5 -3
- package/src/panels/types.ts +13 -0
- package/src/panels/work-plan-panel.ts +10 -0
- package/src/renderer/agent-detail-modal.ts +42 -12
- package/src/renderer/code-block.ts +58 -28
- package/src/renderer/context-inspector.ts +3 -6
- package/src/renderer/conversation-surface.ts +1 -21
- package/src/renderer/diff-view.ts +6 -4
- package/src/renderer/fullscreen-primitives.ts +1 -1
- package/src/renderer/fullscreen-workspace.ts +2 -7
- package/src/renderer/hint-grammar.ts +1 -1
- package/src/renderer/history-search-overlay.ts +10 -16
- package/src/renderer/markdown.ts +9 -1
- package/src/renderer/model-picker-overlay.ts +8 -499
- package/src/renderer/model-workspace.ts +9 -24
- package/src/renderer/overlay-box.ts +7 -9
- package/src/renderer/process-indicator.ts +13 -25
- package/src/renderer/process-modal.ts +17 -2
- package/src/renderer/profile-picker-modal.ts +13 -14
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/selection-modal-overlay.ts +3 -1
- package/src/renderer/semantic-diff.ts +1 -1
- package/src/renderer/settings-modal-helpers.ts +10 -34
- package/src/renderer/shell-surface.ts +29 -9
- package/src/renderer/surface-layout.ts +0 -12
- package/src/renderer/term-caps.ts +1 -1
- package/src/renderer/tool-call.ts +6 -20
- package/src/renderer/ui-factory.ts +98 -14
- package/src/renderer/ui-primitives.ts +18 -1
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +5 -0
- package/src/runtime/bootstrap-hook-bridge.ts +5 -0
- package/src/runtime/bootstrap-shell.ts +12 -1
- package/src/runtime/render-scheduler.ts +80 -0
- package/src/utils/splash-lines.ts +10 -2
- package/src/version.ts +1 -1
- package/src/renderer/file-tree.ts +0 -153
- package/src/renderer/progress.ts +0 -100
- package/src/renderer/status-token.ts +0 -67
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-line-cache.ts — per-message Line[] production cache (WO-209).
|
|
3
|
+
*
|
|
4
|
+
* The measured defect (perf baseline 2026-07-03, transcript.build_1k): appending
|
|
5
|
+
* ONE message to an N-message conversation re-rendered all N messages
|
|
6
|
+
* (45.5 ms p50 / 71.4 MB / 695 k objects per rebuild), because
|
|
7
|
+
* ConversationManager.rebuildHistory() clears the buffer and calls
|
|
8
|
+
* appendConversationMessages() over the entire snapshot on every dirty flag —
|
|
9
|
+
* and markDirty() fires on every mutation. The marginal work for one appended
|
|
10
|
+
* message is ~1/1000th of that.
|
|
11
|
+
*
|
|
12
|
+
* This module memoises the per-message render. Each message's rendered output is
|
|
13
|
+
* a PURE function of its complete inputs:
|
|
14
|
+
* - message identity + content (via a content signature; snapshots are fresh
|
|
15
|
+
* structuredClone copies each call, so string content compares by value and
|
|
16
|
+
* array-valued fields — user ContentPart[] and assistant toolCalls — are
|
|
17
|
+
* serialised; a streaming assistant message mutates its content string in
|
|
18
|
+
* place, which changes the signature and invalidates the entry)
|
|
19
|
+
* - render width
|
|
20
|
+
* - the four display-config values the render reads (line-number mode,
|
|
21
|
+
* collapse threshold, showThinking, showReasoningSummary)
|
|
22
|
+
* - the block-registry base at message start (the code-block collapseKey embeds
|
|
23
|
+
* the GLOBAL block index — `code_${msgIdx}_${blockIdx}` — so a shift in an
|
|
24
|
+
* earlier message's block count changes this message's keys)
|
|
25
|
+
* - the absolute message index (embedded in every collapseKey and used for the
|
|
26
|
+
* system-message kind lookup)
|
|
27
|
+
* - the system-message kind (drives the error-navigation registry side effect)
|
|
28
|
+
* - the live values of every collapseState key the render READS (recorded via a
|
|
29
|
+
* proxy during a miss; a collapse toggle flips a recorded value and
|
|
30
|
+
* invalidates exactly the owning message)
|
|
31
|
+
*
|
|
32
|
+
* Invalidation is by comparison of those complete inputs — the file-preview
|
|
33
|
+
* contentVersion precedent generalised: instead of a single version counter we
|
|
34
|
+
* compare the full input tuple, which is provably complete (a from-scratch
|
|
35
|
+
* rebuild reads nothing else).
|
|
36
|
+
*
|
|
37
|
+
* Correctness contract: a cache-served rebuild is BYTE-IDENTICAL to a cold
|
|
38
|
+
* appendConversationMessages() rebuild. This is guaranteed by construction — a
|
|
39
|
+
* miss renders through the exact same per-message render functions into an
|
|
40
|
+
* isolated scratch context, and the captured lines / block metas / error lines
|
|
41
|
+
* are replayed at the same buffer offsets a cold render would have produced.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { createEmptyLine, type Line } from '../types/grid.ts';
|
|
45
|
+
import type { BlockMeta } from './conversation-types.ts';
|
|
46
|
+
import type { ConversationRenderContext } from './conversation-rendering.ts';
|
|
47
|
+
import {
|
|
48
|
+
renderConversationAssistantMessage,
|
|
49
|
+
renderConversationSystemMessage,
|
|
50
|
+
renderConversationToolMessage,
|
|
51
|
+
renderConversationUserMessage,
|
|
52
|
+
} from './conversation-rendering.ts';
|
|
53
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
54
|
+
// SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
|
|
55
|
+
// conversation-rendering.ts's own import.
|
|
56
|
+
import type { SystemMessageKind } from '@/runtime/index.ts';
|
|
57
|
+
|
|
58
|
+
type Message = ConversationMessageSnapshot;
|
|
59
|
+
|
|
60
|
+
/** The display-config values every message render depends on. */
|
|
61
|
+
interface RenderConfig {
|
|
62
|
+
readonly lineNumberMode: 'all' | 'code' | 'off';
|
|
63
|
+
readonly collapseThreshold: number;
|
|
64
|
+
readonly showThinking: boolean;
|
|
65
|
+
readonly showReasoningSummary: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Content signature: the render-relevant fields of a message, captured so an
|
|
70
|
+
* unchanged message is recognised without re-rendering. Array-valued fields are
|
|
71
|
+
* pre-serialised to strings so equality is a value comparison.
|
|
72
|
+
*/
|
|
73
|
+
interface ContentSig {
|
|
74
|
+
readonly role: Message['role'];
|
|
75
|
+
/** String content, or JSON of ContentPart[] for array-valued user content. */
|
|
76
|
+
readonly content: string;
|
|
77
|
+
readonly cancelled?: boolean;
|
|
78
|
+
readonly model?: string;
|
|
79
|
+
readonly provider?: string;
|
|
80
|
+
readonly reasoningContent?: string;
|
|
81
|
+
readonly reasoningSummary?: string;
|
|
82
|
+
readonly toolCallsJson?: string;
|
|
83
|
+
readonly callId?: string;
|
|
84
|
+
readonly toolName?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Non-content inputs that key the cache entry. */
|
|
88
|
+
interface KeyMeta {
|
|
89
|
+
readonly role: Message['role'];
|
|
90
|
+
readonly width: number;
|
|
91
|
+
readonly lineNumberMode: 'all' | 'code' | 'off';
|
|
92
|
+
readonly collapseThreshold: number;
|
|
93
|
+
readonly showThinking: boolean;
|
|
94
|
+
readonly showReasoningSummary: boolean;
|
|
95
|
+
readonly blockBase: number;
|
|
96
|
+
readonly kind: SystemMessageKind | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface CacheEntry {
|
|
100
|
+
readonly keyMeta: KeyMeta;
|
|
101
|
+
readonly contentSig: ContentSig;
|
|
102
|
+
/** Rendered lines for this message, INCLUDING the trailing blank line. */
|
|
103
|
+
readonly lines: Line[];
|
|
104
|
+
/** Block metas with startLine RELATIVE to the message's first line. */
|
|
105
|
+
readonly blocks: BlockMeta[];
|
|
106
|
+
/** Error-navigation line offsets, RELATIVE to the message's first line. */
|
|
107
|
+
readonly errorRelLines: number[];
|
|
108
|
+
/** [collapseKey, value] pairs the render read; a change invalidates the entry. */
|
|
109
|
+
readonly collapseDeps: Array<[string, boolean | undefined]>;
|
|
110
|
+
/** Memoised rebase of blocks/errors at appliedBase (avoids realloc when the
|
|
111
|
+
* message's buffer offset is unchanged across rebuilds — the common case). */
|
|
112
|
+
appliedBase: number;
|
|
113
|
+
appliedBlocks: BlockMeta[] | null;
|
|
114
|
+
appliedErrors: number[] | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build the content signature for a message. */
|
|
118
|
+
function contentSigOf(m: Message): ContentSig {
|
|
119
|
+
if (m.role === 'user') {
|
|
120
|
+
return {
|
|
121
|
+
role: 'user',
|
|
122
|
+
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
|
123
|
+
cancelled: m.cancelled,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (m.role === 'assistant') {
|
|
127
|
+
return {
|
|
128
|
+
role: 'assistant',
|
|
129
|
+
content: m.content,
|
|
130
|
+
model: m.model,
|
|
131
|
+
provider: m.provider,
|
|
132
|
+
reasoningContent: m.reasoningContent,
|
|
133
|
+
reasoningSummary: m.reasoningSummary,
|
|
134
|
+
toolCallsJson: m.toolCalls ? JSON.stringify(m.toolCalls) : undefined,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (m.role === 'system') {
|
|
138
|
+
return { role: 'system', content: m.content };
|
|
139
|
+
}
|
|
140
|
+
return { role: 'tool', content: m.content, callId: m.callId, toolName: m.toolName };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compare a stored signature against a message WITHOUT allocating a new
|
|
145
|
+
* signature object on the hot (unchanged string-content) path. Array-valued
|
|
146
|
+
* fields fall back to JSON serialisation (rare).
|
|
147
|
+
*/
|
|
148
|
+
function contentUnchanged(sig: ContentSig, m: Message): boolean {
|
|
149
|
+
if (sig.role !== m.role) return false;
|
|
150
|
+
switch (m.role) {
|
|
151
|
+
case 'user':
|
|
152
|
+
return (
|
|
153
|
+
sig.content === (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)) &&
|
|
154
|
+
sig.cancelled === m.cancelled
|
|
155
|
+
);
|
|
156
|
+
case 'assistant':
|
|
157
|
+
return (
|
|
158
|
+
sig.content === m.content &&
|
|
159
|
+
sig.model === m.model &&
|
|
160
|
+
sig.provider === m.provider &&
|
|
161
|
+
sig.reasoningContent === m.reasoningContent &&
|
|
162
|
+
sig.reasoningSummary === m.reasoningSummary &&
|
|
163
|
+
sig.toolCallsJson === (m.toolCalls ? JSON.stringify(m.toolCalls) : undefined)
|
|
164
|
+
);
|
|
165
|
+
case 'system':
|
|
166
|
+
return sig.content === m.content;
|
|
167
|
+
case 'tool':
|
|
168
|
+
return sig.content === m.content && sig.callId === m.callId && sig.toolName === m.toolName;
|
|
169
|
+
default:
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Wrap the real collapseState so reads (.get/.has) are recorded while writes
|
|
176
|
+
* (.set — the auto-collapse default) pass straight through to the real map, so
|
|
177
|
+
* the persistent collapse defaults are established exactly as a cold render
|
|
178
|
+
* would establish them.
|
|
179
|
+
*/
|
|
180
|
+
function makeRecordingCollapseState(
|
|
181
|
+
real: Map<string, boolean>,
|
|
182
|
+
readKeys: Set<string>,
|
|
183
|
+
): Map<string, boolean> {
|
|
184
|
+
return new Proxy(real, {
|
|
185
|
+
get(target, prop, receiver) {
|
|
186
|
+
if (prop === 'get') {
|
|
187
|
+
return (key: string): boolean | undefined => {
|
|
188
|
+
readKeys.add(key);
|
|
189
|
+
return target.get(key);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (prop === 'has') {
|
|
193
|
+
return (key: string): boolean => {
|
|
194
|
+
readKeys.add(key);
|
|
195
|
+
return target.has(key);
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
void receiver;
|
|
199
|
+
const value = Reflect.get(target, prop, target);
|
|
200
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Dispatch a single message to its render function. */
|
|
206
|
+
function renderOne(
|
|
207
|
+
ctx: ConversationRenderContext,
|
|
208
|
+
message: Message,
|
|
209
|
+
width: number,
|
|
210
|
+
absoluteIdx: number,
|
|
211
|
+
cfg: RenderConfig,
|
|
212
|
+
): void {
|
|
213
|
+
if (message.role === 'user') {
|
|
214
|
+
renderConversationUserMessage(ctx, message, width);
|
|
215
|
+
} else if (message.role === 'assistant') {
|
|
216
|
+
renderConversationAssistantMessage(ctx, message, width, cfg.lineNumberMode, cfg.collapseThreshold, absoluteIdx);
|
|
217
|
+
} else if (message.role === 'system') {
|
|
218
|
+
renderConversationSystemMessage(ctx, message, width, absoluteIdx);
|
|
219
|
+
} else if (message.role === 'tool') {
|
|
220
|
+
renderConversationToolMessage(ctx, message, width, absoluteIdx);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* MessageLineCache — per-message Line[] memoisation for ConversationManager.
|
|
226
|
+
*
|
|
227
|
+
* Keyed by absolute message index; each entry validates its COMPLETE input tuple
|
|
228
|
+
* before serving. A rebuild that reuses entries is byte-identical to a cold
|
|
229
|
+
* rebuild; the cache is a pure memoisation with no observable behaviour of its
|
|
230
|
+
* own beyond speed and reduced allocation churn.
|
|
231
|
+
*/
|
|
232
|
+
export class MessageLineCache {
|
|
233
|
+
private entries: Map<number, CacheEntry> = new Map();
|
|
234
|
+
|
|
235
|
+
/** Drop all cached entries (wholesale message replacement / reset). */
|
|
236
|
+
public clear(): void {
|
|
237
|
+
this.entries.clear();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Number of retained entries (for tests / diagnostics). */
|
|
241
|
+
public get size(): number {
|
|
242
|
+
return this.entries.size;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Render `messages` into `context`, reusing cached lines for unchanged
|
|
247
|
+
* messages. Mirrors appendConversationMessages exactly on a cold cache; a warm
|
|
248
|
+
* cache replays identical bytes at identical offsets.
|
|
249
|
+
*
|
|
250
|
+
* @param context the live render context (real history buffer,
|
|
251
|
+
* block/error registries, collapse state, config).
|
|
252
|
+
* @param messages the visible message slice to render.
|
|
253
|
+
* @param width render width.
|
|
254
|
+
* @param messageLineRegistry absolute-index → first-line map, written here.
|
|
255
|
+
* @param msgIndexOffset absolute index of messages[0] (post-clearDisplay slice).
|
|
256
|
+
* @param streamingPlaceholderAbsIdx absolute index of the in-progress streaming
|
|
257
|
+
* placeholder to leave uncached (-1 when not streaming);
|
|
258
|
+
* the incremental streaming path owns its content.
|
|
259
|
+
*/
|
|
260
|
+
public renderInto(
|
|
261
|
+
context: ConversationRenderContext,
|
|
262
|
+
messages: Message[],
|
|
263
|
+
width: number,
|
|
264
|
+
messageLineRegistry: number[],
|
|
265
|
+
msgIndexOffset: number,
|
|
266
|
+
streamingPlaceholderAbsIdx: number,
|
|
267
|
+
): void {
|
|
268
|
+
const cfg: RenderConfig = {
|
|
269
|
+
lineNumberMode: context.configManager?.get('display.lineNumbers') ?? 'off',
|
|
270
|
+
collapseThreshold: context.configManager?.get('display.collapseThreshold') ?? 30,
|
|
271
|
+
showThinking: context.configManager?.get('display.showThinking') ?? false,
|
|
272
|
+
showReasoningSummary: context.configManager?.get('display.showReasoningSummary') ?? false,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const touched = new Set<number>();
|
|
276
|
+
|
|
277
|
+
for (let i = 0; i < messages.length; i++) {
|
|
278
|
+
const message = messages[i]!;
|
|
279
|
+
const absoluteIdx = msgIndexOffset + i;
|
|
280
|
+
const base = context.history.getLineCount();
|
|
281
|
+
const blockBase = context.blockRegistry.length;
|
|
282
|
+
messageLineRegistry[absoluteIdx] = base;
|
|
283
|
+
|
|
284
|
+
const uncacheable = absoluteIdx === streamingPlaceholderAbsIdx;
|
|
285
|
+
const kind = context.messageKindRegistry.get(absoluteIdx);
|
|
286
|
+
|
|
287
|
+
if (!uncacheable) {
|
|
288
|
+
const existing = this.entries.get(absoluteIdx);
|
|
289
|
+
if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, context.collapseState)) {
|
|
290
|
+
this.applyEntry(context, existing, base);
|
|
291
|
+
touched.add(absoluteIdx);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const entry = this.renderScratch(context, message, width, absoluteIdx, cfg, blockBase, kind);
|
|
297
|
+
this.applyEntry(context, entry, base);
|
|
298
|
+
if (!uncacheable) {
|
|
299
|
+
this.entries.set(absoluteIdx, entry);
|
|
300
|
+
touched.add(absoluteIdx);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Mark-and-sweep: a full rebuild renders every currently-visible message, so
|
|
305
|
+
// any entry not touched this pass is off-screen (or evicted by a shrink) and
|
|
306
|
+
// is dropped to bound memory to the visible set.
|
|
307
|
+
if (this.entries.size > touched.size) {
|
|
308
|
+
for (const key of this.entries.keys()) {
|
|
309
|
+
if (!touched.has(key)) this.entries.delete(key);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Validate a cached entry against the message's current complete inputs. */
|
|
315
|
+
private isValid(
|
|
316
|
+
entry: CacheEntry,
|
|
317
|
+
message: Message,
|
|
318
|
+
width: number,
|
|
319
|
+
cfg: RenderConfig,
|
|
320
|
+
blockBase: number,
|
|
321
|
+
kind: SystemMessageKind | undefined,
|
|
322
|
+
collapseState: Map<string, boolean>,
|
|
323
|
+
): boolean {
|
|
324
|
+
const k = entry.keyMeta;
|
|
325
|
+
if (
|
|
326
|
+
k.role !== message.role ||
|
|
327
|
+
k.width !== width ||
|
|
328
|
+
k.lineNumberMode !== cfg.lineNumberMode ||
|
|
329
|
+
k.collapseThreshold !== cfg.collapseThreshold ||
|
|
330
|
+
k.showThinking !== cfg.showThinking ||
|
|
331
|
+
k.showReasoningSummary !== cfg.showReasoningSummary ||
|
|
332
|
+
k.blockBase !== blockBase ||
|
|
333
|
+
k.kind !== kind
|
|
334
|
+
) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
if (!contentUnchanged(entry.contentSig, message)) return false;
|
|
338
|
+
for (const [key, value] of entry.collapseDeps) {
|
|
339
|
+
if (collapseState.get(key) !== value) return false;
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Render a single message into an isolated scratch context, capturing its
|
|
346
|
+
* lines, block metas (message-relative), error-line offsets (message-relative),
|
|
347
|
+
* and the collapse-state reads it depends on. Collapse-default WRITES pass
|
|
348
|
+
* through to the real collapseState so persistent defaults match a cold render.
|
|
349
|
+
*/
|
|
350
|
+
private renderScratch(
|
|
351
|
+
context: ConversationRenderContext,
|
|
352
|
+
message: Message,
|
|
353
|
+
width: number,
|
|
354
|
+
absoluteIdx: number,
|
|
355
|
+
cfg: RenderConfig,
|
|
356
|
+
blockBase: number,
|
|
357
|
+
kind: SystemMessageKind | undefined,
|
|
358
|
+
): CacheEntry {
|
|
359
|
+
const scratchLines: Line[] = [];
|
|
360
|
+
const scratchHistory = {
|
|
361
|
+
addLine: (line: Line): void => { scratchLines.push(line); },
|
|
362
|
+
addLines: (lines: Line[]): void => { for (const line of lines) scratchLines.push(line); },
|
|
363
|
+
getLineCount: (): number => scratchLines.length,
|
|
364
|
+
};
|
|
365
|
+
// Pre-size to blockBase so blockRegistry.length (the global block index the
|
|
366
|
+
// collapseKey embeds) matches the live registry; slice(blockBase) recovers
|
|
367
|
+
// only this message's blocks afterwards.
|
|
368
|
+
const scratchBlocks: BlockMeta[] = new Array(blockBase);
|
|
369
|
+
const scratchErrors: number[] = [];
|
|
370
|
+
const readKeys = new Set<string>();
|
|
371
|
+
const recordingCollapse = makeRecordingCollapseState(context.collapseState, readKeys);
|
|
372
|
+
|
|
373
|
+
const scratchCtx: ConversationRenderContext = {
|
|
374
|
+
history: scratchHistory,
|
|
375
|
+
blockRegistry: scratchBlocks,
|
|
376
|
+
collapseState: recordingCollapse,
|
|
377
|
+
errorLineRegistry: scratchErrors,
|
|
378
|
+
messageKindRegistry: context.messageKindRegistry,
|
|
379
|
+
configManager: context.configManager,
|
|
380
|
+
splashOptions: context.splashOptions,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
renderOne(scratchCtx, message, width, absoluteIdx, cfg);
|
|
384
|
+
// Trailing blank line, exactly as appendConversationMessages appends per message.
|
|
385
|
+
scratchLines.push(createEmptyLine(width));
|
|
386
|
+
|
|
387
|
+
const collapseDeps: Array<[string, boolean | undefined]> = [];
|
|
388
|
+
for (const key of readKeys) collapseDeps.push([key, context.collapseState.get(key)]);
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
keyMeta: {
|
|
392
|
+
role: message.role,
|
|
393
|
+
width,
|
|
394
|
+
lineNumberMode: cfg.lineNumberMode,
|
|
395
|
+
collapseThreshold: cfg.collapseThreshold,
|
|
396
|
+
showThinking: cfg.showThinking,
|
|
397
|
+
showReasoningSummary: cfg.showReasoningSummary,
|
|
398
|
+
blockBase,
|
|
399
|
+
kind,
|
|
400
|
+
},
|
|
401
|
+
contentSig: contentSigOf(message),
|
|
402
|
+
lines: scratchLines,
|
|
403
|
+
blocks: scratchBlocks.slice(blockBase),
|
|
404
|
+
errorRelLines: scratchErrors,
|
|
405
|
+
collapseDeps,
|
|
406
|
+
appliedBase: -1,
|
|
407
|
+
appliedBlocks: null,
|
|
408
|
+
appliedErrors: null,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Replay an entry into the live context at buffer offset `base`. Line objects
|
|
414
|
+
* are shared (never mutated post-production — the compositor reads them into a
|
|
415
|
+
* separate back-buffer). Block/error rebasing is memoised per base so an
|
|
416
|
+
* unchanged message pays zero allocation across rebuilds.
|
|
417
|
+
*/
|
|
418
|
+
private applyEntry(context: ConversationRenderContext, entry: CacheEntry, base: number): void {
|
|
419
|
+
context.history.addLines(entry.lines);
|
|
420
|
+
|
|
421
|
+
if (entry.appliedBase !== base || entry.appliedBlocks === null || entry.appliedErrors === null) {
|
|
422
|
+
entry.appliedBlocks = entry.blocks.map((b) => ({ ...b, startLine: b.startLine + base }));
|
|
423
|
+
entry.appliedErrors = entry.errorRelLines.map((e) => e + base);
|
|
424
|
+
entry.appliedBase = base;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const registry = context.blockRegistry;
|
|
428
|
+
for (const block of entry.appliedBlocks) registry.push(block);
|
|
429
|
+
const errors = context.errorLineRegistry;
|
|
430
|
+
for (const line of entry.appliedErrors) errors.push(line);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { UIFactory } from '../renderer/ui-factory.ts';
|
|
2
2
|
import { renderMarkdownTracked } from '../renderer/markdown.ts';
|
|
3
|
+
import { renderDiffView } from '../renderer/diff-view.ts';
|
|
3
4
|
import { DARK_THEME } from '../renderer/theme.ts';
|
|
4
5
|
import { renderToolCallBlock } from '../renderer/tool-call.ts';
|
|
5
6
|
import { renderThinkingBlock } from '../renderer/thinking.ts';
|
|
@@ -41,7 +42,7 @@ function summarizeCallId(callId: string, maxLength = 24): string {
|
|
|
41
42
|
return callId.length <= maxLength ? callId : `${callId.slice(0, maxLength - 1)}…`;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
interface ConversationRenderContext {
|
|
45
|
+
export interface ConversationRenderContext {
|
|
45
46
|
readonly history: {
|
|
46
47
|
addLine: (line: Line) => void;
|
|
47
48
|
addLines: (lines: Line[]) => void;
|
|
@@ -231,6 +232,9 @@ export function renderConversationToolMessage(
|
|
|
231
232
|
const hasHunk = contentLines.some((l) => l.startsWith('@@ '));
|
|
232
233
|
const isDiff = hasDiffHeader && hasHunk;
|
|
233
234
|
const blockType: 'diff' | 'tool' = isDiff ? 'diff' : 'tool';
|
|
235
|
+
// Parsed once, ahead of the collapse check, so it's available for the
|
|
236
|
+
// block-registry meta merge below regardless of collapsed/expanded state.
|
|
237
|
+
const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
|
|
234
238
|
|
|
235
239
|
const isShort = message.content.length <= 200;
|
|
236
240
|
const isCollapsed = isShort
|
|
@@ -271,6 +275,10 @@ export function renderConversationToolMessage(
|
|
|
271
275
|
dim: true,
|
|
272
276
|
});
|
|
273
277
|
context.history.addLines(rendered);
|
|
278
|
+
} else if (isDiff) {
|
|
279
|
+
// No filename banner: the diff text's own --- / +++ headers already
|
|
280
|
+
// identify the file (matches the pre-v0.9.6 call site in tool-call.ts).
|
|
281
|
+
context.history.addLines(renderDiffView(message.content, width));
|
|
274
282
|
} else {
|
|
275
283
|
let contentToRender = message.content;
|
|
276
284
|
const trimmed = contentToRender.trimStart();
|
|
@@ -295,8 +303,8 @@ export function renderConversationToolMessage(
|
|
|
295
303
|
rawContent: message.content,
|
|
296
304
|
};
|
|
297
305
|
|
|
298
|
-
if (isDiff) {
|
|
299
|
-
meta = { ...meta, ...
|
|
306
|
+
if (isDiff && diffParse) {
|
|
307
|
+
meta = { ...meta, ...diffParse };
|
|
300
308
|
}
|
|
301
309
|
|
|
302
310
|
context.blockRegistry.push(meta);
|
package/src/core/conversation.ts
CHANGED
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
type BlockMeta as SdkBlockMeta,
|
|
14
14
|
} from '@pellux/goodvibes-sdk/platform/core';
|
|
15
15
|
import type { BlockMeta } from './conversation-types.ts';
|
|
16
|
+
import { MessageLineCache } from './conversation-line-cache.ts';
|
|
16
17
|
import {
|
|
17
18
|
addConversationSplashScreen,
|
|
18
|
-
appendConversationMessages,
|
|
19
19
|
conversationTextToLines,
|
|
20
20
|
logConversationText,
|
|
21
21
|
renderConversationAssistantMessage,
|
|
@@ -49,6 +49,46 @@ export type { BlockMeta };
|
|
|
49
49
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
50
50
|
type Message = ConversationMessageSnapshot;
|
|
51
51
|
|
|
52
|
+
/** Matches Orchestrator.usage's shape (SDK platform/core/orchestrator.ts). Not
|
|
53
|
+
* exported by the SDK as a standalone type, so mirrored here. */
|
|
54
|
+
export interface OrchestratorUsageTotals {
|
|
55
|
+
input: number;
|
|
56
|
+
output: number;
|
|
57
|
+
cacheRead: number;
|
|
58
|
+
cacheWrite: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* sumConversationUsage - Fold every assistant message's per-turn usage into
|
|
63
|
+
* running totals, plus the *last* assistant message's own input-token figure
|
|
64
|
+
* (context-window occupancy — not a running sum).
|
|
65
|
+
*
|
|
66
|
+
* TUI-side counterpart to SDK Orchestrator.usage: after a session resume
|
|
67
|
+
* replays historical messages into a freshly-constructed Orchestrator (whose
|
|
68
|
+
* `usage` starts at all zeros — bootstrap.ts always constructs a fresh
|
|
69
|
+
* instance, see W0.9), this lets the caller hydrate the footer's token
|
|
70
|
+
* counters from messages that already carry real usage data instead of
|
|
71
|
+
* waiting for the first new turn to populate them.
|
|
72
|
+
*/
|
|
73
|
+
export function sumConversationUsage(
|
|
74
|
+
messages: readonly ConversationMessageSnapshot[],
|
|
75
|
+
): { usage: OrchestratorUsageTotals; lastInputTokens: number } {
|
|
76
|
+
const usage: OrchestratorUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
77
|
+
let lastInputTokens = 0;
|
|
78
|
+
for (const message of messages) {
|
|
79
|
+
if (message.role !== 'assistant' || !message.usage) continue;
|
|
80
|
+
usage.input += message.usage.inputTokens;
|
|
81
|
+
usage.output += message.usage.outputTokens;
|
|
82
|
+
usage.cacheRead += message.usage.cacheReadTokens ?? 0;
|
|
83
|
+
usage.cacheWrite += message.usage.cacheWriteTokens ?? 0;
|
|
84
|
+
// Context-window occupancy tracks the most recent turn only, not a sum.
|
|
85
|
+
lastInputTokens = message.usage.inputTokens
|
|
86
|
+
+ (message.usage.cacheReadTokens ?? 0)
|
|
87
|
+
+ (message.usage.cacheWriteTokens ?? 0);
|
|
88
|
+
}
|
|
89
|
+
return { usage, lastInputTokens };
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
export class ConversationManager extends SdkConversationManager {
|
|
53
93
|
public history = new InfiniteBuffer();
|
|
54
94
|
private _getWidth: () => number;
|
|
@@ -56,6 +96,13 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
56
96
|
private lastRenderedWidth = 0;
|
|
57
97
|
/** When true the buffer needs to be rebuilt before the next display. */
|
|
58
98
|
private dirty = true;
|
|
99
|
+
/**
|
|
100
|
+
* Per-message Line[] cache (WO-209). rebuildHistory() reuses cached lines for
|
|
101
|
+
* messages whose complete render inputs are unchanged, so appending one message
|
|
102
|
+
* to an N-message conversation no longer re-renders all N. A cache-served
|
|
103
|
+
* rebuild is byte-identical to a cold one (pure memoisation).
|
|
104
|
+
*/
|
|
105
|
+
private lineCache = new MessageLineCache();
|
|
59
106
|
/** Index of the first message not yet appended to the buffer. */
|
|
60
107
|
private appendedUpTo = 0;
|
|
61
108
|
/** Optional config manager for display settings. */
|
|
@@ -300,6 +347,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
300
347
|
public override resetAll(): void {
|
|
301
348
|
super.resetAll();
|
|
302
349
|
this.history.clear();
|
|
350
|
+
this.lineCache.clear();
|
|
303
351
|
this.appendedUpTo = 0;
|
|
304
352
|
this.lastRenderedWidth = 0;
|
|
305
353
|
this.dirty = true;
|
|
@@ -322,6 +370,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
322
370
|
public override replaceMessagesForLLM(newMessages: ProviderMessage[]): void {
|
|
323
371
|
super.replaceMessagesForLLM(newMessages);
|
|
324
372
|
this.history.clear();
|
|
373
|
+
this.lineCache.clear();
|
|
325
374
|
this.appendedUpTo = 0;
|
|
326
375
|
this.lastRenderedWidth = 0;
|
|
327
376
|
this.dirty = true;
|
|
@@ -360,6 +409,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
360
409
|
}): void {
|
|
361
410
|
super.fromJSON(data);
|
|
362
411
|
this.history.clear();
|
|
412
|
+
this.lineCache.clear();
|
|
363
413
|
this.appendedUpTo = 0;
|
|
364
414
|
this.lastRenderedWidth = 0;
|
|
365
415
|
this.dirty = true;
|
|
@@ -374,6 +424,26 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
374
424
|
return this.history.getAllLines();
|
|
375
425
|
}
|
|
376
426
|
|
|
427
|
+
/**
|
|
428
|
+
* clearLineCache - Drop every cached per-message Line[] and force a full cold
|
|
429
|
+
* re-render on the next display. The rendered output is unchanged (the cache is
|
|
430
|
+
* a pure memoisation); this only discards the reuse, e.g. to reclaim memory or
|
|
431
|
+
* to assert cache/cold equivalence in tests.
|
|
432
|
+
*/
|
|
433
|
+
public clearLineCache(): void {
|
|
434
|
+
this.lineCache.clear();
|
|
435
|
+
this.dirty = true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* getLineCacheSize - Number of per-message Line[] entries currently retained.
|
|
440
|
+
* The cache mark-and-sweeps to the visible message set on every rebuild, so
|
|
441
|
+
* this is bounded by what is on screen — exposed for memory-hygiene assertions.
|
|
442
|
+
*/
|
|
443
|
+
public getLineCacheSize(): number {
|
|
444
|
+
return this.lineCache.size;
|
|
445
|
+
}
|
|
446
|
+
|
|
377
447
|
/**
|
|
378
448
|
* rebuildHistory - Full rebuild. Called when width changes or on first render.
|
|
379
449
|
* For incremental appends use flushHistory().
|
|
@@ -419,7 +489,11 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
419
489
|
return;
|
|
420
490
|
}
|
|
421
491
|
|
|
422
|
-
|
|
492
|
+
// The in-progress streaming placeholder (rendered EMPTY above via
|
|
493
|
+
// renderSnapshot) is left uncached: its content mutates in place per delta and
|
|
494
|
+
// the incremental streaming path (updateStreamingBlock) owns it.
|
|
495
|
+
const streamingPlaceholderAbsIdx = isStreaming ? snapshot.length - 1 : -1;
|
|
496
|
+
this.appendMessages(visibleSnapshot, width, displayStart, streamingPlaceholderAbsIdx);
|
|
423
497
|
this.appendedUpTo = snapshot.length;
|
|
424
498
|
|
|
425
499
|
if (isStreaming) {
|
|
@@ -491,8 +565,20 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
491
565
|
* the renderer can resolve messageKindRegistry keys (which are absolute)
|
|
492
566
|
* from its slice-relative loop counter.
|
|
493
567
|
*/
|
|
494
|
-
private appendMessages(
|
|
495
|
-
|
|
568
|
+
private appendMessages(
|
|
569
|
+
messages: Message[],
|
|
570
|
+
width: number,
|
|
571
|
+
msgIndexOffset = 0,
|
|
572
|
+
streamingPlaceholderAbsIdx = -1,
|
|
573
|
+
): void {
|
|
574
|
+
this.lineCache.renderInto(
|
|
575
|
+
this.renderingContext(),
|
|
576
|
+
messages,
|
|
577
|
+
width,
|
|
578
|
+
this.messageLineRegistry,
|
|
579
|
+
msgIndexOffset,
|
|
580
|
+
streamingPlaceholderAbsIdx,
|
|
581
|
+
);
|
|
496
582
|
}
|
|
497
583
|
|
|
498
584
|
/** Find the nearest block to a given line index, optionally filtered by type. */
|