@pellux/goodvibes-tui 1.20.0 → 1.21.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 (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -7
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +1 -1
  6. package/src/cli/doctor.ts +7 -1
  7. package/src/cli/management.ts +1 -1
  8. package/src/cli/package-verification.ts +92 -7
  9. package/src/cli/status.ts +16 -0
  10. package/src/config/read-versioned.ts +115 -2
  11. package/src/core/active-model-identity.ts +153 -0
  12. package/src/core/conversation-event-navigation.ts +42 -0
  13. package/src/core/conversation-search-expansion.ts +67 -0
  14. package/src/core/conversation-splash-state.ts +58 -0
  15. package/src/core/conversation-user-receipts.ts +42 -0
  16. package/src/core/conversation.ts +43 -40
  17. package/src/core/model-routing-chip.ts +38 -18
  18. package/src/core/rewind-turn-anchors.ts +155 -3
  19. package/src/core/stream-event-wiring.ts +298 -17
  20. package/src/core/system-message-router.ts +22 -0
  21. package/src/core/transcript-journal.ts +145 -7
  22. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  23. package/src/daemon/handlers/inbox/index.ts +14 -1
  24. package/src/daemon/handlers/sqlite-store.ts +160 -6
  25. package/src/input/command-registry.ts +8 -0
  26. package/src/input/commands/local-runtime.ts +6 -1
  27. package/src/input/commands/schedule-runtime.ts +15 -2
  28. package/src/input/commands/shell-core.ts +87 -38
  29. package/src/input/composer-capture.ts +47 -0
  30. package/src/input/handler-command-route.ts +4 -0
  31. package/src/input/handler-content-actions.ts +9 -0
  32. package/src/input/handler-feed-routes.ts +1 -0
  33. package/src/input/handler-modal-routes.ts +12 -1
  34. package/src/input/handler-picker-routes.ts +43 -5
  35. package/src/input/handler-shortcuts.ts +1 -1
  36. package/src/input/handler-ui-state.ts +8 -3
  37. package/src/input/model-picker-items.ts +17 -2
  38. package/src/input/model-picker.ts +19 -2
  39. package/src/input/search.ts +183 -89
  40. package/src/main.ts +31 -38
  41. package/src/{cli → providers}/provider-classification.ts +12 -0
  42. package/src/providers/reasoning-effort-surface.ts +399 -0
  43. package/src/renderer/compositor.ts +68 -18
  44. package/src/renderer/header-line.ts +146 -0
  45. package/src/renderer/markdown-inline.ts +143 -0
  46. package/src/renderer/markdown-table.ts +459 -0
  47. package/src/renderer/markdown.ts +6 -335
  48. package/src/renderer/model-workspace.ts +48 -5
  49. package/src/renderer/shell-surface.ts +36 -0
  50. package/src/renderer/ui-factory.ts +25 -78
  51. package/src/runtime/bootstrap-command-parts.ts +36 -4
  52. package/src/runtime/bootstrap-core.ts +1 -1
  53. package/src/runtime/durability-housekeeping.ts +183 -0
  54. package/src/runtime/durability-services.ts +68 -11
  55. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  56. package/src/runtime/provider-fallback.ts +16 -2
  57. package/src/runtime/recovery-decisions.ts +295 -0
  58. package/src/runtime/recovery-prompt.ts +66 -6
  59. package/src/runtime/services.ts +9 -4
  60. package/src/runtime/session-liveness-marker.ts +142 -5
  61. package/src/runtime/workstream-draft-store.ts +198 -25
  62. package/src/runtime/workstream-services.ts +17 -2
  63. package/src/shell/ui-openers.ts +1 -1
  64. package/src/version.ts +1 -1
  65. package/src/work-plans/work-plan-store.ts +334 -16
@@ -14,6 +14,53 @@
14
14
  * written and where.
15
15
  */
16
16
 
17
+ /** The provider-registry surface the `@model:` directive needs. */
18
+ export interface AtModelRegistry {
19
+ setCurrentModel(reference: string): void;
20
+ getCurrentModel(): { id: string; provider: string; registryKey: string; displayName: string };
21
+ }
22
+
23
+ export interface AtModelDeps {
24
+ readonly providerRegistry: AtModelRegistry;
25
+ /** Session runtime whose model/provider labels follow an explicit switch. */
26
+ readonly runtime: { model: string; provider: string };
27
+ readonly configManager: { set(key: string, value: unknown): void };
28
+ /** Surfaces the switch confirmation / unknown-model message. */
29
+ readonly notify: (message: string) => void;
30
+ }
31
+
32
+ const AT_MODEL_PATTERN = /@model:([^\s]+)/g;
33
+
34
+ /**
35
+ * Apply every `@model:<id>` directive in a composer submission and return the
36
+ * text with the directives stripped.
37
+ *
38
+ * The switch is a real, persisted selection (config `provider.model`), not a
39
+ * per-turn override, so the runtime labels are updated alongside it and each
40
+ * one is announced. An unknown id is reported as unknown rather than silently
41
+ * dropped — the user asked for a specific backend and did not get it.
42
+ */
43
+ export function applyAtModelDirective(input: string, deps: AtModelDeps): string {
44
+ let text = input;
45
+ let match: RegExpExecArray | null;
46
+ const pattern = new RegExp(AT_MODEL_PATTERN.source, 'g');
47
+ while ((match = pattern.exec(input)) !== null) {
48
+ const modelId = match[1]!;
49
+ try {
50
+ deps.providerRegistry.setCurrentModel(modelId);
51
+ const def = deps.providerRegistry.getCurrentModel();
52
+ deps.runtime.model = def.id;
53
+ deps.runtime.provider = def.provider;
54
+ deps.configManager.set('provider.model', def.registryKey);
55
+ deps.notify(`[Model] Switched to ${def.displayName} (${def.provider}) via @model:`);
56
+ } catch {
57
+ deps.notify(`[Model] Unknown model: ${modelId}`);
58
+ }
59
+ text = text.replace(match[0], '').trim();
60
+ }
61
+ return text;
62
+ }
63
+
17
64
  export interface ComposerCaptureDeps {
18
65
  readonly sessionMemoryStore: { add(text: string): string };
19
66
  /** Surfaces the confirmation / usage message (a system message the shell renders). */
@@ -86,6 +86,10 @@ export function handleCommandModeToken(state: CommandModeRouteState, token: Inpu
86
86
  const raw = selectedCmd ? `/${selectedCmd.name}` : state.prompt.trim();
87
87
  if (raw.startsWith('/') && state.commandRegistry && state.commandContext) {
88
88
  closeCommandMode();
89
+ // Submitting a command is a submission: the splash yields to command
90
+ // input exactly as it does to text input (owner rule), whether or not
91
+ // the command puts anything in the transcript.
92
+ state.conversationManager?.dismissSplash();
89
93
  const parts = raw.slice(1).trim().split(/\s+/);
90
94
  const name = parts[0];
91
95
  const args = parts.slice(1);
@@ -303,6 +303,11 @@ export function handleBlockCopy(
303
303
  }
304
304
  copyToClipboard(nearest.rawContent);
305
305
  onCopied();
306
+ // An explicit action on this block permanently exempts it from the
307
+ // search-close auto-re-collapse (see ConversationManager.noteUserTouch) —
308
+ // copying content the user just had search reveal is a deliberate choice
309
+ // to keep it visible, not an accident of typing a query.
310
+ conversationManager.searchExpansion.noteUserTouch(nearest.collapseKey);
306
311
  conversationManager.log(`[Copied ${describeBlockForReceipt(nearest)}]`, { fg: '#22c55e' });
307
312
  requestRender();
308
313
  setTimeout(() => requestRender(), 2005);
@@ -324,6 +329,8 @@ export function handleBookmark(
324
329
  }
325
330
  const label = `${nearest.type}: ${nearest.rawContent.slice(0, 40).replace(/\n/g, ' ')}`;
326
331
  const added = bookmarkManager.toggle(nearest.collapseKey, label);
332
+ // See handleBlockCopy's note — bookmarking is an explicit block action too.
333
+ conversationManager.searchExpansion.noteUserTouch(nearest.collapseKey);
327
334
  const target = describeBlockForReceipt(nearest);
328
335
  const msg = added
329
336
  ? `[Bookmarked: ${target}]`
@@ -346,6 +353,8 @@ export function handleBlockSave(
346
353
  requestRender();
347
354
  return;
348
355
  }
356
+ // See handleBlockCopy's note — saving to a file is an explicit block action too.
357
+ conversationManager.searchExpansion.noteUserTouch(nearest.collapseKey);
349
358
  const target = describeBlockForReceipt(nearest);
350
359
  try {
351
360
  const filePath = bookmarkManager.saveToFile(nearest.rawContent, nearest.type);
@@ -468,6 +468,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
468
468
  prompt = '';
469
469
  cursorPos = 0;
470
470
  if (name) {
471
+ state.conversationManager?.dismissSplash(); // command input retires the splash, same as text input
471
472
  const executeCommand = state.commandContext.executeCommand;
472
473
  void executeCommand(name, args).then((handled) => {
473
474
  if (!handled) {
@@ -1,4 +1,5 @@
1
1
  import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
2
+ import { servingEffortForLevel, toEffortModel } from '../providers/reasoning-effort-surface.ts';
2
3
  import type { SelectionResult, SelectionAction } from './selection-modal.ts';
3
4
  import type { CommandContext } from './command-registry.ts';
4
5
  import type { ConfigModal } from './config-modal.ts';
@@ -286,7 +287,17 @@ type SettingsRouteState = {
286
287
  function syncRuntimeAfterSettingReset(ctx: CommandContext | undefined, key: string, value: unknown): void {
287
288
  if (!ctx) return;
288
289
  if (key === 'provider.model') ctx.session.runtime.model = String(value);
289
- if (key === 'provider.reasoningEffort') ctx.session.runtime.reasoningEffort = String(value);
290
+ if (key === 'provider.reasoningEffort') {
291
+ // config holds the REQUESTED level; the session holds the EFFECTIVE one for
292
+ // whichever model is serving, so the reset value is resolved rather than
293
+ // copied straight across. A context with no reachable provider registry
294
+ // (a surface that routes only through the provider API) stores the reset
295
+ // value unresolved rather than failing the whole reset.
296
+ const serving = ctx.provider?.providerRegistry?.getCurrentModel?.();
297
+ ctx.session.runtime.reasoningEffort = serving
298
+ ? servingEffortForLevel(String(value), toEffortModel(serving)).effective ?? ''
299
+ : String(value);
300
+ }
290
301
  }
291
302
 
292
303
  function consumeSettingsPickerRequest(state: SettingsRouteState): void {
@@ -7,6 +7,7 @@ import { ensureProviderKeyThenSelect } from './provider-key-intake.ts';
7
7
  import { resolveAndValidatePath } from '@pellux/goodvibes-sdk/platform/utils';
8
8
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import type { BlockActionId } from '../renderer/block-actions.ts';
10
+ import { offersConfigurableEffort, requestedEffortLevel, servingEffortForLevel, toEffortModel } from '../providers/reasoning-effort-surface.ts';
10
11
 
11
12
  type ModelPickerRouteState = {
12
13
  modelPicker: ModelPickerModal;
@@ -18,6 +19,31 @@ type ModelPickerRouteState = {
18
19
  onModelPickerCommit?: () => boolean;
19
20
  };
20
21
 
22
+ /**
23
+ * The user's REQUESTED reasoning level for this session, or undefined when the
24
+ * command context is not attached. Read from config through the shared helper —
25
+ * never from `session.runtime.reasoningEffort`, which holds the EFFECTIVE level
26
+ * for whichever model is serving and would re-seed a resolution with an already
27
+ * snapped-down value.
28
+ */
29
+ function readRequestedEffort(state: ModelPickerRouteState): string | undefined {
30
+ const configManager = state.commandContext?.platform?.configManager;
31
+ if (!configManager) return undefined;
32
+ return requestedEffortLevel(configManager) || undefined;
33
+ }
34
+
35
+ /**
36
+ * The level the effort step should open on for a model about to be selected:
37
+ * the requested level SNAPPED to that model. Opening on the raw requested level
38
+ * would miss the list entirely whenever the target model caps lower, and
39
+ * showEffortPicker falls back to index 0 — landing the cursor on the LOWEST
40
+ * level rather than on what pressing Enter would actually give you.
41
+ */
42
+ function effortStepPreselect(state: ModelPickerRouteState, model: { id: string; provider?: string; displayName?: string }): string {
43
+ const requested = readRequestedEffort(state) ?? 'medium';
44
+ return servingEffortForLevel(requested, toEffortModel(model as never)).effective ?? requested;
45
+ }
46
+
21
47
  export function handleModelPickerToken(state: ModelPickerRouteState, token: InputToken): boolean {
22
48
  if (!state.modelPicker.active) return false;
23
49
 
@@ -61,9 +87,17 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
61
87
  if (mode === 'model') {
62
88
  const selected = state.modelPicker.getSelected();
63
89
  if (selected) {
64
- const currentEffort = state.commandContext?.session.runtime.reasoningEffort ?? 'medium';
65
- if (state.modelPicker.target === 'main' && selected.reasoningEffort && selected.reasoningEffort.length > 0) {
66
- state.modelPicker.showEffortPicker(selected, currentEffort);
90
+ // Preselect the REQUESTED level, not the effective one: the effort step
91
+ // re-chooses the preference, so it must open on what the user asked for
92
+ // even while a model that caps lower is serving.
93
+ const currentEffort = readRequestedEffort(state) ?? 'medium';
94
+ // Whether an effort step appears at all is now this model's own
95
+ // resolved spec: a model with no configurable reasoning level — or
96
+ // one whose levels are only a labelled best guess the adapters will
97
+ // discard — skips straight to commit instead of showing a list it
98
+ // does not honour.
99
+ if (state.modelPicker.target === 'main' && offersConfigurableEffort(toEffortModel(selected))) {
100
+ state.modelPicker.showEffortPicker(selected, effortStepPreselect(state, selected));
67
101
  } else {
68
102
  const target = state.modelPicker.target;
69
103
  const handled = state.onModelPickerCommit?.() ?? false;
@@ -75,6 +109,9 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
75
109
  // (concealed prompt → secrets manager → live re-register) and
76
110
  // only then completes the original selection.
77
111
  ensureProviderKeyThenSelect(ctx, selected.provider, () => {
112
+ // No effort step ran, so `currentEffort` is carried over, not
113
+ // chosen: the commit path re-resolves from the stored
114
+ // preference instead of treating this as a new choice.
78
115
  ctx.completeModelSelection?.({ model: selected, effort: currentEffort, target });
79
116
  });
80
117
  }
@@ -98,7 +135,8 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
98
135
  const ctx = state.commandContext;
99
136
  if (model && effort && !handled && ctx) {
100
137
  ensureProviderKeyThenSelect(ctx, model.provider, () => {
101
- ctx.completeModelSelection?.({ model, effort, target });
138
+ // The effort STEP: the level below is one the user just picked.
139
+ ctx.completeModelSelection?.({ model, effort, target, effortChosenByUser: true });
102
140
  });
103
141
  }
104
142
  } else if (mode === 'embeddingProvider') {
@@ -119,7 +157,7 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
119
157
  } else {
120
158
  state.modelPicker.contextCapError = null;
121
159
  const validCap = parsedCap !== null && parsedCap > 0 && parsedCap <= 10_000_000 ? parsedCap : null;
122
- const effort = state.commandContext?.session.runtime.reasoningEffort ?? 'medium';
160
+ const effort = readRequestedEffort(state) ?? 'medium';
123
161
  const target = state.modelPicker.target;
124
162
  const handled = state.onModelPickerCommit?.() ?? false;
125
163
  state.modelPicker.close();
@@ -256,7 +256,7 @@ export function handleGlobalShortcutToken(
256
256
  return true;
257
257
 
258
258
  case 'search':
259
- if (state.searchManager.active) state.searchManager.close();
259
+ if (state.searchManager.active) state.searchManager.close(state.conversationManager);
260
260
  else state.searchManager.open();
261
261
  state.requestRender();
262
262
  return true;
@@ -223,10 +223,11 @@ export function handleSearchModeToken(
223
223
  searchManager.search(newQuery, history, state.conversationManager);
224
224
  } else if (token.type === 'key') {
225
225
  if (token.logicalName === 'escape') {
226
- searchManager.close();
226
+ searchManager.close(state.conversationManager);
227
227
  } else if (token.logicalName === 'enter' || token.logicalName === 'tab') {
228
228
  if (searchManager.query.length > 0) {
229
229
  searchManager.lock();
230
+ searchManager.revealCurrentMatch(history, state.conversationManager);
230
231
  const matchLine = searchManager.getCurrentMatchLine();
231
232
  if (matchLine >= 0) {
232
233
  state.scroll(matchLine - state.getScrollTop() - Math.floor(state.getViewportHeight() / 2));
@@ -236,21 +237,23 @@ export function handleSearchModeToken(
236
237
  const newQuery = searchManager.query.slice(0, -1);
237
238
  searchManager.search(newQuery, history, state.conversationManager);
238
239
  } else if (matchesSearchShortcut) {
239
- searchManager.close();
240
+ searchManager.close(state.conversationManager);
240
241
  }
241
242
  }
242
243
  } else {
243
244
  if (token.type === 'key') {
244
245
  if (token.logicalName === 'escape' || matchesSearchShortcut) {
245
- searchManager.close();
246
+ searchManager.close(state.conversationManager);
246
247
  } else if (token.logicalName === 'right' || token.logicalName === 'down') {
247
248
  searchManager.nextMatch();
249
+ searchManager.revealCurrentMatch(history, state.conversationManager);
248
250
  const matchLine = searchManager.getCurrentMatchLine();
249
251
  if (matchLine >= 0) {
250
252
  state.scroll(matchLine - state.getScrollTop() - Math.floor(state.getViewportHeight() / 2));
251
253
  }
252
254
  } else if (token.logicalName === 'left' || token.logicalName === 'up') {
253
255
  searchManager.prevMatch();
256
+ searchManager.revealCurrentMatch(history, state.conversationManager);
254
257
  const matchLine = searchManager.getCurrentMatchLine();
255
258
  if (matchLine >= 0) {
256
259
  state.scroll(matchLine - state.getScrollTop() - Math.floor(state.getViewportHeight() / 2));
@@ -261,12 +264,14 @@ export function handleSearchModeToken(
261
264
  } else if (token.type === 'text') {
262
265
  if (token.value === 'n' || token.value === 'j' || token.value === 'l') {
263
266
  searchManager.nextMatch();
267
+ searchManager.revealCurrentMatch(history, state.conversationManager);
264
268
  const matchLine = searchManager.getCurrentMatchLine();
265
269
  if (matchLine >= 0) {
266
270
  state.scroll(matchLine - state.getScrollTop() - Math.floor(state.getViewportHeight() / 2));
267
271
  }
268
272
  } else if (token.value === 'N' || token.value === 'k' || token.value === 'h') {
269
273
  searchManager.prevMatch();
274
+ searchManager.revealCurrentMatch(history, state.conversationManager);
270
275
  const matchLine = searchManager.getCurrentMatchLine();
271
276
  if (matchLine >= 0) {
272
277
  state.scroll(matchLine - state.getScrollTop() - Math.floor(state.getViewportHeight() / 2));
@@ -203,6 +203,21 @@ export function buildProviderItems(
203
203
  // buildEffortItems — effort levels -> PickerItem[]
204
204
  // ---------------------------------------------------------------------------
205
205
 
206
- export function buildEffortItems(effortLevels: readonly string[]): PickerItem[] {
207
- return effortLevels.map(e => ({ id: e, label: e, detail: EFFORT_DESCRIPTIONS[e] ?? '' }));
206
+ /**
207
+ * Effort levels as picker items.
208
+ *
209
+ * `choices` carries the per-spec descriptions (a budget-typed model shows the
210
+ * thinking-token budget each level sends, a toggle says on/off), so it is used
211
+ * when the caller has a resolved presentation. The bare level table is only the
212
+ * fallback for a caller that has levels but no spec.
213
+ */
214
+ export function buildEffortItems(
215
+ effortLevels: readonly string[],
216
+ choices?: readonly { readonly level: string; readonly description: string }[],
217
+ ): PickerItem[] {
218
+ return effortLevels.map((e) => ({
219
+ id: e,
220
+ label: e,
221
+ detail: choices?.find((choice) => choice.level === e)?.description ?? EFFORT_DESCRIPTIONS[e] ?? '',
222
+ }));
208
223
  }
@@ -1,4 +1,9 @@
1
1
  import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
2
+ import {
3
+ effortPresentationForModel,
4
+ toEffortModel,
5
+ type EffortPresentation,
6
+ } from '../providers/reasoning-effort-surface.ts';
2
7
  import type { FavoritesStore } from '@pellux/goodvibes-sdk/platform/providers';
3
8
  import type { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
4
9
  import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
@@ -87,7 +92,14 @@ export class ModelPickerModal {
87
92
  public scrollOffset = 0;
88
93
  public models: ModelDefinition[] = [];
89
94
  public providers: string[] = [];
95
+ /** Levels the model chosen in model-mode actually offers (see showEffortPicker). */
90
96
  public effortLevels: string[] = [];
97
+ /**
98
+ * How to present those levels honestly: the spec kind decides the headline
99
+ * (named levels vs a token budget vs an on/off toggle), and a best-guess
100
+ * spec carries a caveat. Null outside effort mode.
101
+ */
102
+ public effortPresentation: EffortPresentation | null = null;
91
103
  /** Entries for PickerMode 'embeddingProvider' — the 'embeddings' target's own tiny item list. */
92
104
  public embeddingProviders: EmbeddingProviderPickerEntry[] = [];
93
105
  /** Mode to restore when navigating the targets rail away from 'embeddings'. */
@@ -356,7 +368,11 @@ export class ModelPickerModal {
356
368
  this.previousMode = 'model';
357
369
  this.pendingModel = model;
358
370
  this.searchFocused = false;
359
- this.effortLevels = model.reasoningEffort ?? [];
371
+ // Only this model's real levels: the picker used to read a raw list off
372
+ // the definition, which was the same four values for every model.
373
+ const presentation = effortPresentationForModel(toEffortModel(model));
374
+ this.effortPresentation = presentation;
375
+ this.effortLevels = presentation.choices.map((choice) => choice.level);
360
376
  this.mode = 'effort';
361
377
  const idx = this.effortLevels.indexOf(currentEffort);
362
378
  this.selectedIndex = idx >= 0 ? idx : 0;
@@ -376,6 +392,7 @@ export class ModelPickerModal {
376
392
  this.embeddingProviders = [];
377
393
  this.nonEmbeddingMode = 'model';
378
394
  this.pendingModel = null;
395
+ this.effortPresentation = null;
379
396
  this.contextCapPendingModel = null;
380
397
  this.contextCapQuery = '';
381
398
  this.contextCapError = null;
@@ -548,7 +565,7 @@ export class ModelPickerModal {
548
565
  }));
549
566
  }
550
567
  // effort mode
551
- return buildEffortItems(this.effortLevels);
568
+ return buildEffortItems(this.effortLevels, this.effortPresentation?.choices);
552
569
  }
553
570
 
554
571
  /** Get count of selectable (non-header) items in current mode. */