@pellux/goodvibes-tui 1.21.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +714 -177
- package/docs/foundation-artifacts/peer-contract.json +8 -4
- package/package.json +9 -7
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/service-posture.ts +6 -2
- package/src/config/run-daemon-config-migration.ts +46 -0
- package/src/core/conversation-compaction-render.ts +88 -0
- package/src/core/conversation-line-cache.ts +120 -92
- package/src/core/conversation-rendering.ts +290 -233
- package/src/core/conversation-turn-structure.ts +464 -0
- package/src/core/conversation-types.ts +8 -9
- package/src/daemon/cli.ts +71 -5
- package/src/input/commands/local-runtime.ts +14 -14
- package/src/input/commands/operator-rpc.ts +15 -4
- package/src/input/daemon-owned-settings-descriptions.ts +53 -0
- package/src/input/handler-content-actions.ts +4 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
- package/src/input/onboarding/onboarding-feature-units.ts +38 -0
- package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
- package/src/input/search.ts +1 -1
- package/src/input/settings-modal-data.ts +15 -85
- package/src/input/settings-modal-search.ts +99 -0
- package/src/input/settings-modal-types.ts +3 -1
- package/src/renderer/conversation-surface.ts +37 -11
- package/src/renderer/conversation-tree.ts +179 -0
- package/src/renderer/settings-modal-helpers.ts +56 -2
- package/src/renderer/settings-modal.ts +9 -50
- package/src/renderer/tool-call.ts +38 -5
- package/src/runtime/cloudflare-control-plane.ts +4 -1
- package/src/runtime/feature-settings.ts +7 -0
- package/src/runtime/index.ts +22 -1
- package/src/runtime/legacy-daemon-migration.ts +2 -0
- package/src/verification/verification-ledger.ts +115 -2
- package/src/version.ts +1 -1
- package/src/core/conversation-tool-groups.ts +0 -152
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"status.request",
|
|
12
12
|
"location.request",
|
|
13
13
|
"session.message",
|
|
14
|
-
"automation.run"
|
|
14
|
+
"automation.run",
|
|
15
|
+
"device.capability"
|
|
15
16
|
],
|
|
16
17
|
"scopes": [
|
|
17
18
|
"remote:heartbeat",
|
|
@@ -823,7 +824,8 @@
|
|
|
823
824
|
"status.request",
|
|
824
825
|
"location.request",
|
|
825
826
|
"session.message",
|
|
826
|
-
"automation.run"
|
|
827
|
+
"automation.run",
|
|
828
|
+
"device.capability"
|
|
827
829
|
]
|
|
828
830
|
},
|
|
829
831
|
"command": {
|
|
@@ -1179,7 +1181,8 @@
|
|
|
1179
1181
|
"status.request",
|
|
1180
1182
|
"location.request",
|
|
1181
1183
|
"session.message",
|
|
1182
|
-
"automation.run"
|
|
1184
|
+
"automation.run",
|
|
1185
|
+
"device.capability"
|
|
1183
1186
|
]
|
|
1184
1187
|
},
|
|
1185
1188
|
"command": {
|
|
@@ -2030,7 +2033,8 @@
|
|
|
2030
2033
|
"status.request",
|
|
2031
2034
|
"location.request",
|
|
2032
2035
|
"session.message",
|
|
2033
|
-
"automation.run"
|
|
2036
|
+
"automation.run",
|
|
2037
|
+
"device.capability"
|
|
2034
2038
|
]
|
|
2035
2039
|
},
|
|
2036
2040
|
"command": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.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",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"smoke:tui": "goodvibes-post-build-smoke",
|
|
42
42
|
"build:windows": "bun build src/main.ts --compile --target=bun-windows-x64 --outfile dist/goodvibes-windows.exe",
|
|
43
43
|
"build:all-shell": "bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:macos-x64 && bun run build:macos-arm64 && bun run build:windows",
|
|
44
|
+
"typecheck": "bunx tsc --noEmit",
|
|
45
|
+
"typecheck:test": "bunx tsc --noEmit -p tsconfig.test.json",
|
|
44
46
|
"test": "bun run scripts/run-tests.ts",
|
|
45
47
|
"test:coverage": "bun run scripts/coverage-gate.ts",
|
|
46
48
|
"version": "bun run scripts/prebuild.ts",
|
|
@@ -104,8 +106,8 @@
|
|
|
104
106
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
105
107
|
"@ast-grep/napi": "^0.42.0",
|
|
106
108
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
107
|
-
"@pellux/goodvibes-sdk": "1.
|
|
108
|
-
"@pellux/goodvibes-terminal-shell": "1.
|
|
109
|
+
"@pellux/goodvibes-sdk": "1.15.0",
|
|
110
|
+
"@pellux/goodvibes-terminal-shell": "1.15.0",
|
|
109
111
|
"bash-language-server": "^5.6.0",
|
|
110
112
|
"fuse.js": "^7.1.0",
|
|
111
113
|
"graphql": "^16.13.2",
|
|
@@ -131,10 +133,10 @@
|
|
|
131
133
|
"typescript": "^5.9.3"
|
|
132
134
|
},
|
|
133
135
|
"optionalDependencies": {
|
|
134
|
-
"@pellux/goodvibes-tui-linux-x64": "1.
|
|
135
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.
|
|
136
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.
|
|
137
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.
|
|
136
|
+
"@pellux/goodvibes-tui-linux-x64": "1.22.0",
|
|
137
|
+
"@pellux/goodvibes-tui-linux-arm64": "1.22.0",
|
|
138
|
+
"@pellux/goodvibes-tui-darwin-x64": "1.22.0",
|
|
139
|
+
"@pellux/goodvibes-tui-darwin-arm64": "1.22.0"
|
|
138
140
|
},
|
|
139
141
|
"trustedDependencies": [],
|
|
140
142
|
"overrides": {
|
package/src/cli/entrypoint.ts
CHANGED
|
@@ -31,6 +31,8 @@ import type { CliWorkspaceStatus, CliSandboxStatus, CliRelayStatus } from './sta
|
|
|
31
31
|
import { detectSandboxAvailability, probeSandboxHost } from '@pellux/goodvibes-sdk/platform/tools/exec/sandbox';
|
|
32
32
|
import { createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
|
|
33
33
|
import { ensureGoodvibesGitignore } from './ensure-goodvibes-gitignore.ts';
|
|
34
|
+
import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
|
|
35
|
+
import { describeDaemonConfigMigration } from '@pellux/goodvibes-sdk/platform/config';
|
|
34
36
|
|
|
35
37
|
type ShellEntrypointOwnership = {
|
|
36
38
|
readonly workingDirectory: string;
|
|
@@ -109,6 +111,10 @@ export async function prepareShellCliRuntime(
|
|
|
109
111
|
if (ensureGoodvibesGitignore(bootstrapWorkingDir)) {
|
|
110
112
|
console.log("[goodvibes] added '.goodvibes/' to .gitignore — this directory holds transient TUI state (logs, session cache, exec output), not project source.");
|
|
111
113
|
}
|
|
114
|
+
const daemonConfigMigration = runDaemonConfigMigration(bootstrapHomeDirectory);
|
|
115
|
+
if (daemonConfigMigration?.migrated && (daemonConfigMigration.marker.moved.length + daemonConfigMigration.marker.discarded.length) > 0) {
|
|
116
|
+
console.log(`[goodvibes] ${describeDaemonConfigMigration(daemonConfigMigration.marker)}`);
|
|
117
|
+
}
|
|
112
118
|
const configManager = new ConfigManager({
|
|
113
119
|
workingDir: bootstrapWorkingDir,
|
|
114
120
|
homeDir: bootstrapHomeDirectory,
|
|
@@ -361,8 +361,12 @@ export function createPlatformServiceManager(runtime: CliServiceRuntime): Platfo
|
|
|
361
361
|
command: daemonExecutable.command,
|
|
362
362
|
args: [],
|
|
363
363
|
env: {
|
|
364
|
-
//
|
|
365
|
-
GOODVIBES_DAEMON_HOME
|
|
364
|
+
// Two separate directories, deliberately: GOODVIBES_HOME is the GoodVibes
|
|
365
|
+
// tree root, GOODVIBES_DAEMON_HOME is the daemon's own identity home under
|
|
366
|
+
// it. They used to be the same variable, which meant pointing the daemon's
|
|
367
|
+
// home somewhere else silently moved the whole tree with it.
|
|
368
|
+
GOODVIBES_HOME: runtime.homeDirectory,
|
|
369
|
+
GOODVIBES_DAEMON_HOME: daemonHomeDir,
|
|
366
370
|
GOODVIBES_DAEMON_TOKEN: process.env.GOODVIBES_DAEMON_TOKEN ?? '',
|
|
367
371
|
GOODVIBES_HTTP_TOKEN: process.env.GOODVIBES_HTTP_TOKEN ?? '',
|
|
368
372
|
NODE_ENV: process.env.NODE_ENV ?? 'production',
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-daemon-config-migration.ts — the one call every TUI composition root
|
|
3
|
+
* makes before constructing its `ConfigManager`.
|
|
4
|
+
*
|
|
5
|
+
* Daemon-owned configuration now has exactly one home:
|
|
6
|
+
* `~/.goodvibes/daemon/settings.json`. Before this migration, every product
|
|
7
|
+
* wrote every key (including daemon-only ones like `surfaces.telegram.*`)
|
|
8
|
+
* into its own per-surface silo, and the daemon only ever read
|
|
9
|
+
* `~/.goodvibes/tui/settings.json` — so a value written by, say, the agent
|
|
10
|
+
* surface reported a successful save and configured nothing the daemon could
|
|
11
|
+
* see.
|
|
12
|
+
*
|
|
13
|
+
* `migrateDaemonOwnedConfig` (SDK, `platform/config`) is idempotent and cheap
|
|
14
|
+
* on the fast path (one file read + JSON parse), so it is safe and correct to
|
|
15
|
+
* call this at every composition root that is about to construct a
|
|
16
|
+
* `ConfigManager` — not just the first one to run in a given process. It
|
|
17
|
+
* must never abort startup: any failure is caught, logged with the marker
|
|
18
|
+
* path so the failure is diagnosable, and startup continues on whatever
|
|
19
|
+
* config state already exists.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
migrateDaemonOwnedConfig,
|
|
23
|
+
daemonConfigPath,
|
|
24
|
+
daemonConfigMovedPath,
|
|
25
|
+
type DaemonConfigMigrationResult,
|
|
26
|
+
} from '@pellux/goodvibes-sdk/platform/config';
|
|
27
|
+
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run the daemon-owned-config migration for `homeDir`, tolerating any
|
|
31
|
+
* failure. Returns the migration result on success, or `null` when the
|
|
32
|
+
* migration itself threw (logged as a warning naming the marker path;
|
|
33
|
+
* startup proceeds either way).
|
|
34
|
+
*/
|
|
35
|
+
export function runDaemonConfigMigration(homeDir: string): DaemonConfigMigrationResult | null {
|
|
36
|
+
try {
|
|
37
|
+
return migrateDaemonOwnedConfig({ homeDir, primarySurface: 'tui' });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const markerPath = daemonConfigMovedPath(daemonConfigPath(homeDir));
|
|
40
|
+
logger.warn('daemon-owned config migration failed; continuing with existing config state', {
|
|
41
|
+
markerPath,
|
|
42
|
+
error: summarizeError(error),
|
|
43
|
+
});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-compaction-render.ts — folds a compaction-continuation user
|
|
3
|
+
* message (the instruction block the compactor re-injects after an automatic
|
|
4
|
+
* compaction) to one header + preview line.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from conversation-rendering.ts to keep that file under the
|
|
7
|
+
* architecture line-count gate; behaviour is unchanged.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { activeTheme } from '../renderer/theme.ts';
|
|
11
|
+
import { renderMarkdownTracked } from '../renderer/markdown.ts';
|
|
12
|
+
import { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
|
|
13
|
+
import { GLYPHS } from '../renderer/ui-primitives.ts';
|
|
14
|
+
import type { Line } from '../types/grid.ts';
|
|
15
|
+
import type { BlockMeta } from './conversation-types.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The slice of the render context this module needs, declared structurally
|
|
19
|
+
* rather than imported from conversation-rendering.ts — that import would form
|
|
20
|
+
* a 2-file cycle the architecture check rejects, and this module only ever
|
|
21
|
+
* touches these three fields.
|
|
22
|
+
*/
|
|
23
|
+
export interface CompactionRenderContext {
|
|
24
|
+
readonly history: {
|
|
25
|
+
addLine: (line: Line) => void;
|
|
26
|
+
addLines: (lines: Line[]) => void;
|
|
27
|
+
getLineCount: () => number;
|
|
28
|
+
};
|
|
29
|
+
readonly blockRegistry: BlockMeta[];
|
|
30
|
+
readonly collapseState: Map<string, boolean>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fold a compaction-continuation user message to one header + preview line. */
|
|
34
|
+
export function renderCompactionContinuationMessage(
|
|
35
|
+
context: CompactionRenderContext,
|
|
36
|
+
content: string,
|
|
37
|
+
width: number,
|
|
38
|
+
msgIdx: number,
|
|
39
|
+
): void {
|
|
40
|
+
const T = activeTheme();
|
|
41
|
+
const collapseKey = `msg_${msgIdx}`;
|
|
42
|
+
const blockIdx = context.blockRegistry.length;
|
|
43
|
+
const startLine = context.history.getLineCount();
|
|
44
|
+
const lineCount = content.split('\n').length;
|
|
45
|
+
const isCollapsed = context.collapseState.has(collapseKey)
|
|
46
|
+
? context.collapseState.get(collapseKey)!
|
|
47
|
+
: true;
|
|
48
|
+
if (!context.collapseState.has(collapseKey)) {
|
|
49
|
+
context.collapseState.set(collapseKey, true);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
context.history.addLine(renderConversationEventLine(width, {
|
|
53
|
+
marker: GLYPHS.status.active,
|
|
54
|
+
markerFg: T.toolAccent,
|
|
55
|
+
label: 'compaction handoff',
|
|
56
|
+
labelFg: T.toolAccent,
|
|
57
|
+
detailFg: '244',
|
|
58
|
+
}, [
|
|
59
|
+
{ text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
|
|
60
|
+
]));
|
|
61
|
+
|
|
62
|
+
if (isCollapsed) {
|
|
63
|
+
const rendered = renderConversationCollapsedFragment(
|
|
64
|
+
'compacted-context handoff (re-injected instructions + session summary)',
|
|
65
|
+
width,
|
|
66
|
+
{
|
|
67
|
+
prefix: ` ${GLYPHS.navigation.collapsed} `,
|
|
68
|
+
prefixFg: T.toolAccent,
|
|
69
|
+
text: '244',
|
|
70
|
+
bodyBg: T.collapsedBodyBg,
|
|
71
|
+
dim: true,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
context.history.addLines(rendered);
|
|
75
|
+
} else {
|
|
76
|
+
context.history.addLines(renderMarkdownTracked(content, width).lines);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
context.blockRegistry.push({
|
|
80
|
+
blockIndex: blockIdx,
|
|
81
|
+
collapseKey,
|
|
82
|
+
type: 'tool',
|
|
83
|
+
startLine,
|
|
84
|
+
lineCount: context.history.getLineCount() - startLine,
|
|
85
|
+
rawContent: content,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
@@ -51,12 +51,18 @@ import type { ConversationRenderContext } from './conversation-rendering.ts';
|
|
|
51
51
|
import {
|
|
52
52
|
renderConversationAssistantMessage,
|
|
53
53
|
renderConversationSystemMessage,
|
|
54
|
+
renderConversationToolCallNode,
|
|
54
55
|
renderConversationToolMessage,
|
|
55
56
|
renderConversationUserMessage,
|
|
56
57
|
collectCompletedToolCallIds,
|
|
57
|
-
|
|
58
|
+
isTurnCollapsed,
|
|
58
59
|
} from './conversation-rendering.ts';
|
|
59
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
buildRenderPlan,
|
|
62
|
+
computeAssistantTurns,
|
|
63
|
+
type AssistantTurnMembership,
|
|
64
|
+
type RenderNode,
|
|
65
|
+
} from './conversation-turn-structure.ts';
|
|
60
66
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
61
67
|
// SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
|
|
62
68
|
// conversation-rendering.ts's own import.
|
|
@@ -112,17 +118,27 @@ interface KeyMeta {
|
|
|
112
118
|
*/
|
|
113
119
|
readonly pendingToolKey: string | undefined;
|
|
114
120
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
+
* Turn membership and tree-structure fields, flattened so a change
|
|
122
|
+
* invalidates the entry like any other structural input.
|
|
123
|
+
*
|
|
124
|
+
* This is what makes connector glyphs safe to recompute rather than cache:
|
|
125
|
+
* when a sibling arrives, the previously-last row's `connector` flips
|
|
126
|
+
* `└`→`├`, that value differs from the cached one, and exactly that row
|
|
127
|
+
* re-renders. Nothing else in the row changes, so the compositor repaints a
|
|
128
|
+
* single connector cell.
|
|
121
129
|
*/
|
|
122
|
-
readonly
|
|
123
|
-
readonly
|
|
124
|
-
readonly
|
|
125
|
-
readonly
|
|
130
|
+
readonly turnKey: string | undefined;
|
|
131
|
+
readonly turnIsHead: boolean;
|
|
132
|
+
readonly turnToolCount: number;
|
|
133
|
+
readonly turnSharedLabel: string | undefined;
|
|
134
|
+
readonly turnHasReasoning: boolean;
|
|
135
|
+
/** Row depth and connector — both change when structure around a row changes. */
|
|
136
|
+
readonly depth: number;
|
|
137
|
+
readonly connector: string | undefined;
|
|
138
|
+
/** Ancestor gutter pattern, flattened; a change repaints the row's gutters. */
|
|
139
|
+
readonly openAncestors: string;
|
|
140
|
+
/** Whether this row is followed by the blank separator. */
|
|
141
|
+
readonly trailingBlank: boolean;
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
/**
|
|
@@ -246,22 +262,26 @@ function makeRecordingCollapseState(
|
|
|
246
262
|
});
|
|
247
263
|
}
|
|
248
264
|
|
|
249
|
-
/** Dispatch a single
|
|
265
|
+
/** Dispatch a single planned row to its render function. */
|
|
250
266
|
function renderOne(
|
|
251
267
|
ctx: ConversationRenderContext,
|
|
252
|
-
|
|
268
|
+
node: RenderNode,
|
|
253
269
|
width: number,
|
|
254
|
-
absoluteIdx: number,
|
|
255
270
|
cfg: RenderConfig,
|
|
256
271
|
): void {
|
|
272
|
+
if (node.kind === 'toolcall') {
|
|
273
|
+
renderConversationToolCallNode(ctx, node, width);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const message = node.message;
|
|
257
277
|
if (message.role === 'user') {
|
|
258
|
-
renderConversationUserMessage(ctx, message, width,
|
|
278
|
+
renderConversationUserMessage(ctx, message, width, node.absIdx);
|
|
259
279
|
} else if (message.role === 'assistant') {
|
|
260
|
-
renderConversationAssistantMessage(ctx, message, width, cfg.lineNumberMode, cfg.collapseThreshold,
|
|
280
|
+
renderConversationAssistantMessage(ctx, message, width, cfg.lineNumberMode, cfg.collapseThreshold, node.absIdx);
|
|
261
281
|
} else if (message.role === 'system') {
|
|
262
|
-
renderConversationSystemMessage(ctx, message, width,
|
|
282
|
+
renderConversationSystemMessage(ctx, message, width, node.absIdx);
|
|
263
283
|
} else if (message.role === 'tool') {
|
|
264
|
-
renderConversationToolMessage(ctx, message, width,
|
|
284
|
+
renderConversationToolMessage(ctx, message, width, node.absIdx, node);
|
|
265
285
|
}
|
|
266
286
|
}
|
|
267
287
|
|
|
@@ -274,7 +294,7 @@ function renderOne(
|
|
|
274
294
|
* own beyond speed and reduced allocation churn.
|
|
275
295
|
*/
|
|
276
296
|
export class MessageLineCache {
|
|
277
|
-
private entries: Map<
|
|
297
|
+
private entries: Map<string, CacheEntry> = new Map();
|
|
278
298
|
|
|
279
299
|
/** Drop all cached entries (wholesale message replacement / reset). */
|
|
280
300
|
public clear(): void {
|
|
@@ -319,61 +339,62 @@ export class MessageLineCache {
|
|
|
319
339
|
// Tool calls with no matching tool-result message are still pending; the
|
|
320
340
|
// render context and the cache key both depend on this. (item 2c.)
|
|
321
341
|
const completedToolCallIds = collectCompletedToolCallIds(messages);
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const message = messages[i]!;
|
|
337
|
-
const absoluteIdx = msgIndexOffset + i;
|
|
342
|
+
const assistantTurns = computeAssistantTurns(messages, msgIndexOffset);
|
|
343
|
+
// Structural plan: a permutation of the slice (results lifted under their
|
|
344
|
+
// calls, spawned agents spliced under the call that spawned them), rebuilt
|
|
345
|
+
// every pass so connectors and nesting always reflect live structure.
|
|
346
|
+
const plan = buildRenderPlan(messages, msgIndexOffset, {
|
|
347
|
+
resolveAgentSnapshot: context.resolveAgentSnapshot,
|
|
348
|
+
});
|
|
349
|
+
const renderContext: ConversationRenderContext = { ...context, completedToolCallIds, assistantTurns };
|
|
350
|
+
|
|
351
|
+
const touched = new Set<string>();
|
|
352
|
+
const turnHeaderLines = new Map<string, number>();
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < plan.length; i++) {
|
|
355
|
+
const node = plan[i]!;
|
|
338
356
|
const base = context.history.getLineCount();
|
|
339
357
|
const blockBase = context.blockRegistry.length;
|
|
358
|
+
const isRoot = node.scope === '';
|
|
359
|
+
const turn = assistantTurns.get(node.absIdx);
|
|
360
|
+
|
|
361
|
+
if (node.kind === 'message') {
|
|
362
|
+
if (turn?.isHead) turnHeaderLines.set(turn.turnKey, base);
|
|
363
|
+
// Nested rows index into their own agent's snapshot and must not write
|
|
364
|
+
// the root transcript's registry.
|
|
365
|
+
if (isRoot) {
|
|
366
|
+
messageLineRegistry[node.absIdx] = turn && isTurnCollapsed(turn, context.collapseState)
|
|
367
|
+
? (turnHeaderLines.get(turn.turnKey) ?? base)
|
|
368
|
+
: base;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
340
371
|
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
if (groupMembership?.isFirst) groupHeaderLines.set(groupMembership.groupKey, base);
|
|
347
|
-
// A folded (non-first, currently collapsed) group member renders zero
|
|
348
|
-
// lines of its own — `base` here is just wherever the buffer happens to
|
|
349
|
-
// sit after the last member that DID render, which is the position the
|
|
350
|
-
// NEXT real content starts at, not this message's own position. Anchor
|
|
351
|
-
// it at the group's header line instead, so transcript-event
|
|
352
|
-
// navigation lands on the group rather than skipping past it.
|
|
353
|
-
messageLineRegistry[absoluteIdx] = isFoldedGroupMember(groupMembership, context.collapseState)
|
|
354
|
-
? (groupHeaderLines.get(groupMembership!.groupKey) ?? base)
|
|
355
|
-
: base;
|
|
372
|
+
const next = plan[i + 1];
|
|
373
|
+
const trailingBlank = !next || next.depth === 0;
|
|
374
|
+
const uncacheable = isRoot && node.kind === 'message' && node.absIdx === streamingPlaceholderAbsIdx;
|
|
375
|
+
const kind = isRoot ? context.messageKindRegistry.get(node.absIdx) : undefined;
|
|
376
|
+
const pendingToolKey = pendingToolKeyOf(node.message, completedToolCallIds);
|
|
356
377
|
|
|
357
378
|
if (!uncacheable) {
|
|
358
|
-
const existing = this.entries.get(
|
|
359
|
-
if (existing && this.isValid(existing,
|
|
379
|
+
const existing = this.entries.get(node.id);
|
|
380
|
+
if (existing && this.isValid(existing, node, width, cfg, blockBase, kind, pendingToolKey, turn, trailingBlank, context.collapseState)) {
|
|
360
381
|
this.applyEntry(context, existing, base);
|
|
361
|
-
touched.add(
|
|
382
|
+
touched.add(node.id);
|
|
362
383
|
continue;
|
|
363
384
|
}
|
|
364
385
|
}
|
|
365
386
|
|
|
366
|
-
const entry = this.renderScratch(renderContext,
|
|
387
|
+
const entry = this.renderScratch(renderContext, node, width, cfg, blockBase, kind, pendingToolKey, turn, trailingBlank);
|
|
367
388
|
this.applyEntry(context, entry, base);
|
|
368
389
|
if (!uncacheable) {
|
|
369
|
-
this.entries.set(
|
|
370
|
-
touched.add(
|
|
390
|
+
this.entries.set(node.id, entry);
|
|
391
|
+
touched.add(node.id);
|
|
371
392
|
}
|
|
372
393
|
}
|
|
373
394
|
|
|
374
|
-
// Mark-and-sweep: a full rebuild
|
|
375
|
-
//
|
|
376
|
-
// is dropped to bound memory to the visible set.
|
|
395
|
+
// Mark-and-sweep: a full rebuild plans every currently-visible row, so any
|
|
396
|
+
// entry not touched this pass is gone (off-screen, or a row whose structure
|
|
397
|
+
// no longer exists) and is dropped to bound memory to the visible set.
|
|
377
398
|
if (this.entries.size > touched.size) {
|
|
378
399
|
for (const key of this.entries.keys()) {
|
|
379
400
|
if (!touched.has(key)) this.entries.delete(key);
|
|
@@ -381,21 +402,22 @@ export class MessageLineCache {
|
|
|
381
402
|
}
|
|
382
403
|
}
|
|
383
404
|
|
|
384
|
-
/** Validate a cached entry against the
|
|
405
|
+
/** Validate a cached entry against the row's current complete inputs. */
|
|
385
406
|
private isValid(
|
|
386
407
|
entry: CacheEntry,
|
|
387
|
-
|
|
408
|
+
node: RenderNode,
|
|
388
409
|
width: number,
|
|
389
410
|
cfg: RenderConfig,
|
|
390
411
|
blockBase: number,
|
|
391
412
|
kind: SystemMessageKind | undefined,
|
|
392
413
|
pendingToolKey: string | undefined,
|
|
393
|
-
|
|
414
|
+
turn: AssistantTurnMembership | undefined,
|
|
415
|
+
trailingBlank: boolean,
|
|
394
416
|
collapseState: Map<string, boolean>,
|
|
395
417
|
): boolean {
|
|
396
418
|
const k = entry.keyMeta;
|
|
397
419
|
if (
|
|
398
|
-
k.role !== message.role ||
|
|
420
|
+
k.role !== node.message.role ||
|
|
399
421
|
k.width !== width ||
|
|
400
422
|
k.lineNumberMode !== cfg.lineNumberMode ||
|
|
401
423
|
k.collapseThreshold !== cfg.collapseThreshold ||
|
|
@@ -404,14 +426,19 @@ export class MessageLineCache {
|
|
|
404
426
|
k.blockBase !== blockBase ||
|
|
405
427
|
k.kind !== kind ||
|
|
406
428
|
k.pendingToolKey !== pendingToolKey ||
|
|
407
|
-
k.
|
|
408
|
-
k.
|
|
409
|
-
k.
|
|
410
|
-
k.
|
|
429
|
+
k.turnKey !== turn?.turnKey ||
|
|
430
|
+
k.turnIsHead !== (turn?.isHead ?? false) ||
|
|
431
|
+
k.turnToolCount !== (turn?.toolCallCount ?? 0) ||
|
|
432
|
+
k.turnSharedLabel !== turn?.sharedToolLabel ||
|
|
433
|
+
k.turnHasReasoning !== (turn?.hasReasoning ?? false) ||
|
|
434
|
+
k.depth !== node.depth ||
|
|
435
|
+
k.connector !== node.connector ||
|
|
436
|
+
k.openAncestors !== node.openAncestorDepths.join(',') ||
|
|
437
|
+
k.trailingBlank !== trailingBlank
|
|
411
438
|
) {
|
|
412
439
|
return false;
|
|
413
440
|
}
|
|
414
|
-
if (!contentUnchanged(entry.contentSig, message)) return false;
|
|
441
|
+
if (!contentUnchanged(entry.contentSig, node.message)) return false;
|
|
415
442
|
for (const [key, value] of entry.collapseDeps) {
|
|
416
443
|
if (collapseState.get(key) !== value) return false;
|
|
417
444
|
}
|
|
@@ -419,21 +446,21 @@ export class MessageLineCache {
|
|
|
419
446
|
}
|
|
420
447
|
|
|
421
448
|
/**
|
|
422
|
-
* Render a single
|
|
423
|
-
* lines, block metas (
|
|
424
|
-
*
|
|
449
|
+
* Render a single planned row into an isolated scratch context, capturing its
|
|
450
|
+
* lines, block metas (row-relative), error-line offsets (row-relative), and
|
|
451
|
+
* the collapse-state reads it depends on. Collapse-default WRITES pass
|
|
425
452
|
* through to the real collapseState so persistent defaults match a cold render.
|
|
426
453
|
*/
|
|
427
454
|
private renderScratch(
|
|
428
455
|
context: ConversationRenderContext,
|
|
429
|
-
|
|
456
|
+
node: RenderNode,
|
|
430
457
|
width: number,
|
|
431
|
-
absoluteIdx: number,
|
|
432
458
|
cfg: RenderConfig,
|
|
433
459
|
blockBase: number,
|
|
434
460
|
kind: SystemMessageKind | undefined,
|
|
435
461
|
pendingToolKey: string | undefined,
|
|
436
|
-
|
|
462
|
+
turn: AssistantTurnMembership | undefined,
|
|
463
|
+
trailingBlank: boolean,
|
|
437
464
|
): CacheEntry {
|
|
438
465
|
const scratchLines: Line[] = [];
|
|
439
466
|
const scratchHistory = {
|
|
@@ -441,9 +468,6 @@ export class MessageLineCache {
|
|
|
441
468
|
addLines: (lines: Line[]): void => { for (const line of lines) scratchLines.push(line); },
|
|
442
469
|
getLineCount: (): number => scratchLines.length,
|
|
443
470
|
};
|
|
444
|
-
// Pre-size to blockBase so blockRegistry.length (the global block index the
|
|
445
|
-
// collapseKey embeds) matches the live registry; slice(blockBase) recovers
|
|
446
|
-
// only this message's blocks afterwards.
|
|
447
471
|
const scratchBlocks: BlockMeta[] = new Array(blockBase);
|
|
448
472
|
const scratchErrors: number[] = [];
|
|
449
473
|
const readKeys = new Set<string>();
|
|
@@ -458,16 +482,15 @@ export class MessageLineCache {
|
|
|
458
482
|
configManager: context.configManager,
|
|
459
483
|
splashOptions: context.splashOptions,
|
|
460
484
|
completedToolCallIds: context.completedToolCallIds,
|
|
461
|
-
|
|
485
|
+
assistantTurns: context.assistantTurns,
|
|
486
|
+
resolveAgentSnapshot: context.resolveAgentSnapshot,
|
|
462
487
|
};
|
|
463
488
|
|
|
464
|
-
renderOne(scratchCtx,
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
// unconditional separator.
|
|
470
|
-
if (!isFoldedGroupMember(groupMembership, context.collapseState)) {
|
|
489
|
+
renderOne(scratchCtx, node, width, cfg);
|
|
490
|
+
// A row that rendered nothing (hidden by a collapsed turn) gets no
|
|
491
|
+
// separator either; otherwise the blank lands only after the last row of a
|
|
492
|
+
// top-level unit, so a turn's subtree reads as one block.
|
|
493
|
+
if (scratchLines.length > 0 && trailingBlank) {
|
|
471
494
|
scratchLines.push(createEmptyLine(width));
|
|
472
495
|
}
|
|
473
496
|
|
|
@@ -476,7 +499,7 @@ export class MessageLineCache {
|
|
|
476
499
|
|
|
477
500
|
return {
|
|
478
501
|
keyMeta: {
|
|
479
|
-
role: message.role,
|
|
502
|
+
role: node.message.role,
|
|
480
503
|
width,
|
|
481
504
|
lineNumberMode: cfg.lineNumberMode,
|
|
482
505
|
collapseThreshold: cfg.collapseThreshold,
|
|
@@ -485,12 +508,17 @@ export class MessageLineCache {
|
|
|
485
508
|
blockBase,
|
|
486
509
|
kind,
|
|
487
510
|
pendingToolKey,
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
511
|
+
turnKey: turn?.turnKey,
|
|
512
|
+
turnIsHead: turn?.isHead ?? false,
|
|
513
|
+
turnToolCount: turn?.toolCallCount ?? 0,
|
|
514
|
+
turnSharedLabel: turn?.sharedToolLabel,
|
|
515
|
+
turnHasReasoning: turn?.hasReasoning ?? false,
|
|
516
|
+
depth: node.depth,
|
|
517
|
+
connector: node.connector,
|
|
518
|
+
openAncestors: node.openAncestorDepths.join(','),
|
|
519
|
+
trailingBlank,
|
|
492
520
|
},
|
|
493
|
-
contentSig: contentSigOf(message),
|
|
521
|
+
contentSig: contentSigOf(node.message),
|
|
494
522
|
lines: scratchLines,
|
|
495
523
|
blocks: scratchBlocks.slice(blockBase),
|
|
496
524
|
errorRelLines: scratchErrors,
|