@pellux/goodvibes-tui 0.23.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 (63) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +17 -8
  3. package/package.json +1 -1
  4. package/src/cli/management.ts +80 -10
  5. package/src/core/long-task-notifier.ts +145 -0
  6. package/src/core/session-recovery.ts +147 -0
  7. package/src/core/stream-event-wiring.ts +77 -3
  8. package/src/core/transcript-journal.ts +339 -0
  9. package/src/core/turn-event-wiring.ts +67 -4
  10. package/src/input/commands/control-room-runtime.ts +0 -2
  11. package/src/input/commands/diff-runtime.ts +1 -1
  12. package/src/input/commands/eval.ts +1 -1
  13. package/src/input/commands/health-runtime.ts +23 -4
  14. package/src/input/commands/knowledge.ts +1 -1
  15. package/src/input/commands/local-runtime.ts +1 -2
  16. package/src/input/commands/memory-product-runtime.ts +2 -2
  17. package/src/input/commands/memory.ts +1 -1
  18. package/src/input/commands/onboarding-runtime.ts +0 -1
  19. package/src/input/commands/policy.ts +1 -1
  20. package/src/input/commands/profile-sync-runtime.ts +4 -3
  21. package/src/input/commands/provider.ts +1 -1
  22. package/src/input/commands/qrcode-runtime.ts +0 -1
  23. package/src/input/commands/session-content.ts +2 -2
  24. package/src/input/commands/session-workflow.ts +32 -2
  25. package/src/input/commands/session.ts +1 -1
  26. package/src/input/commands/settings-sync-runtime.ts +9 -9
  27. package/src/input/commands/shell-core.ts +2 -2
  28. package/src/input/commands/work-plan-runtime.ts +8 -8
  29. package/src/input/feed-context-factory.ts +6 -0
  30. package/src/input/handler-feed-routes.ts +19 -1
  31. package/src/input/handler-feed.ts +11 -0
  32. package/src/input/handler-prompt-buffer.ts +28 -0
  33. package/src/input/handler-shortcuts.ts +88 -2
  34. package/src/input/handler-ui-state.ts +2 -2
  35. package/src/input/handler.ts +39 -3
  36. package/src/input/keybindings.ts +33 -3
  37. package/src/input/kill-ring.ts +134 -0
  38. package/src/input/model-picker.ts +18 -1
  39. package/src/input/search.ts +18 -6
  40. package/src/input/settings-modal-activation.ts +134 -0
  41. package/src/input/settings-modal-adjustment.ts +124 -0
  42. package/src/input/settings-modal-data.ts +53 -0
  43. package/src/input/settings-modal.ts +48 -145
  44. package/src/main.ts +33 -33
  45. package/src/panels/base-panel.ts +2 -1
  46. package/src/panels/provider-health-domains.ts +3 -3
  47. package/src/panels/provider-health-panel.ts +13 -9
  48. package/src/panels/provider-health-tracker.ts +7 -4
  49. package/src/panels/settings-sync-panel.ts +3 -3
  50. package/src/panels/work-plan-panel.ts +2 -2
  51. package/src/renderer/diff-view.ts +2 -2
  52. package/src/renderer/help-overlay.ts +1 -0
  53. package/src/renderer/model-picker-overlay.ts +23 -11
  54. package/src/renderer/progress.ts +3 -3
  55. package/src/renderer/search-overlay.ts +8 -5
  56. package/src/renderer/settings-modal.ts +1 -1
  57. package/src/renderer/ui-factory.ts +11 -0
  58. package/src/runtime/bootstrap-hook-bridge.ts +18 -0
  59. package/src/runtime/bootstrap-shell.ts +1 -0
  60. package/src/shell/blocking-input.ts +32 -0
  61. package/src/shell/recovery-input-helpers.ts +71 -0
  62. package/src/utils/terminal-width.ts +10 -3
  63. package/src/version.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
- import { getDisplayWidth } from '../utils/terminal-width.ts';
3
+ import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
4
4
 
5
5
  // Rich spinner frames (used by progress indicators)
6
6
  export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -17,7 +17,7 @@ export function renderSpinner(
17
17
  fg: string = '135'
18
18
  ): Line {
19
19
  const text = ` ${frame} ${label}`;
20
- return UIFactory.stringToLine(text.padEnd(width), width, { fg, bold: true });
20
+ return UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg, bold: true });
21
21
  }
22
22
 
23
23
  /**
@@ -33,7 +33,7 @@ export function renderToolProgress(
33
33
  const counter = `[${current}/${total}]`;
34
34
  const text = ` ${counter} ${label}`;
35
35
  return [
36
- UIFactory.stringToLine(text.padEnd(width), width, { fg: '#ffcc00', bold: true }),
36
+ UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg: '#ffcc00', bold: true }),
37
37
  ];
38
38
  }
39
39
 
@@ -5,16 +5,19 @@ import { createBottomBarLine, writeBottomBarText } from './bottom-bar.ts';
5
5
 
6
6
  /**
7
7
  * Render the search bar as a single Line[] overlay at the bottom of the viewport.
8
- * Format: [ Find: <query> 3/17 up/down [n] next [N] prev [Esc] close ]
8
+ * Format: [ Find: <query> 3/17 [n/N] next/prev [Esc] close ]
9
9
  * The match count is dim grey; the rest of the bar is teal.
10
+ *
11
+ * Case-insensitive search (query lowercased before matching rendered cell text).
10
12
  */
11
13
  export function renderSearchOverlay(
12
14
  manager: SearchManager,
13
15
  width: number
14
16
  ): Line[] {
15
- // Match count text — displayed in dim grey, right of query, left of hints
16
- const matchCount = manager.matches?.length > 0
17
- ? `${manager.currentMatch + 1}/${manager.matches.length} up/down`
17
+ // Match count / status text — displayed in dim grey, right of query
18
+ const hasMatches = manager.matches.length > 0;
19
+ const matchCount = hasMatches
20
+ ? `${manager.currentMatch + 1}/${manager.matches.length}${manager.wrapAround ? ' (wrap)' : ''}`
18
21
  : manager.query.length > 0
19
22
  ? 'No matches'
20
23
  : '';
@@ -23,7 +26,7 @@ export function renderSearchOverlay(
23
26
  const cursor = locked ? '' : '█';
24
27
  const queryDisplay = manager.query + cursor;
25
28
  const hints = locked
26
- ? ' [Up/Down] or [jk] navigate [Bksp] edit [Esc] close'
29
+ ? ' [n/N] next/prev [jk] navigate [Bksp] edit [Esc] close'
27
30
  : ' [Enter/Tab] lock [Esc] close';
28
31
  const label = ' Find: ';
29
32
  const matchStr = matchCount ? ` ${matchCount}` : '';
@@ -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 }> = [];
@@ -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
+ }
@@ -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.23.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;