@pellux/goodvibes-tui 0.29.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/handler-content-actions.ts +8 -3
  8. package/src/input/input-history.ts +17 -4
  9. package/src/main.ts +14 -10
  10. package/src/panels/base-panel.ts +1 -1
  11. package/src/panels/diff-panel.ts +10 -4
  12. package/src/panels/git-panel.ts +2 -2
  13. package/src/renderer/agent-detail-modal.ts +37 -8
  14. package/src/renderer/code-block.ts +58 -28
  15. package/src/renderer/context-inspector.ts +3 -6
  16. package/src/renderer/conversation-surface.ts +1 -21
  17. package/src/renderer/diff-view.ts +6 -4
  18. package/src/renderer/fullscreen-primitives.ts +1 -1
  19. package/src/renderer/fullscreen-workspace.ts +2 -7
  20. package/src/renderer/hint-grammar.ts +1 -1
  21. package/src/renderer/history-search-overlay.ts +10 -16
  22. package/src/renderer/markdown.ts +9 -1
  23. package/src/renderer/model-picker-overlay.ts +8 -499
  24. package/src/renderer/model-workspace.ts +9 -24
  25. package/src/renderer/overlay-box.ts +7 -9
  26. package/src/renderer/process-indicator.ts +13 -25
  27. package/src/renderer/profile-picker-modal.ts +13 -14
  28. package/src/renderer/prompt-content-width.ts +16 -0
  29. package/src/renderer/selection-modal-overlay.ts +3 -1
  30. package/src/renderer/semantic-diff.ts +1 -1
  31. package/src/renderer/settings-modal-helpers.ts +10 -34
  32. package/src/renderer/shell-surface.ts +21 -8
  33. package/src/renderer/surface-layout.ts +0 -12
  34. package/src/renderer/term-caps.ts +1 -1
  35. package/src/renderer/tool-call.ts +6 -20
  36. package/src/renderer/ui-factory.ts +7 -7
  37. package/src/renderer/ui-primitives.ts +18 -1
  38. package/src/runtime/render-scheduler.ts +80 -0
  39. package/src/utils/splash-lines.ts +10 -2
  40. package/src/version.ts +1 -1
  41. package/src/renderer/file-tree.ts +0 -153
  42. package/src/renderer/progress.ts +0 -100
  43. package/src/renderer/status-token.ts +0 -67
package/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.0.0] — 2026-07-03
8
+
9
+ ### Changes
10
+ - d26fc23f ci: drop temporary renderer-excellence branch trigger after merge
11
+ - 532c900b integrate renderer-excellence: renderer performance and correctness effort (v1.0.0 scope)
12
+ - cdf5931d fix(input): normalize CR line endings from bracketed pastes
13
+ - 1318959f chore(perf): re-capture baseline reference medians on a quiet box
14
+ - 946a2c83 integrate WO-210 into renderer-excellence
15
+ - 209ade5f perf(budgets): ratchet transcript.append_one 20ms->6ms; streaming tail proven non-material (WO-210)
16
+ - c7e445c2 integrate WO-209 into renderer-excellence
17
+ - df4d8d03 integrate WO-208 into renderer-excellence
18
+ - f5476715 perf(conversation): per-message Line[] cache — append-one 45ms→0.9ms (WO-209)
19
+ - 76539cd8 perf(renderer): WO-208 coalesce main.ts render() fan-out into one same-tick composite
20
+ - 204342c4 refactor(renderer): delete the six dead exports parked during the R2 literal-ownership split
21
+ - 9c1dbdae integrate WO-207 into renderer-excellence
22
+ - ae660b2a integrate WO-206 into renderer-excellence
23
+ - 6acd74f6 integrate WO-205 into renderer-excellence
24
+ - c6eb004c feat(architecture): WO-206 no-unused-exports rule for src/renderer
25
+ - 8997719b feat(renderer): WO-207 — palette unification across the renderer layer
26
+ - 3ec18bb1 refactor(renderer): WO-205 dedup + dead code + honest picker
27
+ - 43eb8165 ci: run full CI on renderer-excellence pushes for the v1.0.0 effort
28
+ - 6e483f55 fix(renderer): diff panel keeps its shipped colors — the panels are the reference, not diff-view
29
+ - 50755108 integrate WO-204 into renderer-excellence
30
+ - 17425f6a integrate WO-203 into renderer-excellence
31
+ - 68bc13c3 fix(renderer): WO-203 verified correctness fixes
32
+ - 721f4d91 feat(renderer): WO-204 — restore conversation diff rendering, align diff surface colors
33
+ - c5aff410 integrate WO-202 into renderer-excellence
34
+ - 0f03a124 integrate WO-201 into renderer-excellence
35
+ - e5ba2978 test(renderer): WO-201 golden contract expansion — splash, transcript scenes, all overlays
36
+ - 210d5814 perf(scripts): WO-202 line-production benchmarks above the compositor
37
+ - bf6459af fix(tests): skills delete test polls the rescanned state, not just file removal
38
+
7
39
  ## [0.29.0] — 2026-07-03
8
40
 
9
41
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.29.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.29.0",
3
+ "version": "1.0.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -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, ...parseDiffForApply(message.content) };
306
+ if (isDiff && diffParse) {
307
+ meta = { ...meta, ...diffParse };
300
308
  }
301
309
 
302
310
  context.blockRegistry.push(meta);
