@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
@@ -365,7 +365,7 @@ function handleFallbackTest(
365
365
  export const providerCommand: SlashCommand = {
366
366
  name: 'provider-opt',
367
367
  aliases: ['prov-opt'],
368
- description: 'Manage provider routing optimizer (route, pin, explain, fallback).',
368
+ description: 'Manage provider routing optimizer (route, pin, explain, fallback)',
369
369
  usage: '<subcommand> [args]',
370
370
  argsHint: 'optimizer|route|explain-route|pin|fallback',
371
371
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -12,7 +12,6 @@ export function registerQrcodeRuntimeCommands(registry: CommandRegistry): void {
12
12
  name: 'qrcode',
13
13
  aliases: ['qr', 'pair'],
14
14
  description: 'Open the QR code panel for companion app pairing',
15
- usage: '',
16
15
  handler(_args, ctx) {
17
16
  openCommandPanel(ctx, 'qr-code');
18
17
  },
@@ -162,7 +162,7 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
162
162
  registry.register({
163
163
  name: 'undo',
164
164
  aliases: [],
165
- description: 'Undo last action. /undo file — revert last file write/edit. /undo — remove last conversation turn.',
165
+ description: 'Undo last action. /undo file — revert last file write/edit. /undo — remove last conversation turn',
166
166
  usage: '[file]',
167
167
  argsHint: '[file]',
168
168
  handler(args, ctx) {
@@ -191,7 +191,7 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
191
191
 
192
192
  registry.register({
193
193
  name: 'redo',
194
- description: 'Redo last undone action. /redo file — re-apply last reverted file. /redo — restore conversation turn.',
194
+ description: 'Redo last undone action. /redo file — re-apply last reverted file. /redo — restore conversation turn',
195
195
  usage: '[file]',
196
196
  argsHint: '[file]',
197
197
  handler(args, ctx) {
@@ -6,7 +6,8 @@ import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
6
6
  import type { ConversationTitleSource } from '../../core/conversation';
7
7
  import type { SessionReturnContextSummary } from '@/runtime/index.ts';
8
8
  import { formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
9
- import { requirePanelManager, requireProviderApi, requireSessionManager } from './runtime-services.ts';
9
+ import { requirePanelManager, requireProviderApi, requireSessionManager, requireShellPaths } from './runtime-services.ts';
10
+ import { replayJournalForSession } from '../../core/session-recovery.ts';
10
11
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
12
 
12
13
  function parseTranscriptKind(raw: string | undefined): TranscriptEventKind | 'all' {
@@ -240,6 +241,26 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
240
241
  ctx.session.conversationManager.fromJSON({ messages: messages as never[], title: meta.title, titleSource: meta.titleSource });
241
242
  ctx.session.conversationManager.rebuildHistory();
242
243
  ctx.session.runtime.sessionId = found.name;
244
+
245
+ // Journal replay: recover turns that post-date the loaded snapshot.
246
+ const shellPaths = requireShellPaths(ctx);
247
+ const journalReplay = replayJournalForSession({
248
+ homeDirectory: shellPaths.homeDirectory,
249
+ snapshotTimestamp: meta.timestamp,
250
+ conversation: ctx.session.conversationManager,
251
+ sessionId: found.name,
252
+ persistSnapshot: (replayedMessages) => {
253
+ sm.save(found.name, replayedMessages as never[], {
254
+ title: ctx.session.conversationManager.title || meta.title,
255
+ model: meta.model,
256
+ provider: meta.provider,
257
+ timestamp: Date.now(),
258
+ titleSource: meta.titleSource,
259
+ returnContext: meta.returnContext,
260
+ });
261
+ },
262
+ });
263
+
243
264
  if (meta.model) {
244
265
  try {
245
266
  const selected = await providerApi.selectModel(meta.model);
@@ -252,7 +273,16 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
252
273
  }
253
274
  if (meta.provider) ctx.session.runtime.provider = meta.provider;
254
275
  ctx.renderRequest();
255
- ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${messages.length}\n Model: ${meta.model || ctx.session.runtime.model}`);
276
+ const resumedMsgCount = ctx.session.conversationManager.getMessageCount();
277
+ ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${resumedMsgCount}\n Model: ${meta.model || ctx.session.runtime.model}`);
278
+ if (journalReplay.replayed > 0) {
279
+ ctx.print(` [Recovery] Replayed ${journalReplay.replayed} journal record(s) — restored turns since last snapshot.`);
280
+ }
281
+ if (journalReplay.hadCorruptTail && journalReplay.replayed === 0) {
282
+ ctx.print(' [Recovery] Journal tail was corrupt or unrecognised (quarantined). Proceeding with snapshot only.');
283
+ } else if (journalReplay.hadCorruptTail) {
284
+ ctx.print(' [Recovery] Journal tail was partially corrupt (quarantined). Replay stopped at last good record.');
285
+ }
256
286
  const reopenedPanels = reopenPanelsFromReturnContext(ctx, meta.returnContext);
257
287
  const returnContextMode = getReturnContextMode(ctx.platform.configManager);
258
288
  if (returnContextMode !== 'off' && meta.returnContext) {
@@ -339,7 +339,7 @@ function handleCancel(args: string[], context: CommandContext): void {
339
339
  export const sessionCommand: SlashCommand = {
340
340
  name: 'session',
341
341
  aliases: ['sess'],
342
- description: 'Session lifecycle and orchestration: list, resume, fork, save, export, link-task, handoff, graph, cancel.',
342
+ description: 'Session lifecycle and orchestration: list, resume, fork, save, export, link-task, handoff, graph, cancel',
343
343
  usage: '<subcommand> [args]',
344
344
  argsHint: 'list|rename|resume|fork|save|info|export|search|delete|events|groups|hotspots|link-task|handoff|graph|cancel',
345
345
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -24,8 +24,8 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
24
24
 
25
25
  export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry): void {
26
26
  registry.register({
27
- name: 'settingssync',
28
- aliases: ['settings-sync'],
27
+ name: 'settings-sync',
28
+ aliases: ['settingssync'],
29
29
  description: 'Review sync posture, export/import settings-sync bundles, and open the settings sync workspace',
30
30
  usage: '[review|panel|show <key>|staged|conflicts|resolve <key> <local|synced>|failures|rollback-history|export <path>|inspect <path>|pull <path>|push <path>|lock <key> <source> <reason...>|unlock <key>]',
31
31
  handler(args, ctx) {
@@ -39,7 +39,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
39
39
  if (sub === 'show') {
40
40
  const key = args[1] as ConfigKey | undefined;
41
41
  if (!key || !CONFIG_KEYS.has(key)) {
42
- ctx.print('Usage: /settingssync show <config-key>');
42
+ ctx.print('Usage: /settings-sync show <config-key>');
43
43
  return;
44
44
  }
45
45
  ctx.print(formatResolvedSettingReview(ctx.platform.configManager, key));
@@ -63,7 +63,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
63
63
  const key = args[1] as ConfigKey | undefined;
64
64
  const resolution = (args[2] ?? '').toLowerCase();
65
65
  if (!key || !CONFIG_KEYS.has(key) || (resolution !== 'local' && resolution !== 'synced')) {
66
- ctx.print('Usage: /settingssync resolve <config-key> <local|synced>');
66
+ ctx.print('Usage: /settings-sync resolve <config-key> <local|synced>');
67
67
  return;
68
68
  }
69
69
  const changed = resolveSettingsSyncConflict(ctx.platform.configManager, key, resolution);
@@ -102,7 +102,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
102
102
  if (sub === 'export' || sub === 'push') {
103
103
  const pathArg = args[1];
104
104
  if (!pathArg) {
105
- ctx.print(`Usage: /settingssync ${sub} <path>`);
105
+ ctx.print(`Usage: /settings-sync ${sub} <path>`);
106
106
  return;
107
107
  }
108
108
  const targetPath = shellPaths.resolveWorkspacePath(pathArg);
@@ -122,7 +122,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
122
122
  if (sub === 'inspect') {
123
123
  const pathArg = args[1];
124
124
  if (!pathArg) {
125
- ctx.print('Usage: /settingssync inspect <path>');
125
+ ctx.print('Usage: /settings-sync inspect <path>');
126
126
  return;
127
127
  }
128
128
  const sourcePath = shellPaths.resolveWorkspacePath(pathArg);
@@ -133,7 +133,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
133
133
  if (sub === 'pull') {
134
134
  const pathArg = args[1];
135
135
  if (!pathArg) {
136
- ctx.print('Usage: /settingssync pull <path>');
136
+ ctx.print('Usage: /settings-sync pull <path>');
137
137
  return;
138
138
  }
139
139
  const sourcePath = shellPaths.resolveWorkspacePath(pathArg);
@@ -152,7 +152,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
152
152
  const source = args[2];
153
153
  const reason = args.slice(3).join(' ').trim();
154
154
  if (!key || !source || !reason || !CONFIG_KEYS.has(key)) {
155
- ctx.print('Usage: /settingssync lock <config-key> <source> <reason...>');
155
+ ctx.print('Usage: /settings-sync lock <config-key> <source> <reason...>');
156
156
  return;
157
157
  }
158
158
  setManagedSettingLock(key, source, reason, controlPlaneConfigDir);
@@ -162,7 +162,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
162
162
  if (sub === 'unlock') {
163
163
  const key = args[1] as ConfigKey | undefined;
164
164
  if (!key || !CONFIG_KEYS.has(key)) {
165
- ctx.print('Usage: /settingssync unlock <config-key>');
165
+ ctx.print('Usage: /settings-sync unlock <config-key>');
166
166
  return;
167
167
  }
168
168
  ctx.print(clearManagedSettingLock(key, controlPlaneConfigDir) ? `Managed lock cleared for ${key}.` : `No managed lock found for ${key}.`);
@@ -15,7 +15,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
15
15
  aliases: ['m'],
16
16
  description: 'Select or display the current LLM model',
17
17
  usage: '[model-id]',
18
- argsHint: '[name]',
18
+ argsHint: '[model-id]',
19
19
  async handler(args, ctx) {
20
20
  const providerApi = requireProviderApi(ctx);
21
21
  if (args.length === 0) {
@@ -294,7 +294,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
294
294
  aliases: ['e'],
295
295
  description: 'Show or set reasoning effort level',
296
296
  usage: '[level]',
297
- argsHint: '<instant|low|medium|high>',
297
+ argsHint: '[instant|low|medium|high]',
298
298
  async handler(args, ctx) {
299
299
  const currentModel = await requireProviderApi(ctx).getCurrentModel();
300
300
  const validLevels = currentModel.reasoningEffort ?? [];
@@ -37,7 +37,7 @@ function openPanel(ctx: import('../command-registry.ts').CommandContext): void {
37
37
 
38
38
  function formatList(store: WorkPlanStore): string {
39
39
  const items = store.listItems();
40
- if (items.length === 0) return 'Work plan is empty. Add one with /workplan add <title>.';
40
+ if (items.length === 0) return 'Work plan is empty. Add one with /work-plan add <title>.';
41
41
  return [
42
42
  `Work Plan (${items.length})`,
43
43
  ...items.map((item) => {
@@ -78,8 +78,8 @@ function parseAddArgs(args: string[]): { title: string; owner?: string; source?:
78
78
 
79
79
  export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void {
80
80
  registry.register({
81
- name: 'workplan',
82
- aliases: ['wp', 'todo'],
81
+ name: 'work-plan',
82
+ aliases: ['wp', 'todo', 'workplan'],
83
83
  description: 'Track a persistent workspace-scoped work plan',
84
84
  usage: '[panel|list|show|add <title> [--owner name] [--source label] [--notes text]|done <id>|start <id>|block <id>|fail <id>|cancel <id>|pending <id>|remove <id>|clear-done]',
85
85
  argsHint: '[panel|add|list|done]',
@@ -107,7 +107,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
107
107
  if (subcommand === 'add') {
108
108
  const parsed = parseAddArgs(args.slice(1));
109
109
  if (!parsed.title) {
110
- ctx.print('Usage: /workplan add <title> [--owner name] [--source label] [--notes text]');
110
+ ctx.print('Usage: /work-plan add <title> [--owner name] [--source label] [--notes text]');
111
111
  return;
112
112
  }
113
113
  const addOptions = {
@@ -123,7 +123,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
123
123
  if (subcommand === 'remove' || subcommand === 'delete' || subcommand === 'rm') {
124
124
  const id = args[1];
125
125
  if (!id) {
126
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
126
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
127
127
  return;
128
128
  }
129
129
  const item = store.removeItem(id);
@@ -138,7 +138,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
138
138
  if (subcommand === 'cycle' || subcommand === 'toggle') {
139
139
  const id = args[1];
140
140
  if (!id) {
141
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
141
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
142
142
  return;
143
143
  }
144
144
  const item = store.cycleItemStatus(id);
@@ -149,7 +149,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
149
149
  if (status) {
150
150
  const id = args[1];
151
151
  if (!id) {
152
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
152
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
153
153
  return;
154
154
  }
155
155
  const item = store.setItemStatus(id, status);
@@ -157,7 +157,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
157
157
  return;
158
158
  }
159
159
  if (WORK_PLAN_STATUSES.includes(subcommand as WorkPlanItemStatus)) {
160
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
160
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
161
161
  return;
162
162
  }
163
163
  ctx.print(`Unknown workplan subcommand: ${subcommand}`);
@@ -38,6 +38,7 @@ import type { Panel } from '../panels/types.ts';
38
38
  import type { PanelManager } from '../panels/panel-manager.ts';
39
39
  import type { KeybindingsManager } from './keybindings.ts';
40
40
  import type { ModelPickerTarget } from './model-picker.ts';
41
+ import type { KillRing } from './kill-ring.ts';
41
42
  import type { PanelMouseLayout } from './handler-feed-routes.ts';
42
43
 
43
44
  /**
@@ -124,6 +125,7 @@ export interface FeedContextStableRefs {
124
125
  conversationManager: ConversationManager | null;
125
126
  panelManager: PanelManager;
126
127
  keybindingsManager: KeybindingsManager;
128
+ killRing: KillRing;
127
129
  getHistory: () => InfiniteBuffer;
128
130
  getViewportHeight: () => number;
129
131
  getScrollTop: () => number;
@@ -145,6 +147,10 @@ export interface FeedContextClosures {
145
147
  handleRedo: () => void;
146
148
  handlePaste: () => void;
147
149
  saveUndoState: () => void;
150
+ /** Save undo state with text-insertion coalescing (burst typing merges into one group). */
151
+ saveUndoStateForText: () => void;
152
+ /** Break the current coalescing group (call on cursor moves). */
153
+ breakUndoCoalesce: () => void;
148
154
  ensureInputCursorVisible: (contentWidth?: number) => void;
149
155
  registerPaste: (content: string) => string;
150
156
  executeBlockAction: (id: string) => void;
@@ -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) {