@pellux/goodvibes-tui 0.23.0 → 0.24.1
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 +34 -0
- package/README.md +20 -12
- package/docs/foundation-artifacts/operator-contract.json +304 -230
- package/package.json +2 -2
- package/src/cli/management.ts +80 -10
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +77 -3
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- 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/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/shell-core.ts +2 -2
- package/src/input/commands/work-plan-runtime.ts +8 -8
- 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 +33 -33
- 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/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.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -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/terminal-width.ts +10 -3
- 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
|
-
|
|
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: '
|
|
28
|
-
aliases: ['
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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: '[
|
|
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: '
|
|
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 /
|
|
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: '
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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.
|
|
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) {
|