@@ -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,
@@ -56,6 +56,13 @@ export class ConversationManager extends SdkConversationManager {
56
56
  private lastRenderedWidth = 0;
57
57
  /** When true the buffer needs to be rebuilt before the next display. */
58
58
  private dirty = true;
59
+ /**
60
+ * Per-message Line[] cache (WO-209). rebuildHistory() reuses cached lines for
61
+ * messages whose complete render inputs are unchanged, so appending one message
62
+ * to an N-message conversation no longer re-renders all N. A cache-served
63
+ * rebuild is byte-identical to a cold one (pure memoisation).
64
+ */
65
+ private lineCache = new MessageLineCache();
59
66
  /** Index of the first message not yet appended to the buffer. */
60
67
  private appendedUpTo = 0;
61
68
  /** Optional config manager for display settings. */
@@ -300,6 +307,7 @@ export class ConversationManager extends SdkConversationManager {
300
307
  public override resetAll(): void {
301
308
  super.resetAll();
302
309
  this.history.clear();
310
+ this.lineCache.clear();
303
311
  this.appendedUpTo = 0;
304
312
  this.lastRenderedWidth = 0;
305
313
  this.dirty = true;
@@ -322,6 +330,7 @@ export class ConversationManager extends SdkConversationManager {
322
330
  public override replaceMessagesForLLM(newMessages: ProviderMessage[]): void {
323
331
  super.replaceMessagesForLLM(newMessages);
324
332
  this.history.clear();
333
+ this.lineCache.clear();
325
334
  this.appendedUpTo = 0;
326
335
  this.lastRenderedWidth = 0;
327
336
  this.dirty = true;
@@ -360,6 +369,7 @@ export class ConversationManager extends SdkConversationManager {
360
369
  }): void {
361
370
  super.fromJSON(data);
362
371
  this.history.clear();
372
+ this.lineCache.clear();
363
373
  this.appendedUpTo = 0;
364
374
  this.lastRenderedWidth = 0;
365
375
  this.dirty = true;
@@ -374,6 +384,26 @@ export class ConversationManager extends SdkConversationManager {
374
384
  return this.history.getAllLines();
375
385
  }
376
386
 
387
+ /**
388
+ * clearLineCache - Drop every cached per-message Line[] and force a full cold
389
+ * re-render on the next display. The rendered output is unchanged (the cache is
390
+ * a pure memoisation); this only discards the reuse, e.g. to reclaim memory or
391
+ * to assert cache/cold equivalence in tests.
392
+ */
393
+ public clearLineCache(): void {
394
+ this.lineCache.clear();
395
+ this.dirty = true;
396
+ }
397
+
398
+ /**
399
+ * getLineCacheSize - Number of per-message Line[] entries currently retained.
400
+ * The cache mark-and-sweeps to the visible message set on every rebuild, so
401
+ * this is bounded by what is on screen — exposed for memory-hygiene assertions.
402
+ */
403
+ public getLineCacheSize(): number {
404
+ return this.lineCache.size;
405
+ }
406
+
377
407
  /**
378
408
  * rebuildHistory - Full rebuild. Called when width changes or on first render.
379
409
  * For incremental appends use flushHistory().
@@ -419,7 +449,11 @@ export class ConversationManager extends SdkConversationManager {
419
449
  return;
420
450
  }
421
451
 
422
- this.appendMessages(visibleSnapshot, width, displayStart);
452
+ // The in-progress streaming placeholder (rendered EMPTY above via
453
+ // renderSnapshot) is left uncached: its content mutates in place per delta and
454
+ // the incremental streaming path (updateStreamingBlock) owns it.
455
+ const streamingPlaceholderAbsIdx = isStreaming ? snapshot.length - 1 : -1;
456
+ this.appendMessages(visibleSnapshot, width, displayStart, streamingPlaceholderAbsIdx);
423
457
  this.appendedUpTo = snapshot.length;
424
458
 
425
459
  if (isStreaming) {
@@ -491,8 +525,20 @@ export class ConversationManager extends SdkConversationManager {
491
525
  * the renderer can resolve messageKindRegistry keys (which are absolute)
492
526
  * from its slice-relative loop counter.
493
527
  */
494
- private appendMessages(messages: Message[], width: number, msgIndexOffset = 0): void {
495
- appendConversationMessages(this.renderingContext(), messages, width, this.messageLineRegistry, msgIndexOffset);
528
+ private appendMessages(
529
+ messages: Message[],
530
+ width: number,
531
+ msgIndexOffset = 0,
532
+ streamingPlaceholderAbsIdx = -1,
533
+ ): void {
534
+ this.lineCache.renderInto(
535
+ this.renderingContext(),
536
+ messages,
537
+ width,
538
+ this.messageLineRegistry,
539
+ msgIndexOffset,
540
+ streamingPlaceholderAbsIdx,
541
+ );
496
542
  }
497
543
 
498
544
  /** Find the nearest block to a given line index, optionally filtered by type. */
@@ -121,10 +121,15 @@ export function registerPaste(
121
121
  }
122
122
  }
123
123
 
124
- const lines = content.split('\n');
125
- if (lines.length <= 8) return { marker: content, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
124
+ // Terminals transmit the line breaks inside a bracketed paste as \r (the
125
+ // byte Enter sends), and external clipboards can carry \r\n. Normalize to
126
+ // \n only here in the text branch — the image sniffing above must see the
127
+ // raw bytes (PNG's magic sequence itself contains \r\n).
128
+ const text = content.replace(/\r\n?/g, '\n');
129
+ const lines = text.split('\n');
130
+ if (lines.length <= 8) return { marker: text, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
126
131
  const id = `p${state.nextPasteId++}`;
127
- state.pasteRegistry.set(id, content);
132
+ state.pasteRegistry.set(id, text);
128
133
  return { marker: `[TEXT: ${id}, ${lines.length} lines]`, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
129
134
  }
130
135