@pellux/goodvibes-tui 1.19.6 → 1.20.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/management.ts +1 -1
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-rendering.ts +70 -24
- package/src/core/conversation-tool-groups.ts +23 -10
- package/src/core/conversation-types.ts +9 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +59 -2
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +1 -1
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +66 -61
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +51 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +26 -79
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -0
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +362 -0
- package/src/runtime/resume-notice.ts +28 -93
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +7 -3
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -12,34 +12,77 @@
|
|
|
12
12
|
* - compacting → shows in-progress text so the user knows something is running
|
|
13
13
|
* - watch → no hint (not yet actionable)
|
|
14
14
|
* - stable/unknown → no hint
|
|
15
|
+
*
|
|
16
|
+
* Evidence policy (why the guards below exist)
|
|
17
|
+
* ────────────────────────────────────────────
|
|
18
|
+
* A fresh boot printed "Context high (0% used) — auto-compact will run before
|
|
19
|
+
* the next turn." before a single token had been counted. The evaluator was
|
|
20
|
+
* not at fault: `evaluateSessionMaintenance` reaches 'suggest-compact' when
|
|
21
|
+
* `remainingTokens <= 15_000`, and at boot the resolved window is still the
|
|
22
|
+
* SDK's DEFAULT_CONTEXT_WINDOW fallback (8,192 — the "8.2k" the footer meter
|
|
23
|
+
* shows before the real window arrives), so 8,192 free tokens tripped that
|
|
24
|
+
* rule instantly. Arithmetically consistent; factually false. No context had
|
|
25
|
+
* been consumed and no auto-compact was imminent.
|
|
26
|
+
*
|
|
27
|
+
* So a pressure claim here has to be backed by evidence, not merely by a
|
|
28
|
+
* level:
|
|
29
|
+
* 1. Usage has actually been measured. At zero tokens consumed there is no
|
|
30
|
+
* pressure to report, whatever the window says.
|
|
31
|
+
* 2. The window is one the provider vouched for, not the fallback constant.
|
|
32
|
+
* A model whose real window happens to equal the fallback is
|
|
33
|
+
* indistinguishable from "not resolved yet", and in that ambiguity
|
|
34
|
+
* staying quiet is the honest choice — the Tokens panel and /context
|
|
35
|
+
* still show the real numbers either way.
|
|
36
|
+
* Neither guard touches 'compacting', which reports work that is provably
|
|
37
|
+
* running rather than a prediction about headroom.
|
|
15
38
|
*/
|
|
16
39
|
|
|
40
|
+
import { DEFAULT_CONTEXT_WINDOW } from '@pellux/goodvibes-sdk/platform/providers';
|
|
17
41
|
import type { PanelSessionMaintenanceLevel } from '../panels/session-maintenance.ts';
|
|
18
42
|
|
|
19
|
-
|
|
43
|
+
interface ContextStatusHintOptions {
|
|
20
44
|
/** Maintenance level from evaluateSessionMaintenance. */
|
|
21
45
|
readonly level: PanelSessionMaintenanceLevel;
|
|
22
46
|
/** Whether auto-compaction is active (threshold > 0 in config). */
|
|
23
47
|
readonly autoCompactEnabled: boolean;
|
|
24
48
|
/** Current usage percent 0–100. */
|
|
25
49
|
readonly usagePct: number;
|
|
50
|
+
/** Tokens actually counted so far this session (orchestrator.lastInputTokens). */
|
|
51
|
+
readonly currentTokens: number;
|
|
52
|
+
/** The resolved context window the usage percent was computed against. */
|
|
53
|
+
readonly contextWindow: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the numbers backing a pressure claim are real yet: usage has been
|
|
58
|
+
* counted, and the window is not the provider fallback the app boots with.
|
|
59
|
+
*/
|
|
60
|
+
function hasRealContextNumbers(
|
|
61
|
+
options: Pick<ContextStatusHintOptions, 'currentTokens' | 'contextWindow' | 'usagePct'>,
|
|
62
|
+
): boolean {
|
|
63
|
+
if (options.currentTokens <= 0 || options.usagePct <= 0) return false;
|
|
64
|
+
if (options.contextWindow <= 0 || options.contextWindow === DEFAULT_CONTEXT_WINDOW) return false;
|
|
65
|
+
return true;
|
|
26
66
|
}
|
|
27
67
|
|
|
28
68
|
/**
|
|
29
69
|
* Build the passive status-line hint text for context pressure.
|
|
30
70
|
*
|
|
31
|
-
* Returns null when no hint is warranted (stable / watch / unknown)
|
|
32
|
-
*
|
|
33
|
-
*
|
|
71
|
+
* Returns null when no hint is warranted (stable / watch / unknown), and also
|
|
72
|
+
* when a pressure level is not yet backed by real numbers — see the evidence
|
|
73
|
+
* policy above. The caller renders this as a dim informational line — no
|
|
74
|
+
* prompts, no blocking, no confirmation required.
|
|
34
75
|
*/
|
|
35
|
-
|
|
76
|
+
function buildContextStatusHint(options: ContextStatusHintOptions): string | null {
|
|
36
77
|
const { level, autoCompactEnabled, usagePct } = options;
|
|
37
78
|
|
|
38
79
|
switch (level) {
|
|
39
80
|
case 'needs-repair':
|
|
81
|
+
if (!hasRealContextNumbers(options)) return null;
|
|
40
82
|
return ` Context pressure critical (${usagePct}% used) — compaction needs attention. Run /compact or /health review.`;
|
|
41
83
|
|
|
42
84
|
case 'suggest-compact':
|
|
85
|
+
if (!hasRealContextNumbers(options)) return null;
|
|
43
86
|
if (autoCompactEnabled) {
|
|
44
87
|
return ` Context high (${usagePct}% used) — auto-compact will run before the next turn.`;
|
|
45
88
|
}
|
|
@@ -52,3 +95,28 @@ export function buildContextStatusHint(options: ContextStatusHintOptions): strin
|
|
|
52
95
|
return null;
|
|
53
96
|
}
|
|
54
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Evaluate session maintenance and turn it into the footer hint in one call.
|
|
101
|
+
* The ONE public seam of this module, deliberately: the guards above need the
|
|
102
|
+
* exact `currentTokens` / `contextWindow` the evaluator was handed, and a
|
|
103
|
+
* caller free to pass different values to each half is how they drift apart.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveContextStatusHint(input: {
|
|
106
|
+
readonly evaluate: (args: { readonly currentTokens: number; readonly contextWindow: number }) => {
|
|
107
|
+
readonly level: PanelSessionMaintenanceLevel;
|
|
108
|
+
readonly autoCompactEnabled: boolean;
|
|
109
|
+
readonly usagePct: number;
|
|
110
|
+
};
|
|
111
|
+
readonly currentTokens: number;
|
|
112
|
+
readonly contextWindow: number;
|
|
113
|
+
}): string | null {
|
|
114
|
+
const status = input.evaluate({ currentTokens: input.currentTokens, contextWindow: input.contextWindow });
|
|
115
|
+
return buildContextStatusHint({
|
|
116
|
+
level: status.level,
|
|
117
|
+
autoCompactEnabled: status.autoCompactEnabled,
|
|
118
|
+
usagePct: status.usagePct,
|
|
119
|
+
currentTokens: input.currentTokens,
|
|
120
|
+
contextWindow: input.contextWindow,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -38,6 +38,22 @@ export function buildConversationViewport(
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Absolute history-line index of the bottom-most visible row of the
|
|
43
|
+
* conversation viewport. The buffer bottom-aligns content shorter than the
|
|
44
|
+
* viewport (InfiniteBuffer.getSnapshot pads with blank lines at the TOP, not
|
|
45
|
+
* the bottom — see history.ts), so the naive `scrollTop + viewportHeight - 1`
|
|
46
|
+
* overshoots past the real last line whenever the whole transcript already
|
|
47
|
+
* fits on screen. Clamping to `lineCount - 1` handles both cases with one
|
|
48
|
+
* formula: when scrollLocked at the true bottom (the common case), this is
|
|
49
|
+
* exactly the last rendered content line — the block the user is actually
|
|
50
|
+
* looking at, not whatever sits at the TOP of the viewport (scrollTop alone).
|
|
51
|
+
*/
|
|
52
|
+
export function getViewportBottomLine(scrollTop: number, viewportHeight: number, lineCount: number): number {
|
|
53
|
+
if (lineCount <= 0) return 0;
|
|
54
|
+
return Math.max(0, Math.min(scrollTop + viewportHeight - 1, lineCount - 1));
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
export function overlayViewportBottom(
|
|
42
58
|
viewport: readonly Line[],
|
|
43
59
|
overlay: readonly Line[],
|
|
@@ -16,6 +16,7 @@ import { renderSessionPickerModal } from './session-picker-modal.ts';
|
|
|
16
16
|
import { renderProfilePickerModal } from './profile-picker-modal.ts';
|
|
17
17
|
import { renderBookmarkModal } from './bookmark-modal.ts';
|
|
18
18
|
import { renderHelpOverlay, renderShortcutsOverlay } from './help-overlay.ts';
|
|
19
|
+
import { renderBlockActionsMenu } from './block-actions-overlay.ts';
|
|
19
20
|
import { renderAutocompleteOverlay } from './autocomplete-overlay.ts';
|
|
20
21
|
import { renderOnboardingWizard } from './onboarding/onboarding-wizard.ts';
|
|
21
22
|
import { overlayViewportBottom, replaceViewportWithOverlay } from './conversation-layout.ts';
|
|
@@ -118,6 +119,11 @@ export function applyConversationOverlays(
|
|
|
118
119
|
next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
if (!fullscreenClaimed && input.blockActionsMenu.active) {
|
|
123
|
+
const lines = renderBlockActionsMenu(input.blockActionsMenu, conversationWidth, viewportHeight);
|
|
124
|
+
next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
|
|
125
|
+
}
|
|
126
|
+
|
|
121
127
|
if (input.helpOverlayActive) {
|
|
122
128
|
const lines = renderHelpOverlay(conversationWidth, keybindingsManager, commandRegistry.getAll(), input.helpScrollOffset, viewportHeight);
|
|
123
129
|
next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* header-line.ts — the top-of-screen header row: brand, version, optional
|
|
3
|
+
* conversation title, optional git segment, and the right-hand model/provider
|
|
4
|
+
* pair.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from ui-factory.ts (which sits on the 800-line source gate) when
|
|
7
|
+
* the header gained the failover divergence marker. `UIFactory.createHeader`
|
|
8
|
+
* remains the call name everywhere and delegates here.
|
|
9
|
+
*
|
|
10
|
+
* The model/provider pair rendered here is the SERVING backend, resolved by
|
|
11
|
+
* core/active-model-identity.ts — the same resolution the footer uses, so the
|
|
12
|
+
* two surfaces can no longer disagree about who is answering. `note` carries
|
|
13
|
+
* the divergence marker naming the user's configured selection when serving
|
|
14
|
+
* has moved off it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type Line, createEmptyLine } from '../types/grid.ts';
|
|
18
|
+
import { VERSION } from '../version.ts';
|
|
19
|
+
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
20
|
+
import type { GitHeaderInfo } from './git-status.ts';
|
|
21
|
+
import { activeUiTones } from './theme.ts';
|
|
22
|
+
|
|
23
|
+
/** Build the git segment string and its display width. Single source of truth for header layout. */
|
|
24
|
+
function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
|
|
25
|
+
const branch = ` git:${gitInfo.branch}`;
|
|
26
|
+
if (gitInfo.dirty) {
|
|
27
|
+
const text = `${branch} * `;
|
|
28
|
+
return { text, width: getDisplayWidth(text) };
|
|
29
|
+
}
|
|
30
|
+
if (gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
31
|
+
const arrows = (gitInfo.ahead > 0 ? ` +${gitInfo.ahead}` : '') + (gitInfo.behind > 0 ? ` -${gitInfo.behind}` : '');
|
|
32
|
+
const text = `${branch}${arrows} `;
|
|
33
|
+
return { text, width: getDisplayWidth(text) };
|
|
34
|
+
}
|
|
35
|
+
const text = `${branch} `;
|
|
36
|
+
return { text, width: getDisplayWidth(text) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pick the widest divergence marker that still fits beside the brand and the
|
|
41
|
+
* model/provider pair.
|
|
42
|
+
*
|
|
43
|
+
* Graded, never truncated mid-word: the full marker (which names the
|
|
44
|
+
* configured selection) is preferred; a bare "divergent" keyword is the
|
|
45
|
+
* fallback on a narrow terminal; on a very narrow terminal the marker is
|
|
46
|
+
* dropped entirely. Dropping it is safe — what remains is still the SERVING
|
|
47
|
+
* backend, so the header never claims the configured backend is answering.
|
|
48
|
+
* The footer carries the full marker with far more room (see ui-factory's
|
|
49
|
+
* prioritized context segments).
|
|
50
|
+
*/
|
|
51
|
+
function fitHeaderNote(note: string, usedWidth: number, width: number): string {
|
|
52
|
+
if (!note) return '';
|
|
53
|
+
const full = `· ${note} `;
|
|
54
|
+
if (usedWidth + getDisplayWidth(full) <= width) return full;
|
|
55
|
+
const short = '· divergent ';
|
|
56
|
+
return usedWidth + getDisplayWidth(short) <= width ? short : '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render the header row plus its underline.
|
|
61
|
+
*
|
|
62
|
+
* @param width - Terminal columns.
|
|
63
|
+
* @param model - Serving model id (already resolved; see file doc).
|
|
64
|
+
* @param provider - Serving provider id.
|
|
65
|
+
* @param title - Optional conversation title, truncated to fit.
|
|
66
|
+
* @param gitInfo - Optional git branch/dirty/ahead-behind segment.
|
|
67
|
+
* @param version - Defaults to the live build VERSION; tests pass a pinned
|
|
68
|
+
* fixture so golden snapshots survive release bumps.
|
|
69
|
+
* @param note - Divergence marker naming the configured selection when the
|
|
70
|
+
* serving backend is not the one the user chose; '' otherwise.
|
|
71
|
+
*/
|
|
72
|
+
export function renderHeaderLine(
|
|
73
|
+
width: number,
|
|
74
|
+
model: string,
|
|
75
|
+
provider: string,
|
|
76
|
+
title?: string,
|
|
77
|
+
gitInfo?: GitHeaderInfo,
|
|
78
|
+
version: string = VERSION,
|
|
79
|
+
note: string = '',
|
|
80
|
+
): Line[] {
|
|
81
|
+
const lines: Line[] = [];
|
|
82
|
+
// Header/footer/thinking paint on the transparent terminal background, so
|
|
83
|
+
// they read chrome.* (light-terminal-aware) — NOT fg.*/state.*, which stay
|
|
84
|
+
// tuned for the opaque dark modal/panel boxes. Read live per render so a
|
|
85
|
+
// mode flip re-resolves without any module reload (see theme.ts).
|
|
86
|
+
const t = activeUiTones();
|
|
87
|
+
const CYAN = t.accent.brand;
|
|
88
|
+
const GREY = t.chrome.faint;
|
|
89
|
+
const TITLE_COLOR = t.chrome.label;
|
|
90
|
+
const brand = ` GoodVibes `;
|
|
91
|
+
const ver = `v${version} `;
|
|
92
|
+
const stats = ` ${model} `;
|
|
93
|
+
const prov = `(${provider}) `;
|
|
94
|
+
const gitWidth = gitInfo ? buildGitSegment(gitInfo).width : 0;
|
|
95
|
+
// Resolve the marker before the title so the title's fit calculation
|
|
96
|
+
// reserves room for it too.
|
|
97
|
+
const noteStr = fitHeaderNote(note, getDisplayWidth(brand + ver + stats + prov) + gitWidth, width);
|
|
98
|
+
const line = createEmptyLine(width);
|
|
99
|
+
let curX = 0;
|
|
100
|
+
for (const char of brand) { line[curX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
101
|
+
for (const char of ver) { line[curX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
102
|
+
// Optional conversation title — shown after brand/ver, truncated to fit
|
|
103
|
+
if (title) {
|
|
104
|
+
const titleStr = `│ ${title} `;
|
|
105
|
+
// Reserve space for git info (if present) + model/provider + marker on the right
|
|
106
|
+
const rightReserved = getDisplayWidth(stats + prov + noteStr) + gitWidth;
|
|
107
|
+
const maxTitleW = width - curX - rightReserved - 1;
|
|
108
|
+
let displayTitle: string;
|
|
109
|
+
if (getDisplayWidth(titleStr) <= maxTitleW) {
|
|
110
|
+
displayTitle = titleStr;
|
|
111
|
+
} else {
|
|
112
|
+
let truncated = '';
|
|
113
|
+
let w = 0;
|
|
114
|
+
for (const ch of titleStr) {
|
|
115
|
+
const cw = getDisplayWidth(ch);
|
|
116
|
+
if (w + cw > maxTitleW - 3) { truncated += '...'; break; }
|
|
117
|
+
truncated += ch;
|
|
118
|
+
w += cw;
|
|
119
|
+
}
|
|
120
|
+
displayTitle = truncated;
|
|
121
|
+
}
|
|
122
|
+
for (const char of displayTitle) { if (curX < width) line[curX++] = { char, fg: TITLE_COLOR, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
123
|
+
}
|
|
124
|
+
// Build git info segment
|
|
125
|
+
let gitStr = '';
|
|
126
|
+
let gitFg: string = t.chrome.faint;
|
|
127
|
+
if (gitInfo) {
|
|
128
|
+
gitStr = buildGitSegment(gitInfo).text;
|
|
129
|
+
if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
130
|
+
gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const rightSideW = getDisplayWidth(stats + prov + noteStr) + getDisplayWidth(gitStr);
|
|
134
|
+
let rightX = width - rightSideW;
|
|
135
|
+
for (const char of gitStr) { if (rightX >= 0 && rightX < width) line[rightX++] = { char, fg: gitFg, bg: '', bold: false, dim: !gitInfo?.dirty && !(gitInfo?.ahead || gitInfo?.behind), underline: false, italic: false, strikethrough: false }; }
|
|
136
|
+
for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
137
|
+
for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
138
|
+
// The marker reads as a warning, not as chrome: serving has left the
|
|
139
|
+
// selection the user made and that is worth noticing.
|
|
140
|
+
for (const char of noteStr) { if (rightX >= 0 && rightX < width) line[rightX++] = { char, fg: t.chrome.warn, bg: '', bold: false, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
141
|
+
lines.push(line);
|
|
142
|
+
const underline = createEmptyLine(width);
|
|
143
|
+
for (let x = 0; x < width; x++) underline[x] = { char: '━', fg: t.chrome.faint, bg: '', bold: false, dim: false, underline: false, italic: false, strikethrough: false };
|
|
144
|
+
lines.push(underline);
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* renderHelpOverlay — renders the help overlay with keyboard shortcuts and slash commands.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Opened via `/commands`. `?` and `/help` open the searchable command
|
|
5
|
+
* browser (a selection modal that runs the picked command) instead — they
|
|
6
|
+
* are a different, complementary surface, not this one.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { type Line } from '../types/grid.ts';
|
|
@@ -68,22 +70,27 @@ export function renderHelpOverlay(
|
|
|
68
70
|
const shortcutRows: string[] = [
|
|
69
71
|
' Core Navigation',
|
|
70
72
|
' ' + '\u2500'.repeat(40),
|
|
71
|
-
` ${'Up / Down'.padEnd(20)}
|
|
73
|
+
` ${'Up / Down'.padEnd(20)} Recall input history; Down at bottom focuses the process indicator`,
|
|
72
74
|
` ${'PageUp / PageDn'.padEnd(20)} Scroll by full page`,
|
|
73
75
|
` ${kb('search').padEnd(20)} Search conversation (Ctrl+F)`,
|
|
74
76
|
'',
|
|
75
77
|
' Prompt And Editing',
|
|
76
78
|
' ' + '\u2500'.repeat(40),
|
|
77
|
-
` ${'Enter'.padEnd(20)} Submit message`,
|
|
79
|
+
` ${'Enter'.padEnd(20)} Submit message (composer empty: open the block-actions menu)`,
|
|
78
80
|
` ${'Shift+Enter'.padEnd(20)} Insert newline`,
|
|
79
81
|
` ${kb('paste').padEnd(20)} Paste (image priority)`,
|
|
80
82
|
` ${(kb('undo') + ' / ' + kb('redo')).padEnd(20)} Undo / redo`,
|
|
83
|
+
shortcutLine('(paste >8 lines)', 'Folds to [TEXT: pN, M lines] — /pastes previews it before you submit'),
|
|
81
84
|
'',
|
|
82
85
|
' Overlays And Panels',
|
|
83
86
|
' ' + '\u2500'.repeat(40),
|
|
84
|
-
shortcutLine('?', '
|
|
85
|
-
shortcutLine('/
|
|
86
|
-
shortcutLine('
|
|
87
|
+
shortcutLine('? or /help', 'Open the command browser (search & run any command)'),
|
|
88
|
+
shortcutLine('/commands', 'This reference: keyboard shortcuts + full command list'),
|
|
89
|
+
shortcutLine('/shortcuts', 'Keyboard-shortcuts-only reference (a separate, generated overlay)'),
|
|
90
|
+
// Precedence (highest first): a visible panel workspace claims Tab for
|
|
91
|
+
// focus-swap; failing that, a partial path under the cursor claims it for
|
|
92
|
+
// completion; only then does it fall through to collapse/expand.
|
|
93
|
+
shortcutLine('Tab', 'Panel focus-swap, else path-complete, else collapse/expand block'),
|
|
87
94
|
...panelBindingRows,
|
|
88
95
|
'',
|
|
89
96
|
// The shared in-panel contract every workspace panel honors. These are not
|
|
@@ -149,7 +156,7 @@ export function renderHelpOverlay(
|
|
|
149
156
|
['sessions', 'Browse and resume saved sessions'],
|
|
150
157
|
['save', 'Save the current session'],
|
|
151
158
|
['compact', 'Compact the conversation history'],
|
|
152
|
-
['clear', 'Clear the conversation'],
|
|
159
|
+
['clear', 'Clear the conversation display (keeps LLM context)'],
|
|
153
160
|
['keybindings', 'List and customize key bindings'],
|
|
154
161
|
['panel', 'Open, focus, or manage panels'],
|
|
155
162
|
];
|
|
@@ -197,10 +204,11 @@ export function renderHelpOverlay(
|
|
|
197
204
|
}
|
|
198
205
|
} else if (!hasCommand('help')) {
|
|
199
206
|
commandRows.push('', ' Essentials', ' ' + '\u2500'.repeat(40));
|
|
200
|
-
commandRows.push(' /
|
|
207
|
+
commandRows.push(' /commands Show this help overlay');
|
|
208
|
+
commandRows.push(' /help Browse & run any command');
|
|
201
209
|
commandRows.push(' /shortcuts Keyboard shortcut reference');
|
|
202
210
|
commandRows.push(' /model Select LLM model');
|
|
203
|
-
commandRows.push(' /clear Clear conversation');
|
|
211
|
+
commandRows.push(' /clear Clear the conversation display (keeps LLM context)');
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
const allRows = [...shortcutRows, ...commandRows];
|
|
@@ -255,61 +263,69 @@ export function renderShortcutsOverlay(
|
|
|
255
263
|
return ` ${keyCol} ${desc}`;
|
|
256
264
|
}
|
|
257
265
|
|
|
258
|
-
//
|
|
259
|
-
|
|
266
|
+
// Keys that are real, but not KeyAction entries in keybindingsManager
|
|
267
|
+
// (context-dependent chords, or literal characters rather than bindable
|
|
268
|
+
// actions) \u2014 a maintained table rather than hand-picked getAll() calls, so
|
|
269
|
+
// adding a new KeyAction automatically shows up below without anyone
|
|
270
|
+
// remembering to also add a row for it here.
|
|
271
|
+
const HARDCODED_ROWS: ReadonlyArray<[key: string, desc: string]> = [
|
|
272
|
+
['Up / Down', 'Recall input history; Down at bottom focuses the process indicator'],
|
|
273
|
+
['PageUp / PageDn', 'Scroll by full page'],
|
|
274
|
+
['Home / End', 'Jump to start / end of line'],
|
|
275
|
+
['n / N (search)', 'Next / previous match'],
|
|
276
|
+
['Mouse wheel', 'Scroll conversation or hovered panel'],
|
|
277
|
+
['Enter', 'Submit message (composer empty: open the block-actions menu)'],
|
|
278
|
+
['Shift+Enter', 'Insert newline'],
|
|
279
|
+
['@', 'Open file picker'],
|
|
280
|
+
['/', 'Slash command mode'],
|
|
281
|
+
['Esc', 'Close overlay / cancel generation / clear prompt (context-dependent)'],
|
|
282
|
+
['Shift+Tab', 'Cycle permission mode (auto-approve / prompt / manual)'],
|
|
283
|
+
['F2', 'Open the Fleet panel'],
|
|
284
|
+
['? or /help', 'Open the command browser (search & run any command)'],
|
|
285
|
+
// Precedence (highest first): panel focus-swap, else path-completion,
|
|
286
|
+
// else collapse/expand \u2014 see the Panels section's Tab row below for the
|
|
287
|
+
// full chain; this row is the "what does it do here" short form.
|
|
288
|
+
['Tab', 'Collapse/expand block (composer empty \u2014 see Panels: Tab for full precedence)'],
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
const IN_PANEL_ROWS: ReadonlyArray<[key: string, desc: string]> = [
|
|
292
|
+
['j / k', 'Move selection down / up'],
|
|
293
|
+
['g / G', 'Jump to top / bottom'],
|
|
294
|
+
['/', 'Filter the list'],
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
// Generated from the live keybindings table (default bindings AND any user
|
|
298
|
+
// overrides) so this listing can never drift from what a key actually
|
|
299
|
+
// does \u2014 no action is hand-picked, none can be silently missed.
|
|
300
|
+
const allBindings = keybindingsManager.getAll();
|
|
301
|
+
const panelActionRows = allBindings
|
|
302
|
+
.filter((entry) => entry.action.startsWith('panel-'))
|
|
303
|
+
.map((entry) => row(entry.combos.map((c) => keybindingsManager.formatCombo(c)).join(', '), entry.description));
|
|
304
|
+
const otherActionRows = allBindings
|
|
305
|
+
.filter((entry) => !entry.action.startsWith('panel-'))
|
|
306
|
+
.map((entry) => row(entry.combos.map((c) => keybindingsManager.formatCombo(c)).join(', '), entry.description));
|
|
260
307
|
|
|
261
308
|
const allRows: string[] = [
|
|
262
|
-
' Navigation',
|
|
263
|
-
' ' + '\u2500'.repeat(40),
|
|
264
|
-
row('Up / Down', 'Scroll / history recall'),
|
|
265
|
-
row('PageUp / PageDn', 'Scroll by full page'),
|
|
266
|
-
row('Home / End', 'Jump to start / end of line'),
|
|
267
|
-
row(kb('search'), 'Search conversation'),
|
|
268
|
-
row('n / N (search)', 'Next / previous match'),
|
|
269
|
-
row('Mouse wheel', 'Scroll conversation or hovered panel'),
|
|
270
|
-
'',
|
|
271
|
-
' Editing',
|
|
309
|
+
' Navigation & Editing',
|
|
272
310
|
' ' + '\u2500'.repeat(40),
|
|
273
|
-
row(
|
|
274
|
-
row('Shift+Enter', 'Insert newline'),
|
|
275
|
-
row('@', 'Open file picker'),
|
|
276
|
-
row('/', 'Slash command mode'),
|
|
277
|
-
row(kb('paste'), 'Paste (image priority)'),
|
|
278
|
-
row(`${kb('undo')} / ${kb('redo')}`, 'Undo / redo'),
|
|
279
|
-
row(kb('clear-prompt'), 'Clear prompt'),
|
|
280
|
-
row(kb('delete-word'), 'Delete word backward'),
|
|
281
|
-
row(kb('kill-line'), 'Kill to end of line'),
|
|
282
|
-
row(kb('apply-diff-line-start'), 'Apply diff / line start'),
|
|
283
|
-
row(kb('next-error-line-end'), 'Next error / line end'),
|
|
311
|
+
...HARDCODED_ROWS.map(([key, desc]) => row(key, desc)),
|
|
284
312
|
'',
|
|
285
|
-
' Actions',
|
|
313
|
+
' Actions & Shortcuts (customize via /keybindings)',
|
|
286
314
|
' ' + '\u2500'.repeat(40),
|
|
287
|
-
|
|
288
|
-
row(kb('bookmark'), 'Bookmark block'),
|
|
289
|
-
row(kb('block-copy'), 'Copy block to clipboard'),
|
|
290
|
-
row(kb('block-save'), 'Save block to file'),
|
|
291
|
-
row(kb('copy-selection'), 'Copy selection'),
|
|
292
|
-
row('F2', 'Open the Fleet panel'),
|
|
293
|
-
row('?', 'Help overlay'),
|
|
294
|
-
row(`${kb('clear-cancel')} x2`, 'Exit'),
|
|
315
|
+
...otherActionRows,
|
|
295
316
|
'',
|
|
296
317
|
' Panels',
|
|
297
318
|
' ' + '\u2500'.repeat(40),
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
row(
|
|
303
|
-
|
|
304
|
-
row(kb('panel-close'), 'Close active panel'),
|
|
305
|
-
row(kb('panel-close-all'), 'Close all panels'),
|
|
306
|
-
row(kb('panel-ops'), 'Open and focus the Fleet panel'),
|
|
319
|
+
// Full Tab precedence, stated once here as the authoritative chain: a
|
|
320
|
+
// visible panel workspace claims Tab for focus-swap first; failing that,
|
|
321
|
+
// a partial path under the cursor claims it for completion; only then
|
|
322
|
+
// does it fall through to collapse/expand (the Actions section's row).
|
|
323
|
+
row('Tab', 'Panel focus-swap (1st) > path-complete (2nd) > collapse/expand block (3rd)'),
|
|
324
|
+
...panelActionRows,
|
|
307
325
|
'',
|
|
308
326
|
' In-Panel Controls',
|
|
309
327
|
' ' + '\u2500'.repeat(40),
|
|
310
|
-
|
|
311
|
-
row('g / G', 'Jump to top / bottom'),
|
|
312
|
-
row('/', 'Filter the list'),
|
|
328
|
+
...IN_PANEL_ROWS.map(([key, desc]) => row(key, desc)),
|
|
313
329
|
'',
|
|
314
330
|
` Config: /keybindings to list and customize`,
|
|
315
331
|
];
|
|
@@ -69,6 +69,41 @@ function rowCostFor(item: SelectionItem, remaining: number): number {
|
|
|
69
69
|
return 1 + (computeDetailLayout(item, remaining).wrappedDetail?.length ?? 0);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
const MODAL_MARGIN = 4;
|
|
73
|
+
const MODAL_MAX_WIDTH = 72;
|
|
74
|
+
const INDICATOR_WIDTH = 2;
|
|
75
|
+
/** Rows kept clear between the box and the top/bottom of the terminal. */
|
|
76
|
+
const VIEWPORT_INSET_ROWS = 2;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Rows the box spends on its own frame, counted as they are actually pushed
|
|
80
|
+
* below: top and bottom border, the title, the section row (a search label,
|
|
81
|
+
* search input and divider when the modal searches), the "Results" header and
|
|
82
|
+
* the footer hint. `chromeRows` is the budget-math constant the surface
|
|
83
|
+
* metrics subtract from a total; this is the physical count, and it is what
|
|
84
|
+
* decides how tall the box may really grow.
|
|
85
|
+
*/
|
|
86
|
+
function frameRowsFor(modal: SelectionModal): number {
|
|
87
|
+
return modal.allowSearch ? 8 : 6;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Physical rows every row of the list needs, category headers included — the
|
|
92
|
+
* height the box would have to give the list for nothing to be scrolled away.
|
|
93
|
+
*/
|
|
94
|
+
function totalContentRowsFor(modal: SelectionModal, remaining: number): number {
|
|
95
|
+
let rows = 0;
|
|
96
|
+
let lastCategory: string | undefined;
|
|
97
|
+
for (const item of modal.filteredItems) {
|
|
98
|
+
if (item.category && item.category !== lastCategory) {
|
|
99
|
+
lastCategory = item.category;
|
|
100
|
+
rows += 1;
|
|
101
|
+
}
|
|
102
|
+
rows += rowCostFor(item, remaining);
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
72
107
|
/**
|
|
73
108
|
* Render the selection modal as Line[] for overlay in the viewport.
|
|
74
109
|
*/
|
|
@@ -79,9 +114,29 @@ export function renderSelectionModalOverlay(
|
|
|
79
114
|
): Line[] {
|
|
80
115
|
const lines: Line[] = [];
|
|
81
116
|
const chromeRows = modal.allowSearch ? 5 : 4;
|
|
117
|
+
// Width does not depend on the row budget, so the box layout is settled
|
|
118
|
+
// first: the list's real height can only be measured once the wrap width is
|
|
119
|
+
// known. `createOverlayBoxLayout` resolves the requested max width the same
|
|
120
|
+
// way `getOverlaySurfaceMetrics` does, so both agree on the box.
|
|
121
|
+
const layout = createOverlayBoxLayout(width, MODAL_MARGIN, MODAL_MAX_WIDTH);
|
|
122
|
+
const remaining = layout.innerWidth - INDICATOR_WIDTH;
|
|
123
|
+
|
|
124
|
+
// Size to content before falling back to the viewport's height ratio. When
|
|
125
|
+
// every row (and its wrapped detail) fits in the space the terminal
|
|
126
|
+
// actually has, ask for exactly that many rows, so a short list is never
|
|
127
|
+
// split behind a "(n below)" hint with screen still empty below the box —
|
|
128
|
+
// a two-item question in particular must always show both its answers. A
|
|
129
|
+
// list genuinely taller than the terminal keeps the ratio-derived budget
|
|
130
|
+
// and scrolls as before.
|
|
131
|
+
const neededContentRows = totalContentRowsFor(modal, remaining);
|
|
132
|
+
const fittableContentRows = viewportHeight - VIEWPORT_INSET_ROWS - frameRowsFor(modal);
|
|
133
|
+
const growToFitTotalRows = neededContentRows > 0 && neededContentRows <= fittableContentRows
|
|
134
|
+
? neededContentRows + chromeRows
|
|
135
|
+
: undefined;
|
|
136
|
+
|
|
82
137
|
const metrics = getOverlaySurfaceMetrics(width, viewportHeight, {
|
|
83
|
-
margin:
|
|
84
|
-
maxWidth:
|
|
138
|
+
margin: MODAL_MARGIN,
|
|
139
|
+
maxWidth: MODAL_MAX_WIDTH,
|
|
85
140
|
chromeRows,
|
|
86
141
|
minContentRows: 6,
|
|
87
142
|
// Modals size to content: items whose detail wraps onto extra lines can
|
|
@@ -89,8 +144,11 @@ export function renderSelectionModalOverlay(
|
|
|
89
144
|
// ceiling grows with the viewport rather than staying pinned at a small
|
|
90
145
|
// constant (still bounded — never larger than the terminal can show).
|
|
91
146
|
maxContentRows: Math.max(10, viewportHeight - chromeRows - 4),
|
|
147
|
+
// A floor only. The shared budget keeps its own target and ceiling, so a
|
|
148
|
+
// modal whose content is shorter than that target is unaffected: this can
|
|
149
|
+
// raise the budget to fit the list, never lower it.
|
|
150
|
+
minTotalRows: growToFitTotalRows,
|
|
92
151
|
});
|
|
93
|
-
const layout = createOverlayBoxLayout(width, metrics.margin, metrics.boxWidth);
|
|
94
152
|
|
|
95
153
|
lines.push(createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.topLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.topRight, BORDER_FG, DEFAULT_OVERLAY_PALETTE.titleBg));
|
|
96
154
|
|
|
@@ -143,8 +201,6 @@ export function renderSelectionModalOverlay(
|
|
|
143
201
|
putText(line, layout.margin + 2, layout.innerWidth, fitDisplay(message, layout.innerWidth), { fg: MUTED_FG, dim: true });
|
|
144
202
|
lines.push(line);
|
|
145
203
|
} else {
|
|
146
|
-
const indicatorWidth = 2;
|
|
147
|
-
const remaining = layout.innerWidth - indicatorWidth;
|
|
148
204
|
const rowBudget = Math.max(1, metrics.contentRows);
|
|
149
205
|
|
|
150
206
|
// Row-budget-aware windowing: rather than assuming one physical row per
|
|
@@ -210,12 +266,12 @@ export function renderSelectionModalOverlay(
|
|
|
210
266
|
const detailLayout = computeDetailLayout(item, remaining);
|
|
211
267
|
const labelWidth = detailLayout.labelWidth;
|
|
212
268
|
const labelLine = createOverlayContentLine(width, layout, BORDER_FG, isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg);
|
|
213
|
-
putText(labelLine, layout.margin + 2,
|
|
269
|
+
putText(labelLine, layout.margin + 2, INDICATOR_WIDTH, indicator, {
|
|
214
270
|
fg: isSelected ? TITLE_FG : MUTED_FG,
|
|
215
271
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
216
272
|
bold: isSelected,
|
|
217
273
|
});
|
|
218
|
-
putText(labelLine, layout.margin + 2 +
|
|
274
|
+
putText(labelLine, layout.margin + 2 + INDICATOR_WIDTH, labelWidth, fitDisplay(truncateDisplay(item.label, labelWidth), labelWidth), {
|
|
219
275
|
fg: labelColor,
|
|
220
276
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
221
277
|
bold: isSelected,
|
|
@@ -223,11 +279,11 @@ export function renderSelectionModalOverlay(
|
|
|
223
279
|
if (item.detail) {
|
|
224
280
|
if (detailLayout.wrappedDetail === null) {
|
|
225
281
|
// Fits beside the label at a readable width — no truncation needed.
|
|
226
|
-
putText(labelLine, layout.margin + 2 +
|
|
282
|
+
putText(labelLine, layout.margin + 2 + INDICATOR_WIDTH + labelWidth, 2, ' ', {
|
|
227
283
|
fg: BODY_FG,
|
|
228
284
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
229
285
|
});
|
|
230
|
-
putText(labelLine, layout.margin + 2 +
|
|
286
|
+
putText(labelLine, layout.margin + 2 + INDICATOR_WIDTH + labelWidth + 2, detailLayout.detailWidth, fitDisplay(item.detail, detailLayout.detailWidth), {
|
|
231
287
|
fg: detailColor,
|
|
232
288
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
233
289
|
});
|
|
@@ -238,7 +294,7 @@ export function renderSelectionModalOverlay(
|
|
|
238
294
|
lines.push(labelLine);
|
|
239
295
|
for (const detailLineText of detailLayout.wrappedDetail) {
|
|
240
296
|
const detailLine = createOverlayContentLine(width, layout, BORDER_FG, isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg);
|
|
241
|
-
putText(detailLine, layout.margin + 2 +
|
|
297
|
+
putText(detailLine, layout.margin + 2 + INDICATOR_WIDTH, remaining, fitDisplay(detailLineText, remaining), {
|
|
242
298
|
fg: detailColor,
|
|
243
299
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
244
300
|
dim: !isSelected,
|