@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.
Files changed (157) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +38 -37
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. 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
+ }