@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.
- package/CHANGELOG.md +47 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management-utils.ts +352 -0
- package/src/cli/management.ts +116 -344
- package/src/cli/surface-command.ts +1 -1
- package/src/core/context-auto-compact.ts +43 -10
- package/src/core/conversation-rendering.ts +5 -2
- package/src/core/conversation-types.ts +24 -0
- package/src/core/conversation.ts +7 -12
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +199 -7
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- package/src/input/commands/channel-runtime.ts +139 -0
- package/src/input/commands/control-room-runtime.ts +0 -2
- package/src/input/commands/diff-runtime.ts +1 -1
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +23 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-runtime.ts +1 -2
- package/src/input/commands/memory-product-runtime.ts +2 -2
- package/src/input/commands/memory.ts +1 -1
- package/src/input/commands/onboarding-runtime.ts +0 -1
- package/src/input/commands/policy.ts +1 -1
- package/src/input/commands/profile-sync-runtime.ts +4 -3
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +0 -1
- package/src/input/commands/runtime-services.ts +30 -1
- package/src/input/commands/session-content.ts +2 -2
- package/src/input/commands/session-workflow.ts +32 -2
- package/src/input/commands/session.ts +1 -1
- package/src/input/commands/settings-sync-runtime.ts +9 -9
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +56 -6
- package/src/input/commands/work-plan-runtime.ts +8 -8
- package/src/input/commands.ts +2 -0
- package/src/input/feed-context-factory.ts +6 -0
- package/src/input/handler-feed-routes.ts +19 -1
- package/src/input/handler-feed.ts +11 -0
- package/src/input/handler-prompt-buffer.ts +28 -0
- package/src/input/handler-shortcuts.ts +88 -2
- package/src/input/handler-ui-state.ts +2 -2
- package/src/input/handler.ts +39 -3
- package/src/input/keybindings.ts +33 -3
- package/src/input/kill-ring.ts +134 -0
- package/src/input/model-picker.ts +18 -1
- package/src/input/search.ts +18 -6
- package/src/input/settings-modal-activation.ts +134 -0
- package/src/input/settings-modal-adjustment.ts +124 -0
- package/src/input/settings-modal-data.ts +53 -0
- package/src/input/settings-modal.ts +48 -145
- package/src/main.ts +50 -50
- package/src/panels/base-panel.ts +2 -1
- package/src/panels/provider-health-domains.ts +3 -3
- package/src/panels/provider-health-panel.ts +13 -9
- package/src/panels/provider-health-tracker.ts +7 -4
- package/src/panels/settings-sync-panel.ts +3 -3
- package/src/panels/work-plan-panel.ts +2 -2
- package/src/renderer/compaction-history-modal.ts +55 -0
- package/src/renderer/compaction-preview.ts +146 -0
- package/src/renderer/diff-view.ts +2 -2
- package/src/renderer/help-overlay.ts +1 -0
- package/src/renderer/model-picker-overlay.ts +23 -11
- package/src/renderer/progress.ts +3 -3
- package/src/renderer/search-overlay.ts +8 -5
- package/src/renderer/settings-modal-helpers.ts +2 -2
- package/src/renderer/settings-modal.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -0
- package/src/runtime/bootstrap-core.ts +92 -0
- package/src/runtime/bootstrap-hook-bridge.ts +18 -0
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/shell/blocking-input.ts +32 -0
- package/src/shell/recovery-input-helpers.ts +71 -0
- package/src/utils/browser.ts +29 -0
- package/src/utils/terminal-width.ts +10 -3
- 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.
|
|
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) {
|
package/src/input/handler.ts
CHANGED
|
@@ -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
|
-
|
|
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 -
|
|
616
|
-
*
|
|
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
|
/**
|
package/src/input/keybindings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|