@pellux/goodvibes-tui 0.18.20 → 0.18.23

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 (34) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed.ts +44 -6
  8. package/src/input/handler-shortcuts.ts +138 -125
  9. package/src/input/handler.ts +121 -119
  10. package/src/input/keybindings.ts +30 -0
  11. package/src/panels/approval-panel.ts +54 -82
  12. package/src/panels/automation-control-panel.ts +119 -161
  13. package/src/panels/communication-panel.ts +68 -107
  14. package/src/panels/control-plane-panel.ts +116 -172
  15. package/src/panels/hooks-panel.ts +101 -138
  16. package/src/panels/incident-review-panel.ts +55 -107
  17. package/src/panels/local-auth-panel.ts +76 -93
  18. package/src/panels/mcp-panel.ts +108 -155
  19. package/src/panels/ops-control-panel.ts +50 -85
  20. package/src/panels/panel-manager.ts +22 -2
  21. package/src/panels/plugins-panel.ts +36 -60
  22. package/src/panels/routes-panel.ts +89 -141
  23. package/src/panels/scrollable-list-panel.ts +45 -14
  24. package/src/panels/security-panel.ts +101 -137
  25. package/src/panels/services-panel.ts +58 -102
  26. package/src/panels/settings-sync-panel.ts +76 -122
  27. package/src/panels/subscription-panel.ts +63 -86
  28. package/src/panels/tasks-panel.ts +129 -179
  29. package/src/panels/watchers-panel.ts +88 -137
  30. package/src/renderer/buffer.ts +11 -0
  31. package/src/renderer/diff.ts +8 -0
  32. package/src/renderer/help-overlay.ts +37 -28
  33. package/src/renderer/markdown.ts +3 -145
  34. package/src/version.ts +1 -1
