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.
@@ -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, } from "node:fs";
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(["openai/skills", "anthropics/skills", "huggingface/skills"]);
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 { regex: new RegExp(pattern, "i"), patternId, severity, category, description, recommendation };
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 { pattern: category, patternId, category, file, line: 0, match, severity, description, recommendation };
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) => (SEVERITY_ORDER[issue.severity] < SEVERITY_ORDER[worst] ? issue.severity : worst), "info");
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. The role taxonomy
85
- * matches one-to-one across v1/v2/v3; we just wrap each message with
86
- * a stable id (using the index since AgentMessage doesn't carry one).
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()), 1000);
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: `Unknown command: ${cmd}. Type /help for the registered list.`,
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: 0, reads: 0, edits: 0, bashCalls: 0, isStreaming: isStreaming, contextTrend: contextTrend, profile: profile }) }));
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: 0, 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) => {
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
- const totalSeconds = Math.floor(ms / 1000);
82
- const h = Math.floor(totalSeconds / 3600);
83
- const m = Math.floor((totalSeconds % 3600) / 60);
84
- const s = totalSeconds % 60;
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${s.toString().padStart(2, "0")}s`;
89
- return `${s}s`;
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;
@@ -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 false;
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wotann",
3
- "version": "0.5.93",
3
+ "version": "0.5.95",
4
4
  "description": "WOTANN — The All-Father of AI Agent Harnesses",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",