@pellux/goodvibes-tui 0.22.0 → 0.24.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 (79) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +17 -8
  3. package/package.json +1 -1
  4. package/src/cli/management-commands.ts +1 -1
  5. package/src/cli/management-utils.ts +352 -0
  6. package/src/cli/management.ts +116 -344
  7. package/src/cli/surface-command.ts +1 -1
  8. package/src/core/context-auto-compact.ts +43 -10
  9. package/src/core/conversation-rendering.ts +5 -2
  10. package/src/core/conversation-types.ts +24 -0
  11. package/src/core/conversation.ts +7 -12
  12. package/src/core/long-task-notifier.ts +145 -0
  13. package/src/core/session-recovery.ts +147 -0
  14. package/src/core/stream-event-wiring.ts +199 -7
  15. package/src/core/transcript-journal.ts +339 -0
  16. package/src/core/turn-event-wiring.ts +67 -4
  17. package/src/input/commands/channel-runtime.ts +139 -0
  18. package/src/input/commands/control-room-runtime.ts +0 -2
  19. package/src/input/commands/diff-runtime.ts +1 -1
  20. package/src/input/commands/eval.ts +1 -1
  21. package/src/input/commands/health-runtime.ts +23 -4
  22. package/src/input/commands/knowledge.ts +1 -1
  23. package/src/input/commands/local-runtime.ts +1 -2
  24. package/src/input/commands/memory-product-runtime.ts +2 -2
  25. package/src/input/commands/memory.ts +1 -1
  26. package/src/input/commands/onboarding-runtime.ts +0 -1
  27. package/src/input/commands/policy.ts +1 -1
  28. package/src/input/commands/profile-sync-runtime.ts +4 -3
  29. package/src/input/commands/provider.ts +1 -1
  30. package/src/input/commands/qrcode-runtime.ts +0 -1
  31. package/src/input/commands/runtime-services.ts +30 -1
  32. package/src/input/commands/session-content.ts +2 -2
  33. package/src/input/commands/session-workflow.ts +32 -2
  34. package/src/input/commands/session.ts +1 -1
  35. package/src/input/commands/settings-sync-runtime.ts +9 -9
  36. package/src/input/commands/share-runtime.ts +1 -1
  37. package/src/input/commands/shell-core.ts +56 -6
  38. package/src/input/commands/work-plan-runtime.ts +8 -8
  39. package/src/input/commands.ts +2 -0
  40. package/src/input/feed-context-factory.ts +6 -0
  41. package/src/input/handler-feed-routes.ts +19 -1
  42. package/src/input/handler-feed.ts +11 -0
  43. package/src/input/handler-prompt-buffer.ts +28 -0
  44. package/src/input/handler-shortcuts.ts +88 -2
  45. package/src/input/handler-ui-state.ts +2 -2
  46. package/src/input/handler.ts +39 -3
  47. package/src/input/keybindings.ts +33 -3
  48. package/src/input/kill-ring.ts +134 -0
  49. package/src/input/model-picker.ts +18 -1
  50. package/src/input/search.ts +18 -6
  51. package/src/input/settings-modal-activation.ts +134 -0
  52. package/src/input/settings-modal-adjustment.ts +124 -0
  53. package/src/input/settings-modal-data.ts +53 -0
  54. package/src/input/settings-modal.ts +48 -145
  55. package/src/main.ts +50 -50
  56. package/src/panels/base-panel.ts +2 -1
  57. package/src/panels/provider-health-domains.ts +3 -3
  58. package/src/panels/provider-health-panel.ts +13 -9
  59. package/src/panels/provider-health-tracker.ts +7 -4
  60. package/src/panels/settings-sync-panel.ts +3 -3
  61. package/src/panels/work-plan-panel.ts +2 -2
  62. package/src/renderer/compaction-history-modal.ts +55 -0
  63. package/src/renderer/compaction-preview.ts +146 -0
  64. package/src/renderer/diff-view.ts +2 -2
  65. package/src/renderer/help-overlay.ts +1 -0
  66. package/src/renderer/model-picker-overlay.ts +23 -11
  67. package/src/renderer/progress.ts +3 -3
  68. package/src/renderer/search-overlay.ts +8 -5
  69. package/src/renderer/settings-modal-helpers.ts +2 -2
  70. package/src/renderer/settings-modal.ts +1 -1
  71. package/src/renderer/ui-factory.ts +11 -0
  72. package/src/runtime/bootstrap-core.ts +92 -0
  73. package/src/runtime/bootstrap-hook-bridge.ts +18 -0
  74. package/src/runtime/bootstrap-shell.ts +1 -0
  75. package/src/shell/blocking-input.ts +32 -0
  76. package/src/shell/recovery-input-helpers.ts +71 -0
  77. package/src/utils/browser.ts +29 -0
  78. package/src/utils/terminal-width.ts +10 -3
  79. package/src/version.ts +1 -1