@@ -52,144 +52,157 @@ export function handleGlobalShortcutToken(
52
52
  ): boolean {
53
53
  if (token.type !== 'key') return false;
54
54
 
55
- const kb = state.keybindingsManager;
56
- if (kb.matches('copy-selection', token)) {
57
- state.handleCopy();
55
+ // Fast-path: bare pageup/pagedown have no keybinding entry.
56
+ if (token.logicalName === 'pageup') {
57
+ state.scroll(-Math.max(1, viewportHeight - 2));
58
58
  return true;
59
59
  }
60
- if (kb.matches('clear-cancel', token)) {
61
- state.handleCtrlC();
60
+ if (token.logicalName === 'pagedown') {
61
+ state.scroll(Math.max(1, viewportHeight - 2));
62
62
  return true;
63
63
  }
64
+ // Bare escape is also not in the keybinding table.
64
65
  if (token.logicalName === 'escape' && !state.panelFocused) {
65
66
  state.handleEscape();
66
67
  return true;
67
68
  }
68
- if (kb.matches('screen-clear', token)) {
69
- state.commandContext?.clearScreen?.();
70
- return true;
71
- }
72
- if (kb.matches('panel-close-all', token)) {
73
- const pm = state.panelManager;
74
- for (const p of pm.getAllOpen()) pm.close(p.id);
75
- pm.hide();
76
- state.requestRender();
77
- return true;
78
- }
79
- if (kb.matches('panel-close', token)) {
80
- const pm = state.panelManager;
81
- const active = pm.getActivePanel();
82
- if (active) {
83
- pm.close(active.id);
69
+
70
+ // O(1) lookup via inverted map.
71
+ const kb = state.keybindingsManager;
72
+ const action = kb.lookup(token);
73
+
74
+ switch (action) {
75
+ case 'copy-selection':
76
+ state.handleCopy();
77
+ return true;
78
+
79
+ case 'clear-cancel':
80
+ state.handleCtrlC();
81
+ return true;
82
+
83
+ case 'screen-clear':
84
+ state.commandContext?.clearScreen?.();
85
+ return true;
86
+
87
+ case 'panel-close-all': {
88
+ const pm = state.panelManager;
89
+ for (const p of pm.getAllOpen()) pm.close(p.id);
90
+ pm.hide();
84
91
  state.requestRender();
92
+ return true;
85
93
  }
86
- return true;
87
- }
88
- if (kb.matches('panel-picker', token)) {
89
- state.commandContext?.openPanelPicker?.();
90
- state.requestRender();
91
- return true;
92
- }
93
- if (kb.matches('panel-tab-next', token)) {
94
- state.cyclePanelTab('next');
95
- return true;
96
- }
97
- if (kb.matches('panel-tab-prev', token)) {
98
- state.cyclePanelTab('prev');
99
- return true;
100
- }
101
- if (kb.matches('history-search', token)) {
102
- state.historySearch.open(state.prompt);
103
- state.requestRender();
104
- return true;
105
- }
106
- if (kb.matches('search', token)) {
107
- if (state.searchManager.active) state.searchManager.close();
108
- else state.searchManager.open();
109
- state.requestRender();
110
- return true;
111
- }
112
- if (kb.matches('block-copy', token) && !state.commandMode) {
113
- state.handleBlockCopy();
114
- return true;
115
- }
116
- if (kb.matches('bookmark', token) && !state.commandMode) {
117
- state.handleBookmark();
118
- return true;
119
- }
120
- if (kb.matches('block-save', token) && !state.commandMode) {
121
- state.handleBlockSave();
122
- return true;
123
- }
124
- if (kb.matches('delete-word', token)) {
125
- state.saveUndoState();
126
- let pos = state.cursorPos;
127
- while (pos > 0 && state.prompt[pos - 1] === ' ') pos--;
128
- while (pos > 0 && state.prompt[pos - 1] !== ' ') pos--;
129
- state.prompt = state.prompt.slice(0, pos) + state.prompt.slice(state.cursorPos);
130
- state.cursorPos = pos;
131
- state.ensureInputCursorVisible();
132
- return true;
133
- }
134
- if (kb.matches('apply-diff-line-start', token)) {
135
- if (!state.commandMode && state.handleDiffApply()) return true;
136
- const info = state.getWrappedPromptInfo(state.contentWidth);
137
- state.cursorPos = info.wrappedLines.length > 1 ? info.segments[info.cursorWrappedLine].rawStart : 0;
138
- state.ensureInputCursorVisible();
139
- return true;
140
- }
141
- if (kb.matches('next-error-line-end', token)) {
142
- if (state.prompt === '' && !state.commandMode) {
143
- const nextLine = state.conversationManager?.nextErrorLine(state.getScrollTop()) ?? -1;
144
- if (nextLine >= 0) {
145
- state.scroll(nextLine - state.getScrollTop());
94
+
95
+ case 'panel-close': {
96
+ const pm = state.panelManager;
97
+ const active = pm.getActivePanel();
98
+ if (active) {
99
+ pm.close(active.id);
146
100
  state.requestRender();
147
- return true;
148
101
  }
102
+ return true;
149
103
  }
150
- const info = state.getWrappedPromptInfo(state.contentWidth);
151
- state.cursorPos = info.wrappedLines.length > 1
152
- ? info.segments[info.cursorWrappedLine].rawStart + info.segments[info.cursorWrappedLine].length
153
- : state.prompt.length;
154
- state.ensureInputCursorVisible();
155
- return true;
156
- }
157
- if (kb.matches('kill-line', token)) {
158
- state.saveUndoState();
159
- state.prompt = state.prompt.slice(0, state.cursorPos);
160
- state.ensureInputCursorVisible();
161
- return true;
162
- }
163
- if (kb.matches('clear-prompt', token)) {
164
- state.saveUndoState();
165
- state.prompt = '';
166
- state.cursorPos = 0;
167
- if (state.commandMode) {
168
- state.commandMode = false;
169
- state.autocomplete?.reset();
104
+
105
+ case 'panel-picker':
106
+ state.commandContext?.openPanelPicker?.();
107
+ state.requestRender();
108
+ return true;
109
+
110
+ case 'panel-tab-next':
111
+ state.cyclePanelTab('next');
112
+ return true;
113
+
114
+ case 'panel-tab-prev':
115
+ state.cyclePanelTab('prev');
116
+ return true;
117
+
118
+ case 'history-search':
119
+ state.historySearch.open(state.prompt);
120
+ state.requestRender();
121
+ return true;
122
+
123
+ case 'search':
124
+ if (state.searchManager.active) state.searchManager.close();
125
+ else state.searchManager.open();
126
+ state.requestRender();
127
+ return true;
128
+
129
+ case 'block-copy':
130
+ if (!state.commandMode) { state.handleBlockCopy(); return true; }
131
+ return false;
132
+
133
+ case 'bookmark':
134
+ if (!state.commandMode) { state.handleBookmark(); return true; }
135
+ return false;
136
+
137
+ case 'block-save':
138
+ if (!state.commandMode) { state.handleBlockSave(); return true; }
139
+ return false;
140
+
141
+ case 'delete-word': {
142
+ state.saveUndoState();
143
+ let pos = state.cursorPos;
144
+ while (pos > 0 && state.prompt[pos - 1] === ' ') pos--;
145
+ while (pos > 0 && state.prompt[pos - 1] !== ' ') pos--;
146
+ state.prompt = state.prompt.slice(0, pos) + state.prompt.slice(state.cursorPos);
147
+ state.cursorPos = pos;
148
+ state.ensureInputCursorVisible();
149
+ return true;
150
+ }
151
+
152
+ case 'apply-diff-line-start': {
153
+ if (!state.commandMode && state.handleDiffApply()) return true;
154
+ const info = state.getWrappedPromptInfo(state.contentWidth);
155
+ state.cursorPos = info.wrappedLines.length > 1 ? info.segments[info.cursorWrappedLine].rawStart : 0;
156
+ state.ensureInputCursorVisible();
157
+ return true;
170
158
  }
171
- return true;
172
- }
173
- if (kb.matches('undo', token)) {
174
- state.handleUndo();
175
- return true;
176
- }
177
- if (kb.matches('redo', token)) {
178
- state.handleRedo();
179
- return true;
180
- }
181
- if (kb.matches('paste', token)) {
182
- state.handlePaste();
183
- return true;
184
- }
185
- if (token.logicalName === 'pageup') {
186
- state.scroll(-Math.max(1, viewportHeight - 2));
187
- return true;
188
- }
189
- if (token.logicalName === 'pagedown') {
190
- state.scroll(Math.max(1, viewportHeight - 2));
191
- return true;
192
- }
193
159
 
194
- return false;
160
+ case 'next-error-line-end': {
161
+ if (state.prompt === '' && !state.commandMode) {
162
+ const nextLine = state.conversationManager?.nextErrorLine(state.getScrollTop()) ?? -1;
163
+ if (nextLine >= 0) {
164
+ state.scroll(nextLine - state.getScrollTop());
165
+ state.requestRender();
166
+ return true;
167
+ }
168
+ }
169
+ const info = state.getWrappedPromptInfo(state.contentWidth);
170
+ state.cursorPos = info.wrappedLines.length > 1
171
+ ? info.segments[info.cursorWrappedLine].rawStart + info.segments[info.cursorWrappedLine].length
172
+ : state.prompt.length;
173
+ state.ensureInputCursorVisible();
174
+ return true;
175
+ }
176
+
177
+ case 'kill-line':
178
+ state.saveUndoState();
179
+ state.prompt = state.prompt.slice(0, state.cursorPos);
180
+ state.ensureInputCursorVisible();
181
+ return true;
182
+
183
+ case 'clear-prompt':
184
+ state.saveUndoState();
185
+ state.prompt = '';
186
+ state.cursorPos = 0;
187
+ if (state.commandMode) {
188
+ state.commandMode = false;
189
+ state.autocomplete?.reset();
190
+ }
191
+ return true;
192
+
193
+ case 'undo':
194
+ state.handleUndo();
195
+ return true;
196
+
197
+ case 'redo':
198
+ state.handleRedo();
199
+ return true;
200
+
201
+ case 'paste':
202
+ state.handlePaste();
203
+ return true;
204
+
205
+ default:
206
+ return false;
207
+ }
195
208
  }
@@ -81,6 +81,7 @@ import {
81
81
  } from './handler-picker-routes.ts';
82
82
  import { handleGlobalShortcutToken } from './handler-shortcuts.ts';
83
83
  import { feedInputTokens } from './handler-feed.ts';
84
+ import { buildInitialFeedContext } from './feed-context-factory.ts';
84
85
  import { handlePanelIntegrationAction as runPanelIntegrationAction } from './panel-integration-actions.ts';
85
86
  import type { Panel } from '../panels/types.ts';
86
87
  import type { UiRuntimeServices } from '../runtime/ui-services.ts';
@@ -112,6 +113,8 @@ export class InputHandler {
112
113
  private pasteRegistry = new Map<string, string>();
113
114
  private nextPasteId = 1;
114
115
  private lastCtrlCTime = 0;
116
+ /** Long-lived feed context — reused across every feed() call to avoid per-keystroke allocation. */
117
+ private feedContext!: import('./handler-feed.ts').InputFeedContext;
115
118
  private commandRegistry: CommandRegistry | null = null;
116
119
  private commandContext: CommandContext | undefined = undefined;
117
120
  public autocomplete: AutocompleteEngine | null = null;
@@ -208,32 +211,110 @@ export class InputHandler {
208
211
  this.bookmarkModal = new BookmarkModal(uiServices.shell.bookmarkManager);
209
212
  this.sessionPickerModal = new SessionPickerModal(uiServices.sessions.sessionManager);
210
213
  this.profilePickerModal = new ProfilePickerModal(uiServices.shell.profileManager);
214
+ this.initFeedContext();
211
215
  }
212
216
 
213
217
  /**
214
- * setHistory - Wire in the InputHistory instance.
215
- * Optional; if not set, history navigation is disabled.
218
+ * initFeedContext — Build the long-lived InputFeedContext once via factory.
219
+ * See feed-context-factory.ts for full field documentation.
216
220
  */
217
- public setHistory(history: InputHistory): void {
218
- this.inputHistory = history;
221
+ private initFeedContext(): void {
222
+ this.feedContext = buildInitialFeedContext(
223
+ {
224
+ prompt: this.prompt, cursorPos: this.cursorPos, commandMode: this.commandMode,
225
+ panelFocused: this.panelFocused, indicatorFocused: this.indicatorFocused,
226
+ helpOverlayActive: this.helpOverlayActive, helpScrollOffset: this.helpScrollOffset,
227
+ shortcutsOverlayActive: this.shortcutsOverlayActive, shortcutsScrollOffset: this.shortcutsScrollOffset,
228
+ nextPasteId: this.nextPasteId, nextImageId: this.nextImageId,
229
+ mouseDownRow: this.mouseDownRow, mouseDownCol: this.mouseDownCol,
230
+ contentWidth: this.contentWidth, selectionCallback: this.selectionCallback,
231
+ },
232
+ {
233
+ selection: this.selection,
234
+ pasteRegistry: this.pasteRegistry,
235
+ imageRegistry: this.imageRegistry,
236
+ selectionModal: this.selectionModal,
237
+ bookmarkModal: this.bookmarkModal,
238
+ settingsModal: this.settingsModal,
239
+ sessionPickerModal: this.sessionPickerModal,
240
+ profilePickerModal: this.profilePickerModal,
241
+ historySearch: this.historySearch,
242
+ commandRegistry: this.commandRegistry,
243
+ commandContext: this.commandContext,
244
+ autocomplete: this.autocomplete,
245
+ filePicker: this.filePicker,
246
+ modelPicker: this.modelPicker,
247
+ processModal: this.processModal,
248
+ liveTailModal: this.liveTailModal,
249
+ agentDetailModal: this.agentDetailModal,
250
+ contextInspectorModal: this.contextInspectorModal,
251
+ blockActionsMenu: this.blockActionsMenu,
252
+ searchManager: this.searchManager,
253
+ modalStack: this.modalStack,
254
+ inputHistory: this.inputHistory,
255
+ conversationManager: this.conversationManager,
256
+ panelManager: this.uiServices.shell.panelManager,
257
+ keybindingsManager: this.uiServices.shell.keybindingsManager,
258
+ getHistory: this.getHistory,
259
+ getViewportHeight: this.getViewportHeight,
260
+ getScrollTop: this.getScrollTop,
261
+ scroll: this.scroll,
262
+ exitApp: this.exitApp,
263
+ },
264
+ {
265
+ modalOpened: (name: string) => this.modalOpened(name),
266
+ handleEscape: () => { this.handleEscape(); this.syncFeedContextMutableFields(); },
267
+ handleCopy: () => this.handleCopy(),
268
+ handleCtrlC: () => { this.handleCtrlC(); this.syncFeedContextMutableFields(); },
269
+ handleBlockCopy: () => this.handleBlockCopy(),
270
+ handleBookmark: () => this.handleBookmark(),
271
+ handleBlockSave: () => this.handleBlockSave(),
272
+ handleDiffApply: () => this.handleDiffApply(),
273
+ handleUndo: () => { this.handleUndo(); this.syncFeedContextMutableFields(); },
274
+ handleRedo: () => { this.handleRedo(); this.syncFeedContextMutableFields(); },
275
+ handlePaste: () => { this.handlePaste(); this.syncFeedContextMutableFields(); },
276
+ saveUndoState: () => this.saveUndoState(),
277
+ ensureInputCursorVisible: (contentWidth?: number) => this.ensureInputCursorVisible(contentWidth),
278
+ registerPaste: (content: string) => this.registerPaste(content),
279
+ executeBlockAction: (id: string) => this.executeBlockAction(id),
280
+ cyclePanelTab: (direction: 'next' | 'prev') => this.cyclePanelTab(direction),
281
+ onPanelInputConsumed: (activePanel: Panel | null, key: string) => this.handlePanelIntegrationAction(activePanel, key),
282
+ getWrappedPromptInfo: (contentWidth: number) => this.getWrappedPromptInfo(contentWidth),
283
+ moveCursorVertical: (direction: -1 | 1) => this.moveCursorVertical(direction),
284
+ handlePathCompletion: () => this.handlePathCompletion(),
285
+ handleBlockToggle: () => this.handleBlockToggle(),
286
+ findMarkerAtPos: (pos: number) => this.findMarkerAtPos(pos),
287
+ cleanupMarkerRegistry: (text: string) => this.cleanupMarkerRegistry(text),
288
+ expandPrompt: (text: string) => this.expandPrompt(text),
289
+ },
290
+ );
219
291
  }
220
292
 
221
- /**
222
- * setCommandRegistry - Wire in the slash command registry and context.
223
- * Must be called before commands can be processed.
224
- */
293
+ /** Sync mutable handler fields back into feedContext after in-feed mutations. */
294
+ private syncFeedContextMutableFields(): void {
295
+ const ctx = this.feedContext;
296
+ ctx.prompt = this.prompt; ctx.cursorPos = this.cursorPos;
297
+ ctx.commandMode = this.commandMode; ctx.panelFocused = this.panelFocused;
298
+ ctx.indicatorFocused = this.indicatorFocused;
299
+ ctx.helpOverlayActive = this.helpOverlayActive; ctx.helpScrollOffset = this.helpScrollOffset;
300
+ ctx.shortcutsOverlayActive = this.shortcutsOverlayActive; ctx.shortcutsScrollOffset = this.shortcutsScrollOffset;
301
+ ctx.selectionCallback = this.selectionCallback;
302
+ ctx.nextPasteId = this.nextPasteId; ctx.nextImageId = this.nextImageId;
303
+ ctx.mouseDownRow = this.mouseDownRow; ctx.mouseDownCol = this.mouseDownCol;
304
+ }
305
+
306
+ /** Wire in the InputHistory instance. Optional; disables history navigation if unset. */
307
+ public setHistory(history: InputHistory): void { this.inputHistory = history; }
308
+
309
+ /** Wire in the slash command registry and context. Must be called before commands work. */
225
310
  public setCommandRegistry(registry: CommandRegistry, context: CommandContext): void {
226
311
  this.commandRegistry = registry;
227
312
  this.commandContext = context;
228
313
  this.autocomplete = new AutocompleteEngine(registry);
229
314
  }
230
315
 
231
- /**
232
- * setConversationManager - Wire in the conversation manager for block copy/apply/collapse.
233
- */
234
- public setConversationManager(cm: ConversationManager): void {
235
- this.conversationManager = cm;
236
- }
316
+ /** Wire in the conversation manager for block copy/apply/collapse. */
317
+ public setConversationManager(cm: ConversationManager): void { this.conversationManager = cm; }
237
318
 
238
319
  /**
239
320
  * openSelection - Open the generic selection modal with a callback.
@@ -464,6 +545,7 @@ export class InputHandler {
464
545
 
465
546
  /**
466
547
  * feed - Process raw stdin data through the tokenizer.
548
+ * Reuses the long-lived this.feedContext to avoid per-keystroke object allocation.
467
549
  */
468
550
  public feed(data: string): void {
469
551
  const immediateRequestRender = this.requestRender;
@@ -479,111 +561,31 @@ export class InputHandler {
479
561
 
480
562
  this.requestRender = bufferedRequestRender;
481
563
  try {
482
- let context!: import('./handler-feed.ts').InputFeedContext;
483
- const syncFeedContextFromHandler = (): void => {
484
- context.prompt = this.prompt;
485
- context.cursorPos = this.cursorPos;
486
- context.commandMode = this.commandMode;
487
- context.panelFocused = this.panelFocused;
488
- context.indicatorFocused = this.indicatorFocused;
489
- context.helpOverlayActive = this.helpOverlayActive;
490
- context.helpScrollOffset = this.helpScrollOffset;
491
- context.shortcutsOverlayActive = this.shortcutsOverlayActive;
492
- context.shortcutsScrollOffset = this.shortcutsScrollOffset;
493
- context.selectionCallback = this.selectionCallback;
494
- context.nextPasteId = this.nextPasteId;
495
- context.nextImageId = this.nextImageId;
496
- context.mouseDownRow = this.mouseDownRow;
497
- context.mouseDownCol = this.mouseDownCol;
498
- };
499
-
500
- context = {
501
- prompt: this.prompt,
502
- cursorPos: this.cursorPos,
503
- commandMode: this.commandMode,
504
- panelFocused: this.panelFocused,
505
- indicatorFocused: this.indicatorFocused,
506
- helpOverlayActive: this.helpOverlayActive,
507
- helpScrollOffset: this.helpScrollOffset,
508
- shortcutsOverlayActive: this.shortcutsOverlayActive,
509
- shortcutsScrollOffset: this.shortcutsScrollOffset,
510
- nextPasteId: this.nextPasteId,
511
- nextImageId: this.nextImageId,
512
- mouseDownRow: this.mouseDownRow,
513
- mouseDownCol: this.mouseDownCol,
514
- contentWidth: this.contentWidth,
515
- pasteRegistry: this.pasteRegistry,
516
- imageRegistry: this.imageRegistry,
517
- selection: this.selection,
518
- selectionModal: this.selectionModal,
519
- selectionCallback: this.selectionCallback,
520
- bookmarkModal: this.bookmarkModal,
521
- settingsModal: this.settingsModal,
522
- sessionPickerModal: this.sessionPickerModal,
523
- profilePickerModal: this.profilePickerModal,
524
- historySearch: this.historySearch,
525
- commandRegistry: this.commandRegistry,
526
- commandContext: this.commandContext,
527
- autocomplete: this.autocomplete,
528
- filePicker: this.filePicker,
529
- modelPicker: this.modelPicker,
530
- processModal: this.processModal,
531
- liveTailModal: this.liveTailModal,
532
- agentDetailModal: this.agentDetailModal,
533
- contextInspectorModal: this.contextInspectorModal,
534
- blockActionsMenu: this.blockActionsMenu,
535
- searchManager: this.searchManager,
536
- modalStack: this.modalStack,
537
- inputHistory: this.inputHistory,
538
- conversationManager: this.conversationManager,
539
- getHistory: this.getHistory,
540
- getViewportHeight: this.getViewportHeight,
541
- getScrollTop: this.getScrollTop,
542
- scroll: this.scroll,
543
- requestRender: bufferedRequestRender,
544
- modalOpened: (name: string) => this.modalOpened(name),
545
- handleEscape: () => {
546
- this.handleEscape();
547
- syncFeedContextFromHandler();
548
- },
549
- handleCopy: () => this.handleCopy(),
550
- handleCtrlC: () => {
551
- this.handleCtrlC();
552
- syncFeedContextFromHandler();
553
- },
554
- handleBlockCopy: () => this.handleBlockCopy(),
555
- handleBookmark: () => this.handleBookmark(),
556
- handleBlockSave: () => this.handleBlockSave(),
557
- handleDiffApply: () => this.handleDiffApply(),
558
- handleUndo: () => {
559
- this.handleUndo();
560
- syncFeedContextFromHandler();
561
- },
562
- handleRedo: () => {
563
- this.handleRedo();
564
- syncFeedContextFromHandler();
565
- },
566
- handlePaste: () => {
567
- this.handlePaste();
568
- syncFeedContextFromHandler();
569
- },
570
- saveUndoState: () => this.saveUndoState(),
571
- ensureInputCursorVisible: (contentWidth?: number) => this.ensureInputCursorVisible(contentWidth),
572
- registerPaste: (content: string) => this.registerPaste(content),
573
- executeBlockAction: (id: string) => this.executeBlockAction(id),
574
- cyclePanelTab: (direction: 'next' | 'prev') => this.cyclePanelTab(direction),
575
- onPanelInputConsumed: (activePanel: Panel | null, key: string) => this.handlePanelIntegrationAction(activePanel, key),
576
- panelManager: this.uiServices.shell.panelManager,
577
- keybindingsManager: this.uiServices.shell.keybindingsManager,
578
- getWrappedPromptInfo: (contentWidth: number) => this.getWrappedPromptInfo(contentWidth),
579
- moveCursorVertical: (direction: -1 | 1) => this.moveCursorVertical(direction),
580
- handlePathCompletion: () => this.handlePathCompletion(),
581
- handleBlockToggle: () => this.handleBlockToggle(),
582
- findMarkerAtPos: (pos: number) => this.findMarkerAtPos(pos),
583
- cleanupMarkerRegistry: (text: string) => this.cleanupMarkerRegistry(text),
584
- expandPrompt: (text: string) => this.expandPrompt(text),
585
- exitApp: this.exitApp,
586
- };
564
+ const context = this.feedContext;
565
+ // Sync mutable scalars from handler into the reused context.
566
+ context.prompt = this.prompt;
567
+ context.cursorPos = this.cursorPos;
568
+ context.commandMode = this.commandMode;
569
+ context.panelFocused = this.panelFocused;
570
+ context.indicatorFocused = this.indicatorFocused;
571
+ context.helpOverlayActive = this.helpOverlayActive;
572
+ context.helpScrollOffset = this.helpScrollOffset;
573
+ context.shortcutsOverlayActive = this.shortcutsOverlayActive;
574
+ context.shortcutsScrollOffset = this.shortcutsScrollOffset;
575
+ context.selectionCallback = this.selectionCallback;
576
+ context.nextPasteId = this.nextPasteId;
577
+ context.nextImageId = this.nextImageId;
578
+ context.mouseDownRow = this.mouseDownRow;
579
+ context.mouseDownCol = this.mouseDownCol;
580
+ context.contentWidth = this.contentWidth;
581
+ // Sync semi-stable refs that may be wired after construction.
582
+ context.commandRegistry = this.commandRegistry;
583
+ context.commandContext = this.commandContext;
584
+ context.autocomplete = this.autocomplete;
585
+ context.inputHistory = this.inputHistory;
586
+ context.conversationManager = this.conversationManager;
587
+ // Swap requestRender to buffered version for this feed.
588
+ context.requestRender = bufferedRequestRender;
587
589
  this.syncFeedSelectionCallback = (callback) => {
588
590
  context.selectionCallback = callback;
589
591
  };
@@ -138,11 +138,14 @@ function resolveKeybindingsPath(options?: KeybindingsManagerOptions): string {
138
138
  export class KeybindingsManager {
139
139
  private bindings: Record<KeyAction, KeyCombo[]>;
140
140
  private configPath: string;
141
+ /** Inverted lookup map: composite key → KeyAction. Built in buildLookupMap(). */
142
+ private lookupMap = new Map<string, KeyAction>();
141
143
 
142
144
  constructor(options: KeybindingsManagerOptions) {
143
145
  this.configPath = resolveKeybindingsPath(options);
144
146
  // Start with deep copy of defaults
145
147
  this.bindings = this.cloneDefaults();
148
+ this.buildLookupMap();
146
149
  }
147
150
 
148
151
  private cloneDefaults(): Record<KeyAction, KeyCombo[]> {
@@ -184,6 +187,33 @@ export class KeybindingsManager {
184
187
  } catch (err) {
185
188
  logger.debug('keybindings: failed to load config file', { path: this.configPath, err: summarizeError(err) });
186
189
  }
190
+ this.buildLookupMap();
191
+ }
192
+
193
+ /**
194
+ * buildLookupMap — Rebuild the inverted lookup map from the current bindings table.
195
+ * Called after constructor init and after loadFromDisk().
196
+ * Map key format: "logicalName:ctrl:shift:alt" (booleans as 0/1).
197
+ * Last writer wins for duplicate combos (deterministic: iterate actions in order).
198
+ */
199
+ private buildLookupMap(): void {
200
+ this.lookupMap.clear();
201
+ for (const [action, combos] of Object.entries(this.bindings) as [KeyAction, KeyCombo[]][]) {
202
+ for (const combo of combos) {
203
+ const key = `${combo.key}:${combo.ctrl ? 1 : 0}:${combo.shift ? 1 : 0}:${combo.alt ? 1 : 0}`;
204
+ this.lookupMap.set(key, action);
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * lookup — O(1) keybinding lookup by token.
211
+ * Returns the matching KeyAction, or null if no binding matches.
212
+ */
213
+ lookup(token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyAction | null {
214
+ if (!token.logicalName) return null;
215
+ const key = `${token.logicalName}:${token.ctrl ? 1 : 0}:${token.shift ? 1 : 0}:${token.alt ? 1 : 0}`;
216
+ return this.lookupMap.get(key) ?? null;
187
217
  }
188
218
 
189
219
  private validateCombos(combos: unknown[]): combos is KeyCombo[] {