@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,169 @@
1
+ /**
2
+ * TUI-extension settings — config namespaces the TUI reads that are NOT part
3
+ * of the SDK's ConfigManager schema.
4
+ *
5
+ * The SDK owns settings.json's typed schema (its `ConfigKey` union), but its
6
+ * loader deep-merges user JSON over the defaults and only strips unknown
7
+ * `permissions.tools` keys — every other unknown top-level key survives load
8
+ * and round-trips through `save()`. That lets the TUI keep its own namespaces
9
+ * (e.g. `checkpoints.*`) in the same settings.json file and read them back
10
+ * here via `getRaw()`.
11
+ *
12
+ * Every reader hand-validates each field and returns a PARTIAL object holding
13
+ * only the keys the user actually set to a well-typed value. A missing or
14
+ * malformed block yields an empty object rather than throwing, so a bad edit
15
+ * degrades to "use the built-in defaults", never a crash. Returning a partial
16
+ * (rather than filling in defaults here) keeps the underlying default values
17
+ * owned by their real consumer instead of being duplicated in the TUI.
18
+ */
19
+
20
+ import type { ConfigManager } from './index.ts';
21
+
22
+ type RawRecord = Record<string, unknown>;
23
+
24
+ function readNamespace(configManager: Pick<ConfigManager, 'getRaw'>, namespace: string): RawRecord | null {
25
+ const block = (configManager.getRaw() as RawRecord)[namespace];
26
+ if (!block || typeof block !== 'object' || Array.isArray(block)) return null;
27
+ return block as RawRecord;
28
+ }
29
+
30
+ function readBoolean(src: RawRecord, key: string): boolean | undefined {
31
+ const value = src[key];
32
+ return typeof value === 'boolean' ? value : undefined;
33
+ }
34
+
35
+ function readPositiveInt(src: RawRecord, key: string): number | undefined {
36
+ const value = src[key];
37
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
38
+ return Math.floor(value);
39
+ }
40
+
41
+ function readNonEmptyString(src: RawRecord, key: string): string | undefined {
42
+ const value = src[key];
43
+ if (typeof value !== 'string') return undefined;
44
+ const trimmed = value.trim();
45
+ return trimmed.length > 0 ? trimmed : undefined;
46
+ }
47
+
48
+ // ─── statusline.* — scriptable status line ───────────────────────────────────
49
+
50
+ /**
51
+ * User-settable scriptable status line. When `command` is present it is run as
52
+ * a POSIX shell command at each turn boundary and its first stdout line renders
53
+ * in the status area. `timeoutMs` bounds each run.
54
+ */
55
+ export interface StatuslineSettings {
56
+ /** Shell command whose first stdout line renders in the status area. Absent/empty disables the feature. */
57
+ readonly command?: string;
58
+ /** Per-run timeout in milliseconds. Defaults to 2000; clamped to [100, 15000]. */
59
+ readonly timeoutMs?: number;
60
+ }
61
+
62
+ export const STATUSLINE_DEFAULT_TIMEOUT_MS = 2000;
63
+ const STATUSLINE_MIN_TIMEOUT_MS = 100;
64
+ const STATUSLINE_MAX_TIMEOUT_MS = 15_000;
65
+
66
+ // ─── session.* — session behavior ────────────────────────────────────────────
67
+
68
+ /** User-settable session behavior. */
69
+ export interface SessionSettings {
70
+ /**
71
+ * Auto-title an untitled session using the configured tool/helper (weak/fast)
72
+ * model after the first turn. Off by default because it costs a small model
73
+ * call. Only ever sets a system-sourced title; a user-chosen title is left alone.
74
+ */
75
+ readonly autoTitle?: boolean;
76
+ }
77
+
78
+ /** Read `session.*` from settings.json. */
79
+ export function readSessionSettings(configManager: Pick<ConfigManager, 'getRaw'>): SessionSettings {
80
+ const src = readNamespace(configManager, 'session');
81
+ if (!src) return {};
82
+ const out: { autoTitle?: boolean } = {};
83
+ const autoTitle = readBoolean(src, 'autoTitle');
84
+ if (autoTitle !== undefined) out.autoTitle = autoTitle;
85
+ return out;
86
+ }
87
+
88
+ /** Read `statusline.*` from settings.json, validating and clamping the timeout. */
89
+ export function readStatuslineSettings(configManager: Pick<ConfigManager, 'getRaw'>): StatuslineSettings {
90
+ const src = readNamespace(configManager, 'statusline');
91
+ if (!src) return {};
92
+ const out: { command?: string; timeoutMs?: number } = {};
93
+ const command = readNonEmptyString(src, 'command');
94
+ if (command !== undefined) out.command = command;
95
+ const timeoutMs = readPositiveInt(src, 'timeoutMs');
96
+ if (timeoutMs !== undefined) {
97
+ out.timeoutMs = Math.min(STATUSLINE_MAX_TIMEOUT_MS, Math.max(STATUSLINE_MIN_TIMEOUT_MS, timeoutMs));
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // ─── checkpoints.* — workspace checkpoint root-guard options ─────────────────
103
+
104
+ /**
105
+ * User-settable root-guard options for the SDK's WorkspaceCheckpointManager.
106
+ * All optional: an omitted key defers to the manager's own default. Field
107
+ * names and semantics mirror the SDK's `WorkspaceCheckpointManagerOptions`.
108
+ */
109
+ export interface CheckpointGuardSettings {
110
+ /** Prefer the enclosing git repo's top level over the raw workspace root. SDK default: true. */
111
+ readonly preferGitRoot?: boolean;
112
+ /** Opt in to snapshotting a broad root (filesystem root / home / ~/.goodvibes). SDK default: false. */
113
+ readonly allowBroadRoot?: boolean;
114
+ /** Opt in to a first snapshot whose full sweep exceeds maxFirstSnapshotFiles. SDK default: false. */
115
+ readonly allowLargeFirstSnapshot?: boolean;
116
+ /** Ceiling for the first-ever snapshot's file sweep. SDK default: 50000. */
117
+ readonly maxFirstSnapshotFiles?: number;
118
+ /** Run a retention sweep automatically after each create() and once at init. SDK default: true. */
119
+ readonly autoRetention?: boolean;
120
+ }
121
+
122
+ /**
123
+ * Merge the user's `checkpoints.*` root-guard settings onto a base
124
+ * WorkspaceCheckpointManager options object.
125
+ *
126
+ * NOTE: the pinned platform SDK's WorkspaceCheckpointManager may predate these
127
+ * root-guard options — an older constructor reads only
128
+ * workspaceRoot/checkpointDir/runtimeBus/retention/now and silently ignores the
129
+ * rest, making the guard keys INERT. They become effective the moment the SDK
130
+ * is upgraded to a build whose options type declares them (preferGitRoot,
131
+ * allowBroadRoot, allowLargeFirstSnapshot, maxFirstSnapshotFiles,
132
+ * autoRetention) — no code change needed here. The return type intersects the
133
+ * base with CheckpointGuardSettings so the extra keys flow through structurally
134
+ * without an unsafe cast.
135
+ */
136
+ export function withCheckpointGuardSettings<T extends object>(
137
+ base: T,
138
+ configManager: Pick<ConfigManager, 'getRaw'>,
139
+ ): T & CheckpointGuardSettings {
140
+ return { ...base, ...readCheckpointGuardSettings(configManager) };
141
+ }
142
+
143
+ /**
144
+ * Read `checkpoints.*` from settings.json. Returns only the keys present and
145
+ * well-typed; unknown or malformed values are dropped so the SDK's own
146
+ * defaults apply for them.
147
+ */
148
+ export function readCheckpointGuardSettings(configManager: Pick<ConfigManager, 'getRaw'>): CheckpointGuardSettings {
149
+ const src = readNamespace(configManager, 'checkpoints');
150
+ if (!src) return {};
151
+ const out: {
152
+ preferGitRoot?: boolean;
153
+ allowBroadRoot?: boolean;
154
+ allowLargeFirstSnapshot?: boolean;
155
+ maxFirstSnapshotFiles?: number;
156
+ autoRetention?: boolean;
157
+ } = {};
158
+ const preferGitRoot = readBoolean(src, 'preferGitRoot');
159
+ if (preferGitRoot !== undefined) out.preferGitRoot = preferGitRoot;
160
+ const allowBroadRoot = readBoolean(src, 'allowBroadRoot');
161
+ if (allowBroadRoot !== undefined) out.allowBroadRoot = allowBroadRoot;
162
+ const allowLargeFirstSnapshot = readBoolean(src, 'allowLargeFirstSnapshot');
163
+ if (allowLargeFirstSnapshot !== undefined) out.allowLargeFirstSnapshot = allowLargeFirstSnapshot;
164
+ const maxFirstSnapshotFiles = readPositiveInt(src, 'maxFirstSnapshotFiles');
165
+ if (maxFirstSnapshotFiles !== undefined) out.maxFirstSnapshotFiles = maxFirstSnapshotFiles;
166
+ const autoRetention = readBoolean(src, 'autoRetention');
167
+ if (autoRetention !== undefined) out.autoRetention = autoRetention;
168
+ return out;
169
+ }
@@ -24,11 +24,19 @@ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integration
24
24
  import type { PermissionRequestHandler, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
25
25
  import type { FocusTracker } from './focus-tracker.ts';
26
26
  import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
27
+ import type { TerminalNotifier } from './terminal-notifier.ts';
27
28
 
28
29
  export interface ApprovalAlertDeps {
29
30
  readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
30
31
  readonly configGet: ConfigGet;
31
32
  readonly webhookNotifier?: WebhookNotifier | null;
33
+ /**
34
+ * Optional in-terminal (OSC 9) notifier. When present, a permission prompt
35
+ * appearing also emits an in-terminal notification, gated independently by
36
+ * the notifier's own per-signal config + focus rule (separate from the
37
+ * desktop-alert gate below). Absent in tests/headless.
38
+ */
39
+ readonly terminalNotifier?: TerminalNotifier | null;
32
40
  }
33
41
 
34
42
  /**
@@ -48,11 +56,16 @@ export function wrapRequestPermissionWithAlert(
48
56
  }
49
57
 
50
58
  function fireApprovalAlert(request: PermissionPromptRequest, deps: ApprovalAlertDeps): void {
59
+ // PRIVACY: tool name + category only. Never request.args.
60
+ const message = `${request.tool} (${request.category}) is waiting for approval`;
61
+
62
+ // In-terminal (OSC 9) notification fires on its OWN gating (independent of the
63
+ // desktop-alert gate below), so it is emitted before the early return.
64
+ deps.terminalNotifier?.notify('approval-wait', message);
65
+
51
66
  if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnApprovalPending')) return;
52
67
 
53
68
  const title = 'GoodVibes — approval needed';
54
- // PRIVACY: tool name + category only. Never request.args.
55
- const message = `${request.tool} (${request.category}) is waiting for approval`;
56
69
 
57
70
  try {
58
71
  notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Post-compaction receipt rendering.
3
+ *
4
+ * The SDK emits a mandatory COMPACTION_RECEIPT event after every automatic (and
5
+ * the manual) compaction path, so a compaction is never silent. This module
6
+ * turns that receipt into a distinct, multi-line `[Compaction]` block for the
7
+ * transcript. It is a pure formatter — no state, no I/O — so it is unit-testable
8
+ * and the wiring layer only has to route the returned string.
9
+ *
10
+ * Why a visible receipt: automatic behavior (the orchestrator's post-turn
11
+ * auto-compaction) previously ran without any transcript trace. The receipt
12
+ * makes the audit trail visible: what was compacted, the quality the guard
13
+ * computed, whether the standing-instruction chain was re-injected, and the
14
+ * outcome — applied, kept-original (quality guard rejected it), or failed.
15
+ *
16
+ * The `[Compaction]` prefix is one of the FORCE_CONVERSATION_PREFIXES the
17
+ * system-message router always surfaces inline (see system-message-router.ts),
18
+ * so the receipt cannot be routed away into a panel and vanish.
19
+ */
20
+
21
+ /** The shape of the SDK's COMPACTION_RECEIPT event payload we render from. */
22
+ export interface CompactionReceiptInput {
23
+ readonly trigger: 'auto' | 'manual';
24
+ readonly strategy: string;
25
+ readonly tokensBefore: number;
26
+ readonly tokensAfter: number;
27
+ readonly messagesBefore: number;
28
+ readonly messagesAfter: number;
29
+ readonly qualityScore: number;
30
+ readonly qualityGrade: string;
31
+ readonly lowQuality: boolean;
32
+ readonly instructionsReinjected: boolean;
33
+ readonly validationPassed: boolean;
34
+ /**
35
+ * IDs of the sections the compactor actually carried into the compacted
36
+ * output. Rendered plainly so the receipt says which parts of context
37
+ * survived; an empty list is narrated as "none recorded" rather than hidden.
38
+ */
39
+ readonly sectionsIncluded: readonly string[];
40
+ readonly outcome: 'applied' | 'kept-original' | 'failed';
41
+ readonly detail?: string | undefined;
42
+ /**
43
+ * The strategy the caller REQUESTED (from `behavior.compactionStrategy`),
44
+ * present only when it differs from `strategy` — i.e. a distiller→structured
45
+ * fallback happened. `strategy` always names what actually ran.
46
+ */
47
+ readonly requestedStrategy?: string | undefined;
48
+ /** Why the requested strategy fell back to `strategy`. Present only alongside requestedStrategy. */
49
+ readonly strategyFallbackReason?: string | undefined;
50
+ }
51
+
52
+ function fmtN(n: number): string {
53
+ return Math.max(0, Math.round(n)).toLocaleString();
54
+ }
55
+
56
+ /** Human-readable outcome clause naming exactly what happened. */
57
+ function describeOutcome(outcome: CompactionReceiptInput['outcome']): string {
58
+ switch (outcome) {
59
+ case 'applied':
60
+ return 'applied — compacted context committed';
61
+ case 'kept-original':
62
+ return 'kept original — quality guard rejected the summary, conversation retained';
63
+ case 'failed':
64
+ return 'failed — compaction did not produce a usable result';
65
+ default:
66
+ return outcome;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Build the distinct multi-line receipt block. Lines after the header are
72
+ * indented two spaces so the transcript renders them as one grouped block under
73
+ * the `[Compaction] Receipt` heading.
74
+ */
75
+ export function buildCompactionReceiptBlock(receipt: CompactionReceiptInput): string {
76
+ const triggerWord = receipt.trigger === 'auto' ? 'Automatic' : 'Manual';
77
+ const savings = Math.max(0, receipt.tokensBefore - receipt.tokensAfter);
78
+ const savingsPct = receipt.tokensBefore > 0
79
+ ? Math.round((savings / receipt.tokensBefore) * 100)
80
+ : 0;
81
+
82
+ const lines: string[] = [];
83
+ lines.push(`[Compaction] Receipt — ${triggerWord.toLowerCase()} compaction, outcome: ${describeOutcome(receipt.outcome)}`);
84
+
85
+ // Only report the size delta when the summary was actually applied; on
86
+ // kept-original / failed the "after" counts describe the retained original,
87
+ // so a savings figure would be misleading.
88
+ if (receipt.outcome === 'applied') {
89
+ lines.push(
90
+ ` ${receipt.messagesBefore} → ${receipt.messagesAfter} messages, ` +
91
+ `~${fmtN(receipt.tokensBefore)} → ~${fmtN(receipt.tokensAfter)} tokens ` +
92
+ `(saved ~${fmtN(savings)}, ${savingsPct}%).`,
93
+ );
94
+ } else {
95
+ lines.push(` Context retained at ~${fmtN(receipt.tokensBefore)} tokens across ${receipt.messagesBefore} messages.`);
96
+ }
97
+
98
+ const gradeStr = receipt.qualityGrade ? `${receipt.qualityGrade} ` : '';
99
+ const qualitySuffix = receipt.lowQuality ? ' (below the quality bar)' : '';
100
+ lines.push(` Quality: ${gradeStr}${Math.round(receipt.qualityScore)}/100${qualitySuffix} · strategy: ${receipt.strategy || 'unknown'}.`);
101
+
102
+ // Only present on a distiller→structured fallback — say plainly what was
103
+ // requested, what actually ran, and why, so the fallback is never silent.
104
+ if (receipt.requestedStrategy && receipt.requestedStrategy !== receipt.strategy) {
105
+ const reasonSuffix = receipt.strategyFallbackReason ? `: ${receipt.strategyFallbackReason}` : '';
106
+ lines.push(` Requested strategy "${receipt.requestedStrategy}" fell back to "${receipt.strategy}"${reasonSuffix}.`);
107
+ }
108
+
109
+ const reinjected = receipt.instructionsReinjected ? 'standing instructions re-injected' : 'no standing instructions to re-inject';
110
+ const validated = receipt.validationPassed ? 'validation passed' : 'validation did NOT pass';
111
+ lines.push(` ${reinjected}; ${validated}.`);
112
+
113
+ // Name the sections carried into the compacted output. Only meaningful when a
114
+ // summary was actually applied; on kept-original / failed nothing new was
115
+ // committed, so an empty list there is expected, not a fault.
116
+ const sections = (receipt.sectionsIncluded ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
117
+ if (receipt.outcome === 'applied') {
118
+ const sectionsText = sections.length > 0 ? sections.join(', ') : 'none recorded';
119
+ lines.push(` Sections included: ${sectionsText}.`);
120
+ }
121
+
122
+ if (receipt.detail && receipt.detail.trim().length > 0) {
123
+ lines.push(` ${receipt.detail.trim()}`);
124
+ }
125
+
126
+ return lines.join('\n');
127
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * model-routing-chip — turns a mid-session model change into a visible one-line
3
+ * conversation notice so a route/fallback/downgrade is NEVER silent.
4
+ *
5
+ * Driven by the provider registry's `MODEL_CHANGED` runtime event (fired on
6
+ * every setCurrentModel — user command, automatic capability route, or
7
+ * SDK-internal downgrade). The chip's job is the OTHERWISE-SILENT changes: the
8
+ * SDK-internal auto-route/downgrade paths call setCurrentModel with no TUI-side
9
+ * narration of their own.
10
+ *
11
+ * Division of labour with the failover path: the optimizer-gated failover in
12
+ * stream-event-wiring.ts already renders a richer `[Failover] from → to
13
+ * (reason)(+cost)` line AND records the transition into the optimizer's
14
+ * fallback log. So when a MODEL_CHANGED correlates to a just-recorded fallback
15
+ * transition, this chip stays silent (buildRoutingChip returns null) — the
16
+ * [Failover] line already narrated that exact change with its real reason.
17
+ * Every other change is surfaced here.
18
+ *
19
+ * Reason honesty: for the paths this chip surfaces the routing layer exposes no
20
+ * machine-readable reason (a deliberate user switch has none; an SDK-internal
21
+ * downgrade does not thread one to the TUI), so the notice states "reason
22
+ * unknown" rather than omitting the change — per the honesty rule that a change
23
+ * with no explanation is still reported, just marked unknown.
24
+ */
25
+
26
+ /** The subset of a FallbackTransition this module reads (structural, SDK-agnostic). */
27
+ export interface FallbackTransitionLike {
28
+ readonly from: string;
29
+ readonly to: string;
30
+ readonly reason: string;
31
+ readonly ts: number;
32
+ }
33
+
34
+ /** The subset of a MODEL_CHANGED event this module reads. */
35
+ export interface ModelChangedLike {
36
+ readonly registryKey: string;
37
+ readonly provider: string;
38
+ readonly previous?: { readonly registryKey: string; readonly provider: string } | undefined;
39
+ }
40
+
41
+ /** How long after a recorded fallback transition a MODEL_CHANGED still counts as "that failover". */
42
+ export const FALLBACK_CORRELATION_WINDOW_MS = 4_000;
43
+
44
+ /** The forced conversation prefix so the notice is never gated as noise or routed away. */
45
+ export const ROUTING_CHIP_PREFIX = '[Routing]';
46
+
47
+ /**
48
+ * Most recent fallback-log reason for a from→to provider transition within the
49
+ * correlation window, or null. Scans newest-first so the freshest transition wins.
50
+ */
51
+ export function findRecentFallbackReason(
52
+ fallbackLog: readonly FallbackTransitionLike[],
53
+ fromProvider: string,
54
+ toProvider: string,
55
+ now: number,
56
+ windowMs: number = FALLBACK_CORRELATION_WINDOW_MS,
57
+ ): string | null {
58
+ for (let i = fallbackLog.length - 1; i >= 0; i--) {
59
+ const t = fallbackLog[i]!;
60
+ if (t.from === fromProvider && t.to === toProvider && now - t.ts <= windowMs) return t.reason;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * The routing-chip line to emit for a MODEL_CHANGED event, or null to stay
67
+ * silent. Null only when there is no real change (no/same previous model) or
68
+ * when the change was a failover already narrated by its own [Failover] line
69
+ * (correlated via the fallback log). Otherwise returns the honest
70
+ * `[Routing] model changed: old → new (reason unknown)` notice.
71
+ */
72
+ export function buildRoutingChip(
73
+ change: ModelChangedLike,
74
+ fallbackLog: readonly FallbackTransitionLike[],
75
+ now: number,
76
+ ): string | null {
77
+ const prev = change.previous;
78
+ if (!prev || prev.registryKey === change.registryKey) return null;
79
+ const failoverReason = findRecentFallbackReason(fallbackLog, prev.provider, change.provider, now);
80
+ if (failoverReason !== null) return null; // [Failover] already narrated this transition
81
+ return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (reason unknown)`;
82
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Session permission mode — the small vocabulary and cycle logic shared by the
3
+ * Shift+Tab mode-cycle key, the `/plan` toggle, and the footer mode pill.
4
+ *
5
+ * The SDK owns the permission mode: it lives at config key `permissions.mode`
6
+ * and is read by the SDK's PermissionManager.getMode() (which the orchestrator
7
+ * consults for the standing plan-mode instruction). Setting the config value is
8
+ * the SDK-sanctioned way to change the mode — the SDK's mode-change emitter then
9
+ * broadcasts PERMISSION_MODE_CHANGED on the runtime bus so every attached
10
+ * surface can reflect it. This module only maps and cycles the values; it never
11
+ * owns the state.
12
+ *
13
+ * Two vocabularies exist and must not be confused:
14
+ * - the CONFIG values ('prompt' | 'allow-all' | 'custom' | 'plan' |
15
+ * 'accept-edits') — what is stored and what this module operates on.
16
+ * - the user-facing LABELS ('normal' | 'auto' | 'custom' | 'plan' |
17
+ * 'accept-edits') — what the pill shows.
18
+ */
19
+
20
+ /** The config `permissions.mode` values, exactly as the SDK schema defines them. */
21
+ export type PermissionModeValue = 'prompt' | 'allow-all' | 'custom' | 'plan' | 'accept-edits';
22
+
23
+ /**
24
+ * The Shift+Tab cycle order over the four named SESSION modes. `custom` is a
25
+ * per-rule policy, not a session posture, so it is deliberately excluded from
26
+ * the cycle — cycling FROM custom starts at the first entry (normal).
27
+ *
28
+ * Order (matches the settled convention: escalating autonomy, then wrap):
29
+ * normal → accept-edits → plan → auto → normal
30
+ */
31
+ export const PERMISSION_MODE_CYCLE: readonly PermissionModeValue[] = [
32
+ 'prompt',
33
+ 'accept-edits',
34
+ 'plan',
35
+ 'allow-all',
36
+ ] as const;
37
+
38
+ /** User-facing label for a mode value (what the pill and messages show). */
39
+ export function permissionModeLabel(mode: PermissionModeValue | string | undefined): string {
40
+ switch (mode) {
41
+ case 'prompt': return 'normal';
42
+ case 'allow-all': return 'auto';
43
+ case 'plan': return 'plan';
44
+ case 'accept-edits': return 'accept-edits';
45
+ case 'custom': return 'custom';
46
+ default: return 'normal';
47
+ }
48
+ }
49
+
50
+ /**
51
+ * A coarse tone key for the pill, so the renderer can color modes without
52
+ * importing the value vocabulary. `neutral` for normal, `caution` for the
53
+ * autonomy-raising modes, `info` for plan (read-only, safe).
54
+ */
55
+ export type PermissionModeTone = 'neutral' | 'info' | 'caution';
56
+
57
+ export function permissionModeTone(mode: PermissionModeValue | string | undefined): PermissionModeTone {
58
+ switch (mode) {
59
+ case 'plan': return 'info';
60
+ case 'accept-edits': return 'caution';
61
+ case 'allow-all': return 'caution';
62
+ case 'custom': return 'caution';
63
+ default: return 'neutral';
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Next mode in the Shift+Tab cycle. An unknown or `custom` current value maps
69
+ * to the first cycle entry (normal), so the cycle is always well-defined.
70
+ */
71
+ export function nextPermissionMode(current: PermissionModeValue | string | undefined): PermissionModeValue {
72
+ const idx = PERMISSION_MODE_CYCLE.indexOf(current as PermissionModeValue);
73
+ if (idx < 0) return PERMISSION_MODE_CYCLE[0];
74
+ return PERMISSION_MODE_CYCLE[(idx + 1) % PERMISSION_MODE_CYCLE.length];
75
+ }
76
+
77
+ /** True when the value is plan mode. */
78
+ export function isPlanMode(mode: PermissionModeValue | string | undefined): boolean {
79
+ return mode === 'plan';
80
+ }
81
+
82
+ /**
83
+ * The result of a `/plan` toggle: entering plan mode from any non-plan mode,
84
+ * or leaving it back to normal. Kept here so the command and any future caller
85
+ * agree on the toggle target.
86
+ */
87
+ export function togglePlanMode(current: PermissionModeValue | string | undefined): PermissionModeValue {
88
+ return isPlanMode(current) ? 'prompt' : 'plan';
89
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * rewind-receipt.ts — transcript rendering for an applied unified rewind.
3
+ *
4
+ * The SDK's UnifiedRewindService returns a RewindReceipt from rewind.apply (and
5
+ * emits a matching REWIND_APPLIED workspace event). This module turns that
6
+ * receipt into a distinct, multi-line `[Rewind]` block, mirroring the
7
+ * `[Compaction]` receipt (compaction-receipt.ts) so a rewind is never a silent
8
+ * mutation of files or conversation history — the transcript records exactly
9
+ * what was restored and whether it can be reversed.
10
+ *
11
+ * The `[Rewind]` prefix is one of the FORCE_CONVERSATION_PREFIXES the
12
+ * system-message router always surfaces inline (see core/system-message-router.ts),
13
+ * so the receipt cannot be routed into a panel and vanish.
14
+ */
15
+
16
+ /** The subset of the SDK's RewindReceipt this formatter renders. */
17
+ export interface RewindReceiptInput {
18
+ readonly scope: 'files' | 'conversation' | 'both';
19
+ readonly turnId: string | null;
20
+ readonly files: {
21
+ readonly restored: boolean;
22
+ readonly restoredFileCount: number;
23
+ readonly removedFileCount: number;
24
+ readonly safetyCheckpointId: string | null;
25
+ } | null;
26
+ readonly conversation: {
27
+ readonly rewound: boolean;
28
+ readonly droppedMessages: number;
29
+ readonly undoSnapshotId: string | null;
30
+ } | null;
31
+ readonly undoAvailable: boolean;
32
+ readonly warnings: readonly string[];
33
+ }
34
+
35
+ function plural(n: number, word: string): string {
36
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
37
+ }
38
+
39
+ /** Short, human turn reference for the receipt heading. */
40
+ function describeAnchor(turnId: string | null): string {
41
+ return turnId ? `turn ${turnId.length > 12 ? `${turnId.slice(0, 12)}…` : turnId}` : 'the most recent checkpoint';
42
+ }
43
+
44
+ /**
45
+ * Build the distinct multi-line `[Rewind]` receipt block. Pure — no state, no
46
+ * I/O. Lines after the header are indented two spaces so the transcript renders
47
+ * them as one grouped block under the `[Rewind] Receipt` heading.
48
+ */
49
+ export function buildRewindReceiptBlock(receipt: RewindReceiptInput): string {
50
+ const lines: string[] = [];
51
+ lines.push(`[Rewind] Receipt — rewound ${receipt.scope} to ${describeAnchor(receipt.turnId)}.`);
52
+
53
+ if (receipt.files) {
54
+ if (receipt.files.restored) {
55
+ lines.push(
56
+ ` Files: restored ${plural(receipt.files.restoredFileCount, 'file')}, ` +
57
+ `removed ${plural(receipt.files.removedFileCount, 'file')}.`,
58
+ );
59
+ } else {
60
+ lines.push(' Files: not restored — no workspace checkpoint matched this anchor.');
61
+ }
62
+ }
63
+
64
+ if (receipt.conversation) {
65
+ if (receipt.conversation.rewound) {
66
+ lines.push(` Conversation: dropped ${plural(receipt.conversation.droppedMessages, 'message')} after this turn.`);
67
+ } else {
68
+ lines.push(' Conversation: not rewound — no conversation boundary was recorded for this anchor.');
69
+ }
70
+ }
71
+
72
+ lines.push(
73
+ receipt.undoAvailable
74
+ ? ' Reversible: run /undo rewind to restore the pre-rewind state (/redo rewind re-applies).'
75
+ : ' Reversible: no undo point was recorded for this rewind.',
76
+ );
77
+
78
+ for (const warning of receipt.warnings) {
79
+ lines.push(` note: ${warning}`);
80
+ }
81
+
82
+ return lines.join('\n');
83
+ }