@pellux/goodvibes-tui 1.7.0 → 1.9.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/README.md +10 -9
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +71 -14
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +68 -1
- package/src/core/turn-cancellation.ts +7 -2
- package/src/core/turn-event-wiring.ts +10 -2
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +46 -4
- package/src/input/commands/codebase-runtime.ts +46 -6
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/health-runtime.ts +9 -1
- package/src/input/commands/memory.ts +68 -35
- package/src/input/commands/operator-panel-runtime.ts +31 -7
- package/src/input/commands/planning-runtime.ts +95 -6
- package/src/input/commands/qrcode-runtime.ts +25 -5
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/settings-sync-runtime.ts +15 -3
- package/src/input/commands/shell-core.ts +10 -1
- package/src/input/commands/workstream-runtime.ts +168 -18
- package/src/input/config-modal-types.ts +15 -1
- package/src/input/config-modal.ts +227 -12
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +10 -3
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +43 -121
- package/src/input/handler-feed.ts +32 -4
- package/src/input/handler-modal-routes.ts +78 -13
- package/src/input/handler-modal-stack.ts +11 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +15 -8
- package/src/input/handler-shortcuts.ts +59 -9
- package/src/input/handler.ts +6 -1
- package/src/input/keybindings.ts +6 -5
- package/src/input/model-picker.ts +19 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +51 -2
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +28 -27
- package/src/panels/builtin/shared.ts +9 -3
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +89 -131
- package/src/panels/fleet-read-model.ts +43 -0
- package/src/panels/fleet-steer.ts +35 -2
- package/src/panels/fleet-stop.ts +29 -1
- package/src/panels/modals/keybindings-modal.ts +16 -1
- package/src/panels/modals/modal-theme.ts +35 -29
- package/src/panels/modals/pairing-modal.ts +25 -6
- package/src/panels/modals/planning-modal.ts +43 -21
- package/src/panels/modals/work-plan-modal.ts +28 -0
- package/src/panels/panel-manager.ts +15 -4
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -13
- package/src/panels/types.ts +24 -0
- package/src/permissions/prompt.ts +160 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +19 -5
- package/src/renderer/footer-tips.ts +5 -1
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +3 -1
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +18 -3
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +22 -3
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +8 -4
- package/src/runtime/bootstrap-core.ts +33 -11
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +19 -1
- package/src/runtime/bootstrap.ts +118 -5
- package/src/runtime/code-index-services.ts +25 -2
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +26 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +10 -0
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +12 -8
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +19 -3
- package/src/runtime/workstream-services.ts +160 -28
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +46 -3
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +42 -9
- package/src/utils/terminal-width.ts +52 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
100
100
|
"@ast-grep/napi": "^0.42.0",
|
|
101
101
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
102
|
-
"@pellux/goodvibes-sdk": "0.
|
|
102
|
+
"@pellux/goodvibes-sdk": "1.0.0",
|
|
103
103
|
"bash-language-server": "^5.6.0",
|
|
104
104
|
"fuse.js": "^7.1.0",
|
|
105
105
|
"graphql": "^16.13.2",
|
|
@@ -61,7 +61,16 @@ export class SpokenTurnController {
|
|
|
61
61
|
return true;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Returns whether speech was actually ACTIVE when stopped. The notice only
|
|
66
|
+
* prints in that case — stop() on an idle controller used to notify anyway,
|
|
67
|
+
* spamming "[TTS] Spoken output stopped." on every Ctrl+C (batch replay
|
|
68
|
+
* D5/N-noise); callers use the return to decide whether the press "did a
|
|
69
|
+
* job" (see handleCtrlC's consume-on-speech-stop).
|
|
70
|
+
*/
|
|
71
|
+
stop(message?: string): boolean {
|
|
72
|
+
const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
|
|
73
|
+
|| this.chunker !== null || this.abortControllers.size > 0;
|
|
65
74
|
this.pendingPrompt = null;
|
|
66
75
|
this.activeTurnId = null;
|
|
67
76
|
this.chunker?.reset();
|
|
@@ -72,7 +81,8 @@ export class SpokenTurnController {
|
|
|
72
81
|
this.player.stop();
|
|
73
82
|
this.playbackChain = Promise.resolve();
|
|
74
83
|
this.errorReportedForTurn = false;
|
|
75
|
-
if (message) this.notify?.(`[TTS] ${message}`);
|
|
84
|
+
if (message && wasActive) this.notify?.(`[TTS] ${message}`);
|
|
85
|
+
return wasActive;
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
handleTurnEvent(event: TurnEvent): void {
|
|
@@ -8,7 +8,8 @@ import { SpokenTurnController } from './spoken-turn-controller.ts';
|
|
|
8
8
|
export interface SpokenTurnRuntime {
|
|
9
9
|
readonly unsubs: readonly (() => void)[];
|
|
10
10
|
submitNextTurn(prompt: string): boolean;
|
|
11
|
-
|
|
11
|
+
/** Returns whether speech was actually active (see controller.stop). */
|
|
12
|
+
stop(message?: string): boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface WireSpokenTurnRuntimeOptions {
|
package/src/cli/help.ts
CHANGED
|
@@ -283,10 +283,17 @@ export function renderGoodVibesCommandHelp(topic: string, binary = 'goodvibes'):
|
|
|
283
283
|
|
|
284
284
|
export function renderGoodVibesDaemonHelp(binary = 'goodvibes-daemon'): string {
|
|
285
285
|
return [
|
|
286
|
-
`Usage: ${binary} [OPTIONS]`,
|
|
286
|
+
`Usage: ${binary} [COMMAND] [OPTIONS]`,
|
|
287
287
|
'',
|
|
288
288
|
'Starts the headless GoodVibes daemon/API host.',
|
|
289
289
|
'',
|
|
290
|
+
'Commands:',
|
|
291
|
+
' install-service Install + enable the daemon as a systemd user service (survives reboots)',
|
|
292
|
+
' uninstall-service Disable + remove the daemon systemd user service',
|
|
293
|
+
' service-status Show whether the daemon service is installed / enabled / active',
|
|
294
|
+
' migrate-service Guided takeover of a legacy goodvibes-daemon.service unit; prints a plan',
|
|
295
|
+
' unless run with -y/--yes (never auto-migrates)',
|
|
296
|
+
'',
|
|
290
297
|
'Options:',
|
|
291
298
|
' --daemon-home <dir> Override daemon home',
|
|
292
299
|
' --working-dir <dir> Override working directory',
|
|
@@ -5,6 +5,7 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:pa
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { PlatformServiceManager } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
7
7
|
import type { ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
8
|
+
import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
|
|
8
9
|
import type { ConfigManager } from '../config/index.ts';
|
|
9
10
|
import { resolveRuntimeEndpointBinding } from './endpoints.ts';
|
|
10
11
|
import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
|
|
@@ -392,7 +393,7 @@ export async function buildCliServicePosture(
|
|
|
392
393
|
enabled: runtime.configManager.get('service.enabled') === true,
|
|
393
394
|
autostart: runtime.configManager.get('service.autostart') === true,
|
|
394
395
|
restartOnFailure: runtime.configManager.get('service.restartOnFailure') === true,
|
|
395
|
-
daemonEnabled: runtime.configManager
|
|
396
|
+
daemonEnabled: resolveDaemonEnabled(runtime.configManager),
|
|
396
397
|
};
|
|
397
398
|
const serverBackedEnabled = config.daemonEnabled || endpoints.some((endpoint) => endpoint.enabled);
|
|
398
399
|
const issues: string[] = [];
|
package/src/cli/status.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
|
+
import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
3
|
import type { OnboardingCheckMarkersState } from '../runtime/onboarding/index.ts';
|
|
3
4
|
import { resolveRuntimeEndpointBinding } from './endpoints.ts';
|
|
4
5
|
import { isNetworkFacing } from './network-posture.ts';
|
|
@@ -98,7 +99,7 @@ export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliD
|
|
|
98
99
|
const serviceEnabled = config.get('service.enabled') === true;
|
|
99
100
|
const serviceAutostart = config.get('service.autostart') === true;
|
|
100
101
|
const restartOnFailure = config.get('service.restartOnFailure') === true;
|
|
101
|
-
const daemonEnabled = config
|
|
102
|
+
const daemonEnabled = resolveDaemonEnabled(config);
|
|
102
103
|
const listenerEnabled = config.get('danger.httpListener') === true;
|
|
103
104
|
const webEnabled = config.get('web.enabled') === true;
|
|
104
105
|
const controlPlaneEnabled = config.get('controlPlane.enabled') === true;
|
|
@@ -47,7 +47,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
|
|
|
47
47
|
runtime.configManager.setDynamic('web.enabled', enabled);
|
|
48
48
|
if (enabled) {
|
|
49
49
|
enableFeatureFlags(runtime.configManager, getServerSurfaceFeatureFlags({ serverBacked: true, web: true }));
|
|
50
|
-
runtime.configManager.setDynamic('
|
|
50
|
+
runtime.configManager.setDynamic('daemon.enabled', true);
|
|
51
51
|
runtime.configManager.setDynamic('controlPlane.enabled', true);
|
|
52
52
|
const webError = applyTargetEndpointFlagsOrDefault(runtime, 'web');
|
|
53
53
|
if (webError) return { output: webError, exitCode: 2 };
|
|
@@ -70,7 +70,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
|
|
|
70
70
|
}
|
|
71
71
|
else if (target === 'control-plane' || target === 'controlPlane') {
|
|
72
72
|
runtime.configManager.setDynamic('controlPlane.enabled', enabled);
|
|
73
|
-
runtime.configManager.setDynamic('
|
|
73
|
+
runtime.configManager.setDynamic('daemon.enabled', enabled);
|
|
74
74
|
if (enabled) {
|
|
75
75
|
const controlPlaneError = applyTargetEndpointFlagsOrDefault(runtime, 'controlPlane');
|
|
76
76
|
if (controlPlaneError) return { output: controlPlaneError, exitCode: 2 };
|
|
@@ -20,8 +20,13 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
|
|
|
20
20
|
const flags: string[] = [];
|
|
21
21
|
let pendingRisk: ComposerState['pendingRisk'] = 'none';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// An approval wait is the dominant composer state and owns the single honest
|
|
24
|
+
// status tag: `risk:approval-wait`. It is therefore NOT also duplicated as a
|
|
25
|
+
// `approval` flag, and (below) it suppresses the competing `state:` turn tag —
|
|
26
|
+
// the turn is blocked on the user, not really streaming. Precedence:
|
|
27
|
+
// approval-wait > live turn state. (UX-B item 4.)
|
|
28
|
+
const approvalWait = input.pendingApproval === true;
|
|
29
|
+
if (approvalWait) {
|
|
25
30
|
pendingRisk = 'approval-wait';
|
|
26
31
|
}
|
|
27
32
|
if (intent.kind === 'shell') {
|
|
@@ -35,7 +40,7 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
|
|
|
35
40
|
}
|
|
36
41
|
if (input.hasAttachments) flags.push('attachments');
|
|
37
42
|
|
|
38
|
-
const
|
|
43
|
+
const turnStatus = (() => {
|
|
39
44
|
switch (input.turnState) {
|
|
40
45
|
case 'preflight': return 'preflight';
|
|
41
46
|
case 'streaming': return 'streaming';
|
|
@@ -47,6 +52,9 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
|
|
|
47
52
|
default: return 'idle';
|
|
48
53
|
}
|
|
49
54
|
})();
|
|
55
|
+
// 'idle' is suppressed by the footer renderer, so an approval wait shows only
|
|
56
|
+
// `risk:approval-wait` — one honest tag, not three spellings of the wait.
|
|
57
|
+
const statusLabel = approvalWait ? 'idle' : turnStatus;
|
|
50
58
|
|
|
51
59
|
return {
|
|
52
60
|
intent,
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
renderConversationSystemMessage,
|
|
50
50
|
renderConversationToolMessage,
|
|
51
51
|
renderConversationUserMessage,
|
|
52
|
+
collectCompletedToolCallIds,
|
|
52
53
|
} from './conversation-rendering.ts';
|
|
53
54
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
54
55
|
// SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
|
|
@@ -94,6 +95,18 @@ interface KeyMeta {
|
|
|
94
95
|
readonly showReasoningSummary: boolean;
|
|
95
96
|
readonly blockBase: number;
|
|
96
97
|
readonly kind: SystemMessageKind | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* True when this (assistant) message has a tool call with no result yet — it
|
|
100
|
+
* renders as pending rather than done. Keyed so the entry invalidates and
|
|
101
|
+
* re-renders ✓ once the tool result arrives. (UX-B item 2c.)
|
|
102
|
+
*/
|
|
103
|
+
readonly hasPendingTool: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether an assistant message has any tool call still awaiting a result. */
|
|
107
|
+
function hasPendingToolCall(m: Message, completed: ReadonlySet<string>): boolean {
|
|
108
|
+
if (m.role !== 'assistant' || !m.toolCalls) return false;
|
|
109
|
+
return m.toolCalls.some((tc) => tc.id === undefined || !completed.has(tc.id));
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
interface CacheEntry {
|
|
@@ -272,6 +285,11 @@ export class MessageLineCache {
|
|
|
272
285
|
showReasoningSummary: context.configManager?.get('display.showReasoningSummary') ?? false,
|
|
273
286
|
};
|
|
274
287
|
|
|
288
|
+
// Tool calls with no matching tool-result message are still pending; the
|
|
289
|
+
// render context and the cache key both depend on this. (UX-B item 2c.)
|
|
290
|
+
const completedToolCallIds = collectCompletedToolCallIds(messages);
|
|
291
|
+
const renderContext: ConversationRenderContext = { ...context, completedToolCallIds };
|
|
292
|
+
|
|
275
293
|
const touched = new Set<number>();
|
|
276
294
|
|
|
277
295
|
for (let i = 0; i < messages.length; i++) {
|
|
@@ -283,17 +301,18 @@ export class MessageLineCache {
|
|
|
283
301
|
|
|
284
302
|
const uncacheable = absoluteIdx === streamingPlaceholderAbsIdx;
|
|
285
303
|
const kind = context.messageKindRegistry.get(absoluteIdx);
|
|
304
|
+
const hasPendingTool = hasPendingToolCall(message, completedToolCallIds);
|
|
286
305
|
|
|
287
306
|
if (!uncacheable) {
|
|
288
307
|
const existing = this.entries.get(absoluteIdx);
|
|
289
|
-
if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, context.collapseState)) {
|
|
308
|
+
if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, hasPendingTool, context.collapseState)) {
|
|
290
309
|
this.applyEntry(context, existing, base);
|
|
291
310
|
touched.add(absoluteIdx);
|
|
292
311
|
continue;
|
|
293
312
|
}
|
|
294
313
|
}
|
|
295
314
|
|
|
296
|
-
const entry = this.renderScratch(
|
|
315
|
+
const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, hasPendingTool);
|
|
297
316
|
this.applyEntry(context, entry, base);
|
|
298
317
|
if (!uncacheable) {
|
|
299
318
|
this.entries.set(absoluteIdx, entry);
|
|
@@ -319,6 +338,7 @@ export class MessageLineCache {
|
|
|
319
338
|
cfg: RenderConfig,
|
|
320
339
|
blockBase: number,
|
|
321
340
|
kind: SystemMessageKind | undefined,
|
|
341
|
+
hasPendingTool: boolean,
|
|
322
342
|
collapseState: Map<string, boolean>,
|
|
323
343
|
): boolean {
|
|
324
344
|
const k = entry.keyMeta;
|
|
@@ -330,7 +350,8 @@ export class MessageLineCache {
|
|
|
330
350
|
k.showThinking !== cfg.showThinking ||
|
|
331
351
|
k.showReasoningSummary !== cfg.showReasoningSummary ||
|
|
332
352
|
k.blockBase !== blockBase ||
|
|
333
|
-
k.kind !== kind
|
|
353
|
+
k.kind !== kind ||
|
|
354
|
+
k.hasPendingTool !== hasPendingTool
|
|
334
355
|
) {
|
|
335
356
|
return false;
|
|
336
357
|
}
|
|
@@ -355,6 +376,7 @@ export class MessageLineCache {
|
|
|
355
376
|
cfg: RenderConfig,
|
|
356
377
|
blockBase: number,
|
|
357
378
|
kind: SystemMessageKind | undefined,
|
|
379
|
+
hasPendingTool: boolean,
|
|
358
380
|
): CacheEntry {
|
|
359
381
|
const scratchLines: Line[] = [];
|
|
360
382
|
const scratchHistory = {
|
|
@@ -378,6 +400,7 @@ export class MessageLineCache {
|
|
|
378
400
|
messageKindRegistry: context.messageKindRegistry,
|
|
379
401
|
configManager: context.configManager,
|
|
380
402
|
splashOptions: context.splashOptions,
|
|
403
|
+
completedToolCallIds: context.completedToolCallIds,
|
|
381
404
|
};
|
|
382
405
|
|
|
383
406
|
renderOne(scratchCtx, message, width, absoluteIdx, cfg);
|
|
@@ -397,6 +420,7 @@ export class MessageLineCache {
|
|
|
397
420
|
showReasoningSummary: cfg.showReasoningSummary,
|
|
398
421
|
blockBase,
|
|
399
422
|
kind,
|
|
423
|
+
hasPendingTool,
|
|
400
424
|
},
|
|
401
425
|
contentSig: contentSigOf(message),
|
|
402
426
|
lines: scratchLines,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { UIFactory } from '../renderer/ui-factory.ts';
|
|
2
2
|
import { renderMarkdownTracked } from '../renderer/markdown.ts';
|
|
3
3
|
import { renderDiffView } from '../renderer/diff-view.ts';
|
|
4
|
-
import {
|
|
4
|
+
import { activeTheme } from '../renderer/theme.ts';
|
|
5
5
|
import { renderToolCallBlock } from '../renderer/tool-call.ts';
|
|
6
|
+
import { summarizeToolResult } from '../renderer/tool-result-summary.ts';
|
|
6
7
|
import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
|
|
7
8
|
import { renderThinkingBlock } from '../renderer/thinking.ts';
|
|
8
9
|
import { renderSystemMessage } from '../renderer/system-message.ts';
|
|
@@ -21,7 +22,9 @@ import { extractUserDisplayText } from '@pellux/goodvibes-sdk/platform/core';
|
|
|
21
22
|
// conversation-rendering.ts → system-message-router.ts → conversation.ts → conversation-rendering.ts
|
|
22
23
|
import type { SystemMessageKind } from '@/runtime/index.ts';
|
|
23
24
|
|
|
24
|
-
const T =
|
|
25
|
+
// Transcript tokens are read live per render (const T = activeTheme() at the top
|
|
26
|
+
// of each render function that styles content) so a dark→light repaint
|
|
27
|
+
// re-resolves with no module reload. See theme.ts's active-mode runtime note.
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Navigable system message kinds for error-navigation (nextErrorLine/prevErrorLine).
|
|
@@ -39,6 +42,20 @@ const NAVIGABLE_KINDS: ReadonlySet<SystemMessageKind> = new Set(['system', 'wrfc
|
|
|
39
42
|
|
|
40
43
|
type Message = ConversationMessageSnapshot;
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Collect the ids of tool calls that have a matching tool-result message in the
|
|
47
|
+
* given slice — i.e. the tools that actually ran. Used to decide whether an
|
|
48
|
+
* assistant tool call still shows as pending (awaiting approval) or done.
|
|
49
|
+
* (UX-B item 2c.)
|
|
50
|
+
*/
|
|
51
|
+
export function collectCompletedToolCallIds(messages: readonly Message[]): Set<string> {
|
|
52
|
+
const ids = new Set<string>();
|
|
53
|
+
for (const message of messages) {
|
|
54
|
+
if (message.role === 'tool' && message.callId) ids.add(message.callId);
|
|
55
|
+
}
|
|
56
|
+
return ids;
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
function summarizeCallId(callId: string, maxLength = 24): string {
|
|
43
60
|
return callId.length <= maxLength ? callId : `${callId.slice(0, maxLength - 1)}…`;
|
|
44
61
|
}
|
|
@@ -56,6 +73,15 @@ export interface ConversationRenderContext {
|
|
|
56
73
|
readonly messageKindRegistry: ReadonlyMap<number, SystemMessageKind>;
|
|
57
74
|
readonly configManager: ConfigManager | null;
|
|
58
75
|
readonly splashOptions: SplashOptions;
|
|
76
|
+
/**
|
|
77
|
+
* Tool-call ids that have a corresponding tool-result message (i.e. the tool
|
|
78
|
+
* actually ran). An assistant tool call whose id is NOT in this set is still
|
|
79
|
+
* awaiting a decision (e.g. an approval prompt) and renders with a pending
|
|
80
|
+
* glyph instead of the completed ✓. When undefined (single-message callers
|
|
81
|
+
* without sibling context), every tool call renders as done — the prior
|
|
82
|
+
* behaviour. (UX-B item 2c.)
|
|
83
|
+
*/
|
|
84
|
+
readonly completedToolCallIds?: ReadonlySet<string>;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
export function renderConversationUserMessage(
|
|
@@ -63,6 +89,7 @@ export function renderConversationUserMessage(
|
|
|
63
89
|
message: Extract<Message, { role: 'user' }>,
|
|
64
90
|
width: number,
|
|
65
91
|
): void {
|
|
92
|
+
const T = activeTheme();
|
|
66
93
|
const displayText = extractUserDisplayText(message.content);
|
|
67
94
|
if (message.cancelled) {
|
|
68
95
|
context.history.addLines(UIFactory.createMessageBar(width, displayText, T.errorBarBg, '196', ' x ', true));
|
|
@@ -79,6 +106,7 @@ export function renderConversationAssistantMessage(
|
|
|
79
106
|
collapseThreshold: number,
|
|
80
107
|
msgIdx: number,
|
|
81
108
|
): void {
|
|
109
|
+
const T = activeTheme();
|
|
82
110
|
const assistantHeaderDetails = [];
|
|
83
111
|
if (message.model) {
|
|
84
112
|
assistantHeaderDetails.push({ text: ` ${message.model}${message.provider ? ` (${message.provider})` : ''} `, fg: T.modelNameDim, dim: true });
|
|
@@ -196,7 +224,11 @@ export function renderConversationAssistantMessage(
|
|
|
196
224
|
|
|
197
225
|
if (message.toolCalls && message.toolCalls.length > 0) {
|
|
198
226
|
for (const tc of message.toolCalls) {
|
|
199
|
-
|
|
227
|
+
// A tool call with no result message yet is still awaiting a decision
|
|
228
|
+
// (e.g. approval) — show a pending glyph, not the completed ✓.
|
|
229
|
+
const ran = context.completedToolCallIds === undefined
|
|
230
|
+
|| (tc.id !== undefined && context.completedToolCallIds.has(tc.id));
|
|
231
|
+
context.history.addLines(renderToolCallBlock(tc, ran ? 'done' : 'pending', undefined, width));
|
|
200
232
|
}
|
|
201
233
|
}
|
|
202
234
|
}
|
|
@@ -224,6 +256,7 @@ export function renderConversationToolMessage(
|
|
|
224
256
|
width: number,
|
|
225
257
|
msgIdx: number,
|
|
226
258
|
): void {
|
|
259
|
+
const T = activeTheme();
|
|
227
260
|
const collapseKey = `msg_${msgIdx}`;
|
|
228
261
|
const blockIdx = context.blockRegistry.length;
|
|
229
262
|
const startLine = context.history.getLineCount();
|
|
@@ -237,7 +270,17 @@ export function renderConversationToolMessage(
|
|
|
237
270
|
// block-registry meta merge below regardless of collapsed/expanded state.
|
|
238
271
|
const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
|
|
239
272
|
|
|
240
|
-
|
|
273
|
+
// Human one-line summary for tool results (write/read/exec/edit): shown as the
|
|
274
|
+
// collapsed line so the transcript reads "wrote foo.txt (532 B)" instead of a
|
|
275
|
+
// raw JSON blob; the full payload stays behind the expand toggle. Only for
|
|
276
|
+
// 'tool' blocks (diffs render their own view). (UX-B item 3.)
|
|
277
|
+
const resultSummary = blockType === 'tool'
|
|
278
|
+
? summarizeToolResult(message.toolName, message.content)
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
// A summarizable result is kept collapsed-by-default even when short, so the
|
|
282
|
+
// one-line summary wins and the raw JSON is tucked behind the expand toggle.
|
|
283
|
+
const isShort = message.content.length <= 200 && resultSummary === null;
|
|
241
284
|
const isCollapsed = isShort
|
|
242
285
|
? false
|
|
243
286
|
: context.collapseState.has(collapseKey)
|
|
@@ -263,11 +306,20 @@ export function renderConversationToolMessage(
|
|
|
263
306
|
|
|
264
307
|
if (isCollapsed) {
|
|
265
308
|
const collapseSuffixReserve = 30;
|
|
266
|
-
const
|
|
309
|
+
const avail = Math.max(0, width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - collapseSuffixReserve);
|
|
267
310
|
const hiddenCount = lineCount - 1;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
311
|
+
let collapsedText: string;
|
|
312
|
+
if (resultSummary !== null) {
|
|
313
|
+
// Summary line — the header already shows "▸ N lines", so no hidden badge.
|
|
314
|
+
collapsedText = resultSummary.length > avail
|
|
315
|
+
? `${resultSummary.slice(0, Math.max(0, avail - 1))}…`
|
|
316
|
+
: resultSummary;
|
|
317
|
+
} else {
|
|
318
|
+
const preview = contentLines[0].slice(0, avail);
|
|
319
|
+
collapsedText = hiddenCount > 0
|
|
320
|
+
? `${preview}... [${GLYPHS.navigation.collapsed} ${hiddenCount} hidden]`
|
|
321
|
+
: preview;
|
|
322
|
+
}
|
|
271
323
|
const rendered = renderConversationCollapsedFragment(collapsedText, width, {
|
|
272
324
|
prefix: blockType === 'diff' ? ` ${GLYPHS.status.dualPane} ` : ` ${GLYPHS.navigation.collapsed} `,
|
|
273
325
|
prefixFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
|
|
@@ -326,23 +378,28 @@ export function appendConversationMessages(
|
|
|
326
378
|
): void {
|
|
327
379
|
const lineNumberMode = context.configManager?.get('display.lineNumbers') ?? 'off';
|
|
328
380
|
const collapseThreshold = context.configManager?.get('display.collapseThreshold') ?? 30;
|
|
381
|
+
// Derive pending vs done for tool calls from sibling tool-result messages,
|
|
382
|
+
// unless the caller already supplied the set. (UX-B item 2c.)
|
|
383
|
+
const renderContext: ConversationRenderContext = context.completedToolCallIds !== undefined
|
|
384
|
+
? context
|
|
385
|
+
: { ...context, completedToolCallIds: collectCompletedToolCallIds(messages) };
|
|
329
386
|
|
|
330
387
|
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
|
|
331
388
|
const message = messages[msgIdx];
|
|
332
389
|
// absoluteIdx aligns the slice-relative loop counter with the absolute
|
|
333
390
|
// message index used as the key in messageKindRegistry and messageLineRegistry.
|
|
334
391
|
const absoluteIdx = msgIndexOffset + msgIdx;
|
|
335
|
-
messageLineRegistry[absoluteIdx] =
|
|
392
|
+
messageLineRegistry[absoluteIdx] = renderContext.history.getLineCount();
|
|
336
393
|
if (message.role === 'user') {
|
|
337
|
-
renderConversationUserMessage(
|
|
394
|
+
renderConversationUserMessage(renderContext, message, width);
|
|
338
395
|
} else if (message.role === 'assistant') {
|
|
339
|
-
renderConversationAssistantMessage(
|
|
396
|
+
renderConversationAssistantMessage(renderContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
|
|
340
397
|
} else if (message.role === 'system') {
|
|
341
|
-
renderConversationSystemMessage(
|
|
398
|
+
renderConversationSystemMessage(renderContext, message, width, absoluteIdx);
|
|
342
399
|
} else if (message.role === 'tool') {
|
|
343
|
-
renderConversationToolMessage(
|
|
400
|
+
renderConversationToolMessage(renderContext, message, width, absoluteIdx);
|
|
344
401
|
}
|
|
345
|
-
|
|
402
|
+
renderContext.history.addLine(createEmptyLine(width));
|
|
346
403
|
}
|
|
347
404
|
}
|
|
348
405
|
|
|
@@ -155,6 +155,20 @@ export interface WireStreamEventMetricsOptions {
|
|
|
155
155
|
* unavailable for either model, the notice honestly states "cost data unavailable".
|
|
156
156
|
*/
|
|
157
157
|
readonly costLookup?: FailoverCostLookup;
|
|
158
|
+
/**
|
|
159
|
+
* Optional accessor for whether an approval card is currently waiting on the USER. When it
|
|
160
|
+
* returns true, the stall watchdog does NOT emit its "Still waiting on <provider>… Ctrl+C to
|
|
161
|
+
* cancel" hint: the stream is legitimately silent because we asked the user a question, so
|
|
162
|
+
* blaming the provider (or framing it as a stall) would be dishonest. The approval surface owns
|
|
163
|
+
* the honest "Waiting for your approval" label instead.
|
|
164
|
+
*/
|
|
165
|
+
readonly isApprovalPending?: (() => boolean) | undefined;
|
|
166
|
+
/**
|
|
167
|
+
* Stall watchdog threshold in ms. Defaults to the watchdog's own default (STALL_THRESHOLD_MS,
|
|
168
|
+
* 30 000). Exposed only so unit tests can drive the stall path without waiting 30s — production
|
|
169
|
+
* callers omit it.
|
|
170
|
+
*/
|
|
171
|
+
readonly stallThresholdMs?: number | undefined;
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/** Result of wireStreamEventMetrics. */
|
|
@@ -237,6 +251,7 @@ export function wireStreamEventMetrics(
|
|
|
237
251
|
const {
|
|
238
252
|
events, metrics, orchestrator, providerRegistry,
|
|
239
253
|
systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
|
|
254
|
+
isApprovalPending, stallThresholdMs,
|
|
240
255
|
} = options;
|
|
241
256
|
|
|
242
257
|
const unsubs: Array<() => void> = [];
|
|
@@ -364,12 +379,16 @@ export function wireStreamEventMetrics(
|
|
|
364
379
|
const stallWatchdog = createStreamStallWatchdog({
|
|
365
380
|
events: events.turns,
|
|
366
381
|
onStall: (providerName, episode) => {
|
|
382
|
+
// Suppress the provider-blaming stall hint while an approval card is waiting on the user: the
|
|
383
|
+
// stream is silent because WE asked the user a question, not because the provider stalled.
|
|
384
|
+
if (isApprovalPending?.()) return;
|
|
367
385
|
metrics.stallEpisode = episode;
|
|
368
386
|
systemMessageRouter.low(`Still waiting on ${providerName}… Ctrl+C to cancel`);
|
|
369
387
|
render();
|
|
370
388
|
},
|
|
371
389
|
getProviderName: () => providerRegistry.getCurrentModel().provider,
|
|
372
|
-
// thresholdMs
|
|
390
|
+
// thresholdMs defaults to 30 000 in the watchdog; only unit tests override it.
|
|
391
|
+
...(stallThresholdMs !== undefined ? { thresholdMs: stallThresholdMs } : {}),
|
|
373
392
|
});
|
|
374
393
|
unsubs.push(() => stallWatchdog.dispose());
|
|
375
394
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-message noise policy — decides whether an operational status message
|
|
3
|
+
* should reach the transcript unchanged, be dropped, or be folded. Every noisy
|
|
4
|
+
* source these rules catch is emitted (from the SDK) through
|
|
5
|
+
* SystemMessageRouter, so this one funnel is where first-run plumbing is kept
|
|
6
|
+
* out of the user's stream while the information stays reachable elsewhere
|
|
7
|
+
* (fleet panel, /health, /model, activity log). (UX-B item 1.)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface NoiseGateDeps {
|
|
11
|
+
/**
|
|
12
|
+
* True when a WRFC chain is terminal (passed/failed) or gone (killed/removed).
|
|
13
|
+
* Used to suppress stale "[Replay] … transitioned … waiting for action"
|
|
14
|
+
* re-notifications for chains that can no longer act. Absent → never suppress.
|
|
15
|
+
*/
|
|
16
|
+
readonly isChainTerminal?: (chainId: string) => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type NoiseVerdict =
|
|
20
|
+
/** Pass through to the transcript unchanged. */
|
|
21
|
+
| { readonly action: 'emit' }
|
|
22
|
+
/** Suppress entirely — reachable via another live surface. */
|
|
23
|
+
| { readonly action: 'drop' }
|
|
24
|
+
/** Provider "from last session" replay line — buffer and fold to one line. */
|
|
25
|
+
| { readonly action: 'foldProviderReplay' };
|
|
26
|
+
|
|
27
|
+
const EMIT: NoiseVerdict = { action: 'emit' };
|
|
28
|
+
const DROP: NoiseVerdict = { action: 'drop' };
|
|
29
|
+
const FOLD: NoiseVerdict = { action: 'foldProviderReplay' };
|
|
30
|
+
|
|
31
|
+
/** Matches the SDK's per-provider persisted-replay line (1b). */
|
|
32
|
+
const PROVIDER_REPLAY_RE = / — from last session$/;
|
|
33
|
+
|
|
34
|
+
/** Matches the SDK's 30s periodic running-agents snapshot (1d). */
|
|
35
|
+
const AGENTS_RUNNING_SNAPSHOT_RE = /^\[Agents\] \d+ running:/;
|
|
36
|
+
|
|
37
|
+
/** Extracts the chain id from a "[Replay]… WRFC chain <id> transitioned …" line (1c). */
|
|
38
|
+
const REPLAY_CHAIN_RE = /WRFC chain (\S+) transitioned .* waiting for action/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Classify one system message. Pure aside from the injected `isChainTerminal`
|
|
42
|
+
* lookup, so it is trivially testable.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyNoise(message: string, deps: NoiseGateDeps): NoiseVerdict {
|
|
45
|
+
// 1b — provider-discovery replay burst ("[Local] … — from last session").
|
|
46
|
+
if (message.startsWith('[Local]') && PROVIDER_REPLAY_RE.test(message)) {
|
|
47
|
+
return FOLD;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 1d — periodic "[Agents] N running:" status snapshot. The same live detail
|
|
51
|
+
// is shown in the fleet panel (per-agent activity) and the footer count, so
|
|
52
|
+
// the 30-second transcript churn is dropped. Meaningful lifecycle lines
|
|
53
|
+
// ("[Agents] ✓ …", "[Agents] Cohort …", "[Agents] ✗ …") do not match.
|
|
54
|
+
if (AGENTS_RUNNING_SNAPSHOT_RE.test(message)) {
|
|
55
|
+
return DROP;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1c — stale "[Replay] … waiting for action" for a terminal/killed chain.
|
|
59
|
+
if (message.startsWith('[Replay]') && deps.isChainTerminal) {
|
|
60
|
+
const match = REPLAY_CHAIN_RE.exec(message);
|
|
61
|
+
if (match && deps.isChainTerminal(match[1])) return DROP;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return EMIT;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Provider name from a "[Local] <name> at host:port (…) — from last session" line. */
|
|
68
|
+
export function providerNameFromReplay(message: string): string {
|
|
69
|
+
const match = /^\[Local\]\s+(.+?)\s+at\s/.exec(message);
|
|
70
|
+
if (match) return match[1];
|
|
71
|
+
// Fallback: first token after the tag.
|
|
72
|
+
const bare = /^\[Local\]\s+(\S+)/.exec(message);
|
|
73
|
+
return bare ? bare[1] : 'provider';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the single folded summary line for a burst of provider-replay lines.
|
|
78
|
+
* Names a few providers; the full list stays reachable via /health and /model.
|
|
79
|
+
*/
|
|
80
|
+
export function foldProviderReplayLines(messages: readonly string[]): string {
|
|
81
|
+
const names = messages.map(providerNameFromReplay);
|
|
82
|
+
const count = names.length;
|
|
83
|
+
const noun = count === 1 ? 'provider' : 'providers';
|
|
84
|
+
const shown = names.slice(0, 3).join(', ');
|
|
85
|
+
const extra = count > 3 ? `, +${count - 3} more` : '';
|
|
86
|
+
return `[Local] Restored ${count} ${noun} from last session (${shown}${extra})`;
|
|
87
|
+
}
|