wotann 0.5.94 → 0.5.95
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/dist/ui/components/v3/AppV3.d.ts +20 -0
- package/dist/ui/components/v3/AppV3.js +75 -9
- package/dist/ui/components/v3/Statusline.js +9 -6
- package/dist/ui/help-registry.d.ts +14 -0
- package/dist/ui/help-registry.js +18 -0
- package/dist/ui/slash-commands/handlers/v3-intrinsics.d.ts +12 -0
- package/dist/ui/slash-commands/handlers/v3-intrinsics.js +66 -0
- package/package.json +1 -1
|
@@ -42,6 +42,7 @@ import React from "react";
|
|
|
42
42
|
import type { AgentMessage, ProviderName, ProviderStatus } from "../../../core/types.js";
|
|
43
43
|
import type { WotannRuntime } from "../../../core/runtime.js";
|
|
44
44
|
import { type CapabilityProfile } from "../../capability-tier.js";
|
|
45
|
+
import { type TranscriptMessageV3 } from "./Transcript.js";
|
|
45
46
|
export interface AppV3Props {
|
|
46
47
|
readonly version: string;
|
|
47
48
|
readonly providers: readonly ProviderStatus[];
|
|
@@ -87,4 +88,23 @@ export interface AppV3Props {
|
|
|
87
88
|
*/
|
|
88
89
|
readonly snippetDbPathOverride?: string;
|
|
89
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Map an AgentMessage onto a v3 TranscriptMessage, PRESERVING each
|
|
93
|
+
* message's stable id. AgentMessage.id is assigned at creation (see
|
|
94
|
+
* makeMessageId / the assistant `assistantId`); we fall back to a
|
|
95
|
+
* positional id only for legacy messages that predate id assignment.
|
|
96
|
+
*
|
|
97
|
+
* Why a stable, non-positional id matters: it is the prerequisite for
|
|
98
|
+
* the Ink <Static> committed-history split (P1). <Static> keys items
|
|
99
|
+
* by identity to decide which rows it has ALREADY emitted, so a
|
|
100
|
+
* positional id would make it mis-detect new rows the moment a message
|
|
101
|
+
* is inserted mid-array. Preserving the message's own id also lets the
|
|
102
|
+
* assistant row keep one identity across its streamed deltas.
|
|
103
|
+
*
|
|
104
|
+
* Role coercion: AgentMessage's role is a wider union than v3's
|
|
105
|
+
* TranscriptRole. The runtime mostly emits `user`/`assistant`; anything
|
|
106
|
+
* else (e.g. `developer`) falls back to `system` so the row still
|
|
107
|
+
* renders without a missing-style crash.
|
|
108
|
+
*/
|
|
109
|
+
export declare function toTranscriptMessages(messages: readonly AgentMessage[]): readonly TranscriptMessageV3[];
|
|
90
110
|
export declare function AppV3({ version: _version, providers, initialModel, initialProvider, initialMessages, runtime, profileOverride, columnsOverride, rowsOverride, uiStatePathOverride, statuslineConfigPathOverride, layoutConfigPathOverride, snippetDbPathOverride, }: AppV3Props): React.ReactElement;
|
|
@@ -46,6 +46,7 @@ import { buildAgentToolContext } from "../../../core/agent-tool-context.js";
|
|
|
46
46
|
import { AGENT_TOOL_DEFINITIONS, executeAgentTool } from "../../../tools/agent-tools.js";
|
|
47
47
|
import { ThemeProvider, useThemeTone } from "../../theme/context.js";
|
|
48
48
|
import { PALETTES, ThemeManager } from "../../themes.js";
|
|
49
|
+
import { unhandledCommandReason } from "../../help-registry.js";
|
|
49
50
|
import { detectCapabilityProfile } from "../../capability-tier.js";
|
|
50
51
|
import { detectTerminalCapabilities } from "../../terminal-capabilities.js";
|
|
51
52
|
import { extractImageAttachmentsFromPaste, } from "../../image-attachments.js";
|
|
@@ -81,29 +82,49 @@ import { formatSlashResultMessage } from "./SystemMessageCard.js";
|
|
|
81
82
|
import { resolveWotannHomeSubdir } from "../../../utils/wotann-home.js";
|
|
82
83
|
import { RavensFlight, SigilStamp } from "../../animations.js";
|
|
83
84
|
/**
|
|
84
|
-
* Map an AgentMessage onto a v3 TranscriptMessage
|
|
85
|
-
*
|
|
86
|
-
*
|
|
85
|
+
* Map an AgentMessage onto a v3 TranscriptMessage, PRESERVING each
|
|
86
|
+
* message's stable id. AgentMessage.id is assigned at creation (see
|
|
87
|
+
* makeMessageId / the assistant `assistantId`); we fall back to a
|
|
88
|
+
* positional id only for legacy messages that predate id assignment.
|
|
89
|
+
*
|
|
90
|
+
* Why a stable, non-positional id matters: it is the prerequisite for
|
|
91
|
+
* the Ink <Static> committed-history split (P1). <Static> keys items
|
|
92
|
+
* by identity to decide which rows it has ALREADY emitted, so a
|
|
93
|
+
* positional id would make it mis-detect new rows the moment a message
|
|
94
|
+
* is inserted mid-array. Preserving the message's own id also lets the
|
|
95
|
+
* assistant row keep one identity across its streamed deltas.
|
|
87
96
|
*
|
|
88
97
|
* Role coercion: AgentMessage's role is a wider union than v3's
|
|
89
98
|
* TranscriptRole. The runtime mostly emits `user`/`assistant`; anything
|
|
90
99
|
* else (e.g. `developer`) falls back to `system` so the row still
|
|
91
100
|
* renders without a missing-style crash.
|
|
92
101
|
*/
|
|
93
|
-
function toTranscriptMessages(messages) {
|
|
102
|
+
export function toTranscriptMessages(messages) {
|
|
94
103
|
return messages.map((msg, i) => ({
|
|
95
|
-
id: `msg-${i}`,
|
|
104
|
+
id: msg.id ?? `msg-${i}`,
|
|
96
105
|
role: normaliseRole(msg.role),
|
|
97
106
|
content: msg.content,
|
|
98
107
|
...(msg.attachments !== undefined ? { attachments: msg.attachments } : {}),
|
|
99
108
|
}));
|
|
100
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Stable unique id for a host-created message, matching the side-pane
|
|
112
|
+
* id idiom (`${role}-${Date.now()}-${random}`). Assigned at creation so
|
|
113
|
+
* the transcript and the <Static> committed-history split key rows by a
|
|
114
|
+
* stable identity rather than array position.
|
|
115
|
+
*/
|
|
116
|
+
function makeMessageId(role) {
|
|
117
|
+
return `${role}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
118
|
+
}
|
|
101
119
|
function normaliseRole(role) {
|
|
102
120
|
if (role === "user" || role === "assistant" || role === "system" || role === "tool") {
|
|
103
121
|
return role;
|
|
104
122
|
}
|
|
105
123
|
return "system";
|
|
106
124
|
}
|
|
125
|
+
/** Selectable tool-approval modes (ascending autonomy), mirroring the
|
|
126
|
+
* runtime's RuntimePermissionMode — used by the /permission slash handler. */
|
|
127
|
+
const PERMISSION_MODES = ["ask-always", "smart", "auto-approve", "autonomous"];
|
|
107
128
|
function toTranscriptAttachments(images) {
|
|
108
129
|
return Object.freeze(images.map((image) => ({
|
|
109
130
|
kind: "image",
|
|
@@ -238,8 +259,15 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
|
|
|
238
259
|
sideActiveRunRef.current?.abort();
|
|
239
260
|
sideActiveRunRef.current = null;
|
|
240
261
|
}, []);
|
|
262
|
+
// P2 — coarse uptime clock. In the alt-screen buffer, Ink erase-rewrites
|
|
263
|
+
// the WHOLE live frame on any state change, so a 1Hz tick forced a
|
|
264
|
+
// full-frame repaint (visible idle flicker) every second. Session uptime
|
|
265
|
+
// is ambient info that does not need second precision, so tick every 10s
|
|
266
|
+
// (paired with minute-granularity formatUptime). The COMPLETE streaming
|
|
267
|
+
// flicker fix is the inline + <Static> pivot (the AppV4 rebuild — see
|
|
268
|
+
// docs/phase-0-redesign/renderer-decision.md).
|
|
241
269
|
useEffect(() => {
|
|
242
|
-
const id = setInterval(() => setNowMs(Date.now()),
|
|
270
|
+
const id = setInterval(() => setNowMs(Date.now()), 10_000);
|
|
243
271
|
return () => clearInterval(id);
|
|
244
272
|
}, []);
|
|
245
273
|
// ── Keyboard wiring ───────────────────────────────────────────────
|
|
@@ -291,13 +319,27 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
|
|
|
291
319
|
// length approximation that every surface can compute consistently.
|
|
292
320
|
const usedTokens = useMemo(() => Math.ceil(messages.reduce((total, msg) => total + Math.max(1, Math.ceil(msg.content.length / 4)), 0)), [messages]);
|
|
293
321
|
const maxTokens = runtime?.getMaxContextTokens?.() ?? 200_000;
|
|
322
|
+
// Real session cost from the runtime CostTracker (climbs as turns run);
|
|
323
|
+
// falls back to 0 when no runtime/tracker is present. Replaces the prior
|
|
324
|
+
// hardcoded costUsd={0}/cost={0} that made "$0.000" never move.
|
|
325
|
+
const sessionCostUsd = runtime?.getCostTracker?.().getTotalCost?.() ?? 0;
|
|
294
326
|
const [contextTrend, setContextTrend] = useState([usedTokens]);
|
|
295
327
|
const [queuedPrompts, setQueuedPrompts] = useState([]);
|
|
296
328
|
const [pendingImages, setPendingImages] = useState([]);
|
|
297
329
|
const pendingImagesRef = useRef(pendingImages);
|
|
298
330
|
pendingImagesRef.current = pendingImages;
|
|
299
331
|
const submitGateRef = useRef(null);
|
|
332
|
+
// P4 — throttle context-trend sampling. `usedTokens` recomputes on every
|
|
333
|
+
// streamed token, and the unthrottled effect fired a SECOND state update
|
|
334
|
+
// (on top of setMessages) per token, doubling React reconciliation work
|
|
335
|
+
// during a stream. Sample at most every 500ms so the sparkline still
|
|
336
|
+
// trends without the per-token double-render.
|
|
337
|
+
const lastTrendSampleRef = useRef(0);
|
|
300
338
|
useEffect(() => {
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
if (now - lastTrendSampleRef.current < 500)
|
|
341
|
+
return;
|
|
342
|
+
lastTrendSampleRef.current = now;
|
|
301
343
|
setContextTrend((prev) => {
|
|
302
344
|
const last = prev[prev.length - 1];
|
|
303
345
|
if (last === usedTokens)
|
|
@@ -314,6 +356,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
|
|
|
314
356
|
const startRuntimeTurn = useCallback((trimmed, imagesForTurn) => {
|
|
315
357
|
const attachments = toTranscriptAttachments(imagesForTurn);
|
|
316
358
|
const userMessage = {
|
|
359
|
+
id: makeMessageId("user"),
|
|
317
360
|
role: "user",
|
|
318
361
|
content: trimmed,
|
|
319
362
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
@@ -373,6 +416,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
|
|
|
373
416
|
setMessages((prev) => [
|
|
374
417
|
...prev,
|
|
375
418
|
{
|
|
419
|
+
id: makeMessageId("tool"),
|
|
376
420
|
role: "tool",
|
|
377
421
|
toolName: ev.toolName,
|
|
378
422
|
toolCallId: ev.toolCallId,
|
|
@@ -479,7 +523,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
|
|
|
479
523
|
const formatted = formatSlashResultMessage({
|
|
480
524
|
command: cmd,
|
|
481
525
|
ok: false,
|
|
482
|
-
reason:
|
|
526
|
+
reason: unhandledCommandReason(cmd),
|
|
483
527
|
});
|
|
484
528
|
setMessages((prev) => [...prev, { role: "system", content: formatted }]);
|
|
485
529
|
stampSigil("error");
|
|
@@ -809,8 +853,8 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
|
|
|
809
853
|
// too much." Provider status moved to the /providers slash command.
|
|
810
854
|
const heavyChrome = process.env["WOTANN_TUI_HEAVY"] === "1";
|
|
811
855
|
const mainSurface = showSplash ? (_jsxs(Box, { flexDirection: "column", children: [heavyChrome ? (_jsxs(_Fragment, { children: [_jsx(GradientBanner, { profile: profile, state: bannerState, variant: bannerVariant }), _jsx(ModeCycle, { profile: profile, currentMode: currentMode })] })) : null, showTour ? (_jsx(Box, { marginTop: heavyChrome ? 1 : 0, children: _jsx(OnboardingTour, { profile: profile, authChoices: tourAuthChoices, themeChoices: tourThemeChoices, onComplete: finishTour, onDismiss: skipTour }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: heavyChrome ? 1 : 0, marginBottom: 1, paddingLeft: 2, children: [!heavyChrome && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: tone.primary, bold: true, children: "WOTANN" }) })), _jsx(Text, { color: tone.muted, children: "Ready. Type to begin, / for commands, @ to reference files." }), providers.length === 0 ? (_jsx(Box, { marginTop: 1, children: _jsx(NoProviderHint, {}) })) : heavyChrome ? (_jsx(Box, { marginTop: 1, children: _jsx(ProviderStrip, { providers: providers, profile: profile }) })) : null] }))] })) : (_jsx(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: _jsx(Transcript, { messages: transcriptMessages, profile: profile, terminalCapabilities: terminalCapabilities, visibleCount: transcriptVisibleCount, scrollOffset: transcriptScrollOffset }) }));
|
|
812
|
-
const statusBarRow = (_jsx(Box, { marginTop: statusBarPosition === "bottom" ? 1 : 0, marginBottom: statusBarPosition === "top" ? 1 : 0, children: _jsx(StatusBar, { model: initialModel, provider: initialProvider, mode: currentMode, usedTokens: usedTokens, maxTokens: maxTokens, costUsd:
|
|
813
|
-
return (_jsxs(Box, { flexDirection: "column", children: [statusBarPosition === "top" && statusBarRow, _jsxs(Box, { flexDirection: sidePaneVisible && columns >= 80 ? "row" : "column", gap: 1, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: mainSurface }), _jsx(SidePane, { visible: sidePaneVisible, snapshotMessages: sidePaneSnapshotMessages, localMessages: sidePaneLocalMessages, profile: profile, onDismiss: closeSidePane, onPinToHost: () => promoteSidePaneToHost(), children: _jsx(PromptInput, { onSubmit: handleSidePaneSubmit, onChange: setSidePaneDraft, onAbort: handleSidePaneAbort, isStreaming: sidePaneStreaming, value: sidePaneDraft, vimMode: vimMode, placeholder: "Ask in this fork (/pin, /unfork, /clear)", mode: "side" }) })] }), showTypedSlashPanel && (_jsx(Box, { marginBottom: 0, children: _jsx(TypedSlashPanel, { draft: draftValue, commands: paletteCommands }) })), isStreaming && (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(RavensFlight, { active: isStreaming, tone: tone, label: "thinking", width: 16 }) })), !isStreaming && petMode === "raven" && (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(RavensFlight, { active: true, tone: tone, label: "companion", width: 16 }) })), _jsx(Box, { marginTop: 1, paddingX: 2, children: _jsxs(Box, { gap: 1, children: [_jsx(SigilStamp, { kind: sigilKind, tone: tone, nonce: sigilNonce }), _jsx(Statusline, { profile: profile, segments: statuslineSegments, model: initialModel, effort: currentMode, sandbox: runtime?.getPermissionMode?.() ?? "—", tokensUsed: usedTokens, tokensTotal: maxTokens, cost:
|
|
856
|
+
const statusBarRow = (_jsx(Box, { marginTop: statusBarPosition === "bottom" ? 1 : 0, marginBottom: statusBarPosition === "top" ? 1 : 0, children: _jsx(StatusBar, { model: initialModel, provider: initialProvider, mode: currentMode, usedTokens: usedTokens, maxTokens: maxTokens, costUsd: sessionCostUsd, reads: 0, edits: 0, bashCalls: 0, isStreaming: isStreaming, contextTrend: contextTrend, profile: profile }) }));
|
|
857
|
+
return (_jsxs(Box, { flexDirection: "column", children: [statusBarPosition === "top" && statusBarRow, _jsxs(Box, { flexDirection: sidePaneVisible && columns >= 80 ? "row" : "column", gap: 1, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: mainSurface }), _jsx(SidePane, { visible: sidePaneVisible, snapshotMessages: sidePaneSnapshotMessages, localMessages: sidePaneLocalMessages, profile: profile, onDismiss: closeSidePane, onPinToHost: () => promoteSidePaneToHost(), children: _jsx(PromptInput, { onSubmit: handleSidePaneSubmit, onChange: setSidePaneDraft, onAbort: handleSidePaneAbort, isStreaming: sidePaneStreaming, value: sidePaneDraft, vimMode: vimMode, placeholder: "Ask in this fork (/pin, /unfork, /clear)", mode: "side" }) })] }), showTypedSlashPanel && (_jsx(Box, { marginBottom: 0, children: _jsx(TypedSlashPanel, { draft: draftValue, commands: paletteCommands }) })), isStreaming && (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(RavensFlight, { active: isStreaming, tone: tone, label: "thinking", width: 16 }) })), !isStreaming && petMode === "raven" && (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(RavensFlight, { active: true, tone: tone, label: "companion", width: 16 }) })), _jsx(Box, { marginTop: 1, paddingX: 2, children: _jsxs(Box, { gap: 1, children: [_jsx(SigilStamp, { kind: sigilKind, tone: tone, nonce: sigilNonce }), _jsx(Statusline, { profile: profile, segments: statuslineSegments, model: initialModel, effort: currentMode, sandbox: runtime?.getPermissionMode?.() ?? "—", tokensUsed: usedTokens, tokensTotal: maxTokens, cost: sessionCostUsd, goal: activeGoal, uptimeMs: nowMs - sessionStartedAt })] }) }), _jsx(Composer, { profile: profile, children: _jsx(PromptInput, { onSubmit: handleSubmit, onChange: handleChange, onAbort: handleAbort, onPasteImageText: handlePasteImageText, imageAttachmentCount: pendingImages.length, disabled: sidePaneVisible, isStreaming: isStreaming, value: draftValue, vimMode: vimMode, placeholder: sidePaneVisible ? "Side fork owns input (/unfork to return)" : "Ask anything" }) }), _jsx(OverlayPaletteMount, { commands: paletteCommands, onSelect: (cmd) => {
|
|
814
858
|
// Dispatch through the router with the empty arg + the real
|
|
815
859
|
// slash context (so /clear actually clears, etc.). Close the
|
|
816
860
|
// overlay after selection so muscle memory matches Codex/Crush.
|
|
@@ -983,6 +1027,28 @@ version: _version, providers, initialModel = "", initialProvider = "", initialMe
|
|
|
983
1027
|
setVimMode(enabled);
|
|
984
1028
|
themeManager.persist({ vimKeymap: enabled });
|
|
985
1029
|
},
|
|
1030
|
+
// /theme — mirror the Ctrl+Y cycle (themeManager.setTheme + setPalette)
|
|
1031
|
+
// so the slash command and the keybind switch themes identically.
|
|
1032
|
+
getTheme: () => themeManager.getCurrent().name,
|
|
1033
|
+
listThemes: () => themeManager.getThemeNames(),
|
|
1034
|
+
setTheme: (name) => {
|
|
1035
|
+
const ok = themeManager.setTheme(name);
|
|
1036
|
+
if (ok)
|
|
1037
|
+
setPalette(themeManager.getCurrent().colors);
|
|
1038
|
+
return ok;
|
|
1039
|
+
},
|
|
1040
|
+
// /permission — approval mode via the runtime's get/setPermissionMode
|
|
1041
|
+
// (the same modes startRuntimeTurn threads into the agent loop).
|
|
1042
|
+
getPermission: () => runtime?.getPermissionMode?.() ?? "smart",
|
|
1043
|
+
listPermissions: () => PERMISSION_MODES,
|
|
1044
|
+
setPermission: (mode) => {
|
|
1045
|
+
if (!PERMISSION_MODES.includes(mode))
|
|
1046
|
+
return false;
|
|
1047
|
+
if (runtime?.setPermissionMode === undefined)
|
|
1048
|
+
return false;
|
|
1049
|
+
runtime.setPermissionMode(mode);
|
|
1050
|
+
return true;
|
|
1051
|
+
},
|
|
986
1052
|
// Quit is intentionally NOT wired here — the user
|
|
987
1053
|
// expects Ctrl+C to exit, and /quit prints an informational
|
|
988
1054
|
// message explaining that.
|
|
@@ -78,15 +78,18 @@ function formatCost(cost) {
|
|
|
78
78
|
function formatUptime(ms) {
|
|
79
79
|
if (ms === undefined || !Number.isFinite(ms) || ms < 0)
|
|
80
80
|
return "—";
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
// Minute-granularity: session uptime is ambient, so the clock that feeds
|
|
82
|
+
// this ticks at 10s (P2 idle-flicker fix) and we never render seconds —
|
|
83
|
+
// a seconds display would step visibly at the coarse cadence. Sub-minute
|
|
84
|
+
// reads "<1m".
|
|
85
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
86
|
+
const h = Math.floor(totalMinutes / 60);
|
|
87
|
+
const m = totalMinutes % 60;
|
|
85
88
|
if (h > 0)
|
|
86
89
|
return `${h}h${m.toString().padStart(2, "0")}m`;
|
|
87
90
|
if (m > 0)
|
|
88
|
-
return `${m}m
|
|
89
|
-
return
|
|
91
|
+
return `${m}m`;
|
|
92
|
+
return `<1m`;
|
|
90
93
|
}
|
|
91
94
|
function truncateWith(value, max) {
|
|
92
95
|
if (max <= 1)
|
|
@@ -58,3 +58,17 @@ export declare function searchHelp(query: string): readonly HelpEntry[];
|
|
|
58
58
|
* added or removed without test updates.
|
|
59
59
|
*/
|
|
60
60
|
export declare const HELP_ENTRY_COUNT: number;
|
|
61
|
+
/**
|
|
62
|
+
* Canonical ids of every advertised slash command — first token only, so a
|
|
63
|
+
* help entry like "/save [name]" normalizes to "/save". Used at dispatch
|
|
64
|
+
* time to distinguish a *recognized-but-unwired* command (a real feature the
|
|
65
|
+
* TUI advertises but has not yet wired a handler for) from a genuine typo,
|
|
66
|
+
* so the failure message is honest instead of a misleading "Unknown command".
|
|
67
|
+
*/
|
|
68
|
+
export declare const ADVERTISED_COMMAND_IDS: ReadonlySet<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Honest failure reason for a slash command that resolved to no router
|
|
71
|
+
* handler. QB#6: never silent-success; here we also avoid mislabelling a
|
|
72
|
+
* real-but-unwired feature as a typo.
|
|
73
|
+
*/
|
|
74
|
+
export declare function unhandledCommandReason(command: string): string;
|
package/dist/ui/help-registry.js
CHANGED
|
@@ -308,3 +308,21 @@ export function searchHelp(query) {
|
|
|
308
308
|
* added or removed without test updates.
|
|
309
309
|
*/
|
|
310
310
|
export const HELP_ENTRY_COUNT = ALL_HELP_ENTRIES.length;
|
|
311
|
+
/**
|
|
312
|
+
* Canonical ids of every advertised slash command — first token only, so a
|
|
313
|
+
* help entry like "/save [name]" normalizes to "/save". Used at dispatch
|
|
314
|
+
* time to distinguish a *recognized-but-unwired* command (a real feature the
|
|
315
|
+
* TUI advertises but has not yet wired a handler for) from a genuine typo,
|
|
316
|
+
* so the failure message is honest instead of a misleading "Unknown command".
|
|
317
|
+
*/
|
|
318
|
+
export const ADVERTISED_COMMAND_IDS = new Set(ALL_HELP_ENTRIES.map((entry) => entry.command.split(/\s+/)[0] ?? entry.command));
|
|
319
|
+
/**
|
|
320
|
+
* Honest failure reason for a slash command that resolved to no router
|
|
321
|
+
* handler. QB#6: never silent-success; here we also avoid mislabelling a
|
|
322
|
+
* real-but-unwired feature as a typo.
|
|
323
|
+
*/
|
|
324
|
+
export function unhandledCommandReason(command) {
|
|
325
|
+
return ADVERTISED_COMMAND_IDS.has(command)
|
|
326
|
+
? `${command} is a known command that isn't wired into the TUI yet. Type /help for commands you can run now.`
|
|
327
|
+
: `Unknown command: ${command}. Type /help for the registered list.`;
|
|
328
|
+
}
|
|
@@ -55,6 +55,18 @@ export interface ShellStateAccessor {
|
|
|
55
55
|
readonly clearTranscript?: () => void;
|
|
56
56
|
/** Enable/disable Vim composer mode for the active shell. */
|
|
57
57
|
readonly setVimMode?: (enabled: boolean) => void;
|
|
58
|
+
/** Current theme name (e.g. "dark"). */
|
|
59
|
+
readonly getTheme?: () => string;
|
|
60
|
+
/** All selectable theme names — for `/theme` listing + validation. */
|
|
61
|
+
readonly listThemes?: () => readonly string[];
|
|
62
|
+
/** Switch the active theme; returns false if the name is unknown. */
|
|
63
|
+
readonly setTheme?: (name: string) => boolean;
|
|
64
|
+
/** Current tool-approval mode (e.g. "smart"). */
|
|
65
|
+
readonly getPermission?: () => string;
|
|
66
|
+
/** All selectable approval modes — for /permission listing + validation. */
|
|
67
|
+
readonly listPermissions?: () => readonly string[];
|
|
68
|
+
/** Switch the approval mode; returns false if the mode is unknown. */
|
|
69
|
+
readonly setPermission?: (mode: string) => boolean;
|
|
58
70
|
/**
|
|
59
71
|
* Optional quit handler. When provided, /quit and /exit will invoke
|
|
60
72
|
* it; otherwise the handler returns an informational message.
|
|
@@ -172,6 +172,59 @@ function buildVimHandler(state) {
|
|
|
172
172
|
};
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
|
+
/** Build the canonical `/theme` handler bound to a state accessor. */
|
|
176
|
+
function buildThemeHandler(state) {
|
|
177
|
+
return (_ctx, arg) => {
|
|
178
|
+
const arg0 = arg.trim();
|
|
179
|
+
const names = state.getTheme !== undefined ? (state.listThemes?.() ?? []) : [];
|
|
180
|
+
if (arg0.length === 0 || arg0.toLowerCase() === "list") {
|
|
181
|
+
const current = state.getTheme?.() ?? "";
|
|
182
|
+
const lines = ["Themes:"];
|
|
183
|
+
for (const n of names)
|
|
184
|
+
lines.push(`${n === current ? "* " : " "}${n}`);
|
|
185
|
+
lines.push("");
|
|
186
|
+
lines.push("Usage: `/theme <name>` switches the active theme (Ctrl+Y cycles).");
|
|
187
|
+
return { ok: true, message: lines.join("\n") };
|
|
188
|
+
}
|
|
189
|
+
if (typeof state.setTheme !== "function") {
|
|
190
|
+
return { ok: false, reason: "Theme switching is not wired in this shell." };
|
|
191
|
+
}
|
|
192
|
+
if (!state.setTheme(arg0)) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: `Unknown theme "${arg0}".` + (names.length > 0 ? ` Available: ${names.join(", ")}.` : ""),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, message: `Theme switched to "${arg0}".` };
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Build the canonical `/permission` handler bound to a state accessor. */
|
|
202
|
+
function buildPermissionHandler(state) {
|
|
203
|
+
return (_ctx, arg) => {
|
|
204
|
+
const arg0 = arg.trim().toLowerCase();
|
|
205
|
+
const modes = state.getPermission !== undefined ? (state.listPermissions?.() ?? []) : [];
|
|
206
|
+
if (arg0.length === 0 || arg0 === "status") {
|
|
207
|
+
const current = state.getPermission?.() ?? "";
|
|
208
|
+
const lines = ["Approval modes:"];
|
|
209
|
+
for (const m of modes)
|
|
210
|
+
lines.push(`${m === current ? "* " : " "}${m}`);
|
|
211
|
+
lines.push("");
|
|
212
|
+
lines.push("Usage: `/permission <mode>` sets how tool calls are approved.");
|
|
213
|
+
return { ok: true, message: lines.join("\n") };
|
|
214
|
+
}
|
|
215
|
+
if (typeof state.setPermission !== "function") {
|
|
216
|
+
return { ok: false, reason: "Approval-mode switching is not wired in this shell." };
|
|
217
|
+
}
|
|
218
|
+
if (!state.setPermission(arg0)) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: `Unknown approval mode "${arg0}".` +
|
|
222
|
+
(modes.length > 0 ? ` Available: ${modes.join(", ")}.` : ""),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, message: `Approval mode set to "${arg0}".` };
|
|
226
|
+
};
|
|
227
|
+
}
|
|
175
228
|
/** Build the canonical `/quit` handler (also bound to `/exit`). */
|
|
176
229
|
function buildQuitHandler(state) {
|
|
177
230
|
return () => {
|
|
@@ -221,6 +274,19 @@ export function buildV3IntrinsicCommands(state) {
|
|
|
221
274
|
category: "Config",
|
|
222
275
|
handler: buildVimHandler(state),
|
|
223
276
|
},
|
|
277
|
+
{
|
|
278
|
+
id: "/theme",
|
|
279
|
+
description: "Show or switch the color theme (Ctrl+Y cycles)",
|
|
280
|
+
category: "Config",
|
|
281
|
+
handler: buildThemeHandler(state),
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "/permission",
|
|
285
|
+
aliases: ["/permissions"],
|
|
286
|
+
description: "Show or set the tool-approval mode",
|
|
287
|
+
category: "Config",
|
|
288
|
+
handler: buildPermissionHandler(state),
|
|
289
|
+
},
|
|
224
290
|
{
|
|
225
291
|
id: "/clear",
|
|
226
292
|
description: "Clear the transcript",
|