@@ -4,8 +4,8 @@
4
4
  * architecture cap. No layout logic lives here.
5
5
  */
6
6
 
7
- import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal.ts';
8
- import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
7
+ import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
8
+ import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
9
9
  import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
10
10
 
11
11
  function maskSecretValue(value: string): string {
@@ -150,7 +150,7 @@ function buildSettingContext(modal: SettingsModal, entry: SettingEntry): string[
150
150
  ];
151
151
 
152
152
  if (entry.locked) lines.push(`Locked: ${entry.lockReason ?? 'This setting is locked by a higher-priority layer.'}`);
153
- if (entry.conflict) lines.push(`Conflict: resolve with /settingssync resolve ${entry.setting.key} local|synced.`);
153
+ if (entry.conflict) lines.push(`Conflict: resolve with /settings-sync resolve ${entry.setting.key} local|synced.`);
154
154
 
155
155
  lines.push('', entry.setting.description);
156
156
 
@@ -243,6 +243,17 @@ export class UIFactory {
243
243
  });
244
244
  const bottomLine = createBaseLine();
245
245
  for (let x = 0; x < boxWidth; x++) bottomLine[boxStartX + x] = { char: GLYPHS.surface.bottom, fg: BORDER_COLOR, bg: '', bold: false, dim: false, underline: false, italic: false, strikethrough: false };
246
+ // Multi-line indicator lives inside the bottom border (right-aligned) so
247
+ // the footer height stays invariant while the user adds prompt lines.
248
+ if (promptLines.length > 1) {
249
+ const lineCountTag = ` ${promptLines.length}L `;
250
+ let tx = boxStartX + boxWidth - lineCountTag.length - 2;
251
+ for (const ch of lineCountTag) {
252
+ if (tx >= boxStartX + boxWidth - 1) break;
253
+ bottomLine[tx] = { char: ch, fg: '244', bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false };
254
+ tx += 1;
255
+ }
256
+ }
246
257
  lines.push(bottomLine);
247
258
  lines.push(createBaseLine());
248
259
  const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
