wotann 0.5.93 → 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/security/skills-guard.js +26 -5
- 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/raw-mode-guard.d.ts +14 -0
- package/dist/ui/raw-mode-guard.js +28 -2
- 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
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
* escalation, agent config persistence, hardcoded secrets, and invisible text.
|
|
8
8
|
*/
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync
|
|
10
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
11
11
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
|
-
export const TRUSTED_SKILL_REPOS = new Set([
|
|
12
|
+
export const TRUSTED_SKILL_REPOS = new Set([
|
|
13
|
+
"openai/skills",
|
|
14
|
+
"anthropics/skills",
|
|
15
|
+
"huggingface/skills",
|
|
16
|
+
]);
|
|
13
17
|
export const INSTALL_POLICY = {
|
|
14
18
|
builtin: ["allow", "allow", "allow"],
|
|
15
19
|
trusted: ["allow", "allow", "block"],
|
|
@@ -405,7 +409,14 @@ export function contentHash(path) {
|
|
|
405
409
|
return `sha256:${h.digest("hex").slice(0, 16)}`;
|
|
406
410
|
}
|
|
407
411
|
function p(pattern, patternId, severity, category, description, recommendation) {
|
|
408
|
-
return {
|
|
412
|
+
return {
|
|
413
|
+
regex: new RegExp(pattern, "i"),
|
|
414
|
+
patternId,
|
|
415
|
+
severity,
|
|
416
|
+
category,
|
|
417
|
+
description,
|
|
418
|
+
recommendation,
|
|
419
|
+
};
|
|
409
420
|
}
|
|
410
421
|
function buildScanResult(input) {
|
|
411
422
|
const issues = deduplicateIssues(input.findings);
|
|
@@ -489,7 +500,17 @@ function checkStructure(skillDir) {
|
|
|
489
500
|
return findings;
|
|
490
501
|
}
|
|
491
502
|
function structuralIssue(patternId, severity, category, file, match, description, recommendation) {
|
|
492
|
-
return {
|
|
503
|
+
return {
|
|
504
|
+
pattern: category,
|
|
505
|
+
patternId,
|
|
506
|
+
category,
|
|
507
|
+
file,
|
|
508
|
+
line: 0,
|
|
509
|
+
match,
|
|
510
|
+
severity,
|
|
511
|
+
description,
|
|
512
|
+
recommendation,
|
|
513
|
+
};
|
|
493
514
|
}
|
|
494
515
|
function listFiles(root) {
|
|
495
516
|
return listEntries(root).filter((entry) => {
|
|
@@ -524,7 +545,7 @@ function isInside(child, parent) {
|
|
|
524
545
|
function worstSeverity(issues) {
|
|
525
546
|
if (issues.length === 0)
|
|
526
547
|
return "info";
|
|
527
|
-
return issues.reduce((worst, issue) =>
|
|
548
|
+
return issues.reduce((worst, issue) => SEVERITY_ORDER[issue.severity] < SEVERITY_ORDER[worst] ? issue.severity : worst, "info");
|
|
528
549
|
}
|
|
529
550
|
function compareIssueSeverity(a, b) {
|
|
530
551
|
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
@@ -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
|
+
}
|
|
@@ -38,6 +38,20 @@ export interface RawModeStream {
|
|
|
38
38
|
* state is preserved when it works) inside a try/catch. If it throws
|
|
39
39
|
* here, the resolver falls through to `/dev/tty` or refuses cleanly
|
|
40
40
|
* instead of mounting Ink into the silent loop.
|
|
41
|
+
*
|
|
42
|
+
* Node 25 `_handle` exception (added 2026-05-28 — empirical evidence
|
|
43
|
+
* from user diagnostic): on Node v25.x, the probe call throws
|
|
44
|
+
* "Cannot read properties of undefined (reading '_handle')"
|
|
45
|
+
* because the libuv TTY binding (`this._handle`) isn't initialized at
|
|
46
|
+
* probe time — Node 25 lazily binds it on first I/O. The same
|
|
47
|
+
* `setRawMode` call AT INK'S RENDER COMMIT succeeds because by then
|
|
48
|
+
* Ink has read from the stream and the binding is live. Treating
|
|
49
|
+
* this specific error as "passes" (optimistic) lets the mount
|
|
50
|
+
* proceed; the `mount-interactive-ink` Ink-throw backstop catches
|
|
51
|
+
* any genuine failure at render time and writes the same actionable
|
|
52
|
+
* guidance. False-positive cost: a runtime error message instead of
|
|
53
|
+
* a guard refusal — same end UX. True-positive value: working TUI
|
|
54
|
+
* under Node 25, which is the user's current default.
|
|
41
55
|
*/
|
|
42
56
|
export declare function isRawModeCapable(stream: unknown): boolean;
|
|
43
57
|
export interface ResolveStdinOptions {
|
|
@@ -35,6 +35,20 @@ import { ReadStream } from "node:tty";
|
|
|
35
35
|
* state is preserved when it works) inside a try/catch. If it throws
|
|
36
36
|
* here, the resolver falls through to `/dev/tty` or refuses cleanly
|
|
37
37
|
* instead of mounting Ink into the silent loop.
|
|
38
|
+
*
|
|
39
|
+
* Node 25 `_handle` exception (added 2026-05-28 — empirical evidence
|
|
40
|
+
* from user diagnostic): on Node v25.x, the probe call throws
|
|
41
|
+
* "Cannot read properties of undefined (reading '_handle')"
|
|
42
|
+
* because the libuv TTY binding (`this._handle`) isn't initialized at
|
|
43
|
+
* probe time — Node 25 lazily binds it on first I/O. The same
|
|
44
|
+
* `setRawMode` call AT INK'S RENDER COMMIT succeeds because by then
|
|
45
|
+
* Ink has read from the stream and the binding is live. Treating
|
|
46
|
+
* this specific error as "passes" (optimistic) lets the mount
|
|
47
|
+
* proceed; the `mount-interactive-ink` Ink-throw backstop catches
|
|
48
|
+
* any genuine failure at render time and writes the same actionable
|
|
49
|
+
* guidance. False-positive cost: a runtime error message instead of
|
|
50
|
+
* a guard refusal — same end UX. True-positive value: working TUI
|
|
51
|
+
* under Node 25, which is the user's current default.
|
|
38
52
|
*/
|
|
39
53
|
export function isRawModeCapable(stream) {
|
|
40
54
|
if (stream === null || typeof stream !== "object")
|
|
@@ -48,10 +62,22 @@ export function isRawModeCapable(stream) {
|
|
|
48
62
|
setRawMode(currentIsRaw);
|
|
49
63
|
return true;
|
|
50
64
|
}
|
|
51
|
-
catch {
|
|
52
|
-
return
|
|
65
|
+
catch (e) {
|
|
66
|
+
return isNode25HandleInitDefer(e);
|
|
53
67
|
}
|
|
54
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Detect the Node 25+ "_handle is undefined at probe time" pattern.
|
|
71
|
+
* Matches both the literal `_handle` reference and the broader libuv
|
|
72
|
+
* binding init-order race that surfaces under the same wording. Kept
|
|
73
|
+
* separate from `isRawModeCapable` so the rationale lives next to
|
|
74
|
+
* the matcher and the same logic can drive `probeStdinDetail`.
|
|
75
|
+
*/
|
|
76
|
+
function isNode25HandleInitDefer(e) {
|
|
77
|
+
if (!(e instanceof Error))
|
|
78
|
+
return false;
|
|
79
|
+
return e.message.includes("_handle");
|
|
80
|
+
}
|
|
55
81
|
/**
|
|
56
82
|
* Open the controlling terminal as a raw-capable input stream, or
|
|
57
83
|
* `null` if there is none (Windows without a console, detached
|
|
@@ -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",
|