@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
@@ -14,6 +14,7 @@ import {
14
14
  import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, registerPaste } from './handler-content-actions.ts';
15
15
  import type { PanelManager } from '../panels/panel-manager.ts';
16
16
  import type { KeybindingsManager } from './keybindings.ts';
17
+ import type { KillRing } from './kill-ring.ts';
17
18
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
19
 
19
20
  export type PanelFocusRouteState = {
@@ -184,9 +185,12 @@ export type TextRouteState = {
184
185
  filePicker: { open: (insertPos: number, injectMode?: boolean) => void };
185
186
  modalOpened: (name: string) => void;
186
187
  saveUndoState: () => void;
188
+ /** Coalescing undo snapshot for plain text insertions. */
189
+ saveUndoStateForText: () => void;
187
190
  ensureInputCursorVisible: () => void;
188
191
  registerPaste: (content: string) => string;
189
192
  requestRender: () => void;
193
+ killRing: KillRing;
190
194
  };
191
195
 
192
196
  export function handlePromptTextToken(state: TextRouteState, token: InputToken): {
@@ -210,7 +214,8 @@ export function handlePromptTextToken(state: TextRouteState, token: InputToken):
210
214
  if (state.inputHistory?.isBrowsing) {
211
215
  state.inputHistory.resetPosition();
212
216
  }
213
- state.saveUndoState();
217
+ state.killRing.clearYankState();
218
+ state.saveUndoStateForText();
214
219
  const text = state.registerPaste(token.value);
215
220
  let prompt = state.prompt.slice(0, state.cursorPos) + text + state.prompt.slice(state.cursorPos);
216
221
  let cursorPos = state.cursorPos + text.length;
@@ -261,6 +266,8 @@ export type KeyRouteState = {
261
266
  processModal: { open: () => void };
262
267
  modalOpened: (name: string) => void;
263
268
  saveUndoState: () => void;
269
+ /** Break the undo coalescing group (call on cursor moves). */
270
+ breakUndoCoalesce: () => void;
264
271
  ensureInputCursorVisible: (contentWidth?: number) => void;
265
272
  getWrappedPromptInfo: (contentWidth: number) => WrappedPromptInfo;
266
273
  moveCursorVertical: (direction: -1 | 1) => boolean;
@@ -272,6 +279,7 @@ export type KeyRouteState = {
272
279
  scroll: (delta: number) => void;
273
280
  exitApp: () => void;
274
281
  requestRender: () => void;
282
+ killRing: KillRing;
275
283
  };
276
284
 
277
285
  export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
@@ -372,6 +380,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
372
380
 
373
381
  if (token.logicalName === 'backspace') {
374
382
  if (cursorPos > 0) {
383
+ state.killRing.clearYankState();
375
384
  state.saveUndoState();
376
385
  let marker = state.findMarkerAtPos(cursorPos);
377
386
  if (!marker) {
@@ -396,6 +405,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
396
405
 
397
406
  if (token.logicalName === 'delete') {
398
407
  if (cursorPos < prompt.length) {
408
+ state.killRing.clearYankState();
399
409
  state.saveUndoState();
400
410
  const marker = state.findMarkerAtPos(cursorPos + 1);
401
411
  if (marker) {
@@ -417,6 +427,8 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
417
427
  cursorPos = marker ? marker.start : cursorPos - 1;
418
428
  ensureLocalInputCursorVisible();
419
429
  }
430
+ state.killRing.clearYankState();
431
+ state.breakUndoCoalesce();
420
432
  state.requestRender();
421
433
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
422
434
  }
@@ -427,18 +439,24 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
427
439
  cursorPos = marker ? marker.end : cursorPos + 1;
428
440
  ensureLocalInputCursorVisible();
429
441
  }
442
+ state.killRing.clearYankState();
443
+ state.breakUndoCoalesce();
430
444
  state.requestRender();
431
445
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
432
446
  }
433
447
 
434
448
  if (token.logicalName === 'home') {
435
449
  cursorPos = 0;
450
+ state.killRing.clearYankState();
451
+ state.breakUndoCoalesce();
436
452
  ensureLocalInputCursorVisible();
437
453
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
438
454
  }
439
455
 
440
456
  if (token.logicalName === 'end') {
441
457
  cursorPos = prompt.length;
458
+ state.killRing.clearYankState();
459
+ state.breakUndoCoalesce();
442
460
  ensureLocalInputCursorVisible();
443
461
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
444
462
  }
@@ -44,6 +44,7 @@ import { SelectionManager } from './selection.ts';
44
44
  import type { PanelManager } from '../panels/panel-manager.ts';
45
45
  import type { KeybindingsManager } from './keybindings.ts';
46
46
  import type { ModelPickerTarget } from './model-picker.ts';
47
+ import type { KillRing } from './kill-ring.ts';
47
48
 
48
49
  /**
49
50
  * InputFeedContext — The single long-lived context object passed to feedInputTokens
@@ -129,6 +130,7 @@ export interface InputFeedContext {
129
130
  readonly modalStack: string[];
130
131
  inputHistory: InputHistory | null;
131
132
  conversationManager: ConversationManager | null;
133
+ readonly killRing: KillRing;
132
134
  readonly getHistory: () => InfiniteBuffer;
133
135
  readonly getViewportHeight: () => number;
134
136
  readonly getScrollTop: () => number;
@@ -146,6 +148,10 @@ export interface InputFeedContext {
146
148
  readonly handleRedo: () => void;
147
149
  readonly handlePaste: () => void;
148
150
  readonly saveUndoState: () => void;
151
+ /** Coalescing variant: consecutive text insertions within UNDO_COALESCE_MS merge into one group. */
152
+ readonly saveUndoStateForText: () => void;
153
+ /** Break the current coalescing group (cursor moves call this). */
154
+ readonly breakUndoCoalesce: () => void;
149
155
  readonly ensureInputCursorVisible: (contentWidth?: number) => void;
150
156
  readonly registerPaste: (content: string) => string;
151
157
  readonly executeBlockAction: (id: string) => void;
@@ -283,6 +289,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
283
289
  cyclePanelTab: context.cyclePanelTab,
284
290
  panelManager: context.panelManager,
285
291
  keybindingsManager: context.keybindingsManager,
292
+ killRing: context.killRing,
286
293
  };
287
294
  if (handleGlobalShortcutToken(shortcutState, token, viewportHeight)) {
288
295
  context.prompt = shortcutState.prompt;
@@ -336,9 +343,11 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
336
343
  filePicker: context.filePicker,
337
344
  modalOpened: context.modalOpened,
338
345
  saveUndoState: context.saveUndoState,
346
+ saveUndoStateForText: context.saveUndoStateForText,
339
347
  ensureInputCursorVisible: () => context.ensureInputCursorVisible(),
340
348
  registerPaste: context.registerPaste,
341
349
  requestRender: context.requestRender,
350
+ killRing: context.killRing,
342
351
  }, token);
343
352
  if (textRoute.handled) {
344
353
  context.prompt = textRoute.prompt;
@@ -395,6 +404,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
395
404
  processModal: context.processModal,
396
405
  modalOpened: context.modalOpened,
397
406
  saveUndoState: context.saveUndoState,
407
+ breakUndoCoalesce: context.breakUndoCoalesce,
398
408
  ensureInputCursorVisible: context.ensureInputCursorVisible,
399
409
  getWrappedPromptInfo: context.getWrappedPromptInfo,
400
410
  moveCursorVertical: context.moveCursorVertical,
@@ -406,6 +416,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
406
416
  scroll: context.scroll,
407
417
  exitApp: context.exitApp,
408
418
  requestRender: context.requestRender,
419
+ killRing: context.killRing,
409
420
  }, token);
410
421
  if (keyRoute.handled) {
411
422
  context.prompt = keyRoute.prompt;
@@ -12,6 +12,34 @@ export type WrappedPromptInfo = {
12
12
 
13
13
  export type UndoState = { prompt: string; cursorPos: number };
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // Undo coalescing support
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Milliseconds within which consecutive text insertions are merged into one
20
+ * undo group. Cursor moves or kill/yank operations always break the group. */
21
+ export const UNDO_COALESCE_MS = 500;
22
+
23
+ export type EditKind = 'text' | 'kill' | 'yank' | 'other';
24
+
25
+ /**
26
+ * shouldCoalesceUndo — returns true when the new edit should be merged into
27
+ * the most recent undo group rather than creating a new snapshot.
28
+ *
29
+ * Coalesces only when:
30
+ * - Both the last edit and the incoming edit are plain text insertions
31
+ * - The time delta is within UNDO_COALESCE_MS
32
+ */
33
+ export function shouldCoalesceUndo(
34
+ lastEditKind: EditKind,
35
+ incomingKind: EditKind,
36
+ lastEditMs: number,
37
+ nowMs: number,
38
+ ): boolean {
39
+ if (lastEditKind !== 'text' || incomingKind !== 'text') return false;
40
+ return (nowMs - lastEditMs) < UNDO_COALESCE_MS;
41
+ }
42
+
15
43
  export function wordWrapLine(line: string, maxW: number): string[] {
16
44
  if (maxW <= 0) return [line];
17
45
  if (line.length === 0) return [''];
@@ -6,6 +6,8 @@ import type { ConversationManager } from '../core/conversation';
6
6
  import type { AutocompleteEngine } from './autocomplete.ts';
7
7
  import type { PanelManager } from '../panels/panel-manager.ts';
8
8
  import type { KeybindingsManager } from './keybindings.ts';
9
+ import type { KillRing } from './kill-ring.ts';
10
+ import { wordBoundaryBack, wordBoundaryForward } from './kill-ring.ts';
9
11
 
10
12
  type WrappedPromptInfo = {
11
13
  wrappedLines: string[];
@@ -43,6 +45,7 @@ export type GlobalShortcutRouteState = {
43
45
  handlePaste: () => void;
44
46
  handleEscape: () => void;
45
47
  cyclePanelTab: (direction: 'next' | 'prev') => void;
48
+ killRing: KillRing;
46
49
  };
47
50
 
48
51
  export function handleGlobalShortcutToken(
@@ -148,6 +151,8 @@ export function handleGlobalShortcutToken(
148
151
  let pos = state.cursorPos;
149
152
  while (pos > 0 && state.prompt[pos - 1] === ' ') pos--;
150
153
  while (pos > 0 && state.prompt[pos - 1] !== ' ') pos--;
154
+ const killedWord = state.prompt.slice(pos, state.cursorPos);
155
+ if (killedWord) { state.killRing.push(killedWord); state.killRing.clearYankState(); }
151
156
  state.prompt = state.prompt.slice(0, pos) + state.prompt.slice(state.cursorPos);
152
157
  state.cursorPos = pos;
153
158
  state.ensureInputCursorVisible();
@@ -179,13 +184,18 @@ export function handleGlobalShortcutToken(
179
184
  return true;
180
185
  }
181
186
 
182
- case 'kill-line':
187
+ case 'kill-line': {
188
+ const killed = state.prompt.slice(state.cursorPos);
183
189
  state.saveUndoState();
190
+ state.killRing.push(killed);
191
+ state.killRing.clearYankState();
184
192
  state.prompt = state.prompt.slice(0, state.cursorPos);
185
193
  state.ensureInputCursorVisible();
186
194
  return true;
195
+ }
187
196
 
188
- case 'clear-prompt':
197
+ case 'clear-prompt': {
198
+ // Legacy full-clear: keep as alias but do NOT call this when kill-to-start is bound.
189
199
  state.saveUndoState();
190
200
  state.prompt = '';
191
201
  state.cursorPos = 0;
@@ -194,6 +204,82 @@ export function handleGlobalShortcutToken(
194
204
  state.autocomplete?.reset();
195
205
  }
196
206
  return true;
207
+ }
208
+
209
+ case 'kill-to-start': {
210
+ // Kill from start of buffer to cursor, push to ring.
211
+ const killed = state.prompt.slice(0, state.cursorPos);
212
+ state.saveUndoState();
213
+ state.killRing.push(killed);
214
+ state.killRing.clearYankState();
215
+ state.prompt = state.prompt.slice(state.cursorPos);
216
+ state.cursorPos = 0;
217
+ state.ensureInputCursorVisible();
218
+ return true;
219
+ }
220
+
221
+ case 'kill-word-forward': {
222
+ // Kill from cursor to end of next word, push to ring.
223
+ const end = wordBoundaryForward(state.prompt, state.cursorPos);
224
+ const killed = state.prompt.slice(state.cursorPos, end);
225
+ if (killed) {
226
+ state.saveUndoState();
227
+ state.killRing.push(killed);
228
+ state.killRing.clearYankState();
229
+ state.prompt = state.prompt.slice(0, state.cursorPos) + state.prompt.slice(end);
230
+ state.ensureInputCursorVisible();
231
+ }
232
+ return true;
233
+ }
234
+
235
+ case 'word-back': {
236
+ const newPos = wordBoundaryBack(state.prompt, state.cursorPos);
237
+ if (newPos !== state.cursorPos) {
238
+ state.killRing.clearYankState();
239
+ state.cursorPos = newPos;
240
+ state.ensureInputCursorVisible();
241
+ }
242
+ return true;
243
+ }
244
+
245
+ case 'word-forward': {
246
+ const newPos = wordBoundaryForward(state.prompt, state.cursorPos);
247
+ if (newPos !== state.cursorPos) {
248
+ state.killRing.clearYankState();
249
+ state.cursorPos = newPos;
250
+ state.ensureInputCursorVisible();
251
+ }
252
+ return true;
253
+ }
254
+
255
+ case 'yank': {
256
+ const text = state.killRing.yank();
257
+ if (text) {
258
+ state.saveUndoState();
259
+ state.prompt = state.prompt.slice(0, state.cursorPos) + text + state.prompt.slice(state.cursorPos);
260
+ state.cursorPos += text.length;
261
+ state.ensureInputCursorVisible();
262
+ }
263
+ return true;
264
+ }
265
+
266
+ case 'yank-pop': {
267
+ // Only valid immediately after a yank or yank-pop.
268
+ if (!state.killRing.lastActionWasYank) return false;
269
+ // Undo the previous yank by restoring: we store the pre-yank snapshot on
270
+ // the undo stack so a single undo covers the whole yank sequence.
271
+ // For yank-pop: replace the last yanked text with the next ring entry.
272
+ // We rely on the undo stack having the pre-yank state at the top.
273
+ state.handleUndo();
274
+ const text = state.killRing.yankPop();
275
+ if (text) {
276
+ state.saveUndoState();
277
+ state.prompt = state.prompt.slice(0, state.cursorPos) + text + state.prompt.slice(state.cursorPos);
278
+ state.cursorPos += text.length;
279
+ state.ensureInputCursorVisible();
280
+ }
281
+ return true;
282
+ }
197
283
 
198
284
  case 'undo':
199
285
  state.handleUndo();
@@ -267,13 +267,13 @@ export function handleSearchModeToken(
267
267
  searchManager.unlock();
268
268
  }
269
269
  } else if (token.type === 'text') {
270
- if (token.value === 'j' || token.value === 'l') {
270
+ if (token.value === 'n' || token.value === 'j' || token.value === 'l') {
271
271
  searchManager.nextMatch();
272
272
  const matchLine = searchManager.getCurrentMatchLine();
273
273
  if (matchLine >= 0) {
274
274
  state.scroll(matchLine - state.getScrollTop() - Math.floor(state.getViewportHeight() / 2));
275
275
  }
276
- } else if (token.value === 'k' || token.value === 'h') {
276
+ } else if (token.value === 'N' || token.value === 'k' || token.value === 'h') {
277
277
  searchManager.prevMatch();
278
278
  const matchLine = searchManager.getCurrentMatchLine();
279
279
  if (matchLine >= 0) {
@@ -76,10 +76,12 @@ import {
76
76
  moveCursorVertical,
77
77
  redoPromptState,
78
78
  saveUndoState,
79
+ shouldCoalesceUndo,
79
80
  undoPromptState,
80
81
  wordWrapLine,
81
82
  type WrappedPromptInfo,
82
83
  } from './handler-prompt-buffer.ts';
84
+ import { KillRing } from './kill-ring.ts';
83
85
  import { clearModalStack, handleEscape, modalOpened } from './handler-modal-stack.ts';
84
86
  import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
85
87
  import {
@@ -198,7 +200,15 @@ export class InputHandler implements InputHandlerLike {
198
200
  // ── Undo / Redo ────────────────────────────────────────────────────────────
199
201
  public undoStack: Array<{ prompt: string; cursorPos: number }> = [];
200
202
  public redoStack: Array<{ prompt: string; cursorPos: number }> = [];
201
- public static readonly MAX_UNDO = 50;
203
+ /** Maximum undo groups retained. Oldest are evicted when the limit is hit. */
204
+ public static readonly MAX_UNDO = 100;
205
+ /** Timestamp (Date.now()) of the last saveUndoState call, used for coalescing. */
206
+ public lastUndoMs = 0;
207
+ /** Edit kind of the last saveUndoState call, used for coalescing. */
208
+ public lastUndoKind: import('./handler-prompt-buffer.ts').EditKind = 'other';
209
+
210
+ // ── Kill ring ───────────────────────────────────────────────────────────────
211
+ public killRing = new KillRing();
202
212
 
203
213
  // ── Path completion (Tab on path-like token) ───────────────────────────────
204
214
  /** Current list of path completions cycling on repeated Tab presses. */
@@ -301,6 +311,7 @@ export class InputHandler implements InputHandlerLike {
301
311
  conversationManager: this.conversationManager,
302
312
  panelManager: this.uiServices.shell.panelManager,
303
313
  keybindingsManager: this.uiServices.shell.keybindingsManager,
314
+ killRing: this.killRing,
304
315
  getHistory: this.getHistory,
305
316
  getViewportHeight: this.getViewportHeight,
306
317
  getScrollTop: this.getScrollTop,
@@ -320,6 +331,8 @@ export class InputHandler implements InputHandlerLike {
320
331
  handleRedo: () => { this.handleRedo(); this.syncFeedContextMutableFields(); },
321
332
  handlePaste: () => { this.handlePaste(); this.syncFeedContextMutableFields(); },
322
333
  saveUndoState: () => this.saveUndoState(),
334
+ saveUndoStateForText: () => this.saveUndoStateForText(),
335
+ breakUndoCoalesce: () => { this.lastUndoKind = 'other'; },
323
336
  ensureInputCursorVisible: (contentWidth?: number) => this.ensureInputCursorVisible(contentWidth),
324
337
  registerPaste: (content: string) => this.registerPaste(content),
325
338
  executeBlockAction: (id: string) => this.executeBlockAction(id),
@@ -612,11 +625,34 @@ export class InputHandler implements InputHandlerLike {
612
625
  // ── Undo / Redo methods ─────────────────────────────────────────────────
613
626
 
614
627
  /**
615
- * saveUndoState - Snapshot current prompt + cursor onto the undo stack.
616
- * Clears the redo stack because a new edit invalidates future states.
628
+ * saveUndoState - Unconditionally snapshot current prompt + cursor onto the
629
+ * undo stack (kill, yank, delete, or other non-text edits). Clears redo stack.
617
630
  */
618
631
  public saveUndoState(): void {
619
632
  saveUndoState(this.undoStack, this.redoStack, this.prompt, this.cursorPos, InputHandler.MAX_UNDO);
633
+ this.lastUndoMs = Date.now();
634
+ this.lastUndoKind = 'other';
635
+ }
636
+
637
+ /**
638
+ * saveUndoStateForText - Snapshot with coalescing for plain text insertions.
639
+ * Consecutive text edits within UNDO_COALESCE_MS are merged into one group
640
+ * (the snapshot is skipped; the group absorbs the characters typed in the
641
+ * burst). Cursor moves, kill, and yank operations break the group.
642
+ *
643
+ * Call this instead of saveUndoState() from the text-insertion path only.
644
+ */
645
+ public saveUndoStateForText(): void {
646
+ const now = Date.now();
647
+ if (shouldCoalesceUndo(this.lastUndoKind, 'text', this.lastUndoMs, now)) {
648
+ // Coalesce: skip the snapshot but update the timestamp so the window
649
+ // keeps sliding until the burst ends.
650
+ this.lastUndoMs = now;
651
+ return;
652
+ }
653
+ saveUndoState(this.undoStack, this.redoStack, this.prompt, this.cursorPos, InputHandler.MAX_UNDO);
654
+ this.lastUndoMs = now;
655
+ this.lastUndoKind = 'text';
620
656
  }
621
657
 
622
658
  /**
@@ -51,7 +51,13 @@ export type KeyAction =
51
51
  | 'undo'
52
52
  | 'redo'
53
53
  | 'paste'
54
- | 'replay-panel';
54
+ | 'replay-panel'
55
+ | 'word-back'
56
+ | 'word-forward'
57
+ | 'kill-to-start'
58
+ | 'kill-word-forward'
59
+ | 'yank'
60
+ | 'yank-pop';
55
61
 
56
62
  /** Human-readable description for each action (used in /keybindings display). */
57
63
  export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
@@ -72,11 +78,17 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
72
78
  'apply-diff-line-start': 'Apply nearest diff / move to line start',
73
79
  'next-error-line-end': 'Navigate to next error / move to line end',
74
80
  'kill-line': 'Kill to end of line',
75
- 'clear-prompt': 'Clear the prompt',
81
+ 'clear-prompt': 'Clear the entire prompt (Alt+U; kill-to-start owns Ctrl+U)',
76
82
  'undo': 'Undo last prompt edit',
77
83
  'redo': 'Redo last undone edit',
78
84
  'paste': 'Paste from clipboard (image priority)',
79
85
  'replay-panel': 'Open / close the Replay panel',
86
+ 'word-back': 'Move cursor to start of previous word (Alt+B)',
87
+ 'word-forward': 'Move cursor to end of next word (Alt+F)',
88
+ 'kill-to-start': 'Kill from cursor to start of line into kill ring (Ctrl+U)',
89
+ 'kill-word-forward': 'Kill word forward into kill ring (Alt+D)',
90
+ 'yank': 'Yank (paste) from kill ring (Ctrl+Shift+Y)',
91
+ 'yank-pop': 'Rotate kill ring and yank next entry (Alt+Y)',
80
92
  };
81
93
 
82
94
  /** Default key bindings for all actions. */
@@ -98,11 +110,29 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
98
110
  'apply-diff-line-start': [{ key: 'a', ctrl: true }],
99
111
  'next-error-line-end': [{ key: 'e', ctrl: true }],
100
112
  'kill-line': [{ key: 'k', ctrl: true }],
101
- 'clear-prompt': [{ key: 'u', ctrl: true }],
113
+ // Alt+U: clear entire prompt. Ctrl+U is owned by kill-to-start (readline
114
+ // convention). Alt+U is unused by any other default and is representable by
115
+ // the tokenizer's { key, alt } combo form.
116
+ 'clear-prompt': [{ key: 'u', alt: true }],
102
117
  'undo': [{ key: 'z', ctrl: true }],
103
118
  'redo': [{ key: 'z', ctrl: true, shift: true }],
104
119
  'paste': [{ key: 'v', ctrl: true }],
105
120
  'replay-panel': [{ key: 'r', ctrl: true, shift: true }],
121
+ // Word navigation (Alt+B / Alt+F — emacs readline standard)
122
+ 'word-back': [{ key: 'b', alt: true }],
123
+ 'word-forward': [{ key: 'f', alt: true }],
124
+ // Kill-ring operations.
125
+ // Note: 'kill-line' (Ctrl+K) kills to end; 'kill-to-start' (Ctrl+U) kills to start.
126
+ // 'clear-prompt' (Alt+U) clears the entire buffer regardless of cursor position.
127
+ // kill-to-start owns Ctrl+U (readline convention); clear-prompt uses Alt+U.
128
+ 'kill-to-start': [{ key: 'u', ctrl: true }],
129
+ // Alt+D: kill word forward (no prior conflict)
130
+ 'kill-word-forward': [{ key: 'd', alt: true }],
131
+ // Ctrl+Shift+Y: yank from kill ring.
132
+ // CONFLICT RESOLVED: Ctrl+Y was 'block-copy'; yank moved to Ctrl+Shift+Y.
133
+ 'yank': [{ key: 'y', ctrl: true, shift: true }],
134
+ // Alt+Y: yank-pop (rotate ring after yank)
135
+ 'yank-pop': [{ key: 'y', alt: true }],
106
136
  };
107
137
 
108
138
  /** Resolved overrides type: each key can be a single combo or array. */
@@ -0,0 +1,134 @@
1
+ // ---------------------------------------------------------------------------
2
+ // KillRing — emacs/readline-compatible kill ring implementation.
3
+ //
4
+ // The ring holds up to MAX_ENTRIES text strings. Kill commands push to the
5
+ // head. Yank pastes from the current yank pointer (default: head). Yank-pop
6
+ // rotates the pointer one step further into the ring without adding a new
7
+ // entry. Consecutive yank-pops cycle through all ring entries.
8
+ //
9
+ // Word-boundary helpers are co-located here because they are used by both
10
+ // kill-word-back (Ctrl+W) and kill-word-forward (Alt+D).
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export const KILL_RING_MAX = 32;
14
+
15
+ /**
16
+ * KillRing — bounded circular ring of killed text segments.
17
+ *
18
+ * All public mutation methods are pure-functional helpers at module level;
19
+ * this class owns the mutable state so the handler can hold a single ref.
20
+ */
21
+ export class KillRing {
22
+ private entries: string[] = [];
23
+ /** Index into `entries` for the next yank. -1 means the ring is empty. */
24
+ private yankPointer = -1;
25
+ /** Whether the last edit action was a yank (enables yank-pop). */
26
+ public lastActionWasYank = false;
27
+
28
+ /**
29
+ * Push a text segment onto the head of the ring.
30
+ * Trims the ring to MAX_ENTRIES. Resets the yank pointer to 0 (head).
31
+ * Empty strings are silently ignored.
32
+ */
33
+ push(text: string): void {
34
+ if (!text) return;
35
+ this.entries.unshift(text);
36
+ if (this.entries.length > KILL_RING_MAX) {
37
+ this.entries.length = KILL_RING_MAX;
38
+ }
39
+ this.yankPointer = 0;
40
+ this.lastActionWasYank = false;
41
+ }
42
+
43
+ /**
44
+ * Yank — return the entry at the current yank pointer.
45
+ * Returns '' if the ring is empty.
46
+ * Sets lastActionWasYank so a subsequent yank-pop is valid.
47
+ */
48
+ yank(): string {
49
+ if (this.entries.length === 0) return '';
50
+ this.yankPointer = Math.max(0, Math.min(this.yankPointer, this.entries.length - 1));
51
+ this.lastActionWasYank = true;
52
+ return this.entries[this.yankPointer] ?? '';
53
+ }
54
+
55
+ /**
56
+ * YankPop — advance the yank pointer by one step (wrapping) and return the
57
+ * new entry. Only valid after a yank; if the ring has <=1 entry, returns the
58
+ * same string. Returns '' if the ring is empty.
59
+ */
60
+ yankPop(): string {
61
+ if (this.entries.length === 0) return '';
62
+ this.yankPointer = (this.yankPointer + 1) % this.entries.length;
63
+ this.lastActionWasYank = true;
64
+ return this.entries[this.yankPointer] ?? '';
65
+ }
66
+
67
+ /** True when there is at least one entry in the ring. */
68
+ get hasEntries(): boolean {
69
+ return this.entries.length > 0;
70
+ }
71
+
72
+ /** Current ring contents (newest first). Read-only snapshot for tests. */
73
+ getEntries(): readonly string[] {
74
+ return this.entries;
75
+ }
76
+
77
+ /** Reset yank state when the user makes a non-yank edit. */
78
+ clearYankState(): void {
79
+ this.lastActionWasYank = false;
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Word boundary helpers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * isWordChar — true when the character is a "word" character.
89
+ * Word = letter (Unicode), digit, or underscore. This is the emacs/readline
90
+ * word boundary definition used for Alt+B, Alt+F, Ctrl+W, Alt+D.
91
+ */
92
+ function isWordChar(ch: string): boolean {
93
+ return /[\p{L}\p{N}_]/u.test(ch);
94
+ }
95
+
96
+ /**
97
+ * wordBoundaryBack — find the start of the word that the cursor is in, or the
98
+ * start of the previous word if the cursor is at a non-word character.
99
+ *
100
+ * Emacs Alt+B semantics:
101
+ * - Skip non-word chars backward
102
+ * - Skip word chars backward
103
+ * - Return resulting position
104
+ *
105
+ * Returns the new cursor position (>= 0).
106
+ */
107
+ export function wordBoundaryBack(text: string, pos: number): number {
108
+ let p = pos;
109
+ // Skip non-word chars first (move past punctuation/spaces)
110
+ while (p > 0 && !isWordChar(text[p - 1]!)) p--;
111
+ // Then skip word chars (the word body)
112
+ while (p > 0 && isWordChar(text[p - 1]!)) p--;
113
+ return p;
114
+ }
115
+
116
+ /**
117
+ * wordBoundaryForward — find the position just past the end of the next word.
118
+ *
119
+ * Emacs Alt+F semantics:
120
+ * - Skip non-word chars forward
121
+ * - Skip word chars forward
122
+ * - Return resulting position
123
+ *
124
+ * Returns the new cursor position (<= text.length).
125
+ */
126
+ export function wordBoundaryForward(text: string, pos: number): number {
127
+ let p = pos;
128
+ const len = text.length;
129
+ // Skip non-word chars first
130
+ while (p < len && !isWordChar(text[p]!)) p++;
131
+ // Then skip word chars
132
+ while (p < len && isWordChar(text[p]!)) p++;
133
+ return p;
134
+ }
@@ -67,7 +67,7 @@ export class ModelPickerModal {
67
67
  constructor(
68
68
  private readonly favoritesStore: Pick<FavoritesStore, 'getRecentModels'>,
69
69
  private readonly benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
70
- private readonly providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
70
+ private readonly providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog' | 'getSyntheticCanonicalModels'>,
71
71
  ) {}
72
72
 
73
73
  public active = false;
@@ -547,6 +547,23 @@ export class ModelPickerModal {
547
547
  return this.providerRegistry.getSyntheticModelInfoFromCatalog(modelId);
548
548
  }
549
549
 
550
+ /**
551
+ * Returns the ordered backend ladder for a synthetic model.
552
+ * Each entry describes a real provider/model that the synthetic model routes to.
553
+ * Returns null when the model ID is not found in the synthetic catalog.
554
+ */
555
+ getSyntheticChain(modelId: string): Array<{ position: number; provider: string; model: string; registryKey: string }> | null {
556
+ const canonical = this.providerRegistry.getSyntheticCanonicalModels();
557
+ const entry = canonical.find((m) => m.id === modelId);
558
+ if (!entry) return null;
559
+ return entry.backends.map((b, idx) => ({
560
+ position: idx,
561
+ provider: b.providerName,
562
+ model: b.modelId,
563
+ registryKey: b.registryKey ?? `${b.providerName}:${b.modelId}`,
564
+ }));
565
+ }
566
+
550
567
  getBenchmarkEntry(model: ModelDefinition) {
551
568
  return this.benchmarkStore.getBenchmarks(model.id) ?? this.benchmarkStore.getBenchmarks(model.displayName);
552
569
  }