@pellux/goodvibes-tui 0.28.0 → 1.0.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 +133 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/conversation-line-cache.ts +432 -0
- package/src/core/conversation-rendering.ts +11 -3
- package/src/core/conversation.ts +50 -4
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-content-actions.ts +8 -3
- package/src/input/handler-feed-routes.ts +12 -35
- package/src/input/handler-feed.ts +4 -8
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler.ts +43 -6
- package/src/input/input-history.ts +17 -4
- package/src/input/keybindings.ts +48 -8
- package/src/input/panel-integration-actions.ts +107 -1
- package/src/main.ts +19 -11
- package/src/panels/agent-inspector-panel.ts +125 -36
- package/src/panels/agent-inspector-shared.ts +144 -0
- package/src/panels/approval-panel.ts +67 -16
- package/src/panels/automation-control-panel.ts +368 -124
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +232 -73
- package/src/panels/communication-panel.ts +58 -20
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +351 -118
- package/src/panels/cost-tracker-panel.ts +165 -7
- package/src/panels/debug-panel.ts +312 -159
- package/src/panels/diff-panel.ts +209 -57
- package/src/panels/docs-panel.ts +152 -66
- package/src/panels/eval-panel.ts +220 -42
- package/src/panels/file-explorer-panel.ts +202 -122
- package/src/panels/file-preview-panel.ts +132 -57
- package/src/panels/git-panel.ts +310 -129
- package/src/panels/hooks-panel.ts +150 -22
- package/src/panels/incident-review-panel.ts +223 -35
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +212 -86
- package/src/panels/knowledge-graph-panel.ts +461 -101
- package/src/panels/local-auth-panel.ts +240 -28
- package/src/panels/marketplace-panel.ts +193 -26
- package/src/panels/memory-panel.ts +78 -77
- package/src/panels/ops-control-panel.ts +146 -29
- package/src/panels/ops-strategy-panel.ts +72 -4
- package/src/panels/orchestration-panel.ts +231 -69
- package/src/panels/panel-list-panel.ts +154 -131
- package/src/panels/panel-manager.ts +97 -9
- package/src/panels/plan-dashboard-panel.ts +333 -17
- package/src/panels/plugins-panel.ts +184 -29
- package/src/panels/policy-panel.ts +210 -38
- package/src/panels/polish-core.ts +7 -2
- package/src/panels/polish.ts +23 -4
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +62 -113
- package/src/panels/provider-health-panel.ts +434 -518
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +116 -30
- package/src/panels/remote-panel.ts +114 -36
- package/src/panels/routes-panel.ts +63 -22
- package/src/panels/sandbox-panel.ts +174 -42
- package/src/panels/scrollable-list-panel.ts +19 -135
- package/src/panels/security-panel.ts +133 -33
- package/src/panels/services-panel.ts +116 -64
- package/src/panels/session-browser-panel.ts +73 -2
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +335 -72
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +86 -33
- package/src/panels/symbol-outline-panel.ts +248 -108
- package/src/panels/system-messages-panel.ts +114 -13
- package/src/panels/tasks-panel.ts +326 -139
- package/src/panels/thinking-panel.ts +43 -10
- package/src/panels/token-budget-panel.ts +194 -18
- package/src/panels/tool-inspector-panel.ts +144 -34
- package/src/panels/types.ts +40 -4
- package/src/panels/work-plan-panel.ts +280 -17
- package/src/panels/worktree-panel.ts +175 -42
- package/src/panels/wrfc-panel.ts +116 -24
- package/src/renderer/agent-detail-modal.ts +37 -8
- package/src/renderer/code-block.ts +58 -28
- package/src/renderer/context-inspector.ts +3 -6
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/conversation-surface.ts +1 -21
- package/src/renderer/diff-view.ts +6 -4
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +23 -17
- package/src/renderer/fullscreen-workspace.ts +2 -7
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/history-search-overlay.ts +10 -16
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/markdown.ts +9 -1
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +8 -485
- package/src/renderer/model-workspace.ts +9 -24
- package/src/renderer/overlay-box.ts +23 -19
- package/src/renderer/process-indicator.ts +15 -25
- package/src/renderer/profile-picker-modal.ts +13 -14
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/selection-modal-overlay.ts +3 -1
- package/src/renderer/semantic-diff.ts +1 -1
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal-helpers.ts +10 -34
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +54 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/surface-layout.ts +0 -12
- package/src/renderer/term-caps.ts +1 -1
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/tool-call.ts +6 -20
- package/src/renderer/ui-factory.ts +48 -37
- package/src/renderer/ui-primitives.ts +41 -3
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/runtime/render-scheduler.ts +80 -0
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +2 -2
- package/src/utils/splash-lines.ts +54 -5
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -635
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -238
- package/src/panels/forensics-panel.ts +0 -378
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -255
- package/src/panels/provider-stats-panel.ts +0 -391
- package/src/panels/schedule-panel.ts +0 -365
- package/src/panels/watchers-panel.ts +0 -213
- package/src/renderer/file-tree.ts +0 -153
- package/src/renderer/progress.ts +0 -99
- package/src/renderer/status-token.ts +0 -67
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/runtime/diagnostics/panels/policy.ts +0 -177
package/src/core/conversation.ts
CHANGED
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
type BlockMeta as SdkBlockMeta,
|
|
14
14
|
} from '@pellux/goodvibes-sdk/platform/core';
|
|
15
15
|
import type { BlockMeta } from './conversation-types.ts';
|
|
16
|
+
import { MessageLineCache } from './conversation-line-cache.ts';
|
|
16
17
|
import {
|
|
17
18
|
addConversationSplashScreen,
|
|
18
|
-
appendConversationMessages,
|
|
19
19
|
conversationTextToLines,
|
|
20
20
|
logConversationText,
|
|
21
21
|
renderConversationAssistantMessage,
|
|
@@ -56,6 +56,13 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
56
56
|
private lastRenderedWidth = 0;
|
|
57
57
|
/** When true the buffer needs to be rebuilt before the next display. */
|
|
58
58
|
private dirty = true;
|
|
59
|
+
/**
|
|
60
|
+
* Per-message Line[] cache (WO-209). rebuildHistory() reuses cached lines for
|
|
61
|
+
* messages whose complete render inputs are unchanged, so appending one message
|
|
62
|
+
* to an N-message conversation no longer re-renders all N. A cache-served
|
|
63
|
+
* rebuild is byte-identical to a cold one (pure memoisation).
|
|
64
|
+
*/
|
|
65
|
+
private lineCache = new MessageLineCache();
|
|
59
66
|
/** Index of the first message not yet appended to the buffer. */
|
|
60
67
|
private appendedUpTo = 0;
|
|
61
68
|
/** Optional config manager for display settings. */
|
|
@@ -300,6 +307,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
300
307
|
public override resetAll(): void {
|
|
301
308
|
super.resetAll();
|
|
302
309
|
this.history.clear();
|
|
310
|
+
this.lineCache.clear();
|
|
303
311
|
this.appendedUpTo = 0;
|
|
304
312
|
this.lastRenderedWidth = 0;
|
|
305
313
|
this.dirty = true;
|
|
@@ -322,6 +330,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
322
330
|
public override replaceMessagesForLLM(newMessages: ProviderMessage[]): void {
|
|
323
331
|
super.replaceMessagesForLLM(newMessages);
|
|
324
332
|
this.history.clear();
|
|
333
|
+
this.lineCache.clear();
|
|
325
334
|
this.appendedUpTo = 0;
|
|
326
335
|
this.lastRenderedWidth = 0;
|
|
327
336
|
this.dirty = true;
|
|
@@ -360,6 +369,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
360
369
|
}): void {
|
|
361
370
|
super.fromJSON(data);
|
|
362
371
|
this.history.clear();
|
|
372
|
+
this.lineCache.clear();
|
|
363
373
|
this.appendedUpTo = 0;
|
|
364
374
|
this.lastRenderedWidth = 0;
|
|
365
375
|
this.dirty = true;
|
|
@@ -374,6 +384,26 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
374
384
|
return this.history.getAllLines();
|
|
375
385
|
}
|
|
376
386
|
|
|
387
|
+
/**
|
|
388
|
+
* clearLineCache - Drop every cached per-message Line[] and force a full cold
|
|
389
|
+
* re-render on the next display. The rendered output is unchanged (the cache is
|
|
390
|
+
* a pure memoisation); this only discards the reuse, e.g. to reclaim memory or
|
|
391
|
+
* to assert cache/cold equivalence in tests.
|
|
392
|
+
*/
|
|
393
|
+
public clearLineCache(): void {
|
|
394
|
+
this.lineCache.clear();
|
|
395
|
+
this.dirty = true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* getLineCacheSize - Number of per-message Line[] entries currently retained.
|
|
400
|
+
* The cache mark-and-sweeps to the visible message set on every rebuild, so
|
|
401
|
+
* this is bounded by what is on screen — exposed for memory-hygiene assertions.
|
|
402
|
+
*/
|
|
403
|
+
public getLineCacheSize(): number {
|
|
404
|
+
return this.lineCache.size;
|
|
405
|
+
}
|
|
406
|
+
|
|
377
407
|
/**
|
|
378
408
|
* rebuildHistory - Full rebuild. Called when width changes or on first render.
|
|
379
409
|
* For incremental appends use flushHistory().
|
|
@@ -419,7 +449,11 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
419
449
|
return;
|
|
420
450
|
}
|
|
421
451
|
|
|
422
|
-
|
|
452
|
+
// The in-progress streaming placeholder (rendered EMPTY above via
|
|
453
|
+
// renderSnapshot) is left uncached: its content mutates in place per delta and
|
|
454
|
+
// the incremental streaming path (updateStreamingBlock) owns it.
|
|
455
|
+
const streamingPlaceholderAbsIdx = isStreaming ? snapshot.length - 1 : -1;
|
|
456
|
+
this.appendMessages(visibleSnapshot, width, displayStart, streamingPlaceholderAbsIdx);
|
|
423
457
|
this.appendedUpTo = snapshot.length;
|
|
424
458
|
|
|
425
459
|
if (isStreaming) {
|
|
@@ -491,8 +525,20 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
491
525
|
* the renderer can resolve messageKindRegistry keys (which are absolute)
|
|
492
526
|
* from its slice-relative loop counter.
|
|
493
527
|
*/
|
|
494
|
-
private appendMessages(
|
|
495
|
-
|
|
528
|
+
private appendMessages(
|
|
529
|
+
messages: Message[],
|
|
530
|
+
width: number,
|
|
531
|
+
msgIndexOffset = 0,
|
|
532
|
+
streamingPlaceholderAbsIdx = -1,
|
|
533
|
+
): void {
|
|
534
|
+
this.lineCache.renderInto(
|
|
535
|
+
this.renderingContext(),
|
|
536
|
+
messages,
|
|
537
|
+
width,
|
|
538
|
+
this.messageLineRegistry,
|
|
539
|
+
msgIndexOffset,
|
|
540
|
+
streamingPlaceholderAbsIdx,
|
|
541
|
+
);
|
|
496
542
|
}
|
|
497
543
|
|
|
498
544
|
/** Find the nearest block to a given line index, optionally filtered by type. */
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
2
|
+
import { CostTrackerPanel } from '../../panels/cost-tracker-panel.ts';
|
|
3
|
+
import { openCommandPanel, requirePanelManager } from './runtime-services.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* /cost budget <usd> — makes the CostTrackerPanel's budget alert real (WO-139).
|
|
7
|
+
* Opens the cost panel (creating it via the registered factory if not already
|
|
8
|
+
* open) and sets its budget threshold directly, so the meter+alert already
|
|
9
|
+
* built into CostTrackerPanel.render() (:266-290) actually fires. 0 disables
|
|
10
|
+
* the alert. Mirrors the /auth local rotate-password → panel-instance-call
|
|
11
|
+
* pattern (local-auth-runtime.ts) rather than only printing a signpost.
|
|
12
|
+
*/
|
|
13
|
+
export function registerCostRuntimeCommands(registry: CommandRegistry): void {
|
|
14
|
+
registry.register({
|
|
15
|
+
name: 'cost',
|
|
16
|
+
description: 'Inspect session/agent cost tracking and configure the budget alert threshold',
|
|
17
|
+
usage: '[panel|budget <usd>]',
|
|
18
|
+
handler(args, ctx) {
|
|
19
|
+
const sub = (args[0] ?? 'panel').toLowerCase();
|
|
20
|
+
|
|
21
|
+
if (sub === 'panel' || sub === 'open') {
|
|
22
|
+
openCommandPanel(ctx, 'cost');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (sub === 'budget') {
|
|
27
|
+
const raw = args[1];
|
|
28
|
+
const usd = raw !== undefined ? Number(raw) : NaN;
|
|
29
|
+
if (raw === undefined || !Number.isFinite(usd) || usd < 0) {
|
|
30
|
+
ctx.print('Usage: /cost budget <usd> (0 disables the alert)');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const panelManager = requirePanelManager(ctx);
|
|
34
|
+
const panel = panelManager.open('cost');
|
|
35
|
+
if (panel instanceof CostTrackerPanel) {
|
|
36
|
+
panel.setBudgetThreshold(usd);
|
|
37
|
+
ctx.print(usd > 0
|
|
38
|
+
? `Cost budget alert set to $${usd.toFixed(2)}.`
|
|
39
|
+
: 'Cost budget alert disabled.');
|
|
40
|
+
} else {
|
|
41
|
+
ctx.print('Cost tracking is not available in this session.');
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ctx.print('Usage: /cost [panel|budget <usd>]');
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -175,8 +175,12 @@ export function registerOperatorRuntimeCommands(registry: CommandRegistry): void
|
|
|
175
175
|
const sub = args[0];
|
|
176
176
|
|
|
177
177
|
if (sub === 'view' || sub === undefined) {
|
|
178
|
+
// Parity with the Ctrl+O keybind (handler-shortcuts.ts): prefer the
|
|
179
|
+
// flag-wired opener, else fall back to the always-registered panel,
|
|
180
|
+
// which renders its own honest not-configured state.
|
|
178
181
|
if (ctx.openOpsPanel) ctx.openOpsPanel();
|
|
179
|
-
else ctx.
|
|
182
|
+
else if (ctx.showPanel) ctx.showPanel('ops-control');
|
|
183
|
+
else ctx.print('Operator Control Plane panel is not available in this shell.');
|
|
180
184
|
return;
|
|
181
185
|
}
|
|
182
186
|
|
package/src/input/commands.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { registerOnboardingRuntimeCommands } from './commands/onboarding-runtime
|
|
|
56
56
|
import { registerTtsRuntimeCommands } from './commands/tts-runtime.ts';
|
|
57
57
|
import { registerCloudflareRuntimeCommands } from './commands/cloudflare-runtime.ts';
|
|
58
58
|
import { registerWorkPlanRuntimeCommands } from './commands/work-plan-runtime.ts';
|
|
59
|
+
import { registerCostRuntimeCommands } from './commands/cost-runtime.ts';
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
62
|
* registerBuiltinCommands - Register all built-in slash commands into the registry.
|
|
@@ -107,6 +108,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
|
|
|
107
108
|
registerTtsRuntimeCommands(registry);
|
|
108
109
|
registerCloudflareRuntimeCommands(registry);
|
|
109
110
|
registerWorkPlanRuntimeCommands(registry);
|
|
111
|
+
registerCostRuntimeCommands(registry);
|
|
110
112
|
registerLocalRuntimeCommands(registry);
|
|
111
113
|
registerDiscoveryRuntimeCommands(registry);
|
|
112
114
|
registerPlanningRuntimeCommands(registry);
|
|
@@ -121,10 +121,15 @@ export function registerPaste(
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
// Terminals transmit the line breaks inside a bracketed paste as \r (the
|
|
125
|
+
// byte Enter sends), and external clipboards can carry \r\n. Normalize to
|
|
126
|
+
// \n only here in the text branch — the image sniffing above must see the
|
|
127
|
+
// raw bytes (PNG's magic sequence itself contains \r\n).
|
|
128
|
+
const text = content.replace(/\r\n?/g, '\n');
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
if (lines.length <= 8) return { marker: text, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
|
|
126
131
|
const id = `p${state.nextPasteId++}`;
|
|
127
|
-
state.pasteRegistry.set(id,
|
|
132
|
+
state.pasteRegistry.set(id, text);
|
|
128
133
|
return { marker: `[TEXT: ${id}, ${lines.length} lines]`, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
|
|
129
134
|
}
|
|
130
135
|
|
|
@@ -78,14 +78,14 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
|
|
|
78
78
|
return { handled: true, panelFocused };
|
|
79
79
|
}
|
|
80
80
|
const kb = state.keybindingsManager;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
// NOTE: panel-tab-next/prev, panel-close, and panel-close-all are handled
|
|
82
|
+
// globally in handleGlobalShortcutToken, which runs earlier in the feed
|
|
83
|
+
// loop and consumes those tokens before they ever reach this focused-panel
|
|
84
|
+
// route. Their old copies lived here too and had drifted (this route never
|
|
85
|
+
// called pm.hide() on close-all); they were unreachable, so they are gone
|
|
86
|
+
// and close/close-all semantics now live in exactly one place. Only
|
|
87
|
+
// panel-focus-toggle stays here, because it is meaningful only while the
|
|
88
|
+
// panel workspace already owns focus (it swaps between the two panes).
|
|
89
89
|
if (kb.matches('panel-focus-toggle', token)) {
|
|
90
90
|
// Switch keyboard focus between the top and bottom panes (no-op when
|
|
91
91
|
// there is no visible, non-empty bottom pane).
|
|
@@ -93,33 +93,10 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
|
|
|
93
93
|
state.requestRender();
|
|
94
94
|
return { handled: true, panelFocused };
|
|
95
95
|
}
|
|
96
|
-
// Alt+1..9
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
if (token.meta && !token.ctrl && /^[1-9]$/.test(token.logicalName ?? '')) {
|
|
101
|
-
const index = Number(token.logicalName) - 1;
|
|
102
|
-
state.panelManager.activateWorkspaceIndex(index);
|
|
103
|
-
state.requestRender();
|
|
104
|
-
return { handled: true, panelFocused };
|
|
105
|
-
}
|
|
106
|
-
if (kb.matches('panel-close-all', token)) {
|
|
107
|
-
const pm = state.panelManager;
|
|
108
|
-
for (const p of pm.getAllOpen()) pm.close(p.id);
|
|
109
|
-
panelFocused = false;
|
|
110
|
-
state.requestRender();
|
|
111
|
-
return { handled: true, panelFocused };
|
|
112
|
-
}
|
|
113
|
-
if (kb.matches('panel-close', token)) {
|
|
114
|
-
const pm = state.panelManager;
|
|
115
|
-
const active = pm.getActivePanel();
|
|
116
|
-
if (active) {
|
|
117
|
-
pm.close(active.id);
|
|
118
|
-
}
|
|
119
|
-
panelFocused = false;
|
|
120
|
-
state.requestRender();
|
|
121
|
-
return { handled: true, panelFocused };
|
|
122
|
-
}
|
|
96
|
+
// Alt+1..9 (panel-tab-1..9) is a global KeyAction consumed earlier by
|
|
97
|
+
// handleGlobalShortcutToken, so those meta+digit tokens never reach here.
|
|
98
|
+
// Any other ctrl/meta combo this route does not own falls through so the
|
|
99
|
+
// global handler (which ran first) or the prompt routes can act on it.
|
|
123
100
|
if (token.ctrl || token.meta) {
|
|
124
101
|
return { handled: false, panelFocused };
|
|
125
102
|
}
|
|
@@ -251,14 +251,10 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
if (token.type === 'key') {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|| context.panelManager.getActivePanel() === null)
|
|
259
|
-
) {
|
|
260
|
-
context.panelFocused = false;
|
|
261
|
-
}
|
|
254
|
+
// Focus can never disagree with workspace visibility: PanelManager owns
|
|
255
|
+
// focusTarget and self-heals it, and context.panelFocused was seeded from
|
|
256
|
+
// it at feed entry — so no manual "unfocus if panels vanished" patch is
|
|
257
|
+
// needed here anymore.
|
|
262
258
|
const shortcutState = {
|
|
263
259
|
panelFocused: context.panelFocused,
|
|
264
260
|
prompt: context.prompt,
|
|
@@ -115,6 +115,21 @@ export function handleGlobalShortcutToken(
|
|
|
115
115
|
state.requestRender();
|
|
116
116
|
return true;
|
|
117
117
|
|
|
118
|
+
case 'panel-focus-toggle': {
|
|
119
|
+
// Global entry point for the focus-toggle key (Ctrl+G): from the prompt
|
|
120
|
+
// it grabs focus for the panel workspace. Once the workspace already has
|
|
121
|
+
// focus we let it fall through (return false) so handlePanelFocusToken
|
|
122
|
+
// can do the top/bottom pane swap — keeping that behavior in one place.
|
|
123
|
+
if (state.panelFocused) return false;
|
|
124
|
+
const pm = state.panelManager;
|
|
125
|
+
if (pm.isVisible() && pm.getAllOpen().length > 0) {
|
|
126
|
+
state.panelFocused = true;
|
|
127
|
+
state.requestRender();
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
case 'panel-tab-next':
|
|
119
134
|
state.cyclePanelTab('next');
|
|
120
135
|
return true;
|
|
@@ -123,6 +138,42 @@ export function handleGlobalShortcutToken(
|
|
|
123
138
|
state.cyclePanelTab('prev');
|
|
124
139
|
return true;
|
|
125
140
|
|
|
141
|
+
case 'panel-tab-1':
|
|
142
|
+
case 'panel-tab-2':
|
|
143
|
+
case 'panel-tab-3':
|
|
144
|
+
case 'panel-tab-4':
|
|
145
|
+
case 'panel-tab-5':
|
|
146
|
+
case 'panel-tab-6':
|
|
147
|
+
case 'panel-tab-7':
|
|
148
|
+
case 'panel-tab-8':
|
|
149
|
+
case 'panel-tab-9': {
|
|
150
|
+
// Alt+1..9: jump directly to the Nth workspace tab. Routed globally (like
|
|
151
|
+
// panel-tab-next/prev) so the jump works whether focus is on the prompt or
|
|
152
|
+
// the workspace; gated on visibility, matching cyclePanelTab semantics.
|
|
153
|
+
const pm = state.panelManager;
|
|
154
|
+
if (pm.isVisible()) {
|
|
155
|
+
const index = Number(action.slice('panel-tab-'.length)) - 1;
|
|
156
|
+
pm.activateWorkspaceIndex(index);
|
|
157
|
+
state.requestRender();
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'panel-ops': {
|
|
163
|
+
// Ctrl+O: open the Ops Control panel. Prefer the command-context callback
|
|
164
|
+
// (wired only when the operator-control-plane feature flag is on) so it
|
|
165
|
+
// reuses whatever show/open semantics bootstrap.ts set up; otherwise fall
|
|
166
|
+
// back to opening the always-registered 'ops-control' panel type directly
|
|
167
|
+
// — it renders an honest not-configured state when its deps are absent.
|
|
168
|
+
if (state.commandContext?.openOpsPanel) {
|
|
169
|
+
state.commandContext.openOpsPanel();
|
|
170
|
+
} else {
|
|
171
|
+
state.panelManager.open('ops-control');
|
|
172
|
+
state.requestRender();
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
126
177
|
case 'history-search':
|
|
127
178
|
state.historySearch.open(state.prompt);
|
|
128
179
|
state.requestRender();
|
package/src/input/handler.ts
CHANGED
|
@@ -140,8 +140,33 @@ export class InputHandler implements InputHandlerLike {
|
|
|
140
140
|
public commandMode = false;
|
|
141
141
|
/** True when the process indicator bar has keyboard focus. */
|
|
142
142
|
public indicatorFocused = false;
|
|
143
|
-
/**
|
|
144
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Fallback focus store used only when the panel manager does not implement
|
|
145
|
+
* focus ownership (lightweight test stubs). Production always delegates to
|
|
146
|
+
* PanelManager, which is the single source of truth.
|
|
147
|
+
*/
|
|
148
|
+
private _panelFocusedFallback = false;
|
|
149
|
+
/**
|
|
150
|
+
* True when keyboard focus is on the active panel (arrow/enter go to panel,
|
|
151
|
+
* not prompt). Ownership lives in PanelManager (focusTarget); this reads and
|
|
152
|
+
* writes through to it so handler focus can never disagree with what the
|
|
153
|
+
* panel workspace actually shows.
|
|
154
|
+
*/
|
|
155
|
+
public get panelFocused(): boolean {
|
|
156
|
+
const pm = this.uiServices.shell.panelManager;
|
|
157
|
+
return typeof pm.getFocusTarget === 'function'
|
|
158
|
+
? pm.getFocusTarget() === 'panel'
|
|
159
|
+
: this._panelFocusedFallback;
|
|
160
|
+
}
|
|
161
|
+
public set panelFocused(value: boolean) {
|
|
162
|
+
const pm = this.uiServices.shell.panelManager;
|
|
163
|
+
if (typeof pm.focusPanels === 'function' && typeof pm.focusPrompt === 'function') {
|
|
164
|
+
if (value) pm.focusPanels();
|
|
165
|
+
else pm.focusPrompt();
|
|
166
|
+
} else {
|
|
167
|
+
this._panelFocusedFallback = value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
145
170
|
|
|
146
171
|
public tokenizer = new InputTokenizer();
|
|
147
172
|
public pasteRegistry = new Map<string, string>();
|
|
@@ -468,6 +493,15 @@ export class InputHandler implements InputHandlerLike {
|
|
|
468
493
|
};
|
|
469
494
|
|
|
470
495
|
this.requestRender = bufferedRequestRender;
|
|
496
|
+
// Snapshot the overlay flags before the feed: command handlers executed
|
|
497
|
+
// synchronously during token processing (e.g. /shortcuts, /help via
|
|
498
|
+
// ui-openers) mutate these directly on the handler, and an unconditional
|
|
499
|
+
// write-back of the pre-feed snapshot would silently revert them in the
|
|
500
|
+
// same keystroke (the "/shortcuts never displays" defect).
|
|
501
|
+
const helpOverlayActiveBefore = this.helpOverlayActive;
|
|
502
|
+
const helpScrollOffsetBefore = this.helpScrollOffset;
|
|
503
|
+
const shortcutsOverlayActiveBefore = this.shortcutsOverlayActive;
|
|
504
|
+
const shortcutsScrollOffsetBefore = this.shortcutsScrollOffset;
|
|
471
505
|
try {
|
|
472
506
|
const context = this.feedContext;
|
|
473
507
|
// Sync mutable scalars from handler into the reused context.
|
|
@@ -506,10 +540,13 @@ export class InputHandler implements InputHandlerLike {
|
|
|
506
540
|
this.commandMode = context.commandMode;
|
|
507
541
|
this.panelFocused = context.panelFocused;
|
|
508
542
|
this.indicatorFocused = context.indicatorFocused;
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
this.
|
|
543
|
+
// Overlay flags: only apply the pipeline's value when the pipeline
|
|
544
|
+
// itself changed it (e.g. Escape closing the overlay). Otherwise keep
|
|
545
|
+
// the live handler value, which a command handler may have just set.
|
|
546
|
+
if (context.helpOverlayActive !== helpOverlayActiveBefore) this.helpOverlayActive = context.helpOverlayActive;
|
|
547
|
+
if (context.helpScrollOffset !== helpScrollOffsetBefore) this.helpScrollOffset = context.helpScrollOffset;
|
|
548
|
+
if (context.shortcutsOverlayActive !== shortcutsOverlayActiveBefore) this.shortcutsOverlayActive = context.shortcutsOverlayActive;
|
|
549
|
+
if (context.shortcutsScrollOffset !== shortcutsScrollOffsetBefore) this.shortcutsScrollOffset = context.shortcutsScrollOffset;
|
|
513
550
|
this.selectionCallback = context.selectionCallback;
|
|
514
551
|
this.nextPasteId = context.nextPasteId;
|
|
515
552
|
this.nextImageId = context.nextImageId;
|
|
@@ -351,15 +351,28 @@ export class InputHistory {
|
|
|
351
351
|
return redactedText;
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Entries persisted before CR normalization was deployed (registerPaste now
|
|
356
|
+
* converts the \r line separators terminals send in bracketed pastes to \n)
|
|
357
|
+
* may still carry literal \r bytes; launder them on load so history recall
|
|
358
|
+
* cannot reintroduce \r into the composer.
|
|
359
|
+
*/
|
|
360
|
+
private launderLineBreaks(text: string): string {
|
|
361
|
+
return text.replace(/\r\n?/g, '\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
354
364
|
private normalizeStoredEntry(entry: unknown): StoredInputHistoryEntry | null {
|
|
355
|
-
if (typeof entry === 'string') return entry;
|
|
365
|
+
if (typeof entry === 'string') return this.launderLineBreaks(entry);
|
|
356
366
|
if (!entry || typeof entry !== 'object') return null;
|
|
357
367
|
const record = entry as Record<string, unknown>;
|
|
358
368
|
if (typeof record.text !== 'string') return null;
|
|
359
|
-
const text = record.text.trim();
|
|
369
|
+
const text = this.launderLineBreaks(record.text).trim();
|
|
360
370
|
if (!text) return null;
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
const recallText = typeof record.recallText === 'string'
|
|
372
|
+
? this.launderLineBreaks(record.recallText).trim()
|
|
373
|
+
: '';
|
|
374
|
+
if (recallText && recallText !== text) {
|
|
375
|
+
return { text, recallText };
|
|
363
376
|
}
|
|
364
377
|
return text;
|
|
365
378
|
}
|
package/src/input/keybindings.ts
CHANGED
|
@@ -38,6 +38,16 @@ export type KeyAction =
|
|
|
38
38
|
| 'panel-close-all'
|
|
39
39
|
| 'panel-tab-next'
|
|
40
40
|
| 'panel-tab-prev'
|
|
41
|
+
| 'panel-tab-1'
|
|
42
|
+
| 'panel-tab-2'
|
|
43
|
+
| 'panel-tab-3'
|
|
44
|
+
| 'panel-tab-4'
|
|
45
|
+
| 'panel-tab-5'
|
|
46
|
+
| 'panel-tab-6'
|
|
47
|
+
| 'panel-tab-7'
|
|
48
|
+
| 'panel-tab-8'
|
|
49
|
+
| 'panel-tab-9'
|
|
50
|
+
| 'panel-ops'
|
|
41
51
|
| 'panel-focus-toggle'
|
|
42
52
|
| 'history-search'
|
|
43
53
|
| 'search'
|
|
@@ -52,7 +62,6 @@ export type KeyAction =
|
|
|
52
62
|
| 'undo'
|
|
53
63
|
| 'redo'
|
|
54
64
|
| 'paste'
|
|
55
|
-
| 'replay-panel'
|
|
56
65
|
| 'word-back'
|
|
57
66
|
| 'word-forward'
|
|
58
67
|
| 'kill-to-start'
|
|
@@ -70,6 +79,16 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
|
|
|
70
79
|
'panel-close-all': 'Close all open panels',
|
|
71
80
|
'panel-tab-next': 'Next workspace panel tab',
|
|
72
81
|
'panel-tab-prev': 'Previous workspace panel tab',
|
|
82
|
+
'panel-tab-1': 'Jump to workspace panel tab 1',
|
|
83
|
+
'panel-tab-2': 'Jump to workspace panel tab 2',
|
|
84
|
+
'panel-tab-3': 'Jump to workspace panel tab 3',
|
|
85
|
+
'panel-tab-4': 'Jump to workspace panel tab 4',
|
|
86
|
+
'panel-tab-5': 'Jump to workspace panel tab 5',
|
|
87
|
+
'panel-tab-6': 'Jump to workspace panel tab 6',
|
|
88
|
+
'panel-tab-7': 'Jump to workspace panel tab 7',
|
|
89
|
+
'panel-tab-8': 'Jump to workspace panel tab 8',
|
|
90
|
+
'panel-tab-9': 'Jump to workspace panel tab 9',
|
|
91
|
+
'panel-ops': 'Open the Ops Control panel',
|
|
73
92
|
'panel-focus-toggle': 'Switch keyboard focus between top and bottom pane',
|
|
74
93
|
'history-search': 'Reverse input history search',
|
|
75
94
|
'search': 'Toggle conversation search',
|
|
@@ -84,7 +103,6 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
|
|
|
84
103
|
'undo': 'Undo last prompt edit',
|
|
85
104
|
'redo': 'Redo last undone edit',
|
|
86
105
|
'paste': 'Paste from clipboard (image priority)',
|
|
87
|
-
'replay-panel': 'Open / close the Replay panel',
|
|
88
106
|
'word-back': 'Move cursor to start of previous word (Alt+B)',
|
|
89
107
|
'word-forward': 'Move cursor to end of next word (Alt+F)',
|
|
90
108
|
'kill-to-start': 'Kill from cursor to start of line into kill ring (Ctrl+U)',
|
|
@@ -103,6 +121,24 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
|
|
|
103
121
|
'panel-close-all': [{ key: 'x', ctrl: true, shift: true }],
|
|
104
122
|
'panel-tab-next': [{ key: ']', ctrl: true }],
|
|
105
123
|
'panel-tab-prev': [{ key: '[', ctrl: true }],
|
|
124
|
+
// Alt+1..9: jump directly to the Nth workspace panel tab (across both panes).
|
|
125
|
+
// The tokenizer delivers Alt as the token's `meta` modifier; comboMatches /
|
|
126
|
+
// lookup treat `meta` as an alias for `alt`, so these alt-combos route through
|
|
127
|
+
// the same rebindable path as every other action.
|
|
128
|
+
'panel-tab-1': [{ key: '1', alt: true }],
|
|
129
|
+
'panel-tab-2': [{ key: '2', alt: true }],
|
|
130
|
+
'panel-tab-3': [{ key: '3', alt: true }],
|
|
131
|
+
'panel-tab-4': [{ key: '4', alt: true }],
|
|
132
|
+
'panel-tab-5': [{ key: '5', alt: true }],
|
|
133
|
+
'panel-tab-6': [{ key: '6', alt: true }],
|
|
134
|
+
'panel-tab-7': [{ key: '7', alt: true }],
|
|
135
|
+
'panel-tab-8': [{ key: '8', alt: true }],
|
|
136
|
+
'panel-tab-9': [{ key: '9', alt: true }],
|
|
137
|
+
// Ctrl+O: open the Ops Control panel (operator intervention console).
|
|
138
|
+
// Routed globally in handleGlobalShortcutToken: prefers commandContext.openOpsPanel()
|
|
139
|
+
// when the operator-control-plane feature flag wired it, else falls back to opening
|
|
140
|
+
// the always-registered 'ops-control' panel type directly via the panel manager.
|
|
141
|
+
'panel-ops': [{ key: 'o', ctrl: true }],
|
|
106
142
|
// Ctrl+G: toggle keyboard focus between the top and bottom panes. Ctrl+G is
|
|
107
143
|
// otherwise unbound in the default table.
|
|
108
144
|
'panel-focus-toggle': [{ key: 'g', ctrl: true }],
|
|
@@ -122,7 +158,6 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
|
|
|
122
158
|
'undo': [{ key: 'z', ctrl: true }],
|
|
123
159
|
'redo': [{ key: 'z', ctrl: true, shift: true }],
|
|
124
160
|
'paste': [{ key: 'v', ctrl: true }],
|
|
125
|
-
'replay-panel': [{ key: 'r', ctrl: true, shift: true }],
|
|
126
161
|
// Word navigation (Alt+B / Alt+F — emacs readline standard)
|
|
127
162
|
'word-back': [{ key: 'b', alt: true }],
|
|
128
163
|
'word-forward': [{ key: 'f', alt: true }],
|
|
@@ -245,9 +280,13 @@ export class KeybindingsManager {
|
|
|
245
280
|
* lookup — O(1) keybinding lookup by token.
|
|
246
281
|
* Returns the matching KeyAction, or null if no binding matches.
|
|
247
282
|
*/
|
|
248
|
-
lookup(token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyAction | null {
|
|
283
|
+
lookup(token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean }): KeyAction | null {
|
|
249
284
|
if (!token.logicalName) return null;
|
|
250
|
-
|
|
285
|
+
// The tokenizer delivers the Alt modifier as `meta`; the binding table stores
|
|
286
|
+
// it as `alt`. Treat the two as one modifier at the matching boundary so
|
|
287
|
+
// Alt-based combos (word-nav, kill-ring, panel-tab-1..9) resolve at runtime.
|
|
288
|
+
const alt = token.alt ?? token.meta;
|
|
289
|
+
const key = `${token.logicalName}:${token.ctrl ? 1 : 0}:${token.shift ? 1 : 0}:${alt ? 1 : 0}`;
|
|
251
290
|
return this.lookupMap.get(key) ?? null;
|
|
252
291
|
}
|
|
253
292
|
|
|
@@ -268,7 +307,7 @@ export class KeybindingsManager {
|
|
|
268
307
|
*/
|
|
269
308
|
matches(
|
|
270
309
|
action: KeyAction,
|
|
271
|
-
token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean },
|
|
310
|
+
token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean },
|
|
272
311
|
): boolean {
|
|
273
312
|
const combos = this.bindings[action];
|
|
274
313
|
if (!combos) return false;
|
|
@@ -277,12 +316,13 @@ export class KeybindingsManager {
|
|
|
277
316
|
|
|
278
317
|
private comboMatches(
|
|
279
318
|
combo: KeyCombo,
|
|
280
|
-
token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean },
|
|
319
|
+
token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean },
|
|
281
320
|
): boolean {
|
|
282
321
|
if (token.logicalName !== combo.key) return false;
|
|
283
322
|
if (!!combo.ctrl !== !!token.ctrl) return false;
|
|
284
323
|
if (!!combo.shift !== !!token.shift) return false;
|
|
285
|
-
|
|
324
|
+
// Alt arrives as `meta` from the tokenizer; accept either as the Alt modifier.
|
|
325
|
+
if (!!combo.alt !== !!(token.alt ?? token.meta)) return false;
|
|
286
326
|
return true;
|
|
287
327
|
}
|
|
288
328
|
|