@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
@@ -25,6 +25,9 @@ import {
25
25
  } from './settings-modal-secrets.ts';
26
26
  import type { FeatureFlagManager } from '@/runtime/index.ts';
27
27
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
28
+ import { isWorktreeSetupListConfigKey, parseWorktreeSetupListInput } from './worktree-setup-config.ts';
29
+ import { isSandboxExecListConfigKey, parseSandboxExecListInput } from './sandbox-exec-config.ts';
30
+ import { isExecEnvScrubAllowlistConfigKey, parseExecEnvScrubAllowlistInput } from './exec-env-scrub-config.ts';
28
31
 
29
32
  import {
30
33
  SETTINGS_CATEGORIES,
@@ -48,6 +51,7 @@ import {
48
51
  import { getSettingLabel } from '../renderer/settings-modal-helpers.ts';
49
52
  import {
50
53
  applySettingValue,
54
+ applyFlagState,
51
55
  type SettingAppliedCallback,
52
56
  } from './settings-modal-mutations.ts';
53
57
  import {
@@ -56,7 +60,6 @@ import {
56
60
  } from './settings-modal-activation.ts';
57
61
  import {
58
62
  adjustSelected as _adjustSelected,
59
- toggleSelectedFlag as _toggleSelectedFlag,
60
63
  } from './settings-modal-adjustment.ts';
61
64
  import {
62
65
  resetSelected as _resetSelected,
@@ -203,7 +206,7 @@ export class SettingsModal {
203
206
  this.serviceRegistry = serviceRegistry;
204
207
  this.mcpRegistry = mcpRegistry ?? null;
205
208
  this.onSettingApplied = options?.onSettingApplied ?? null;
206
- this.groups = buildSettingGroups(configManager);
209
+ this.groups = buildSettingGroups(configManager, featureFlagManager);
207
210
  this.flagEntries = buildFlagEntries(featureFlagManager);
208
211
  this.mcpEntries = buildMcpEntries(this.mcpRegistry);
209
212
  this.subscriptionEntries = buildSubscriptionEntries(subscriptionManager, serviceRegistry);
@@ -378,11 +381,14 @@ export class SettingsModal {
378
381
  return items[Math.max(0, Math.min(items.length - 1, this.selectedIndex))] ?? null;
379
382
  }
380
383
 
381
- /** Get the currently selected flag entry (flags tab only). */
384
+ /**
385
+ * Get the flag entry for the currently selected feature-unit header row, or
386
+ * null when the selected row is a plain config setting. Feature-unit headers
387
+ * now live across topical categories (not a single flags tab), so this reads
388
+ * the selected SettingEntry's attached flag rather than a separate list.
389
+ */
382
390
  getSelectedFlag(): FlagEntry | null {
383
- if (this.currentCategory !== 'flags') return null;
384
- if (this.flagEntries.length === 0) return null;
385
- return this.flagEntries[Math.max(0, Math.min(this.flagEntries.length - 1, this.selectedIndex))] ?? null;
391
+ return this.getSelected()?.flag ?? null;
386
392
  }
387
393
 
388
394
  getSelectedMcp(): McpEntry | null {
@@ -493,11 +499,32 @@ export class SettingsModal {
493
499
  * only (require restart). runtimeToggleable flags toggle immediately.
494
500
  */
495
501
  toggleSelectedFlag(): void {
496
- _toggleSelectedFlag({
497
- featureFlagManager: this.featureFlagManager,
498
- configManager: this.configManager,
499
- getSelectedFlag: () => this.getSelectedFlag(),
500
- });
502
+ const entry = this.getSelected();
503
+ if (!entry?.flag) return;
504
+ if (entry.flag.state === 'killed') return;
505
+ this._toggleFlagValue(entry.setting.key as ConfigKey, entry.flag.state !== 'enabled');
506
+ }
507
+
508
+ /**
509
+ * Toggle the feature flag behind a `featureFlags.<id>` header row. Finds the
510
+ * header entry, applies the desired state through the flag manager (runtime +
511
+ * persisted override), and refreshes the header row's value/default marker in
512
+ * place. Killed flags and no-op toggles are ignored.
513
+ */
514
+ private _toggleFlagValue(key: ConfigKey, value: unknown): void {
515
+ if (!this.featureFlagManager || !this.configManager) return;
516
+ let target: SettingEntry | null = null;
517
+ for (const entries of this.groups.values()) {
518
+ const candidate = entries.find((e) => e.setting.key === key && e.flag);
519
+ if (candidate) { target = candidate; break; }
520
+ }
521
+ if (!target?.flag) return;
522
+ if (target.flag.state === 'killed') return;
523
+ const desired = value ? 'enabled' : 'disabled';
524
+ if (target.flag.state === desired) return;
525
+ applyFlagState(target.flag, desired, this.featureFlagManager, this.configManager);
526
+ target.currentValue = target.flag.state === 'enabled';
527
+ target.isDefault = target.flag.state === target.flag.flag.defaultState;
501
528
  }
502
529
 
503
530
  /**
@@ -565,7 +592,20 @@ export class SettingsModal {
565
592
  return false;
566
593
  }
567
594
 
568
- if (setting.type === 'string' && isSecretConfigKey(setting.key)) {
595
+ if (isWorktreeSetupListConfigKey(setting.key)) {
596
+ // Comma-separated display/edit convention for the array-backed
597
+ // worktree.setup.* keys — see worktree-setup-config.ts.
598
+ this._setValue(setting.key, parseWorktreeSetupListInput(this.editBuffer));
599
+ } else if (isSandboxExecListConfigKey(setting.key)) {
600
+ // Same comma-separated convention for the array-backed
601
+ // sandbox.egressAllowlist / sandbox.workspaceWritable keys — see
602
+ // sandbox-exec-config.ts.
603
+ this._setValue(setting.key, parseSandboxExecListInput(this.editBuffer));
604
+ } else if (isExecEnvScrubAllowlistConfigKey(setting.key)) {
605
+ // Same comma-separated convention for permissions.execEnvScrubAllowlist
606
+ // — see exec-env-scrub-config.ts.
607
+ this._setValue(setting.key, parseExecEnvScrubAllowlistInput(this.editBuffer));
608
+ } else if (setting.type === 'string' && isSecretConfigKey(setting.key)) {
569
609
  setSecretBackedSettingValue({
570
610
  key: setting.key,
571
611
  value: String(parsed ?? ''),
@@ -656,11 +696,14 @@ export class SettingsModal {
656
696
  }
657
697
  }
658
698
 
659
- /** Returns [] for the flags/mcp/subscriptions categories. */
699
+ /**
700
+ * Returns [] for the mcp/subscriptions categories (which render their own
701
+ * entry types). The 'flags' category now flows through the normal group path
702
+ * — it holds the no-config feature-unit toggles (Advanced Features).
703
+ */
660
704
  private _currentItems(): SettingEntry[] {
661
705
  if (
662
- this.currentCategory === 'flags'
663
- || this.currentCategory === 'mcp'
706
+ this.currentCategory === 'mcp'
664
707
  || this.currentCategory === 'subscriptions'
665
708
  ) return [];
666
709
  const items = this.groups.get(this.currentCategory) ?? [];
@@ -673,6 +716,15 @@ export class SettingsModal {
673
716
  private _setValue(key: ConfigKey, value: unknown): void {
674
717
  if (!this.configManager) return;
675
718
 
719
+ // Feature-unit toggle headers write to `featureFlags.<id>`; route those to
720
+ // the feature-flag manager (runtime toggle + persisted override) instead of
721
+ // a plain config write. Reset/adjust/activate all funnel a boolean value
722
+ // here (true = enabled), so this is the single flag-toggle chokepoint.
723
+ if (typeof key === 'string' && key.startsWith('featureFlags.')) {
724
+ this._toggleFlagValue(key, value);
725
+ return;
726
+ }
727
+
676
728
  const callback: SettingAppliedCallback | null = this.onSettingApplied
677
729
  ? (change) => this.onSettingApplied!(change)
678
730
  : null;
@@ -7,7 +7,10 @@ export interface SubmissionRouterInput {
7
7
  readonly hasAttachments?: boolean;
8
8
  }
9
9
 
10
- const PLAN_COMMANDS = new Set(['plan']);
10
+ // The project-planning command (renamed from /plan to /project-plan when /plan
11
+ // became the plan-mode toggle) takes a free-form goal, so it keeps the distinct
12
+ // 'plan' composer intent/label. The /plan mode-toggle is a plain slash command.
13
+ const PLAN_COMMANDS = new Set(['project-plan', 'planning']);
11
14
  const REVIEW_COMMANDS = new Set(['review']);
12
15
  const PANEL_COMMANDS = new Set(['panel']);
13
16
  const ORCHESTRATION_COMMANDS = new Set([
@@ -0,0 +1,93 @@
1
+ /**
2
+ * worktree-setup-config.ts — TUI-local synthetic settings entries for
3
+ * worktree.setup.commands / worktree.setup.carryOverGlobs.
4
+ *
5
+ * Neither key is in the SDK's ConfigKey union: the SDK's cold-start worktree
6
+ * setup (platform/runtime/worktree/setup.ts, resolveWorktreeSetupConfig)
7
+ * reads them off a generic `get(key: string)` rather than the typed
8
+ * CONFIG_SCHEMA, so without this file they are invisible to /config and the
9
+ * settings modal even though they fully control what runs when a fresh
10
+ * isolated worktree is provisioned (setup commands, then untracked-file
11
+ * carry-over). Same rationale as the other synthetic settings in
12
+ * settings-modal-data.ts (tts.speed, behavior.notifyAfterSeconds, etc).
13
+ *
14
+ * Both keys hold a JSON array of strings on disk. The settings modal's
15
+ * inline editor is a single-line text field, so — matching the SDK's own
16
+ * convention for controlPlane.cors.allowedOrigins — they are displayed and
17
+ * edited as a comma-separated list and parsed back into an array on commit
18
+ * (see isWorktreeSetupListConfigKey's use in settings-modal.ts#commitEdit).
19
+ */
20
+ import type { ConfigKey, ConfigManager, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
21
+ import type { SettingEntry } from './settings-modal-types.ts';
22
+
23
+ export const WORKTREE_SETUP_COMMANDS_CONFIG_KEY = 'worktree.setup.commands' as ConfigKey;
24
+ export const WORKTREE_SETUP_CARRY_OVER_GLOBS_CONFIG_KEY = 'worktree.setup.carryOverGlobs' as ConfigKey;
25
+
26
+ const WORKTREE_SETUP_LIST_CONFIG_KEYS: ReadonlySet<ConfigKey> = new Set([
27
+ WORKTREE_SETUP_COMMANDS_CONFIG_KEY,
28
+ WORKTREE_SETUP_CARRY_OVER_GLOBS_CONFIG_KEY,
29
+ ]);
30
+
31
+ /** True for the two worktree-setup keys, whose stored value is a string array edited as a comma-separated list. */
32
+ export function isWorktreeSetupListConfigKey(key: ConfigKey): boolean {
33
+ return WORKTREE_SETUP_LIST_CONFIG_KEYS.has(key);
34
+ }
35
+
36
+ /**
37
+ * Read a config value as a string array — mirrors the SDK's own tolerant
38
+ * readStringArray (non-array/malformed degrades to empty).
39
+ *
40
+ * Defensive try/catch (now belt-and-suspenders): the SDK registers the
41
+ * `worktree` section in DEFAULT_CONFIG (setup.commands / setup.carryOverGlobs,
42
+ * both empty lists) as of this repack, so ConfigManager.get no longer throws
43
+ * for these keys on a fresh store. The guard is retained so an older SDK — or
44
+ * a config store whose section was pruned — degrades to an empty list here
45
+ * (this read runs on every settings-modal build) rather than crashing /config
46
+ * or /settings.
47
+ */
48
+ export function readWorktreeSetupList(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string[] {
49
+ let raw: unknown;
50
+ try {
51
+ raw = configManager.get(key);
52
+ } catch {
53
+ return [];
54
+ }
55
+ if (!Array.isArray(raw)) return [];
56
+ return raw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
57
+ }
58
+
59
+ /** Parse a comma-separated edit-buffer submission back into a string array (trimmed, empties dropped). */
60
+ export function parseWorktreeSetupListInput(text: string): string[] {
61
+ return text.split(',').map((part) => part.trim()).filter((part) => part.length > 0);
62
+ }
63
+
64
+ export const WORKTREE_SETUP_COMMANDS_SYNTHETIC_SETTING: ConfigSetting = {
65
+ key: WORKTREE_SETUP_COMMANDS_CONFIG_KEY,
66
+ type: 'string',
67
+ default: [],
68
+ description: 'Comma-separated shell command lines run in order in a freshly-created isolated worktree before it is handed to an agent (e.g. "bun install, bun run codegen"). A command that exits non-zero stops setup there and marks the worktree failed — visible on /worktree review and the worktree record, never silent. Empty = no setup commands run.',
69
+ };
70
+
71
+ export const WORKTREE_SETUP_CARRY_OVER_GLOBS_SYNTHETIC_SETTING: ConfigSetting = {
72
+ key: WORKTREE_SETUP_CARRY_OVER_GLOBS_CONFIG_KEY,
73
+ type: 'string',
74
+ default: [],
75
+ description: 'Comma-separated glob patterns (e.g. ".env, .env.*, config/local.json") of UNTRACKED files copied from the source working tree into a freshly-created isolated worktree, after setup commands run. Only files git reports as untracked are eligible — committed files already carry over through the worktree checkout itself. Empty = nothing carried over.',
76
+ };
77
+
78
+ function buildWorktreeSetupListEntry(configManager: Pick<ConfigManager, 'get'>, setting: ConfigSetting): SettingEntry {
79
+ const currentValue = readWorktreeSetupList(configManager, setting.key);
80
+ return {
81
+ setting,
82
+ currentValue,
83
+ isDefault: currentValue.length === 0,
84
+ };
85
+ }
86
+
87
+ export function buildWorktreeSetupCommandsSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
88
+ return buildWorktreeSetupListEntry(configManager, WORKTREE_SETUP_COMMANDS_SYNTHETIC_SETTING);
89
+ }
90
+
91
+ export function buildWorktreeSetupCarryOverGlobsSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
92
+ return buildWorktreeSetupListEntry(configManager, WORKTREE_SETUP_CARRY_OVER_GLOBS_SYNTHETIC_SETTING);
93
+ }
package/src/main.ts CHANGED
@@ -49,7 +49,7 @@ import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
49
49
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
50
50
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
51
51
  import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
52
- import { createRenderScheduler } from './runtime/render-scheduler.ts';
52
+ import { createRenderScheduler } from '@pellux/goodvibes-terminal-shell';
53
53
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
54
54
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
55
55
  import { footerFleetCost } from './panels/fleet-read-model.ts';
@@ -57,9 +57,15 @@ import { formatUserFacingErrorLine } from './core/format-user-error.ts';
57
57
  import { wireStreamEventMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
58
58
  import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
59
59
  import { buildContextStatusHint } from './renderer/context-status-hint.ts';
60
+ import { isEffectiveDangerMode } from './config/index.ts';
61
+ import { createScriptableStatusline } from './core/scriptable-statusline.ts';
62
+ import { applyComposerCapture } from './input/composer-capture.ts';
63
+ import { createSessionAutoTitler } from './core/session-auto-titler.ts';
64
+ import { makeComposerEditorOpener } from './input/composer-editor.ts';
60
65
  import { evaluateSessionMaintenance } from '@/runtime/index.ts';
61
66
  import { createCancelGeneration } from './core/turn-cancellation.ts';
62
67
  import { wrapRequestPermissionWithAlert } from './core/approval-alert.ts';
68
+ import { createTerminalNotifier } from './core/terminal-notifier.ts';
63
69
  import { setPanelFrameRequester } from './panels/base-panel.ts';
64
70
 
65
71
  import { ALT_SCREEN_ENTER, ALT_SCREEN_EXIT, MOUSE_ENABLE, MOUSE_DISABLE, CURSOR_HIDE, CURSOR_SHOW, CLEAR_SCREEN, KEYBOARD_EXT_ENABLE, KEYBOARD_EXT_DISABLE, PASTE_ENABLE, PASTE_DISABLE, FOCUS_ENABLE, FOCUS_DISABLE } from './renderer/terminal-escapes.ts';
@@ -223,17 +229,16 @@ async function main() {
223
229
  setRecoveryInterval: (value) => { recoveryInterval = value; },
224
230
  getStopSpokenOutputForExit: () => stopSpokenOutputForExit,
225
231
  });
226
- const {
227
- exitApp,
228
- resizeHandler,
229
- sigintHandler,
230
- unhandledRejectionHandler,
231
- uncaughtExceptionHandler,
232
- terminationSignalHandler,
233
- exitListener,
234
- } = lifecycle;
232
+ const { exitApp, resizeHandler, sigintHandler, unhandledRejectionHandler, uncaughtExceptionHandler, terminationSignalHandler, exitListener } = lifecycle;
235
233
  commandContext.exit = exitApp;
236
234
 
235
+ // In-terminal (OSC 9) notifier (approval-wait/turn-end/agent-blocked); writes
236
+ // are restore-gated so no escape sequence lands after the shell resumes.
237
+ const terminalNotifier = createTerminalNotifier({
238
+ stdout, focusTracker: ctx.services.focusTracker, isReleased: () => lifecycle.isTerminalRestored(),
239
+ configGet: (k: string) => configManager.get(k as Parameters<typeof configManager.get>[0]),
240
+ });
241
+
237
242
  const spokenTurns = wireSpokenTurnRuntime({
238
243
  voiceService: ctx.services.voiceService,
239
244
  configManager,
@@ -243,6 +248,15 @@ async function main() {
243
248
  // Exit-path stop: bounded drain of the audio already playing (see stopForExit).
244
249
  stopSpokenOutputForExit = () => spokenTurns.stopForExit();
245
250
  unsubs.push(...spokenTurns.unsubs);
251
+ // Scriptable status line: runs the user's `statusline.command` at turn boundaries.
252
+ const scriptableStatusline = createScriptableStatusline({ configManager, cwd: workingDir, turns: uiServices.events.turns, onChange: () => render() });
253
+ unsubs.push(...scriptableStatusline.unsubs);
254
+ // Auto-title an untitled session via the weak/fast tool model (session.autoTitle, off by default).
255
+ const sessionAutoTitler = createSessionAutoTitler({
256
+ conversation, model: ctx.services.toolLLM, configManager, turns: uiServices.events.turns,
257
+ onTitled: (title) => { systemMessageRouter.high(`[Session] Auto-titled: "${title}"`); render(); },
258
+ });
259
+ unsubs.push(...sessionAutoTitler.unsubs);
246
260
  unsubs.push(attachSpokenTurnModelRouting({
247
261
  orchestrator,
248
262
  providerRegistry,
@@ -269,18 +283,11 @@ async function main() {
269
283
  }
270
284
  processedText = processedText.replace(atModelMatch[0], '').trim();
271
285
  }
272
- if (processedText.startsWith('!#')) {
273
- const memoryText = processedText.slice(2).trim();
274
- if (!memoryText) {
275
- systemMessageRouter.high('[Memory] Usage: !# <text to pin as session memory>');
276
- render();
277
- processedText = '';
278
- } else {
279
- const memId = ctx.services.sessionMemoryStore.add(memoryText);
280
- systemMessageRouter.high(`[Memory] Pinned: "${memoryText}" (${memId})`);
281
- processedText = memoryText;
282
- }
283
- }
286
+ // Composer capture markers: `!#` pins + sends; `#` saves a note without sending.
287
+ processedText = applyComposerCapture(processedText, {
288
+ sessionMemoryStore: ctx.services.sessionMemoryStore,
289
+ notify: (message) => systemMessageRouter.high(message),
290
+ }).text;
284
291
  if (processedText || content) {
285
292
  void (async () => {
286
293
  const inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
@@ -323,27 +330,27 @@ async function main() {
323
330
 
324
331
  commandContext.submitInput = submitInput;
325
332
  commandContext.submitSpokenInput = (text, content) => submitInput(text, content, { spokenOutput: true });
326
- commandContext.stopSpokenOutput = () => spokenTurns.stop();
327
- commandContext.pasteFromClipboard = () => input.handlePaste();
333
+ commandContext.stopSpokenOutput = () => spokenTurns.stop(); commandContext.pasteFromClipboard = () => input.handlePaste();
328
334
  commandContext.executeCommand = (name, args) => commandRegistry.execute(name, args, commandContext);
335
+ // Late-patched: bootstrap.ts populates uiServices.platform.externalServices AFTER commandContext is built.
336
+ commandContext.platform.externalServices = uiServices.platform.externalServices;
329
337
  commandContext.cancelGeneration = cancelGeneration;
330
338
  commandContext.isGenerating = () => orchestrator.isThinking;
331
- commandContext.jumpToBookmark = jumpToBookmark;
332
- commandContext.scrollToLine = scrollToLine;
339
+ commandContext.jumpToBookmark = jumpToBookmark; commandContext.scrollToLine = scrollToLine;
333
340
  commandContext.clearScreen = () => {
334
341
  compositor.resetDiff();
335
342
  allowTerminalWrite(() => stdout.write(CLEAR_SCREEN));
336
343
  render();
337
344
  };
338
- commandContext.requestFullRepaint = () => { compositor.resetDiff(); render(); };
345
+ commandContext.requestFullRepaint = () => { compositor.resetDiff(); render(); }; commandContext.beginConcealedInput = (req) => input.beginConcealedInput(req);
339
346
  permissionPromptRef.requestPermission = wrapRequestPermissionWithAlert((request) =>
340
347
  new Promise((resolve) => {
341
348
  pendingPermission = {
342
349
  ...request,
343
- ...buildPendingPermissionExtras(request, resolve),
350
+ ...buildPendingPermissionExtras(request, resolve, approvalBroker),
344
351
  };
345
352
  render();
346
- }), { focusTracker: ctx.services.focusTracker, configGet: (k: string) => configManager.get(k as Parameters<typeof configManager.get>[0]), webhookNotifier: ctx.services.webhookNotifier });
353
+ }), { focusTracker: ctx.services.focusTracker, configGet: (k: string) => configManager.get(k as Parameters<typeof configManager.get>[0]), webhookNotifier: ctx.services.webhookNotifier, terminalNotifier });
347
354
 
348
355
  const input: InputHandler = new InputHandler(
349
356
  () => render(),
@@ -403,6 +410,7 @@ async function main() {
403
410
  orchestratorRefs.scrollToEnd = scrollToEnd;
404
411
 
405
412
  input.setCommandRegistry(commandRegistry, commandContext);
413
+ commandContext.openComposerEditor = makeComposerEditorOpener({ buffer: input, stdin, stdout, writeGuard: allowTerminalWrite, repaint: () => { compositor.resetDiff(); render(); }, cwd: workingDir, env: process.env, notify: (m) => systemMessageRouter.high(m) });
406
414
  input.setConversationManager(conversation);
407
415
  input.setContentWidth(getPromptContentWidth());
408
416
  input.filePicker.setOnUpdate(() => render());
@@ -481,21 +489,13 @@ async function main() {
481
489
  provider: runtime.provider,
482
490
  contextWindow,
483
491
  contextStatusHint,
492
+ scriptableStatusLine: scriptableStatusline.current(),
484
493
  // Compact footer posture on short terminals so the shell stays usable.
485
494
  compact: height < 30,
486
495
  // behavior.autoCompactThreshold is stored as a percent integer (e.g. 80);
487
496
  // the meter expects a fraction [0..1]. Clamp to [0,1] to guard nonsense values.
488
497
  compactThreshold: Math.min(1, Math.max(0, (configManager.get('behavior.autoCompactThreshold') as number) / 100)),
489
- dangerMode: (() => {
490
- if (configManager.get('behavior.autoApprove')) return true;
491
- const permMode = configManager.get('permissions.mode');
492
- if (permMode === 'allow-all') return true;
493
- if (permMode === 'custom') {
494
- const tools = configManager.getCategory('permissions').tools;
495
- if (Object.values(tools).every(v => v === 'allow')) return true;
496
- }
497
- return false;
498
- })(),
498
+ dangerMode: isEffectiveDangerMode(configManager),
499
499
  lastInputTokens: orchestrator.lastInputTokens,
500
500
  commandArgsHint,
501
501
  hitlMode: modeManager.getHITLMode(),
@@ -508,7 +508,7 @@ async function main() {
508
508
  composerMode: composerState.modeLabel,
509
509
  composerStatus: composerState.statusLabel,
510
510
  composerFlags: composerState.flags,
511
- composerPendingRisk: composerState.pendingRisk,
511
+ composerPendingRisk: composerState.pendingRisk, permissionMode: configManager.get('permissions.mode') as string,
512
512
  }).lines;
513
513
 
514
514
  const onboardingOwnsScreen = input.onboardingWizard.active;
@@ -547,7 +547,7 @@ async function main() {
547
547
  // Calculate how many rows are consumed by overlays (thinking, permissions, queue, file picker)
548
548
  let overlayRows = 0;
549
549
  if (orchestrator.isThinking) overlayRows += 2; // spinner + blank
550
- if (pendingPermission) overlayRows += PermissionPromptUI.getPromptHeight(pendingPermission, pendingPermission.hunkState, pendingPermission.detailsExpanded);
550
+ if (pendingPermission) overlayRows += PermissionPromptUI.getPromptHeight(pendingPermission, pendingPermission.hunkState, pendingPermission.detailsExpanded, pendingPermission.requestedBy);
551
551
  overlayRows += orchestrator.messageQueue.length * 3; // queued messages
552
552
  // File picker and model picker overlay rows computed from actual rendered line count below
553
553
  // Selection modal overlay rows are computed from actual rendered line count below
@@ -597,7 +597,7 @@ async function main() {
597
597
  }
598
598
 
599
599
  if (pendingPermission) {
600
- viewport.push(...PermissionPromptUI.createPromptLines(conversationWidth, pendingPermission, pendingPermission.hunkState, pendingPermission.detailsExpanded));
600
+ viewport.push(...PermissionPromptUI.createPromptLines(conversationWidth, pendingPermission, pendingPermission.hunkState, pendingPermission.detailsExpanded, pendingPermission.requestedBy));
601
601
  }
602
602
 
603
603
  orchestrator.messageQueue.forEach(msg => {
@@ -687,7 +687,7 @@ async function main() {
687
687
  gitStatusProvider,
688
688
  lastGitInfoRef,
689
689
  buildSessionContinuityHints,
690
- render, webhookNotifier: ctx.services.webhookNotifier, focusTracker: ctx.services.focusTracker,
690
+ render, webhookNotifier: ctx.services.webhookNotifier, focusTracker: ctx.services.focusTracker, terminalNotifier, runtimeBus: uiServices.runtime.runtimeBus,
691
691
  });
692
692
  unsubs.push(...turnUnsubs);
693
693
 
@@ -1,6 +1,7 @@
1
1
  import type { PanelManager } from '../panel-manager.ts';
2
2
  import { GitPanel } from '../git-panel.ts';
3
3
  import { DiffPanel } from '../diff-panel.ts';
4
+ import { DiffReviewPanel } from '../diff-review-panel.ts';
4
5
  import { CostTrackerPanel } from '../cost-tracker-panel.ts';
5
6
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
6
7
  import { requireUiServices, withUnconfiguredFallback } from './shared.ts';
@@ -42,6 +43,19 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
42
43
  factory: () => new DiffPanel(requireUiServices(deps).environment.workingDirectory, deps.requestRender),
43
44
  });
44
45
 
46
+ manager.registerType({
47
+ id: 'review',
48
+ name: 'Review',
49
+ icon: 'R',
50
+ category: 'development',
51
+ description: 'Comment on session diff hunks and steer the comments to the model',
52
+ factory: () => new DiffReviewPanel(
53
+ requireUiServices(deps).environment.workingDirectory,
54
+ deps.requestRender,
55
+ deps.sessionChangeTracker ? () => deps.sessionChangeTracker!.getChangedFiles() : undefined,
56
+ ),
57
+ });
58
+
45
59
  // WO-110: 'inspector' registration moved to builtin/agent.ts (category
46
60
  // 'agent') — it now absorbs the merged agent-logs capabilities.
47
61
 
@@ -0,0 +1,189 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Diff review model — pure parsing + steering-payload helpers for the
3
+ // comment-on-hunk review loop (see diff-review-panel.ts).
4
+ //
5
+ // The diff this operates on is the git working tree vs a base ref (HEAD),
6
+ // filtered to the files the current session actually edited (the SDK's
7
+ // SessionChangeTracker). That is the honest source: it is CURRENT working-tree
8
+ // content, not a turn-start snapshot — the panel labels it exactly that way.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** One hunk of one file's diff, with enough structure to steer a comment at it. */
12
+ export interface ReviewHunk {
13
+ readonly filePath: string;
14
+ readonly fileIndex: number;
15
+ readonly hunkIndex: number;
16
+ /** The raw `@@ -a,b +c,d @@` header line. */
17
+ readonly header: string;
18
+ readonly oldStart: number;
19
+ readonly oldCount: number;
20
+ readonly newStart: number;
21
+ readonly newCount: number;
22
+ /** Raw hunk body lines, each still carrying its leading ' ', '+' or '-'. */
23
+ readonly bodyLines: readonly string[];
24
+ readonly added: number;
25
+ readonly removed: number;
26
+ }
27
+
28
+ export interface ReviewFile {
29
+ readonly filePath: string;
30
+ readonly fileIndex: number;
31
+ readonly hunks: readonly ReviewHunk[];
32
+ }
33
+
34
+ /** Extract the target file path from one `diff --git`-delimited chunk. */
35
+ function extractFilePath(chunk: string): string {
36
+ const quotedGit = chunk.match(/^diff --git "a\/(.+)" "b\/(.+)"$/m);
37
+ if (quotedGit) return quotedGit[2]!;
38
+ const plainGit = chunk.match(/^diff --git a\/.+? b\/(.+)$/m);
39
+ if (plainGit) return plainGit[1]!;
40
+ const combined = chunk.match(/^diff --(?:cc|combined) "?([^"\n]+?)"?$/m);
41
+ if (combined) return combined[1]!;
42
+ const plus = chunk.match(/^\+\+\+ (?:b\/)?"?([^"\n]+?)"?\s*$/m);
43
+ if (plus && plus[1] !== '/dev/null') return plus[1]!;
44
+ const minus = chunk.match(/^--- (?:a\/)?"?([^"\n]+?)"?\s*$/m);
45
+ if (minus && minus[1] !== '/dev/null') return minus[1]!;
46
+ return 'unknown';
47
+ }
48
+
49
+ const HUNK_HEADER_RE = /^@@\s+-([0-9]+)(?:,([0-9]+))?\s+\+([0-9]+)(?:,([0-9]+))?\s+@@/;
50
+
51
+ /** Parse a multi-file unified `git diff` into per-file, per-hunk structures. */
52
+ export function parseReviewDiff(raw: string): ReviewFile[] {
53
+ const files: ReviewFile[] = [];
54
+ const chunks = raw.split(/(?=^diff --git |^diff --cc |^diff --combined )/m);
55
+ let fileIndex = 0;
56
+ for (const chunk of chunks) {
57
+ if (!chunk.trim()) continue;
58
+ const filePath = extractFilePath(chunk);
59
+ const hunks: ReviewHunk[] = [];
60
+ const lines = chunk.split('\n');
61
+ let current: { header: string; oldStart: number; oldCount: number; newStart: number; newCount: number; body: string[] } | null = null;
62
+ const flush = (): void => {
63
+ if (!current) return;
64
+ let added = 0;
65
+ let removed = 0;
66
+ for (const l of current.body) {
67
+ if (l.startsWith('+')) added++;
68
+ else if (l.startsWith('-')) removed++;
69
+ }
70
+ hunks.push({
71
+ filePath,
72
+ fileIndex,
73
+ hunkIndex: hunks.length,
74
+ header: current.header,
75
+ oldStart: current.oldStart,
76
+ oldCount: current.oldCount,
77
+ newStart: current.newStart,
78
+ newCount: current.newCount,
79
+ bodyLines: current.body,
80
+ added,
81
+ removed,
82
+ });
83
+ current = null;
84
+ };
85
+ for (const line of lines) {
86
+ const m = line.match(HUNK_HEADER_RE);
87
+ if (m) {
88
+ flush();
89
+ current = {
90
+ header: line,
91
+ oldStart: parseInt(m[1]!, 10),
92
+ oldCount: m[2] !== undefined ? parseInt(m[2], 10) : 1,
93
+ newStart: parseInt(m[3]!, 10),
94
+ newCount: m[4] !== undefined ? parseInt(m[4], 10) : 1,
95
+ body: [],
96
+ };
97
+ continue;
98
+ }
99
+ if (current) current.body.push(line);
100
+ }
101
+ flush();
102
+ if (hunks.length > 0) files.push({ filePath, fileIndex, hunks });
103
+ fileIndex++;
104
+ }
105
+ return files;
106
+ }
107
+
108
+ /** Flatten files into a single navigable hunk list, in file/hunk order. */
109
+ export function flattenHunks(files: readonly ReviewFile[]): ReviewHunk[] {
110
+ const out: ReviewHunk[] = [];
111
+ for (const file of files) out.push(...file.hunks);
112
+ return out;
113
+ }
114
+
115
+ /** The new-file line range a hunk covers (1-based, inclusive). */
116
+ export function hunkLineRange(hunk: ReviewHunk): { start: number; end: number } {
117
+ const start = hunk.newStart;
118
+ const end = hunk.newCount > 0 ? hunk.newStart + hunk.newCount - 1 : hunk.newStart;
119
+ return { start, end };
120
+ }
121
+
122
+ /** A short excerpt of a hunk's patch, header included, capped to `maxLines` body lines. */
123
+ export function hunkExcerpt(hunk: ReviewHunk, maxLines = 8): string {
124
+ const body = hunk.bodyLines.slice(0, maxLines);
125
+ const truncated = hunk.bodyLines.length > maxLines ? [`… (+${hunk.bodyLines.length - maxLines} more lines)`] : [];
126
+ return [hunk.header, ...body, ...truncated].join('\n');
127
+ }
128
+
129
+ /** The full unified-diff hunk text (header + body) for one ReviewHunk — the input checkpoints.revertHunk takes. */
130
+ export function hunkPatchText(hunk: ReviewHunk): string {
131
+ return [hunk.header, ...hunk.bodyLines].join('\n');
132
+ }
133
+
134
+ /** The subset of checkpoints.revertHunk's receipt the transcript block renders. */
135
+ export interface HunkRevertReceiptInput {
136
+ readonly path: string;
137
+ readonly hunkHeader: string;
138
+ readonly addedLinesRemoved: number;
139
+ readonly removedLinesRestored: number;
140
+ readonly safetyCheckpointId: string | null;
141
+ }
142
+
143
+ /**
144
+ * Build the distinct `[Revert]` transcript block for one applied hunk revert.
145
+ * Pure. The `[Revert]` prefix is force-surfaced inline (system-message-router.ts)
146
+ * so a working-tree mutation from /review is never a silent change.
147
+ */
148
+ export function buildHunkRevertReceiptBlock(receipt: HunkRevertReceiptInput): string {
149
+ const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`;
150
+ const lines = [
151
+ `[Revert] Receipt — reverted one hunk in ${receipt.path} (${receipt.hunkHeader}).`,
152
+ ` Restored ${plural(receipt.removedLinesRestored, 'deleted line')}, removed ${plural(receipt.addedLinesRemoved, 'added line')}.`,
153
+ receipt.safetyCheckpointId
154
+ ? ` Reversible: a pre-revert checkpoint was taken — /checkpoints restore ${receipt.safetyCheckpointId} undoes it.`
155
+ : ' Reversible: the working tree already matched the latest checkpoint (nothing extra to snapshot).',
156
+ ];
157
+ return lines.join('\n');
158
+ }
159
+
160
+ /** A comment attached to a specific hunk, ready to steer. */
161
+ export interface HunkComment {
162
+ readonly hunk: ReviewHunk;
163
+ readonly comment: string;
164
+ }
165
+
166
+ /**
167
+ * Build the structured steering message a review comment submits to the session.
168
+ * Each block names the file, the new-file line range, the comment, and a short
169
+ * patch excerpt so the model knows exactly what the comment targets. The
170
+ * `sourceLabel` states the honest provenance of the diff (working tree vs base).
171
+ */
172
+ export function buildSteerMessage(items: readonly HunkComment[], sourceLabel: string): string {
173
+ const blocks = items.map(({ hunk, comment }) => {
174
+ const range = hunkLineRange(hunk);
175
+ return [
176
+ `Review comment on ${hunk.filePath} (lines ${range.start}-${range.end}, new file):`,
177
+ comment,
178
+ '',
179
+ 'Referenced change:',
180
+ '```diff',
181
+ hunkExcerpt(hunk),
182
+ '```',
183
+ ].join('\n');
184
+ });
185
+ const header = items.length === 1
186
+ ? `I am reviewing the current changes (${sourceLabel}) and left a comment on a specific hunk. Please address it.`
187
+ : `I am reviewing the current changes (${sourceLabel}) and left comments on ${items.length} hunks. Please address each one.`;
188
+ return [header, '', ...blocks].join('\n\n');
189
+ }