@pellux/goodvibes-tui 1.7.0 → 1.9.1
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 +11 -0
- 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/config/credential-availability.ts +122 -0
- package/src/config/index.ts +15 -0
- 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 +46 -24
- 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 +55 -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.1",
|
|
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 };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential-availability.ts — W6-C1 client-side credential-status read.
|
|
3
|
+
*
|
|
4
|
+
* When the TUI acts as a CLIENT of an adopted external daemon (the host-service
|
|
5
|
+
* `mode === 'external'` topology — see runtime/bootstrap.ts), provider/model/secret
|
|
6
|
+
* STATUS is read from that daemon's `credentials.get` wire method rather than from
|
|
7
|
+
* the TUI's own surfaceRoot ('tui') store. This module folds a `credentials.get`
|
|
8
|
+
* outcome into an honest availability value, mirroring the goodvibes-webui v1.0.1
|
|
9
|
+
* `deriveCredentialAvailability` contract exactly:
|
|
10
|
+
*
|
|
11
|
+
* - a 503 CREDENTIAL_STORE_UNAVAILABLE (matched by machine code), a METHOD_NOT_FOUND
|
|
12
|
+
* from an older daemon, or ANY transport failure -> { available: false, reason }
|
|
13
|
+
* — an honest, reason-carrying "unavailable" state.
|
|
14
|
+
* - NEVER a fabricated "configured"; NEVER a secret byte. Only the boolean status
|
|
15
|
+
* metadata surface (key / configured / usable / source / secure) is carried.
|
|
16
|
+
*
|
|
17
|
+
* STATUS ONLY moves to the daemon path. Secret RESOLUTION — the value-reads provider
|
|
18
|
+
* auth needs, plus the env-only API-key posture — stays local and is untouched by
|
|
19
|
+
* this module. The daemon's `credentials.get` never returns raw key bytes over the
|
|
20
|
+
* wire (see the SDK decision record 2026-07-06-config-sharing-shared-tier-and-secret-read),
|
|
21
|
+
* so no plaintext can reach a caller through this path by construction.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One credential's status metadata from the daemon's shared store — never bytes. */
|
|
25
|
+
export interface CredentialStatusEntry {
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly configured: boolean;
|
|
28
|
+
readonly usable: boolean;
|
|
29
|
+
readonly source?: string;
|
|
30
|
+
readonly secure?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Honest availability: either a status list, or a reason we could not read it. */
|
|
34
|
+
export type CredentialAvailability =
|
|
35
|
+
| { readonly available: true; readonly credentials: readonly CredentialStatusEntry[] }
|
|
36
|
+
| { readonly available: false; readonly reason: string };
|
|
37
|
+
|
|
38
|
+
/** A `credentials.get` invocation outcome — success value OR a thrown error. */
|
|
39
|
+
export type CredentialStatusOutcome =
|
|
40
|
+
| { readonly ok: true; readonly value: unknown }
|
|
41
|
+
| { readonly ok: false; readonly error: unknown };
|
|
42
|
+
|
|
43
|
+
/** Credential-read posture: read the daemon's shared store, or the local own store. */
|
|
44
|
+
export type CredentialReadMode = 'host' | 'client';
|
|
45
|
+
|
|
46
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
47
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readString(record: Record<string, unknown> | null, key: string): string {
|
|
51
|
+
if (!record) return '';
|
|
52
|
+
const value = record[key];
|
|
53
|
+
return typeof value === 'string' ? value : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map the authoritative host-service mode string to the credential-read posture.
|
|
58
|
+
* Only 'external' (a separately-running daemon this TUI adopted) reads credential
|
|
59
|
+
* STATUS over the wire; every other mode ('embedded'/'disabled'/'blocked'/
|
|
60
|
+
* 'incompatible'/'unavailable') is the local host reading its own store.
|
|
61
|
+
*/
|
|
62
|
+
export function credentialReadModeFromHostMode(hostMode: string): CredentialReadMode {
|
|
63
|
+
return hostMode === 'external' ? 'client' : 'host';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fold a `credentials.get` outcome into an honest availability value. Mirrors
|
|
68
|
+
* goodvibes-webui v1.0.1 `deriveCredentialAvailability`: any failure becomes a
|
|
69
|
+
* reason-carrying `available: false`; a malformed success (no credentials array)
|
|
70
|
+
* is treated as unavailable rather than fabricated as configured; on success only
|
|
71
|
+
* the boolean status surface is carried, never a secret value.
|
|
72
|
+
*/
|
|
73
|
+
export function deriveCredentialAvailability(outcome: CredentialStatusOutcome): CredentialAvailability {
|
|
74
|
+
if (!outcome.ok) {
|
|
75
|
+
const err = asRecord(outcome.error);
|
|
76
|
+
const code = readString(err, 'code');
|
|
77
|
+
if (code === 'CREDENTIAL_STORE_UNAVAILABLE') {
|
|
78
|
+
return { available: false, reason: 'The daemon has no shared credential store wired.' };
|
|
79
|
+
}
|
|
80
|
+
if (code === 'METHOD_NOT_FOUND' || code === 'NOT_INVOKABLE') {
|
|
81
|
+
return { available: false, reason: 'This daemon does not serve credential status yet.' };
|
|
82
|
+
}
|
|
83
|
+
return { available: false, reason: 'Credential status unavailable right now.' };
|
|
84
|
+
}
|
|
85
|
+
const value = asRecord(outcome.value);
|
|
86
|
+
const raw = value?.credentials;
|
|
87
|
+
if (!Array.isArray(raw)) return { available: false, reason: 'Credential status unavailable right now.' };
|
|
88
|
+
const credentials: CredentialStatusEntry[] = [];
|
|
89
|
+
for (const item of raw) {
|
|
90
|
+
const rec = asRecord(item);
|
|
91
|
+
const key = readString(rec, 'key');
|
|
92
|
+
if (!rec || !key) continue;
|
|
93
|
+
credentials.push({
|
|
94
|
+
key,
|
|
95
|
+
configured: rec.configured === true,
|
|
96
|
+
usable: rec.usable === true,
|
|
97
|
+
source: readString(rec, 'source') || undefined,
|
|
98
|
+
secure: rec.secure === true ? true : rec.secure === false ? false : undefined,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { available: true, credentials };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Client-side credential-status read. Invokes the adopted external daemon's
|
|
106
|
+
* `credentials.get` and folds success OR any thrown transport/daemon error into an
|
|
107
|
+
* honest availability. The daemon's `credentials.get` transport error carries the
|
|
108
|
+
* machine `code` at the top level (e.g. an HttpStatusError whose body supplied
|
|
109
|
+
* `CREDENTIAL_STORE_UNAVAILABLE` / `METHOD_NOT_FOUND`), which `deriveCredentialAvailability`
|
|
110
|
+
* classifies. Only the boolean status surface is returned; a raw secret value can
|
|
111
|
+
* never reach a caller through this path.
|
|
112
|
+
*/
|
|
113
|
+
export async function readClientCredentialStatus(
|
|
114
|
+
invokeCredentialsGet: () => Promise<unknown>,
|
|
115
|
+
): Promise<CredentialAvailability> {
|
|
116
|
+
try {
|
|
117
|
+
const value = await invokeCredentialsGet();
|
|
118
|
+
return deriveCredentialAvailability({ ok: true, value });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return deriveCredentialAvailability({ ok: false, error });
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -55,3 +55,18 @@ export function getConfiguredSystemPrompt(configManager: Pick<ConfigManager, 'ge
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export { getConfiguredApiKeys, resolveApiKeys } from '@pellux/goodvibes-sdk/platform/config';
|
|
58
|
+
|
|
59
|
+
// W6-C1: client-side credential-status read (honest-degrade, status-only). When the
|
|
60
|
+
// TUI acts as a client of an adopted external daemon it reads provider/model/secret
|
|
61
|
+
// STATUS from that daemon's `credentials.get`, never from its own surfaceRoot store.
|
|
62
|
+
export {
|
|
63
|
+
credentialReadModeFromHostMode,
|
|
64
|
+
deriveCredentialAvailability,
|
|
65
|
+
readClientCredentialStatus,
|
|
66
|
+
} from './credential-availability.ts';
|
|
67
|
+
export type {
|
|
68
|
+
CredentialAvailability,
|
|
69
|
+
CredentialReadMode,
|
|
70
|
+
CredentialStatusEntry,
|
|
71
|
+
CredentialStatusOutcome,
|
|
72
|
+
} from './credential-availability.ts';
|
|
@@ -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
|
|