@@ -123,6 +123,88 @@ export interface BootstrapCoreState {
123
123
 
124
124
  export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
125
125
 
126
+ // ---------------------------------------------------------------------------
127
+ // Operator narration of inbound channel events
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Narrate an inbound channel event to the operator via the SystemMessageRouter.
132
+ *
133
+ * When an external surface (GitHub, Slack, ntfy, etc.) triggers an agent turn,
134
+ * this function produces a human-readable system message so the operator can
135
+ * observe which event caused the turn. Returns null for internal/companion
136
+ * sources that do not need operator narration.
137
+ *
138
+ * @param event - The normalized inbound event descriptor.
139
+ * @returns A narration string, or null if no narration is appropriate.
140
+ */
141
+ export function narrateInboundEvent(event: {
142
+ source: string;
143
+ metadata: Readonly<Record<string, unknown>> | undefined;
144
+ }): string | null {
145
+ const { source, metadata } = event;
146
+ if (!source) return null;
147
+
148
+ // Derive the effective surface — prefer metadata.surface, fall back to source.
149
+ const surface = typeof metadata?.surface === 'string' ? metadata.surface : source;
150
+
151
+ // Internal / companion sources do not need operator narration.
152
+ if (surface === 'companion' || source === 'companion') return null;
153
+ if (surface === 'internal' || source === 'internal') return null;
154
+
155
+ // Build a surface label for the log prefix.
156
+ const label = ((): string => {
157
+ switch (surface) {
158
+ case 'github': return '[GitHub]';
159
+ case 'slack': return '[Slack]';
160
+ case 'discord': return '[Discord]';
161
+ case 'ntfy': return '[ntfy]';
162
+ case 'homeassistant': return '[HomeAssistant]';
163
+ case 'telegram': return '[Telegram]';
164
+ case 'google-chat': return '[Google Chat]';
165
+ case 'signal': return '[Signal]';
166
+ case 'whatsapp': return '[WhatsApp]';
167
+ case 'msteams': return '[Teams]';
168
+ case 'imessage': return '[iMessage]';
169
+ case 'bluebubbles': return '[BlueBubbles]';
170
+ case 'mattermost': return '[Mattermost]';
171
+ case 'matrix': return '[Matrix]';
172
+ case 'webhook': return '[Webhook]';
173
+ default: return `[${surface[0]!.toUpperCase()}${surface.slice(1)}]`;
174
+ }
175
+ })();
176
+
177
+ const eventType = typeof metadata?.eventType === 'string' ? metadata.eventType : null;
178
+ const eventAction = typeof metadata?.eventAction === 'string' ? metadata.eventAction : null;
179
+ const topic = typeof metadata?.topic === 'string' ? metadata.topic : null;
180
+ const prNumber = typeof metadata?.prNumber === 'number' ? metadata.prNumber : null;
181
+ const issueNumber = typeof metadata?.issueNumber === 'number' ? metadata.issueNumber : null;
182
+ const repo = typeof metadata?.repo === 'string' ? metadata.repo : null;
183
+
184
+ // Build event-specific detail for GitHub events.
185
+ if (surface === 'github' && eventType) {
186
+ const actionPart = eventAction ? ` ${eventAction}` : '';
187
+ let detail = `${eventType}${actionPart} → agent triggered`;
188
+ if (prNumber !== null) {
189
+ detail = `PR #${prNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
190
+ } else if (issueNumber !== null) {
191
+ detail = `Issue #${issueNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
192
+ } else if (repo) {
193
+ detail = `${eventType}${actionPart} in ${repo} → agent triggered`;
194
+ }
195
+ return `${label} ${detail}`;
196
+ }
197
+
198
+ // ntfy: include topic when available.
199
+ if (surface === 'ntfy' && topic) {
200
+ return `${label} inbound message on topic '${topic}' → agent triggered`;
201
+ }
202
+
203
+ // Generic narration for all other surfaces.
204
+ const eventDetail = eventType ? ` ${eventType}${eventAction ? ` ${eventAction}` : ''}` : '';
205
+ return `${label}${eventDetail} inbound event → agent triggered`;
206
+ }
207
+
126
208
  export function companionMessageToOrchestratorInputOptions(
127
209
  payload: CompanionMessagePayload,
128
210
  ): OrchestratorUserInputOptions {
@@ -525,6 +607,16 @@ export async function initializeBootstrapCore(
525
607
  runtimeUnsubs.push(runtimeBus.on<Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>>(
526
608
  'COMPANION_MESSAGE_RECEIVED',
527
609
  ({ payload }) => {
610
+ // Narrate inbound external events to the operator so they can observe
611
+ // which channel event triggered the agent turn.
612
+ const narration = narrateInboundEvent({
613
+ source: payload.source,
614
+ metadata: payload.metadata,
615
+ });
616
+ if (narration) {
617
+ routeOrBuffer(narration, 'low');
618
+ }
619
+
528
620
  if (orchestratorHandleUserInputRef.value) {
529
621
  // Delegate to the orchestrator: adds user message + fires a real LLM turn.
530
622
  // Preserve surface origin metadata so the SDK can correlate replies back
@@ -13,6 +13,7 @@ import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
13
13
  import type { PanelManager } from '../panels/panel-manager.ts';
14
14
  import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
15
15
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
16
+ import { replayJournalForSession } from '../core/session-recovery.ts';
16
17
 
17
18
  export interface ResumeSessionOptions {
18
19
  readonly runtimeBus: RuntimeEventBus;
@@ -27,6 +28,7 @@ export interface ResumeSessionOptions {
27
28
  readonly panelManager: PanelManager;
28
29
  readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
29
30
  readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require'>;
31
+ readonly homeDirectory: string;
30
32
  }
31
33
 
32
34
  export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => void {
@@ -47,6 +49,22 @@ export function createResumeSessionHandler(options: ResumeSessionOptions): (sess
47
49
  titleSource: meta.titleSource,
48
50
  });
49
51
  options.runtime.sessionId = sessionId;
52
+ replayJournalForSession({
53
+ homeDirectory: options.homeDirectory,
54
+ sessionId,
55
+ snapshotTimestamp: meta.timestamp ?? 0,
56
+ conversation: options.conversation,
57
+ persistSnapshot: (replayedMessages) => {
58
+ options.sessionManager.save(sessionId, replayedMessages as never[], {
59
+ title: options.conversation.title || meta.title,
60
+ model: meta.model,
61
+ provider: meta.provider,
62
+ timestamp: Date.now(),
63
+ titleSource: meta.titleSource,
64
+ returnContext: meta.returnContext,
65
+ });
66
+ },
67
+ });
50
68
  options.onSessionIdChanged?.(sessionId);
51
69
  if (meta?.model) options.runtime.model = meta.model;
52
70
  if (meta?.provider) options.runtime.provider = meta.provider;
@@ -106,6 +106,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
106
106
  panelManager: services.panelManager,
107
107
  configManager,
108
108
  providerRegistry: services.providerRegistry,
109
+ homeDirectory: services.homeDirectory,
109
110
  });
110
111
 
111
112
  const openAgentDetailRef: { fn: (agentId: string) => void } = { fn: (_agentId: string) => {} };
@@ -2,6 +2,8 @@ import type { ConversationManager } from '../core/conversation';
2
2
  import type { PermissionRequest } from '@pellux/goodvibes-sdk/platform/permissions';
3
3
  import type { SessionSnapshot } from '@/runtime/index.ts';
4
4
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
5
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
6
+ import { replayJournalForSession } from '../core/session-recovery.ts';
5
7
 
6
8
  export type PendingPermissionState = PermissionRequest & {
7
9
  resolve: (approved: boolean, remember?: boolean) => void;
@@ -17,6 +19,22 @@ export type BlockingInputHandlerOptions = {
17
19
  render: () => void;
18
20
  loadRecoveryConversation: () => SessionSnapshot | null;
19
21
  deleteRecoveryFile: () => void;
22
+ /**
23
+ * Absolute home directory used to locate the transcript journal for this
24
+ * recovery session. Required for journal replay on Ctrl+R restore.
25
+ */
26
+ homeDirectory: string;
27
+ /**
28
+ * The session ID that the recovery file belongs to. Required for journal
29
+ * replay so the correct journal path can be resolved.
30
+ */
31
+ sessionId: string;
32
+ /**
33
+ * Persist the post-replay snapshot so the WAL gap is durably closed.
34
+ * Called with the replayed message list. Best-effort — failures are swallowed
35
+ * inside replayJournalForSession.
36
+ */
37
+ persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
20
38
  /**
21
39
  * Optional callback invoked after Ctrl+R restore to reopen panels captured in
22
40
  * the recovery snapshot's returnContext. When provided (as wired in main.ts),
@@ -47,6 +65,9 @@ export function handleBlockingShellInput(
47
65
  render,
48
66
  loadRecoveryConversation,
49
67
  deleteRecoveryFile,
68
+ homeDirectory,
69
+ sessionId,
70
+ persistSnapshot,
50
71
  reopenPanels,
51
72
  } = options;
52
73
 
@@ -86,6 +107,17 @@ export function handleBlockingShellInput(
86
107
  title: recovery.title,
87
108
  titleSource: recovery.titleSource,
88
109
  });
110
+ // Replay journal records that post-date the recovery snapshot so turns
111
+ // written after the last recovery-file write (but before SIGKILL) are
112
+ // not silently dropped. snapshotTimestamp=0 when timestamp is absent so
113
+ // all journal records are replayed — safer than dropping.
114
+ replayJournalForSession({
115
+ homeDirectory,
116
+ sessionId,
117
+ snapshotTimestamp: recovery.timestamp ?? 0,
118
+ conversation,
119
+ persistSnapshot,
120
+ });
89
121
  reopenPanels?.(recovery);
90
122
  systemMessageRouter.high('[Recovery] Session restored.');
91
123
  deleteRecoveryFile();
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Helper factories for main()'s stdin fast-path: the Ctrl+R recovery
3
+ * persistence/panel-reopen callbacks and the one-key error-retry
4
+ * affordance. Extracted from main.ts so the entrypoint stays under the
5
+ * architecture line ceiling; main() wires these with its live services.
6
+ */
7
+
8
+ import type { ConversationMessageSnapshot } from '../core/conversation.ts';
9
+ import type { SessionSnapshot } from '@/runtime/index.ts';
10
+
11
+ export interface PersistRecoveryDeps {
12
+ readonly sessionManager: {
13
+ save(id: string, msgs: never[], meta: { title: string; model: string; provider: string; timestamp: number }): unknown;
14
+ };
15
+ readonly runtime: { readonly sessionId: string; readonly model: string; readonly provider: string };
16
+ readonly conversation: { readonly title?: string | null };
17
+ }
18
+
19
+ /** Persist a replayed/restored snapshot through the session manager. */
20
+ export function createPersistRecoverySnapshot(deps: PersistRecoveryDeps): (msgs: ConversationMessageSnapshot[]) => void {
21
+ return (msgs) => void deps.sessionManager.save(deps.runtime.sessionId, msgs as never[], {
22
+ title: deps.conversation.title ?? '',
23
+ model: deps.runtime.model,
24
+ provider: deps.runtime.provider,
25
+ timestamp: Date.now(),
26
+ });
27
+ }
28
+
29
+ export interface ReopenPanelsDeps {
30
+ readonly panelManager: { open(id: string): void; show(): void };
31
+ readonly render: () => void;
32
+ }
33
+
34
+ /** Reopen the panels recorded in a restored session's return context (capped at 4). */
35
+ export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: SessionSnapshot) => void {
36
+ return (snapshot) => {
37
+ for (const panelId of (snapshot.returnContext?.openPanels ?? []).slice(0, 4)) {
38
+ try { deps.panelManager.open(panelId); } catch { /* unknown panel id */ }
39
+ }
40
+ if ((snapshot.returnContext?.openPanels?.length ?? 0) > 0) { deps.panelManager.show(); deps.render(); }
41
+ };
42
+ }
43
+
44
+ export interface ErrorAffordanceDeps {
45
+ /** True when the failover retry context is armed (a retry is actually possible). */
46
+ readonly retryArmed: boolean;
47
+ /** Re-submit the failed turn via the shared failover retry path (no duplicate user messages). */
48
+ readonly retry: () => void;
49
+ readonly openModelPicker: () => void;
50
+ readonly render: () => void;
51
+ }
52
+
53
+ /**
54
+ * Handle one keypress while the error-retry affordance is active.
55
+ * 'r' retries on the current provider when armed; 'm' opens the model
56
+ * picker. Returns true when the key was consumed; any other key returns
57
+ * false so the caller routes it as normal input.
58
+ */
59
+ export function handleErrorAffordanceKey(data: string, deps: ErrorAffordanceDeps): boolean {
60
+ if (data === 'r' && deps.retryArmed) {
61
+ deps.retry();
62
+ deps.render();
63
+ return true;
64
+ }
65
+ if (data === 'm') {
66
+ deps.openModelPicker();
67
+ deps.render();
68
+ return true;
69
+ }
70
+ return false;
71
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * browser.ts — cross-platform browser launcher utility.
3
+ *
4
+ * Extracted from cli/management.ts so it can be used by input-layer commands
5
+ * without creating an upward cli→input dependency. Lives in utils (Layer 0)
6
+ * and has no imports from shell-UI or entrypoint layers.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
+
12
+ /**
13
+ * Open the given URL in the user's default browser.
14
+ * Returns a status string (success or error description).
15
+ * Does not throw — errors are returned as a descriptive string.
16
+ */
17
+ export function openBrowser(url: string): string {
18
+ const platform = process.platform;
19
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
20
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
21
+ try {
22
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
23
+ child.once('error', () => {});
24
+ child.unref();
25
+ return 'browser open requested';
26
+ } catch (error) {
27
+ return `browser open failed: ${summarizeError(error)}`;
28
+ }
29
+ }
@@ -43,9 +43,16 @@ function stripAnsi(text: string): string {
43
43
 
44
44
  /**
45
45
  * Calculates the visual width of a string in the terminal.
46
- * Handles CJK characters, emoji (including ZWJ sequences), and
47
- * variation selectors correctly as double-width.
48
- * ANSI escape sequences (SGR/CSI/OSC-8) are stripped before measurement.
46
+ * Handles CJK characters, emoji, and variation selectors correctly as
47
+ * double-width. ANSI escape sequences (SGR/CSI/OSC-8) are stripped before
48
+ * measurement.
49
+ *
50
+ * NOTE: Width is measured per Unicode scalar value (code point), not per
51
+ * grapheme cluster. ZWJ sequences (e.g. 👨‍👩‍👧‍👦) are handled component-by-component:
52
+ * each component's width is summed and ZWJ/VS chars contribute zero width,
53
+ * so the total is accurate. However, truncation (truncateDisplay) may split
54
+ * a ZWJ family mid-sequence, leaving dangling ZWJ/VS characters. This is a
55
+ * cosmetic degradation only — line widths remain correct.
49
56
  */
50
57
  export function getDisplayWidth(text: string): number {
51
58
  text = stripAnsi(text);
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.22.0';
9
+ let _version = '0.24.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;