@pellux/goodvibes-tui 1.13.0 → 1.14.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 +101 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +363 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +79 -0
- package/src/runtime/services.ts +38 -37
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- package/src/runtime/render-scheduler.ts +0 -90
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rewind-turn-anchors.ts — the session-scoped join key between the conversation
|
|
3
|
+
* and the workspace-checkpoint store, for message-anchored rewind.
|
|
4
|
+
*
|
|
5
|
+
* The unified rewind service (SDK platform/rewind) anchors a rewind to a
|
|
6
|
+
* `{ sessionId, turnId }`. Files-scope resolves that turnId against the
|
|
7
|
+
* workspace checkpoints the turn engine already stamps with the same turnId.
|
|
8
|
+
* Conversation-scope, however, needs to know HOW MANY conversation messages
|
|
9
|
+
* existed at that turn boundary — a mapping the SDK's checkpoint store does not
|
|
10
|
+
* carry (TUI turn-checkpoints are not stamped with a conversation-message
|
|
11
|
+
* count). This registry supplies exactly that mapping: at every TURN_COMPLETED
|
|
12
|
+
* the TUI records the turnId together with the live `conversation.getMessageCount()`,
|
|
13
|
+
* so a later rewind to that turnId can truncate the conversation to precisely
|
|
14
|
+
* the boundary the files checkpoint captured.
|
|
15
|
+
*
|
|
16
|
+
* Scope + lifetime: in-memory, keyed by sessionId. The registry is the live
|
|
17
|
+
* working copy; it is mirrored to a small per-session sidecar file next to the
|
|
18
|
+
* session's JSONL (`<sessionsDir>/<sessionId>.anchors.json`) at every turn, and
|
|
19
|
+
* `restoreTurnAnchors` reloads that sidecar on resume so message-anchored
|
|
20
|
+
* /rewind works identically before and after a resume. The sidecar's
|
|
21
|
+
* `messageCount` boundaries stay valid because a resume rehydrates the same
|
|
22
|
+
* message history the anchors were recorded against.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
|
|
27
|
+
|
|
28
|
+
const { getUserSessionsDir } = operations;
|
|
29
|
+
|
|
30
|
+
/** One recorded turn boundary — the rewind coordinator's per-turn anchor. */
|
|
31
|
+
export interface TurnAnchor {
|
|
32
|
+
/** The turn engine's turn id, shared with the workspace checkpoint's `turnId`. */
|
|
33
|
+
readonly turnId: string;
|
|
34
|
+
/** A short human label (the truncated user prompt) for the recent-turns picker. */
|
|
35
|
+
readonly label: string;
|
|
36
|
+
/** `conversation.getMessageCount()` captured at this turn's completion — the conversation truncation boundary. */
|
|
37
|
+
readonly messageCount: number;
|
|
38
|
+
/** Wall-clock ms at capture, for ordering + age display. */
|
|
39
|
+
readonly at: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Hard cap so a very long session cannot grow this registry without bound. */
|
|
43
|
+
const MAX_ANCHORS_PER_SESSION = 500;
|
|
44
|
+
|
|
45
|
+
const registry = new Map<string, TurnAnchor[]>();
|
|
46
|
+
|
|
47
|
+
/** Trim a user prompt to a single compact line for the picker. */
|
|
48
|
+
export function summarizeTurnLabel(text: string | null | undefined, max = 72): string {
|
|
49
|
+
const oneLine = (text ?? '').replace(/\s+/g, ' ').trim();
|
|
50
|
+
if (oneLine.length === 0) return '(no prompt text)';
|
|
51
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Record a completed turn's anchor. Idempotent per turnId: a repeated turnId
|
|
56
|
+
* updates the existing entry in place (the checkpoint engine can re-snapshot a
|
|
57
|
+
* turn) rather than duplicating it.
|
|
58
|
+
*/
|
|
59
|
+
export function recordTurnAnchor(sessionId: string, anchor: TurnAnchor): void {
|
|
60
|
+
if (!sessionId || !anchor.turnId) return;
|
|
61
|
+
const list = registry.get(sessionId) ?? [];
|
|
62
|
+
const existingIndex = list.findIndex((a) => a.turnId === anchor.turnId);
|
|
63
|
+
if (existingIndex >= 0) {
|
|
64
|
+
list[existingIndex] = anchor;
|
|
65
|
+
} else {
|
|
66
|
+
list.push(anchor);
|
|
67
|
+
if (list.length > MAX_ANCHORS_PER_SESSION) list.splice(0, list.length - MAX_ANCHORS_PER_SESSION);
|
|
68
|
+
}
|
|
69
|
+
registry.set(sessionId, list);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** All recorded anchors for a session, oldest first (chronological). */
|
|
73
|
+
export function getTurnAnchors(sessionId: string): readonly TurnAnchor[] {
|
|
74
|
+
return registry.get(sessionId) ?? [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve an anchor by exact turnId, or null when this run never recorded it. */
|
|
78
|
+
export function resolveTurnAnchor(sessionId: string, turnId: string): TurnAnchor | null {
|
|
79
|
+
return registry.get(sessionId)?.find((a) => a.turnId === turnId) ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Drop a session's anchors. Exposed for tests and session reset. */
|
|
83
|
+
export function clearTurnAnchors(sessionId: string): void {
|
|
84
|
+
registry.delete(sessionId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- Cross-resume persistence -------------------------------------------------
|
|
88
|
+
//
|
|
89
|
+
// The TUI mirrors the in-memory registry to a sidecar next to the session file.
|
|
90
|
+
// Surface is always 'tui' (the sessions the TUI reads/writes live under the same
|
|
91
|
+
// scoped directory), matching how the session JSONL itself is persisted.
|
|
92
|
+
const ANCHOR_SIDECAR_SURFACE = 'tui';
|
|
93
|
+
const ANCHOR_SIDECAR_VERSION = 1;
|
|
94
|
+
|
|
95
|
+
/** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
|
|
96
|
+
function anchorSidecarPath(sessionId: string, workingDirectory: string): string | null {
|
|
97
|
+
if (!sessionId || !workingDirectory) return null;
|
|
98
|
+
// A sessionId is a filename component; refuse anything with path separators so
|
|
99
|
+
// a malformed id can never escape the sessions directory.
|
|
100
|
+
if (/[\\/]/.test(sessionId)) return null;
|
|
101
|
+
return join(getUserSessionsDir(workingDirectory, ANCHOR_SIDECAR_SURFACE), `${sessionId}.anchors.json`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isTurnAnchor(value: unknown): value is TurnAnchor {
|
|
105
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
106
|
+
const a = value as Record<string, unknown>;
|
|
107
|
+
return (
|
|
108
|
+
typeof a.turnId === 'string' && a.turnId.length > 0 &&
|
|
109
|
+
typeof a.label === 'string' &&
|
|
110
|
+
typeof a.messageCount === 'number' && Number.isFinite(a.messageCount) &&
|
|
111
|
+
typeof a.at === 'number' && Number.isFinite(a.at)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Write the current in-memory anchors for a session to its sidecar so they
|
|
117
|
+
* survive a resume. Best-effort and atomic (temp file + rename): a failed or
|
|
118
|
+
* torn write must never break the turn that triggered it. Called after each
|
|
119
|
+
* `recordTurnAnchor` at TURN_COMPLETED.
|
|
120
|
+
*/
|
|
121
|
+
export function persistTurnAnchors(sessionId: string, workingDirectory: string): void {
|
|
122
|
+
const path = anchorSidecarPath(sessionId, workingDirectory);
|
|
123
|
+
if (!path) return;
|
|
124
|
+
const list = registry.get(sessionId) ?? [];
|
|
125
|
+
if (list.length === 0) return; // nothing to mirror yet
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(getUserSessionsDir(workingDirectory, ANCHOR_SIDECAR_SURFACE), { recursive: true });
|
|
128
|
+
const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
|
|
129
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
130
|
+
writeFileSync(tmp, payload);
|
|
131
|
+
renameSync(tmp, path);
|
|
132
|
+
} catch {
|
|
133
|
+
/* best-effort; a rewind-anchor persist miss must never break the turn */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reload a session's persisted anchors into the in-memory registry on resume.
|
|
139
|
+
* Returns the number of anchors restored (0 when no sidecar exists or it is
|
|
140
|
+
* unreadable). Idempotent via `recordTurnAnchor`'s per-turnId dedup, so a resume
|
|
141
|
+
* that later re-records the same turn keeps a single entry.
|
|
142
|
+
*/
|
|
143
|
+
export function restoreTurnAnchors(sessionId: string, workingDirectory: string): number {
|
|
144
|
+
const path = anchorSidecarPath(sessionId, workingDirectory);
|
|
145
|
+
if (!path || !existsSync(path)) return 0;
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { anchors?: unknown };
|
|
148
|
+
if (!parsed || !Array.isArray(parsed.anchors)) return 0;
|
|
149
|
+
let restored = 0;
|
|
150
|
+
for (const candidate of parsed.anchors) {
|
|
151
|
+
if (isTurnAnchor(candidate)) {
|
|
152
|
+
recordTurnAnchor(sessionId, candidate);
|
|
153
|
+
restored++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return restored;
|
|
157
|
+
} catch {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scriptable status line.
|
|
3
|
+
*
|
|
4
|
+
* When the user sets `statusline.command` in settings.json, that command is run
|
|
5
|
+
* as a POSIX shell command (`/bin/sh -c <command>`) at each turn boundary and
|
|
6
|
+
* its first stdout line is shown in the status area. Every run is bounded by a
|
|
7
|
+
* timeout (`statusline.timeoutMs`, default 2000ms) so a slow or hung command
|
|
8
|
+
* can never stall the UI, and runs never overlap: a refresh requested while one
|
|
9
|
+
* is in flight coalesces into a single trailing run.
|
|
10
|
+
*
|
|
11
|
+
* Output is sanitized to one safe line: first line only, ANSI escape sequences
|
|
12
|
+
* and control characters stripped, trimmed, and length-capped. On failure
|
|
13
|
+
* (non-zero exit, timeout, spawn error) the previous value is cleared so the
|
|
14
|
+
* status area does not show stale text.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ConfigManager } from '../config/index.ts';
|
|
18
|
+
import { readStatuslineSettings, STATUSLINE_DEFAULT_TIMEOUT_MS, type StatuslineSettings } from '../config/tui-extension-settings.ts';
|
|
19
|
+
|
|
20
|
+
/** Runs the shell command and resolves its raw stdout, or throws on failure/timeout. */
|
|
21
|
+
export type StatuslineCommandRunner = (command: string, cwd: string, timeoutMs: number) => Promise<string>;
|
|
22
|
+
|
|
23
|
+
export interface ScriptableStatuslineOptions {
|
|
24
|
+
readonly configManager: Pick<ConfigManager, 'getRaw'>;
|
|
25
|
+
readonly cwd: string;
|
|
26
|
+
/** Turn-boundary event subscription surface. */
|
|
27
|
+
readonly turns: { on(event: string, handler: () => void): () => void };
|
|
28
|
+
/** Called after a refresh actually changes the rendered value, to trigger a repaint. */
|
|
29
|
+
readonly onChange?: () => void;
|
|
30
|
+
/** Injectable command runner (defaults to a Bun.spawn-based POSIX shell runner). Test seam. */
|
|
31
|
+
readonly runner?: StatuslineCommandRunner;
|
|
32
|
+
/** Read settings each refresh so live config edits take effect. Test seam / override. */
|
|
33
|
+
readonly readSettings?: (configManager: Pick<ConfigManager, 'getRaw'>) => StatuslineSettings;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ScriptableStatusline {
|
|
37
|
+
/** The current sanitized status line, or null when disabled or the last run failed. */
|
|
38
|
+
current(): string | null;
|
|
39
|
+
/** Request a refresh now (coalesced against any in-flight run). */
|
|
40
|
+
refresh(): void;
|
|
41
|
+
/** Turn-boundary unsubscribe handles. */
|
|
42
|
+
readonly unsubs: ReadonlyArray<() => void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MAX_OUTPUT_CHARS = 512;
|
|
46
|
+
// Built from escape codes so no literal control byte appears in source.
|
|
47
|
+
// CSI/ANSI escape sequences: ESC (0x1b) '[' params intermediates final-byte.
|
|
48
|
+
const ANSI_ESCAPE = new RegExp('\\x1b\\[[0-9;?]*[ -/]*[@-~]', 'g');
|
|
49
|
+
// C0 control characters (0x00-0x1f) and DEL (0x7f).
|
|
50
|
+
const CONTROL_CHARS = new RegExp('[\\x00-\\x1f\\x7f]', 'g');
|
|
51
|
+
|
|
52
|
+
/** Reduce raw command stdout to a single safe status line. Exported for testing. */
|
|
53
|
+
export function sanitizeStatuslineOutput(raw: string): string | null {
|
|
54
|
+
const firstLine = raw.split('\n', 1)[0] ?? '';
|
|
55
|
+
const cleaned = firstLine
|
|
56
|
+
.replace(ANSI_ESCAPE, '')
|
|
57
|
+
.replace(/\t/g, ' ')
|
|
58
|
+
.replace(CONTROL_CHARS, '')
|
|
59
|
+
.trim();
|
|
60
|
+
if (!cleaned) return null;
|
|
61
|
+
return cleaned.length > MAX_OUTPUT_CHARS ? cleaned.slice(0, MAX_OUTPUT_CHARS) : cleaned;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Default runner: `/bin/sh -c <command>` via Bun.spawn with a hard timeout. */
|
|
65
|
+
const defaultRunner: StatuslineCommandRunner = async (command, cwd, timeoutMs) => {
|
|
66
|
+
const proc = Bun.spawn(['/bin/sh', '-c', command], {
|
|
67
|
+
stdout: 'pipe',
|
|
68
|
+
stderr: 'ignore',
|
|
69
|
+
cwd,
|
|
70
|
+
timeout: timeoutMs,
|
|
71
|
+
killSignal: 'SIGKILL',
|
|
72
|
+
});
|
|
73
|
+
const out = await new Response(proc.stdout).text();
|
|
74
|
+
const exitCode = await proc.exited;
|
|
75
|
+
// A timed-out process is killed by Bun; treat any non-zero exit (including the
|
|
76
|
+
// kill) as failure so stale output is cleared rather than shown.
|
|
77
|
+
if (exitCode !== 0) throw new Error(`statusline command exited with code ${exitCode}`);
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function createScriptableStatusline(options: ScriptableStatuslineOptions): ScriptableStatusline {
|
|
82
|
+
const runner = options.runner ?? defaultRunner;
|
|
83
|
+
const readSettings = options.readSettings ?? readStatuslineSettings;
|
|
84
|
+
let value: string | null = null;
|
|
85
|
+
let inFlight = false;
|
|
86
|
+
let pending = false;
|
|
87
|
+
|
|
88
|
+
const setValue = (next: string | null): void => {
|
|
89
|
+
if (next === value) return;
|
|
90
|
+
value = next;
|
|
91
|
+
options.onChange?.();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const run = async (): Promise<void> => {
|
|
95
|
+
inFlight = true;
|
|
96
|
+
try {
|
|
97
|
+
const settings = readSettings(options.configManager);
|
|
98
|
+
if (!settings.command) {
|
|
99
|
+
setValue(null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const timeoutMs = settings.timeoutMs ?? STATUSLINE_DEFAULT_TIMEOUT_MS;
|
|
103
|
+
try {
|
|
104
|
+
const raw = await runner(settings.command, options.cwd, timeoutMs);
|
|
105
|
+
setValue(sanitizeStatuslineOutput(raw));
|
|
106
|
+
} catch {
|
|
107
|
+
// Non-zero exit, timeout, or spawn error: clear rather than show stale text.
|
|
108
|
+
setValue(null);
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
inFlight = false;
|
|
112
|
+
if (pending) {
|
|
113
|
+
pending = false;
|
|
114
|
+
void run();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const refresh = (): void => {
|
|
120
|
+
if (inFlight) {
|
|
121
|
+
pending = true;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
void run();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const unsubs: Array<() => void> = [
|
|
128
|
+
options.turns.on('TURN_COMPLETED', refresh),
|
|
129
|
+
options.turns.on('TURN_ERROR', refresh),
|
|
130
|
+
options.turns.on('TURN_CANCEL', refresh),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
// Prime once so an enabled statusline shows before the first turn completes.
|
|
134
|
+
refresh();
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
current: () => value,
|
|
138
|
+
refresh,
|
|
139
|
+
unsubs,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session auto-titler.
|
|
3
|
+
*
|
|
4
|
+
* When `session.autoTitle` is enabled, an untitled session is given a short
|
|
5
|
+
* title generated by the configured tool/helper (weak/fast) model after the
|
|
6
|
+
* first turn completes. It is OFF by default because it spends a small model
|
|
7
|
+
* call. It only ever sets a SYSTEM-sourced title, so a user-chosen title is
|
|
8
|
+
* never overwritten, and it runs at most once per session.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
12
|
+
import type { ConfigManager } from '../config/index.ts';
|
|
13
|
+
import { readSessionSettings } from '../config/tui-extension-settings.ts';
|
|
14
|
+
|
|
15
|
+
/** Minimal conversation surface the titler needs. */
|
|
16
|
+
export interface TitlerConversation {
|
|
17
|
+
readonly title: string;
|
|
18
|
+
getTitleSource(): 'system' | 'user';
|
|
19
|
+
setSystemTitle(value: string): void;
|
|
20
|
+
getMessageSnapshot(): ConversationMessageSnapshot[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal weak-model surface (satisfied by the tool LLM). */
|
|
24
|
+
export interface TitlerModel {
|
|
25
|
+
chat(prompt: string, options?: { maxTokens?: number; systemPrompt?: string }): Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SessionAutoTitlerOptions {
|
|
29
|
+
readonly conversation: TitlerConversation;
|
|
30
|
+
readonly model: TitlerModel;
|
|
31
|
+
readonly configManager: Pick<ConfigManager, 'getRaw'>;
|
|
32
|
+
readonly turns: { on(event: string, handler: () => void): () => void };
|
|
33
|
+
/** Called with the generated title after it is applied (for a system message / repaint). */
|
|
34
|
+
readonly onTitled?: (title: string) => void;
|
|
35
|
+
/** Test seams. */
|
|
36
|
+
readonly readSettings?: (configManager: Pick<ConfigManager, 'getRaw'>) => { autoTitle?: boolean };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionAutoTitler {
|
|
40
|
+
readonly unsubs: ReadonlyArray<() => void>;
|
|
41
|
+
/** Attempt titling now (used by the turn handler; exposed for testing). */
|
|
42
|
+
maybeTitle(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MAX_TITLE_CHARS = 60;
|
|
46
|
+
const TITLE_SYSTEM_PROMPT =
|
|
47
|
+
'You write terse chat titles. Reply with ONLY a 3 to 6 word title, Title Case, no surrounding quotes, no trailing punctuation, no preamble.';
|
|
48
|
+
|
|
49
|
+
/** Extract plain text from a user message's content. */
|
|
50
|
+
function userText(snapshot: ConversationMessageSnapshot[]): string | null {
|
|
51
|
+
for (const message of snapshot) {
|
|
52
|
+
if (message.role !== 'user') continue;
|
|
53
|
+
const { content } = message;
|
|
54
|
+
if (typeof content === 'string') {
|
|
55
|
+
const trimmed = content.trim();
|
|
56
|
+
if (trimmed) return trimmed;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const text = content
|
|
60
|
+
.map((part) => (part && typeof part === 'object' && 'text' in part && typeof (part as { text: unknown }).text === 'string' ? (part as { text: string }).text : ''))
|
|
61
|
+
.join(' ')
|
|
62
|
+
.trim();
|
|
63
|
+
if (text) return text;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reduce a model reply to a clean single-line title. */
|
|
69
|
+
export function sanitizeTitle(raw: string): string | null {
|
|
70
|
+
const firstLine = (raw.split('\n', 1)[0] ?? '').trim();
|
|
71
|
+
const unquoted = firstLine.replace(/^["'`]+|["'`]+$/g, '').trim();
|
|
72
|
+
const cleaned = unquoted.replace(/[.!?,;:]+$/g, '').replace(/\s+/g, ' ').trim();
|
|
73
|
+
if (!cleaned) return null;
|
|
74
|
+
return cleaned.length > MAX_TITLE_CHARS ? cleaned.slice(0, MAX_TITLE_CHARS).trim() : cleaned;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createSessionAutoTitler(options: SessionAutoTitlerOptions): SessionAutoTitler {
|
|
78
|
+
const readSettings = options.readSettings ?? readSessionSettings;
|
|
79
|
+
let attempted = false;
|
|
80
|
+
|
|
81
|
+
const maybeTitle = async (): Promise<void> => {
|
|
82
|
+
if (attempted) return;
|
|
83
|
+
if (!readSettings(options.configManager).autoTitle) return;
|
|
84
|
+
// Never overwrite a user-chosen title.
|
|
85
|
+
if (options.conversation.getTitleSource() === 'user') return;
|
|
86
|
+
const first = userText(options.conversation.getMessageSnapshot());
|
|
87
|
+
if (!first) return;
|
|
88
|
+
attempted = true; // Set before the async call so a burst of TURN_COMPLETED fires once.
|
|
89
|
+
try {
|
|
90
|
+
const reply = await options.model.chat(
|
|
91
|
+
`Title this conversation. First user message:\n"""${first.slice(0, 2000)}"""`,
|
|
92
|
+
{ maxTokens: 24, systemPrompt: TITLE_SYSTEM_PROMPT },
|
|
93
|
+
);
|
|
94
|
+
const title = sanitizeTitle(reply);
|
|
95
|
+
if (!title) return;
|
|
96
|
+
// Re-check: a user may have set a title during the await.
|
|
97
|
+
if (options.conversation.getTitleSource() === 'user') return;
|
|
98
|
+
options.conversation.setSystemTitle(title);
|
|
99
|
+
options.onTitled?.(title);
|
|
100
|
+
} catch {
|
|
101
|
+
// Tool LLM unavailable or failed: leave the session untitled, silently.
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const unsubs: Array<() => void> = [
|
|
106
|
+
options.turns.on('TURN_COMPLETED', () => { void maybeTitle(); }),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
return { unsubs, maybeTitle };
|
|
110
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { UiRuntimeEvents } from '@/runtime/index.ts';
|
|
2
2
|
import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
|
|
3
|
+
import { buildRoutingChip } from './model-routing-chip.ts';
|
|
3
4
|
import { formatUserFacingErrorLine } from './format-user-error.ts';
|
|
4
5
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
5
6
|
|
|
@@ -75,6 +76,8 @@ interface FailoverOptimizer {
|
|
|
75
76
|
readonly enabled: boolean;
|
|
76
77
|
testFallback(profile?: Record<string, unknown>): { readonly chain: readonly FailoverChainNode[] };
|
|
77
78
|
recordFallbackTransition(from: string, to: string, reason: string): void;
|
|
79
|
+
/** Recent fallback transitions — read by the routing chip to skip double-narrating a failover. */
|
|
80
|
+
readonly fallbackLog: readonly { readonly from: string; readonly to: string; readonly reason: string; readonly ts: number }[];
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** Minimal system-message surface required for user-visible notifications. */
|
|
@@ -300,6 +303,23 @@ export function wireStreamEventMetrics(
|
|
|
300
303
|
failoverVisited.clear();
|
|
301
304
|
}));
|
|
302
305
|
|
|
306
|
+
// Routing chip (never-silent model change): every MODEL_CHANGED that is not
|
|
307
|
+
// already narrated by the richer [Failover] line becomes a one-line
|
|
308
|
+
// conversation notice. Deferred a microtask so a failover's
|
|
309
|
+
// recordFallbackTransition (which runs right AFTER its setCurrentModel) has
|
|
310
|
+
// landed in the fallback log before buildRoutingChip checks for correlation.
|
|
311
|
+
// Degrade gracefully when a bare test double omits the providers feed.
|
|
312
|
+
if (events.providers) {
|
|
313
|
+
unsubs.push(events.providers.on('MODEL_CHANGED', (change) => {
|
|
314
|
+
queueMicrotask(() => {
|
|
315
|
+
const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
|
|
316
|
+
if (chip === null) return;
|
|
317
|
+
systemMessageRouter.high(chip);
|
|
318
|
+
render();
|
|
319
|
+
});
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
|
|
303
323
|
unsubs.push(events.turns.on('TURN_ERROR', (event) => {
|
|
304
324
|
const errVal: string = event.error;
|
|
305
325
|
|
|
@@ -76,7 +76,7 @@ export type SystemMessagePriority = 'high' | 'low';
|
|
|
76
76
|
* high-priority message inline (model/provider/session confirmations still
|
|
77
77
|
* honour the configured target), so the routing-target config stays meaningful.
|
|
78
78
|
*/
|
|
79
|
-
const FORCE_CONVERSATION_PREFIXES = ['[Error]', '[Failover]', '[Compaction]', '[Context]'] as const;
|
|
79
|
+
const FORCE_CONVERSATION_PREFIXES = ['[Error]', '[Failover]', '[Routing]', '[Compaction]', '[Context]', '[Rewind]', '[Revert]'] as const;
|
|
80
80
|
|
|
81
81
|
function mustReachConversation(message: string): boolean {
|
|
82
82
|
return FORCE_CONVERSATION_PREFIXES.some((prefix) => message.startsWith(prefix));
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal-notifier — emits in-terminal desktop notifications via the OSC 9
|
|
3
|
+
* escape sequence (`ESC ] 9 ; <text> BEL`) and, optionally, an audible bell.
|
|
4
|
+
*
|
|
5
|
+
* This is distinct from the OS-level desktop notifications the alert classes in
|
|
6
|
+
* this directory fire through the SDK's notifyCompletion: OSC 9 is delivered by
|
|
7
|
+
* the terminal emulator itself, so it works over SSH and inside tmux where a
|
|
8
|
+
* host-side notify daemon is not reachable. The two are complementary — a user
|
|
9
|
+
* can enable either, both, or neither.
|
|
10
|
+
*
|
|
11
|
+
* Three signals, each with its own config toggle (documented in
|
|
12
|
+
* settings-modal-data.ts): a tool call waiting on approval (default on), a turn
|
|
13
|
+
* finishing (default off), and a delegated agent blocking on human input
|
|
14
|
+
* (default off). A separate `behavior.terminalBell` toggle (default off) adds an
|
|
15
|
+
* audible BEL to whichever of those fire.
|
|
16
|
+
*
|
|
17
|
+
* Focus rule (per the feature brief): emit only when the terminal is NOT focused
|
|
18
|
+
* if focus is knowable, and unconditionally when the terminal never reported
|
|
19
|
+
* focus — exactly FocusTracker.shouldAlertWhenUnfocused(). This is deliberately
|
|
20
|
+
* NOT tied to the alert classes' master `notifyOnlyWhenUnfocused` gate: an
|
|
21
|
+
* in-terminal notification only makes sense for an unattended window.
|
|
22
|
+
*
|
|
23
|
+
* Write discipline: the escape sequence goes out through allowTerminalWrite (so
|
|
24
|
+
* the terminal-output guard passes it rather than intercepting it as stray
|
|
25
|
+
* output) and NEVER after the terminal has been handed back to the shell
|
|
26
|
+
* (isReleased() — the same restore gate the render scheduler honors). No cursor
|
|
27
|
+
* positioning is involved, so an OSC 9 emitted between frames is safe.
|
|
28
|
+
*/
|
|
29
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
30
|
+
import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
|
|
31
|
+
import { readBooleanConfig, type ConfigGet } from './alert-gating.ts';
|
|
32
|
+
import type { FocusTracker } from './focus-tracker.ts';
|
|
33
|
+
|
|
34
|
+
export type TerminalNotifySignal = 'approval-wait' | 'turn-end' | 'agent-blocked';
|
|
35
|
+
|
|
36
|
+
interface SignalSpec {
|
|
37
|
+
readonly configKey: string;
|
|
38
|
+
readonly defaultOn: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Per-signal config keys + defaults. Kept in sync with settings-modal-data.ts. */
|
|
42
|
+
export const TERMINAL_NOTIFY_SIGNALS: Record<TerminalNotifySignal, SignalSpec> = {
|
|
43
|
+
'approval-wait': { configKey: 'behavior.terminalNotifyApprovalWait', defaultOn: true },
|
|
44
|
+
'turn-end': { configKey: 'behavior.terminalNotifyTurnEnd', defaultOn: false },
|
|
45
|
+
'agent-blocked': { configKey: 'behavior.terminalNotifyAgentBlocked', defaultOn: false },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The audible-bell toggle key. Default off. */
|
|
49
|
+
export const TERMINAL_BELL_KEY = 'behavior.terminalBell';
|
|
50
|
+
|
|
51
|
+
const BEL = '\x07';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build an OSC 9 desktop-notification sequence. Control characters in the
|
|
55
|
+
* message are collapsed to spaces so they cannot terminate the sequence early
|
|
56
|
+
* or corrupt the terminal, and the text is length-capped defensively.
|
|
57
|
+
*/
|
|
58
|
+
export function buildOsc9(message: string): string {
|
|
59
|
+
const safe = message.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 200);
|
|
60
|
+
return `\x1b]9;${safe}${BEL}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TerminalNotifierDeps {
|
|
64
|
+
readonly stdout: { write(s: string): boolean };
|
|
65
|
+
readonly configGet: ConfigGet;
|
|
66
|
+
readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
|
|
67
|
+
/** True once the terminal has been restored to the shell — no writes after that. */
|
|
68
|
+
readonly isReleased: () => boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TerminalNotifier {
|
|
72
|
+
/** Emit an OSC 9 notification (and optional bell) for `signal`, subject to its toggle + the focus rule. */
|
|
73
|
+
notify(signal: TerminalNotifySignal, message: string): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when `signal` should emit right now (toggle on AND the focus rule allows it). Exposed for tests. */
|
|
77
|
+
export function shouldEmitTerminalNotification(
|
|
78
|
+
signal: TerminalNotifySignal,
|
|
79
|
+
configGet: ConfigGet,
|
|
80
|
+
focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>,
|
|
81
|
+
): boolean {
|
|
82
|
+
const spec = TERMINAL_NOTIFY_SIGNALS[signal];
|
|
83
|
+
if (!readBooleanConfig(configGet, spec.configKey, spec.defaultOn)) return false;
|
|
84
|
+
return focusTracker.shouldAlertWhenUnfocused();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createTerminalNotifier(deps: TerminalNotifierDeps): TerminalNotifier {
|
|
88
|
+
return {
|
|
89
|
+
notify(signal, message) {
|
|
90
|
+
if (!shouldEmitTerminalNotification(signal, deps.configGet, deps.focusTracker)) return;
|
|
91
|
+
// Never write escape sequences once the terminal is back on the shell.
|
|
92
|
+
if (deps.isReleased()) return;
|
|
93
|
+
const bell = readBooleanConfig(deps.configGet, TERMINAL_BELL_KEY, false);
|
|
94
|
+
const seq = buildOsc9(message) + (bell ? BEL : '');
|
|
95
|
+
try {
|
|
96
|
+
allowTerminalWrite(() => deps.stdout.write(seq));
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.debug('terminal-notifier: OSC9 write error', { error: String(err) });
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|