wotann 0.5.94 → 0.5.96

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/index.js CHANGED
@@ -224,7 +224,7 @@ program
224
224
  // `--no-fullscreen` or `WOTANN_FULLSCREEN=0`.
225
225
  .option("--fullscreen", "Use the alternate screen buffer (default: on)", true)
226
226
  .option("--no-fullscreen", "Render in main scrollback (legacy mode)")
227
- .option("--renderer <renderer>", "TUI renderer: ink | opentui", "ink")
227
+ .option("--renderer <renderer>", "TUI renderer: ink (alt-screen) | inline (no-flicker main-buffer)", "ink")
228
228
  .action(async (options) => {
229
229
  if (options.workspace !== undefined) {
230
230
  try {
@@ -399,25 +399,15 @@ program
399
399
  "ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY etc. " +
400
400
  "in your environment. Continuing to TUI in static mode.\n");
401
401
  }
402
- if (options.renderer === "opentui") {
403
- if (!isInteractiveTTY) {
404
- process.stderr.write("[wotann] --renderer opentui requires an interactive TTY.\n");
405
- return;
406
- }
407
- const { mountOpenTuiChat } = await import("./ui/opentui-chat.js");
408
- const handle = await mountOpenTuiChat({
409
- version: VERSION,
410
- providers: interactive.providers,
411
- initialModel: interactive.initialModel,
412
- initialProvider: interactive.initialProvider,
413
- runtime: interactive.runtime,
414
- fullscreen: options.fullscreen,
415
- });
416
- await handle.waitUntilExit;
417
- return;
418
- }
419
- if (options.renderer !== undefined && options.renderer !== "ink") {
420
- process.stderr.write("[wotann] Unknown renderer. Use --renderer ink or --renderer opentui.\n");
402
+ // The `opentui` renderer was retired (docs/phase-0-redesign/
403
+ // renderer-decision.md): @opentui loads its Zig core via bun:ffi, so
404
+ // it was dead-on-arrival under stock Node — it typechecked but threw
405
+ // on import for every user. The no-flicker win is delivered on Ink
406
+ // instead via `--renderer inline` (main-buffer + <Static>).
407
+ if (options.renderer !== undefined &&
408
+ options.renderer !== "ink" &&
409
+ options.renderer !== "inline") {
410
+ process.stderr.write("[wotann] Unknown renderer. Use --renderer ink (alt-screen) or --renderer inline (no-flicker main-buffer).\n");
421
411
  return;
422
412
  }
423
413
  // Pre-load the heavy UI modules BEFORE entering alt-buffer so the
@@ -426,13 +416,19 @@ program
426
416
  // AppV3 + Ink + react-reconciler hydrate, which is exactly the
427
417
  // "npx wotann hangs and shows a black screen" symptom users have
428
418
  // reported (2026-05-23 P0). Parallel imports cut the wait further.
429
- const [{ AppV3 }, { mountInteractiveInk }, altBufferModule] = await Promise.all([
419
+ const [{ AppV3 }, { mountInteractiveInk }, altBufferModule, { isInlineRenderRequested }] = await Promise.all([
430
420
  import("./ui/components/v3/index.js"),
431
421
  import("./ui/mount-interactive-ink.js"),
432
422
  import("./ui/alt-buffer.js"),
423
+ import("./ui/inline-render.js"),
433
424
  ]);
434
425
  const { isAltBufferRequested, enterAltBuffer, exitAltBuffer } = altBufferModule;
435
- const wantsAltBuffer = isAltBufferRequested(options.fullscreen !== false);
426
+ // Inline (main-buffer + <Static>) mode skips alt-buffer entry — the
427
+ // two are mutually exclusive (the alt buffer has no scrollback for
428
+ // <Static> to write committed history into). Opt in via
429
+ // `--renderer inline` OR WOTANN_TUI_INLINE=1; see src/ui/inline-render.ts.
430
+ const inlineMode = options.renderer === "inline" || isInlineRenderRequested();
431
+ const wantsAltBuffer = isAltBufferRequested(options.fullscreen !== false) && !inlineMode;
436
432
  // Mount the single V3 shell through the ONE guarded gate every
437
433
  // interactive Ink mount must use: viewport repair, raw-mode-
438
434
  // capable stdin resolution, refuse-cleanly-with-guidance.
@@ -455,6 +451,7 @@ program
455
451
  initialModel: interactive.initialModel,
456
452
  initialProvider: interactive.initialProvider,
457
453
  runtime: interactive.runtime,
454
+ inline: inlineMode,
458
455
  }), {
459
456
  onResolved: () => {
460
457
  if (wantsAltBuffer)
@@ -1960,13 +1957,17 @@ program
1960
1957
  // (npx pitch-black fix 2026-05-23). Same fullscreen-mode hook as
1961
1958
  // `wotann start` — env var only here (no CLI flag on `wotann
1962
1959
  // resume`). Default ON; disable via `WOTANN_FULLSCREEN=0`.
1963
- const [{ AppV3 }, { mountInteractiveInk }, altBufferModule] = await Promise.all([
1960
+ const [{ AppV3 }, { mountInteractiveInk }, altBufferModule, { isInlineRenderRequested }] = await Promise.all([
1964
1961
  import("./ui/components/v3/index.js"),
1965
1962
  import("./ui/mount-interactive-ink.js"),
1966
1963
  import("./ui/alt-buffer.js"),
1964
+ import("./ui/inline-render.js"),
1967
1965
  ]);
1968
1966
  const { isAltBufferRequested, enterAltBuffer, exitAltBuffer } = altBufferModule;
1969
- const wantsAltBuffer = isAltBufferRequested(true);
1967
+ // Inline main-buffer + <Static> rendering is mutually exclusive with
1968
+ // the alt buffer (phased rollout behind WOTANN_TUI_INLINE=1).
1969
+ const inlineMode = isInlineRenderRequested();
1970
+ const wantsAltBuffer = isAltBufferRequested(true) && !inlineMode;
1970
1971
  // v0.5.89 hotfix: alt-buffer entry is now gated through `onResolved`
1971
1972
  // so a guard refusal never swallows the diagnostic. See start
1972
1973
  // command for the full discussion.
@@ -1979,6 +1980,7 @@ program
1979
1980
  initialProvider: session.provider,
1980
1981
  initialMessages: session.messages,
1981
1982
  runtime: interactive.runtime,
1983
+ inline: inlineMode,
1982
1984
  }), {
1983
1985
  onResolved: () => {
1984
1986
  if (wantsAltBuffer)
@@ -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[];
@@ -49,6 +50,15 @@ export interface AppV3Props {
49
50
  readonly initialProvider?: ProviderName;
50
51
  readonly initialMessages?: readonly AgentMessage[];
51
52
  readonly runtime?: WotannRuntime;
53
+ /**
54
+ * AppV4 inline render mode — committed history renders in Ink `<Static>`
55
+ * (written once into the terminal's native scrollback) and only the
56
+ * in-flight turn repaints, eliminating streaming flicker. The mount path
57
+ * (src/index.ts) also skips the alt-screen buffer when this is set, since
58
+ * `<Static>` needs main-buffer scrollback. Phased rollout behind
59
+ * `WOTANN_TUI_INLINE=1`; defaults off.
60
+ */
61
+ readonly inline?: boolean;
52
62
  /**
53
63
  * Optional capability-profile override — primarily for tests so the
54
64
  * tier is deterministic without manipulating `process.env`. When
@@ -87,4 +97,23 @@ export interface AppV3Props {
87
97
  */
88
98
  readonly snippetDbPathOverride?: string;
89
99
  }
90
- export declare function AppV3({ version: _version, providers, initialModel, initialProvider, initialMessages, runtime, profileOverride, columnsOverride, rowsOverride, uiStatePathOverride, statuslineConfigPathOverride, layoutConfigPathOverride, snippetDbPathOverride, }: AppV3Props): React.ReactElement;
100
+ /**
101
+ * Map an AgentMessage onto a v3 TranscriptMessage, PRESERVING each
102
+ * message's stable id. AgentMessage.id is assigned at creation (see
103
+ * makeMessageId / the assistant `assistantId`); we fall back to a
104
+ * positional id only for legacy messages that predate id assignment.
105
+ *
106
+ * Why a stable, non-positional id matters: it is the prerequisite for
107
+ * the Ink <Static> committed-history split (P1). <Static> keys items
108
+ * by identity to decide which rows it has ALREADY emitted, so a
109
+ * positional id would make it mis-detect new rows the moment a message
110
+ * is inserted mid-array. Preserving the message's own id also lets the
111
+ * assistant row keep one identity across its streamed deltas.
112
+ *
113
+ * Role coercion: AgentMessage's role is a wider union than v3's
114
+ * TranscriptRole. The runtime mostly emits `user`/`assistant`; anything
115
+ * else (e.g. `developer`) falls back to `system` so the row still
116
+ * renders without a missing-style crash.
117
+ */
118
+ export declare function toTranscriptMessages(messages: readonly AgentMessage[]): readonly TranscriptMessageV3[];
119
+ export declare function AppV3({ version: _version, providers, initialModel, initialProvider, initialMessages, runtime, inline, 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",
@@ -203,7 +224,7 @@ function NoProviderHint() {
203
224
  const choices = useMemo(() => buildOnboardingAuthChoices().slice(0, 4), []);
204
225
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tone.muted, children: "No provider configured \u2014 authenticate the standard way:" }), choices.map((c) => (_jsxs(Text, { color: tone.muted, children: [" • ", c.label, ": ", c.hint] }, c.id)))] }));
205
226
  }
206
- function AppV3Inner({ providers, initialModel, initialProvider, messages, isStreaming, setIsStreaming, draftValue, setDraftValue, setMessages, profile, terminalCapabilities, runtime, escPrimed, quitConfirm, bannerVariant, setBannerVariant, slashRouter, slashContext, themeManager, tourResolved, setTourResolved, persistedOnboarded, sidePaneVisible, sidePaneSnapshotMessages, sidePaneLocalMessages, sidePaneStreaming, setSidePaneLocalMessages, setSidePaneStreaming, closeSidePane, toggleSidePane, statuslineSegments, sessionStartedAt, activeGoal, vimMode, petMode, statusBarPosition, }) {
227
+ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStreaming, setIsStreaming, draftValue, setDraftValue, setMessages, profile, terminalCapabilities, runtime, escPrimed, quitConfirm, bannerVariant, setBannerVariant, slashRouter, slashContext, themeManager, tourResolved, setTourResolved, persistedOnboarded, sidePaneVisible, sidePaneSnapshotMessages, sidePaneLocalMessages, sidePaneStreaming, setSidePaneLocalMessages, setSidePaneStreaming, closeSidePane, toggleSidePane, statuslineSegments, sessionStartedAt, activeGoal, vimMode, petMode, statusBarPosition, inline, staticEpoch, }) {
207
228
  const { tone } = useThemeTone();
208
229
  const overlay = useOverlayManager();
209
230
  const { columns, rows } = useTerminalWidth();
@@ -216,6 +237,11 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
216
237
  const maxTranscriptScrollOffset = Math.max(0, transcriptMessages.length - transcriptVisibleCount);
217
238
  const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
218
239
  const activeRunRef = useRef(null);
240
+ // AppV4 inline render — the committed-prefix length captured at turn
241
+ // start. While streaming, messages[0..streamBoundary) stay frozen in
242
+ // <Static> and only the in-flight turn (the suffix) repaints. See the
243
+ // `committedCount` derivation below.
244
+ const streamBoundaryRef = useRef(0);
219
245
  const sideActiveRunRef = useRef(null);
220
246
  // Hermes Gap 2 — interrupt-and-redirect: persistent channel for the
221
247
  // host composer's mid-stream redirects. Ctrl+R captures the current
@@ -226,6 +252,13 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
226
252
  const redirectChannelRef = useRef(new InMemoryRedirectChannel());
227
253
  const [sidePaneDraft, setSidePaneDraft] = useState("");
228
254
  const [nowMs, setNowMs] = useState(() => Date.now());
255
+ // AppV4 inline render boundary. Streaming → freeze the committed prefix
256
+ // at the turn-start length (only the in-flight turn repaints). Idle →
257
+ // everything is committed (→ <Static>). The min() guards against a stale
258
+ // boundary briefly exceeding the live length (e.g. right after /clear).
259
+ const committedCount = isStreaming
260
+ ? Math.min(streamBoundaryRef.current, transcriptMessages.length)
261
+ : transcriptMessages.length;
229
262
  useEffect(() => {
230
263
  setTranscriptScrollOffset(0);
231
264
  }, [messages.length]);
@@ -238,8 +271,15 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
238
271
  sideActiveRunRef.current?.abort();
239
272
  sideActiveRunRef.current = null;
240
273
  }, []);
274
+ // P2 — coarse uptime clock. In the alt-screen buffer, Ink erase-rewrites
275
+ // the WHOLE live frame on any state change, so a 1Hz tick forced a
276
+ // full-frame repaint (visible idle flicker) every second. Session uptime
277
+ // is ambient info that does not need second precision, so tick every 10s
278
+ // (paired with minute-granularity formatUptime). The COMPLETE streaming
279
+ // flicker fix is the inline + <Static> pivot (the AppV4 rebuild — see
280
+ // docs/phase-0-redesign/renderer-decision.md).
241
281
  useEffect(() => {
242
- const id = setInterval(() => setNowMs(Date.now()), 1000);
282
+ const id = setInterval(() => setNowMs(Date.now()), 10_000);
243
283
  return () => clearInterval(id);
244
284
  }, []);
245
285
  // ── Keyboard wiring ───────────────────────────────────────────────
@@ -291,13 +331,27 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
291
331
  // length approximation that every surface can compute consistently.
292
332
  const usedTokens = useMemo(() => Math.ceil(messages.reduce((total, msg) => total + Math.max(1, Math.ceil(msg.content.length / 4)), 0)), [messages]);
293
333
  const maxTokens = runtime?.getMaxContextTokens?.() ?? 200_000;
334
+ // Real session cost from the runtime CostTracker (climbs as turns run);
335
+ // falls back to 0 when no runtime/tracker is present. Replaces the prior
336
+ // hardcoded costUsd={0}/cost={0} that made "$0.000" never move.
337
+ const sessionCostUsd = runtime?.getCostTracker?.().getTotalCost?.() ?? 0;
294
338
  const [contextTrend, setContextTrend] = useState([usedTokens]);
295
339
  const [queuedPrompts, setQueuedPrompts] = useState([]);
296
340
  const [pendingImages, setPendingImages] = useState([]);
297
341
  const pendingImagesRef = useRef(pendingImages);
298
342
  pendingImagesRef.current = pendingImages;
299
343
  const submitGateRef = useRef(null);
344
+ // P4 — throttle context-trend sampling. `usedTokens` recomputes on every
345
+ // streamed token, and the unthrottled effect fired a SECOND state update
346
+ // (on top of setMessages) per token, doubling React reconciliation work
347
+ // during a stream. Sample at most every 500ms so the sparkline still
348
+ // trends without the per-token double-render.
349
+ const lastTrendSampleRef = useRef(0);
300
350
  useEffect(() => {
351
+ const now = Date.now();
352
+ if (now - lastTrendSampleRef.current < 500)
353
+ return;
354
+ lastTrendSampleRef.current = now;
301
355
  setContextTrend((prev) => {
302
356
  const last = prev[prev.length - 1];
303
357
  if (last === usedTokens)
@@ -314,6 +368,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
314
368
  const startRuntimeTurn = useCallback((trimmed, imagesForTurn) => {
315
369
  const attachments = toTranscriptAttachments(imagesForTurn);
316
370
  const userMessage = {
371
+ id: makeMessageId("user"),
317
372
  role: "user",
318
373
  content: trimmed,
319
374
  ...(attachments.length > 0 ? { attachments } : {}),
@@ -345,6 +400,12 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
345
400
  const turnContext = messages;
346
401
  const abortController = new AbortController();
347
402
  activeRunRef.current = abortController;
403
+ // Freeze the inline-render commit boundary at the pre-turn length so
404
+ // the prior history stays in <Static> and only this turn (user msg +
405
+ // streaming assistant + tool rows) repaints live. On finalize,
406
+ // isStreaming flips false and committedCount jumps to the full
407
+ // length, committing the whole turn to scrollback at once.
408
+ streamBoundaryRef.current = messages.length;
348
409
  setIsStreaming(true);
349
410
  setMessages((prev) => [...prev, userMessage]);
350
411
  void (async () => {
@@ -373,6 +434,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
373
434
  setMessages((prev) => [
374
435
  ...prev,
375
436
  {
437
+ id: makeMessageId("tool"),
376
438
  role: "tool",
377
439
  toolName: ev.toolName,
378
440
  toolCallId: ev.toolCallId,
@@ -479,7 +541,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
479
541
  const formatted = formatSlashResultMessage({
480
542
  command: cmd,
481
543
  ok: false,
482
- reason: `Unknown command: ${cmd}. Type /help for the registered list.`,
544
+ reason: unhandledCommandReason(cmd),
483
545
  });
484
546
  setMessages((prev) => [...prev, { role: "system", content: formatted }]);
485
547
  stampSigil("error");
@@ -808,9 +870,9 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
808
870
  // "the UI isnt quite what I like… Minimal yet powerful. It's just a bit
809
871
  // too much." Provider status moved to the /providers slash command.
810
872
  const heavyChrome = process.env["WOTANN_TUI_HEAVY"] === "1";
811
- 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) => {
873
+ 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, inline: inline, committedCount: committedCount, staticEpoch: staticEpoch }) }));
874
+ 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 }) }));
875
+ 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
876
  // Dispatch through the router with the empty arg + the real
815
877
  // slash context (so /clear actually clears, etc.). Close the
816
878
  // overlay after selection so muscle memory matches Codex/Crush.
@@ -857,10 +919,15 @@ export function AppV3({
857
919
  // (the gradient banner replaced the version-tagged splash). Prefixed
858
920
  // with `_` so the noUnusedParameters check is satisfied while the
859
921
  // prop stays in the public interface.
860
- version: _version, providers, initialModel = "", initialProvider = "", initialMessages = [], runtime, profileOverride, columnsOverride, rowsOverride, uiStatePathOverride, statuslineConfigPathOverride, layoutConfigPathOverride, snippetDbPathOverride, }) {
922
+ version: _version, providers, initialModel = "", initialProvider = "", initialMessages = [], runtime, inline = false, profileOverride, columnsOverride, rowsOverride, uiStatePathOverride, statuslineConfigPathOverride, layoutConfigPathOverride, snippetDbPathOverride, }) {
861
923
  const themeManager = useThemeManager(uiStatePathOverride);
862
924
  const [palette, setPalette] = useState(themeManager.getCurrent().colors);
863
925
  const [messages, setMessages] = useState([...initialMessages]);
926
+ // AppV4 inline render: bumping this remounts the <Static> committed-
927
+ // history region with a fresh append-index. Bumped by clearTranscript
928
+ // (/clear) — Static only appends, so without a remount it would silently
929
+ // drop every row added after a clear (its lastIndex stays stale).
930
+ const [staticEpoch, setStaticEpoch] = useState(0);
864
931
  const [isStreaming, setIsStreaming] = useState(false);
865
932
  const [draftValue, setDraftValue] = useState("");
866
933
  const [activeModel, setActiveModel] = useState(initialModel);
@@ -977,12 +1044,40 @@ version: _version, providers, initialModel = "", initialProvider = "", initialMe
977
1044
  providerRef.current = next;
978
1045
  setActiveProvider(next);
979
1046
  },
980
- clearTranscript: () => setMessages([]),
1047
+ clearTranscript: () => {
1048
+ setMessages([]);
1049
+ // Remount <Static> so the inline-render append-index resets; the
1050
+ // already-emitted rows remain in native scrollback (Claude-Code
1051
+ // semantics: /clear starts a fresh surface below).
1052
+ setStaticEpoch((epoch) => epoch + 1);
1053
+ },
981
1054
  setVimMode: (enabled) => {
982
1055
  vimModeRef.current = enabled;
983
1056
  setVimMode(enabled);
984
1057
  themeManager.persist({ vimKeymap: enabled });
985
1058
  },
1059
+ // /theme — mirror the Ctrl+Y cycle (themeManager.setTheme + setPalette)
1060
+ // so the slash command and the keybind switch themes identically.
1061
+ getTheme: () => themeManager.getCurrent().name,
1062
+ listThemes: () => themeManager.getThemeNames(),
1063
+ setTheme: (name) => {
1064
+ const ok = themeManager.setTheme(name);
1065
+ if (ok)
1066
+ setPalette(themeManager.getCurrent().colors);
1067
+ return ok;
1068
+ },
1069
+ // /permission — approval mode via the runtime's get/setPermissionMode
1070
+ // (the same modes startRuntimeTurn threads into the agent loop).
1071
+ getPermission: () => runtime?.getPermissionMode?.() ?? "smart",
1072
+ listPermissions: () => PERMISSION_MODES,
1073
+ setPermission: (mode) => {
1074
+ if (!PERMISSION_MODES.includes(mode))
1075
+ return false;
1076
+ if (runtime?.setPermissionMode === undefined)
1077
+ return false;
1078
+ runtime.setPermissionMode(mode);
1079
+ return true;
1080
+ },
986
1081
  // Quit is intentionally NOT wired here — the user
987
1082
  // expects Ctrl+C to exit, and /quit prints an informational
988
1083
  // message explaining that.
@@ -1145,5 +1240,5 @@ version: _version, providers, initialModel = "", initialProvider = "", initialMe
1145
1240
  }, 2000);
1146
1241
  return () => clearTimeout(id);
1147
1242
  }, [escPrimed, quitConfirm]);
1148
- return (_jsx(ThemeProvider, { palette: palette ?? PALETTES.dark, children: _jsx(WidthAwareLayout, { overrideColumns: columnsOverride, overrideRows: rowsOverride, children: _jsx(OverlayManager, { closeOnEscape: false, children: _jsx(AppV3Inner, { providers: providers, initialModel: activeModel, initialProvider: activeProvider, messages: messages, isStreaming: isStreaming, setIsStreaming: setIsStreaming, draftValue: draftValue, setDraftValue: setDraftValue, setMessages: setMessages, profile: profile, terminalCapabilities: terminalCapabilities, runtime: runtime, escPrimed: escPrimed, quitConfirm: quitConfirm, bannerVariant: bannerVariant, setBannerVariant: setBannerVariant, slashRouter: slashRouter, slashContext: slashContextRef.current, themeManager: themeManager, tourResolved: tourResolved, setTourResolved: setTourResolved, persistedOnboarded: persistedOnboarded, sidePaneVisible: sidePaneVisible, sidePaneSnapshotMessages: sidePaneSnapshotMessages, sidePaneLocalMessages: sidePaneLocalMessages, sidePaneStreaming: sidePaneStreaming, setSidePaneLocalMessages: setSidePaneLocalMessages, setSidePaneStreaming: setSidePaneStreaming, closeSidePane: closeSidePane, toggleSidePane: toggleSidePane, statuslineSegments: statuslineSegments, sessionStartedAt: sessionStartedAtRef.current, activeGoal: activeGoal, vimMode: vimMode, petMode: petMode, statusBarPosition: statusBarPosition }) }) }) }));
1243
+ return (_jsx(ThemeProvider, { palette: palette ?? PALETTES.dark, children: _jsx(WidthAwareLayout, { overrideColumns: columnsOverride, overrideRows: rowsOverride, children: _jsx(OverlayManager, { closeOnEscape: false, children: _jsx(AppV3Inner, { providers: providers, initialModel: activeModel, initialProvider: activeProvider, messages: messages, isStreaming: isStreaming, setIsStreaming: setIsStreaming, draftValue: draftValue, setDraftValue: setDraftValue, setMessages: setMessages, profile: profile, terminalCapabilities: terminalCapabilities, runtime: runtime, escPrimed: escPrimed, quitConfirm: quitConfirm, bannerVariant: bannerVariant, setBannerVariant: setBannerVariant, slashRouter: slashRouter, slashContext: slashContextRef.current, themeManager: themeManager, tourResolved: tourResolved, setTourResolved: setTourResolved, persistedOnboarded: persistedOnboarded, sidePaneVisible: sidePaneVisible, sidePaneSnapshotMessages: sidePaneSnapshotMessages, sidePaneLocalMessages: sidePaneLocalMessages, sidePaneStreaming: sidePaneStreaming, setSidePaneLocalMessages: setSidePaneLocalMessages, setSidePaneStreaming: setSidePaneStreaming, closeSidePane: closeSidePane, toggleSidePane: toggleSidePane, statuslineSegments: statuslineSegments, sessionStartedAt: sessionStartedAtRef.current, activeGoal: activeGoal, vimMode: vimMode, petMode: petMode, statusBarPosition: statusBarPosition, inline: inline, staticEpoch: staticEpoch }) }) }) }));
1149
1244
  }
@@ -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)
@@ -45,6 +45,26 @@ export interface TranscriptProps {
45
45
  */
46
46
  readonly scrollOffset?: number;
47
47
  readonly terminalCapabilities: TerminalCapabilities;
48
+ /**
49
+ * AppV4 inline mode: render committed history via Ink `<Static>`
50
+ * (write-once into native terminal scrollback) and only the in-flight
51
+ * turn live. Off (default) keeps the legacy windowed alt-buffer render.
52
+ */
53
+ readonly inline?: boolean;
54
+ /**
55
+ * Count of finalized (committed) messages. In inline mode,
56
+ * `messages[0..committedCount)` render in `<Static>` and
57
+ * `messages[committedCount..)` render live. Defaults to `messages.length`
58
+ * (everything committed — the idle state) when omitted.
59
+ */
60
+ readonly committedCount?: number;
61
+ /**
62
+ * Monotonic epoch that remounts the `<Static>` region. Bumped on
63
+ * `/clear` so Static's internal append-index resets to 0 — required
64
+ * because Static only ever appends and would otherwise silently drop
65
+ * every row added after a clear.
66
+ */
67
+ readonly staticEpoch?: number;
48
68
  }
49
69
  export interface TranscriptWindow {
50
70
  readonly messages: readonly TranscriptMessageV3[];
@@ -53,4 +73,4 @@ export interface TranscriptWindow {
53
73
  readonly scrollOffset: number;
54
74
  }
55
75
  export declare function selectTranscriptWindow(messages: readonly TranscriptMessageV3[], visibleCount: number | undefined, scrollOffset?: number): TranscriptWindow;
56
- export declare function Transcript({ messages, profile, visibleCount, scrollOffset, terminalCapabilities, }: TranscriptProps): React.ReactElement;
76
+ export declare function Transcript({ messages, profile, visibleCount, scrollOffset, terminalCapabilities, inline, committedCount, staticEpoch, }: TranscriptProps): React.ReactElement;
@@ -1,36 +1,9 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "#wotann-jsx/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { glyph } from "../../theme/tokens.js";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "#wotann-jsx/jsx-runtime";
2
+ import { Box, Static, Text } from "ink";
4
3
  import { useThemeTone } from "../../theme/context.js";
5
4
  import { spacingForWidth } from "../../theme/tokens-v3.js";
6
5
  import { useTerminalWidth } from "./WidthAwareLayout.js";
7
- import { parseSlashResultMessage, SystemMessageCard } from "./SystemMessageCard.js";
8
- import { KittyGraphics } from "./KittyGraphics.js";
9
- // Minimal role markers — Claude Code / Codex parity. User gets a
10
- // single subtle ❯ (same glyph as the composer prompt); the assistant
11
- // is PURE content (no badge, no label, no gutter) — the biggest
12
- // declutter; system/tool get a dim · marker. No per-message gutter
13
- // bar, no Norse runes, nothing bold.
14
- const ROLE_STYLES_AB = {
15
- user: { gutterTone: "primary", badge: glyph.prompt, label: "" },
16
- assistant: { gutterTone: "muted", badge: "", label: "" },
17
- system: { gutterTone: "warning", badge: "·", label: "system" },
18
- tool: { gutterTone: "muted", badge: "·", label: "tool" },
19
- };
20
- const ROLE_STYLES_C = {
21
- user: { gutterTone: "primary", badge: ">", label: "" },
22
- assistant: { gutterTone: "muted", badge: "", label: "" },
23
- system: { gutterTone: "warning", badge: "*", label: "system" },
24
- tool: { gutterTone: "muted", badge: "*", label: "tool" },
25
- };
26
- function formatTime(timestamp) {
27
- if (typeof timestamp !== "number" || !Number.isFinite(timestamp))
28
- return null;
29
- const d = new Date(timestamp);
30
- const hh = String(d.getHours()).padStart(2, "0");
31
- const mm = String(d.getMinutes()).padStart(2, "0");
32
- return `${hh}:${mm}`;
33
- }
6
+ import { TranscriptRow } from "./TranscriptRow.js";
34
7
  export function selectTranscriptWindow(messages, visibleCount, scrollOffset = 0) {
35
8
  const capacity = typeof visibleCount === "number" && Number.isFinite(visibleCount)
36
9
  ? Math.max(1, Math.floor(visibleCount))
@@ -54,36 +27,23 @@ export function selectTranscriptWindow(messages, visibleCount, scrollOffset = 0)
54
27
  scrollOffset: clampedOffset,
55
28
  };
56
29
  }
57
- export function Transcript({ messages, profile, visibleCount, scrollOffset = 0, terminalCapabilities, }) {
30
+ export function Transcript({ messages, profile, visibleCount, scrollOffset = 0, terminalCapabilities, inline = false, committedCount, staticEpoch = 0, }) {
58
31
  const { tone } = useThemeTone();
59
32
  const { breakpoint } = useTerminalWidth();
60
33
  const spacing = spacingForWidth(breakpoint);
61
- const styles = profile.tier === "C" ? ROLE_STYLES_C : ROLE_STYLES_AB;
34
+ // AppV4 inline render: committed history Ink <Static> (written once
35
+ // into the terminal's native scrollback), and ONLY the in-flight turn
36
+ // repaints. This is the streaming-flicker fix. It requires the main
37
+ // buffer (the alt buffer has no scrollback); the mount path guarantees
38
+ // alt-buffer is skipped whenever inline is on. The windowed
39
+ // selectTranscriptWindow paging below is replaced by native scrollback.
40
+ if (inline) {
41
+ const boundary = Math.max(0, Math.min(committedCount ?? messages.length, messages.length));
42
+ const committed = messages.slice(0, boundary);
43
+ const live = messages.slice(boundary);
44
+ const pad = spacing.transcriptRowPaddingX;
45
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: committed, children: (msg) => (_jsx(Box, { paddingX: pad, children: _jsx(TranscriptRow, { msg: msg, profile: profile, terminalCapabilities: terminalCapabilities }) }, msg.id)) }, staticEpoch), live.length > 0 && (_jsx(Box, { flexDirection: "column", paddingX: pad, children: live.map((msg) => (_jsx(TranscriptRow, { msg: msg, profile: profile, terminalCapabilities: terminalCapabilities }, msg.id))) }))] }));
46
+ }
62
47
  const window = selectTranscriptWindow(messages, visibleCount, scrollOffset);
63
- return (_jsxs(Box, { flexDirection: "column", paddingX: spacing.transcriptRowPaddingX, children: [window.hiddenBefore > 0 && _jsxs(Text, { color: tone.muted, children: ["\u2191 ", window.hiddenBefore, " earlier"] }), window.messages.map((msg) => {
64
- // Route slash-command results to the richer SystemMessageCard so
65
- // a `/model` dispatch reads as a harness response rather than a
66
- // user message. The marker-based handshake keeps the Transcript
67
- // schema unchanged — system messages without the marker fall
68
- // through to the default render below.
69
- if (msg.role === "system") {
70
- const payload = parseSlashResultMessage(msg.content);
71
- if (payload !== null) {
72
- return _jsx(SystemMessageCard, { payload: payload, profile: profile }, msg.id);
73
- }
74
- }
75
- const style = styles[msg.role];
76
- const gutterColor = tone[style.gutterTone];
77
- const timeString = formatTime(msg.timestamp);
78
- const lines = msg.content.length === 0 ? [""] : msg.content.split("\n");
79
- const attachments = msg.attachments ?? [];
80
- const hasHeader = style.badge !== "" || style.label !== "" || timeString !== null;
81
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [hasHeader && (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [style.badge !== "" && _jsx(Text, { color: gutterColor, children: style.badge }), style.label !== "" && _jsx(Text, { color: gutterColor, children: style.label })] }), timeString !== null && _jsx(Text, { color: tone.muted, children: timeString })] })), lines.map((line, i) => (_jsx(Text, { color: tone.text, children: line.length === 0 ? " " : line }, `msg-${msg.id}-line-${i}`))), attachments.map((attachment, i) => {
82
- if (attachment.kind === "image" && attachment.dataUri !== undefined) {
83
- return (_jsx(KittyGraphics, { source: attachment.dataUri, capabilities: terminalCapabilities, rows: profile.tier === "C" ? 3 : 5, columns: profile.tier === "C" ? 28 : 40, caption: attachment.path }, `msg-${msg.id}-att-${i}`));
84
- }
85
- const label = attachment.kind === "image" ? "image" : "file";
86
- return (_jsxs(Text, { color: tone.muted, italic: true, children: ["[", label, ": ", attachment.path, "]"] }, `msg-${msg.id}-att-${i}`));
87
- })] }, msg.id));
88
- }), window.hiddenAfter > 0 && _jsxs(Text, { color: tone.muted, children: ["\u2193 ", window.hiddenAfter, " newer"] })] }));
48
+ return (_jsxs(Box, { flexDirection: "column", paddingX: spacing.transcriptRowPaddingX, children: [window.hiddenBefore > 0 && _jsxs(Text, { color: tone.muted, children: ["\u2191 ", window.hiddenBefore, " earlier"] }), window.messages.map((msg) => (_jsx(TranscriptRow, { msg: msg, profile: profile, terminalCapabilities: terminalCapabilities }, msg.id))), window.hiddenAfter > 0 && _jsxs(Text, { color: tone.muted, children: ["\u2193 ", window.hiddenAfter, " newer"] })] }));
89
49
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * TranscriptRow — a single memoized conversation row.
3
+ *
4
+ * Extracted from `Transcript.tsx`'s inline row map so it can be rendered
5
+ * in BOTH places the AppV4 inline model needs it:
6
+ * 1. Committed history inside Ink `<Static>` (write-once → terminal
7
+ * scrollback). Static never re-renders an emitted row, so the memo
8
+ * is moot there — but identity-stable rows keep the contract clean.
9
+ * 2. The live in-flight turn (the streaming assistant row + its tool
10
+ * rows). Here the memo earns its keep: while the assistant row's
11
+ * content grows token-by-token, the sibling rows (the user prompt,
12
+ * finished tool rows) MUST NOT re-render.
13
+ *
14
+ * Why a CUSTOM comparator (not the default shallow `React.memo`):
15
+ * `toTranscriptMessages` rebuilds a fresh object for every message on
16
+ * every render (`messages.map((m) => ({...}))`), so a by-reference memo
17
+ * would re-render every row on every streamed token — defeating the
18
+ * point. We compare the fields that actually drive the render. The
19
+ * `attachments` array survives a reference check because
20
+ * `toTranscriptMessages` threads the SAME array through (it never copies
21
+ * it), so an unchanged message keeps an identity-stable attachments ref.
22
+ *
23
+ * Rendering is byte-identical to the prior inline map (Phase 1 is a pure
24
+ * no-op refactor): the horizontal padding stays a concern of the
25
+ * container/Static call-site, never the row, so this component composes
26
+ * the same in either mounting context.
27
+ */
28
+ import React from "react";
29
+ import type { CapabilityProfile } from "../../capability-tier.js";
30
+ import type { TerminalCapabilities } from "../../terminal-capabilities.js";
31
+ import type { TranscriptMessageV3 } from "./Transcript.js";
32
+ export interface TranscriptRowProps {
33
+ readonly msg: TranscriptMessageV3;
34
+ readonly profile: CapabilityProfile;
35
+ readonly terminalCapabilities: TerminalCapabilities;
36
+ }
37
+ declare function TranscriptRowImpl({ msg, profile, terminalCapabilities, }: TranscriptRowProps): React.ReactElement;
38
+ /**
39
+ * Memo comparator — return TRUE to SKIP a re-render. Exported for direct
40
+ * unit testing (the streaming hot-path depends on this returning true for
41
+ * an unchanged sibling row while the assistant row streams).
42
+ */
43
+ export declare function transcriptRowsEqual(prev: TranscriptRowProps, next: TranscriptRowProps): boolean;
44
+ export declare const TranscriptRow: React.MemoExoticComponent<typeof TranscriptRowImpl>;
45
+ export {};
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "#wotann-jsx/jsx-runtime";
2
+ /**
3
+ * TranscriptRow — a single memoized conversation row.
4
+ *
5
+ * Extracted from `Transcript.tsx`'s inline row map so it can be rendered
6
+ * in BOTH places the AppV4 inline model needs it:
7
+ * 1. Committed history inside Ink `<Static>` (write-once → terminal
8
+ * scrollback). Static never re-renders an emitted row, so the memo
9
+ * is moot there — but identity-stable rows keep the contract clean.
10
+ * 2. The live in-flight turn (the streaming assistant row + its tool
11
+ * rows). Here the memo earns its keep: while the assistant row's
12
+ * content grows token-by-token, the sibling rows (the user prompt,
13
+ * finished tool rows) MUST NOT re-render.
14
+ *
15
+ * Why a CUSTOM comparator (not the default shallow `React.memo`):
16
+ * `toTranscriptMessages` rebuilds a fresh object for every message on
17
+ * every render (`messages.map((m) => ({...}))`), so a by-reference memo
18
+ * would re-render every row on every streamed token — defeating the
19
+ * point. We compare the fields that actually drive the render. The
20
+ * `attachments` array survives a reference check because
21
+ * `toTranscriptMessages` threads the SAME array through (it never copies
22
+ * it), so an unchanged message keeps an identity-stable attachments ref.
23
+ *
24
+ * Rendering is byte-identical to the prior inline map (Phase 1 is a pure
25
+ * no-op refactor): the horizontal padding stays a concern of the
26
+ * container/Static call-site, never the row, so this component composes
27
+ * the same in either mounting context.
28
+ */
29
+ import React from "react";
30
+ import { Box, Text } from "ink";
31
+ import { glyph } from "../../theme/tokens.js";
32
+ import { useThemeTone } from "../../theme/context.js";
33
+ import { parseSlashResultMessage, SystemMessageCard } from "./SystemMessageCard.js";
34
+ import { KittyGraphics } from "./KittyGraphics.js";
35
+ // Minimal role markers — Claude Code / Codex parity. User gets a
36
+ // single subtle ❯ (same glyph as the composer prompt); the assistant
37
+ // is PURE content (no badge, no label, no gutter) — the biggest
38
+ // declutter; system/tool get a dim · marker. No per-message gutter
39
+ // bar, no Norse runes, nothing bold.
40
+ const ROLE_STYLES_AB = {
41
+ user: { gutterTone: "primary", badge: glyph.prompt, label: "" },
42
+ assistant: { gutterTone: "muted", badge: "", label: "" },
43
+ system: { gutterTone: "warning", badge: "·", label: "system" },
44
+ tool: { gutterTone: "muted", badge: "·", label: "tool" },
45
+ };
46
+ const ROLE_STYLES_C = {
47
+ user: { gutterTone: "primary", badge: ">", label: "" },
48
+ assistant: { gutterTone: "muted", badge: "", label: "" },
49
+ system: { gutterTone: "warning", badge: "*", label: "system" },
50
+ tool: { gutterTone: "muted", badge: "*", label: "tool" },
51
+ };
52
+ function formatTime(timestamp) {
53
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp))
54
+ return null;
55
+ const d = new Date(timestamp);
56
+ const hh = String(d.getHours()).padStart(2, "0");
57
+ const mm = String(d.getMinutes()).padStart(2, "0");
58
+ return `${hh}:${mm}`;
59
+ }
60
+ function TranscriptRowImpl({ msg, profile, terminalCapabilities, }) {
61
+ const { tone } = useThemeTone();
62
+ const styles = profile.tier === "C" ? ROLE_STYLES_C : ROLE_STYLES_AB;
63
+ // Route slash-command results to the richer SystemMessageCard so a
64
+ // `/model` dispatch reads as a harness response rather than a user
65
+ // message. The marker-based handshake keeps the Transcript schema
66
+ // unchanged — system messages without the marker fall through to the
67
+ // default render below.
68
+ if (msg.role === "system") {
69
+ const payload = parseSlashResultMessage(msg.content);
70
+ if (payload !== null) {
71
+ return _jsx(SystemMessageCard, { payload: payload, profile: profile });
72
+ }
73
+ }
74
+ const style = styles[msg.role];
75
+ const gutterColor = tone[style.gutterTone];
76
+ const timeString = formatTime(msg.timestamp);
77
+ const lines = msg.content.length === 0 ? [""] : msg.content.split("\n");
78
+ const attachments = msg.attachments ?? [];
79
+ const hasHeader = style.badge !== "" || style.label !== "" || timeString !== null;
80
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [hasHeader && (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [style.badge !== "" && _jsx(Text, { color: gutterColor, children: style.badge }), style.label !== "" && _jsx(Text, { color: gutterColor, children: style.label })] }), timeString !== null && _jsx(Text, { color: tone.muted, children: timeString })] })), lines.map((line, i) => (_jsx(Text, { color: tone.text, children: line.length === 0 ? " " : line }, `msg-${msg.id}-line-${i}`))), attachments.map((attachment, i) => {
81
+ if (attachment.kind === "image" && attachment.dataUri !== undefined) {
82
+ return (_jsx(KittyGraphics, { source: attachment.dataUri, capabilities: terminalCapabilities, rows: profile.tier === "C" ? 3 : 5, columns: profile.tier === "C" ? 28 : 40, caption: attachment.path }, `msg-${msg.id}-att-${i}`));
83
+ }
84
+ const label = attachment.kind === "image" ? "image" : "file";
85
+ return (_jsxs(Text, { color: tone.muted, italic: true, children: ["[", label, ": ", attachment.path, "]"] }, `msg-${msg.id}-att-${i}`));
86
+ })] }));
87
+ }
88
+ /**
89
+ * Memo comparator — return TRUE to SKIP a re-render. Exported for direct
90
+ * unit testing (the streaming hot-path depends on this returning true for
91
+ * an unchanged sibling row while the assistant row streams).
92
+ */
93
+ export function transcriptRowsEqual(prev, next) {
94
+ return (prev.msg.id === next.msg.id &&
95
+ prev.msg.content === next.msg.content &&
96
+ prev.msg.role === next.msg.role &&
97
+ prev.msg.timestamp === next.msg.timestamp &&
98
+ prev.msg.attachments === next.msg.attachments &&
99
+ prev.profile === next.profile &&
100
+ prev.terminalCapabilities === next.terminalCapabilities);
101
+ }
102
+ export const TranscriptRow = React.memo(TranscriptRowImpl, transcriptRowsEqual);
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Inline-render mode gate (AppV4 flicker fix, phased rollout).
3
+ *
4
+ * The complete streaming-flicker fix renders the chat surface INLINE in
5
+ * the terminal's MAIN buffer (committed history written once into native
6
+ * scrollback via Ink `<Static>`) instead of the alternate screen buffer.
7
+ * The alt buffer has no scrollback, so `<Static>` is incompatible with it
8
+ * (committed rows would scroll off irrecoverably) — see
9
+ * docs/phase-0-redesign/phase-1-inline-architecture.md §0.
10
+ *
11
+ * Why a flag (not default-on yet): the mount-path change can only be
12
+ * validated for "feel" + cross-terminal correctness (iTerm2 / Terminal.app
13
+ * / tmux) on a PHYSICAL terminal, which CI/headless harnesses cannot
14
+ * reproduce (this is the same fragility that drove the PR #35-#38
15
+ * raw-mode/mount hardening). So inline mode ships behind
16
+ * `WOTANN_TUI_INLINE=1` for opt-in verification; once confirmed on a real
17
+ * terminal it becomes the default for the chat surface.
18
+ *
19
+ * The objective gate (eraseLines-sequence drop ≥90% vs the alt-buffer
20
+ * baseline) is exercised by the PTY byte-trace test, which DOES run here.
21
+ */
22
+ /**
23
+ * Whether this session should render the chat surface inline in the main
24
+ * buffer (Ink `<Static>` committed history) rather than in the alt-screen
25
+ * buffer. Reads `WOTANN_TUI_INLINE`; defaults OFF during the phased
26
+ * rollout. Pass an explicit env for tests.
27
+ */
28
+ export declare function isInlineRenderRequested(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Inline-render mode gate (AppV4 flicker fix, phased rollout).
3
+ *
4
+ * The complete streaming-flicker fix renders the chat surface INLINE in
5
+ * the terminal's MAIN buffer (committed history written once into native
6
+ * scrollback via Ink `<Static>`) instead of the alternate screen buffer.
7
+ * The alt buffer has no scrollback, so `<Static>` is incompatible with it
8
+ * (committed rows would scroll off irrecoverably) — see
9
+ * docs/phase-0-redesign/phase-1-inline-architecture.md §0.
10
+ *
11
+ * Why a flag (not default-on yet): the mount-path change can only be
12
+ * validated for "feel" + cross-terminal correctness (iTerm2 / Terminal.app
13
+ * / tmux) on a PHYSICAL terminal, which CI/headless harnesses cannot
14
+ * reproduce (this is the same fragility that drove the PR #35-#38
15
+ * raw-mode/mount hardening). So inline mode ships behind
16
+ * `WOTANN_TUI_INLINE=1` for opt-in verification; once confirmed on a real
17
+ * terminal it becomes the default for the chat surface.
18
+ *
19
+ * The objective gate (eraseLines-sequence drop ≥90% vs the alt-buffer
20
+ * baseline) is exercised by the PTY byte-trace test, which DOES run here.
21
+ */
22
+ /** Truthy values that opt a session into inline main-buffer rendering. */
23
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
24
+ /**
25
+ * Whether this session should render the chat surface inline in the main
26
+ * buffer (Ink `<Static>` committed history) rather than in the alt-screen
27
+ * buffer. Reads `WOTANN_TUI_INLINE`; defaults OFF during the phased
28
+ * rollout. Pass an explicit env for tests.
29
+ */
30
+ export function isInlineRenderRequested(env = process.env) {
31
+ const value = env["WOTANN_TUI_INLINE"];
32
+ if (value === undefined)
33
+ return false;
34
+ return TRUTHY.has(value.trim().toLowerCase());
35
+ }
@@ -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.94",
3
+ "version": "0.5.96",
4
4
  "description": "WOTANN — The All-Father of AI Agent Harnesses",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,19 +0,0 @@
1
- import { type CliRenderer } from "@opentui/core";
2
- import { type Root } from "@opentui/react";
3
- import type { ProviderName, ProviderStatus } from "../core/types.js";
4
- import type { WotannRuntime } from "../core/runtime.js";
5
- export interface OpenTuiChatOptions {
6
- readonly version: string;
7
- readonly providers: readonly ProviderStatus[];
8
- readonly initialProvider?: ProviderName;
9
- readonly initialModel?: string;
10
- readonly runtime: WotannRuntime;
11
- readonly fullscreen?: boolean;
12
- }
13
- export interface OpenTuiChatHandle {
14
- readonly renderer: CliRenderer;
15
- readonly root: Root;
16
- readonly waitUntilExit: Promise<void>;
17
- stop(): Promise<void>;
18
- }
19
- export declare function mountOpenTuiChat(options: OpenTuiChatOptions): Promise<OpenTuiChatHandle>;
@@ -1,285 +0,0 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { createCliRenderer } from "@opentui/core";
3
- import { createRoot, useKeyboard } from "@opentui/react";
4
- import { runAgent } from "../core/runtime-agent-loop.js";
5
- import { buildAgentToolContext } from "../core/agent-tool-context.js";
6
- import { AGENT_TOOL_DEFINITIONS, executeAgentTool } from "../tools/agent-tools.js";
7
- export async function mountOpenTuiChat(options) {
8
- const screenMode = options.fullscreen === false ? "main-screen" : "alternate-screen";
9
- const renderer = await createCliRenderer({
10
- screenMode,
11
- exitOnCtrlC: false,
12
- useMouse: true,
13
- enableMouseMovement: true,
14
- targetFps: 30,
15
- maxFps: 60,
16
- clearOnShutdown: options.fullscreen !== false,
17
- useKittyKeyboard: {
18
- disambiguate: true,
19
- alternateKeys: true,
20
- events: false,
21
- allKeysAsEscapes: false,
22
- reportText: true,
23
- },
24
- });
25
- const root = createRoot(renderer);
26
- let done = false;
27
- let resolveExit;
28
- const waitUntilExit = new Promise((resolve) => {
29
- resolveExit = resolve;
30
- });
31
- const stop = async () => {
32
- if (done)
33
- return;
34
- done = true;
35
- root.unmount();
36
- renderer.destroy();
37
- resolveExit?.();
38
- };
39
- root.render(React.createElement(OpenTuiChatApp, { ...options, onExit: () => void stop() }));
40
- return { renderer, root, waitUntilExit, stop };
41
- }
42
- function OpenTuiChatApp(props) {
43
- const providerSummary = useMemo(() => summarizeProviders(props.providers), [props.providers]);
44
- const [draft, setDraft] = useState("");
45
- const [isStreaming, setIsStreaming] = useState(false);
46
- const [messages, setMessages] = useState(() => [
47
- {
48
- id: "system-welcome",
49
- role: "system",
50
- content: `WOTANN ${props.version} native OpenTUI renderer ready. ` +
51
- "Type a prompt, /help, /clear, or /quit.",
52
- },
53
- ]);
54
- const messagesRef = useRef(messages);
55
- const queueRef = useRef([]);
56
- const abortRef = useRef(null);
57
- const submitPromptRef = useRef(async () => { });
58
- useEffect(() => {
59
- messagesRef.current = messages;
60
- }, [messages]);
61
- const appendMessage = useCallback((message) => {
62
- setMessages((prev) => [...prev, message]);
63
- }, []);
64
- const appendAssistantDelta = useCallback((id, content, model, provider) => {
65
- setMessages((prev) => {
66
- const existing = prev.find((message) => message.id === id);
67
- if (!existing) {
68
- return [...prev, { id, role: "assistant", content, model, provider }];
69
- }
70
- return prev.map((message) => message.id === id
71
- ? {
72
- ...message,
73
- content: `${message.content}${content}`,
74
- ...(model ? { model } : {}),
75
- ...(provider ? { provider } : {}),
76
- }
77
- : message);
78
- });
79
- }, []);
80
- const runQueued = useCallback(() => {
81
- const [next, ...rest] = queueRef.current;
82
- queueRef.current = rest;
83
- if (next) {
84
- void submitPromptRef.current(next);
85
- }
86
- }, []);
87
- const submitPrompt = useCallback(async (rawPrompt) => {
88
- const prompt = rawPrompt.trim();
89
- if (!prompt)
90
- return;
91
- if (prompt === "/quit" || prompt === "/exit") {
92
- props.onExit();
93
- return;
94
- }
95
- if (prompt === "/clear") {
96
- setMessages([]);
97
- return;
98
- }
99
- if (prompt === "/help") {
100
- appendMessage({
101
- id: `system-${Date.now()}`,
102
- role: "system",
103
- content: "Commands: /help, /clear, /quit. Enter submits. Esc aborts an active turn. " +
104
- "OpenTUI provides native scroll, mouse, keyboard, and alternate-screen rendering.",
105
- });
106
- return;
107
- }
108
- if (isStreaming) {
109
- queueRef.current = [...queueRef.current, prompt];
110
- appendMessage({
111
- id: `queued-${Date.now()}`,
112
- role: "system",
113
- content: `Queued next turn: ${prompt}`,
114
- });
115
- return;
116
- }
117
- const userMessage = {
118
- id: `user-${Date.now()}`,
119
- role: "user",
120
- content: prompt,
121
- };
122
- const turnContext = messagesRef.current;
123
- const abortController = new AbortController();
124
- abortRef.current = abortController;
125
- setIsStreaming(true);
126
- appendMessage(userMessage);
127
- const assistantId = `assistant-${Date.now()}`;
128
- let failed = false;
129
- try {
130
- for await (const event of runAgent({
131
- prompt,
132
- context: turnContext,
133
- model: props.initialModel || undefined,
134
- provider: props.initialProvider || undefined,
135
- tools: AGENT_TOOL_DEFINITIONS,
136
- signal: abortController.signal,
137
- query: (queryOptions) => props.runtime.query(queryOptions),
138
- executeTool: (name, input) => executeAgentTool(name, input, buildAgentToolContext(props.runtime, {
139
- workingDir: props.runtime.getWorkingDir(),
140
- permissionMode: props.runtime.getPermissionMode(),
141
- })),
142
- })) {
143
- if (abortController.signal.aborted)
144
- break;
145
- if ("kind" in event) {
146
- if (event.kind === "tool_result") {
147
- appendMessage({
148
- id: `tool-${Date.now()}`,
149
- role: "tool",
150
- toolName: event.toolName,
151
- toolCallId: event.toolCallId,
152
- content: `${event.toolName}: ${previewToolResult(event.result)}`,
153
- });
154
- }
155
- continue;
156
- }
157
- if (event.type === "text" && event.content.length > 0) {
158
- appendAssistantDelta(assistantId, event.content, event.model, event.provider);
159
- }
160
- else if (event.type === "error") {
161
- failed = true;
162
- appendMessage({
163
- id: `error-${Date.now()}`,
164
- role: "system",
165
- content: `Runtime error: ${event.content || "query failed"}`,
166
- });
167
- }
168
- }
169
- }
170
- catch (err) {
171
- failed = true;
172
- appendMessage({
173
- id: `error-${Date.now()}`,
174
- role: "system",
175
- content: `Runtime error: ${err instanceof Error ? err.message : String(err)}`,
176
- });
177
- }
178
- finally {
179
- abortRef.current = null;
180
- setIsStreaming(false);
181
- if (!failed && abortController.signal.aborted) {
182
- appendMessage({
183
- id: `abort-${Date.now()}`,
184
- role: "system",
185
- content: "Turn aborted.",
186
- });
187
- }
188
- runQueued();
189
- }
190
- }, [
191
- appendAssistantDelta,
192
- appendMessage,
193
- isStreaming,
194
- props,
195
- runQueued,
196
- ]);
197
- useEffect(() => {
198
- submitPromptRef.current = submitPrompt;
199
- }, [submitPrompt]);
200
- useKeyboard((key) => {
201
- if (key.name === "escape" && abortRef.current) {
202
- abortRef.current.abort();
203
- return;
204
- }
205
- if ((key.name === "c" && key.ctrl) || key.name === "q") {
206
- props.onExit();
207
- }
208
- });
209
- const transcript = messages.map(formatMessage).join("\n\n");
210
- const status = isStreaming
211
- ? "streaming - Esc aborts"
212
- : queueRef.current.length > 0
213
- ? `${queueRef.current.length} queued`
214
- : "ready";
215
- return React.createElement("box", {
216
- id: "wotann-opentui-root",
217
- flexDirection: "column",
218
- width: "100%",
219
- height: "100%",
220
- padding: 1,
221
- backgroundColor: "#05070d",
222
- gap: 1,
223
- }, React.createElement("box", {
224
- id: "wotann-opentui-header",
225
- border: true,
226
- borderColor: "#6ee7f9",
227
- paddingX: 1,
228
- height: 5,
229
- flexDirection: "column",
230
- }, React.createElement("text", {
231
- content: `WOTANN ${props.version} | OpenTUI native renderer | ${status}`,
232
- fg: "#67e8f9",
233
- }), React.createElement("text", {
234
- content: providerSummary,
235
- fg: "#a7f3d0",
236
- })), React.createElement("scrollbox", {
237
- id: "wotann-opentui-transcript",
238
- flexGrow: 1,
239
- border: true,
240
- borderColor: "#334155",
241
- paddingX: 1,
242
- scrollY: true,
243
- stickyScroll: true,
244
- stickyStart: "bottom",
245
- }, React.createElement("text", {
246
- content: transcript || "No messages yet.",
247
- fg: "#e5e7eb",
248
- })), React.createElement("box", {
249
- id: "wotann-opentui-composer",
250
- border: true,
251
- borderColor: isStreaming ? "#f59e0b" : "#22c55e",
252
- paddingX: 1,
253
- height: 4,
254
- }, React.createElement("input", {
255
- id: "wotann-opentui-input",
256
- value: draft,
257
- placeholder: isStreaming ? "Streaming... type to queue next prompt" : "Ask WOTANN...",
258
- focused: true,
259
- onInput: setDraft,
260
- onChange: setDraft,
261
- onSubmit: (value) => {
262
- setDraft("");
263
- void submitPrompt(value);
264
- },
265
- })));
266
- }
267
- function summarizeProviders(providers) {
268
- const available = providers.filter((provider) => provider.available);
269
- if (available.length === 0) {
270
- return "No provider configured. Run wotann init or set a provider API key.";
271
- }
272
- return `Providers: ${available.map((provider) => provider.label || provider.provider).join(", ")}`;
273
- }
274
- function formatMessage(message) {
275
- const label = message.role === "assistant" && message.provider
276
- ? `assistant:${message.provider}${message.model ? `/${message.model}` : ""}`
277
- : message.toolName
278
- ? `tool:${message.toolName}`
279
- : message.role;
280
- return `${label}> ${message.content}`;
281
- }
282
- function previewToolResult(result) {
283
- const oneLine = result.replace(/\s+/g, " ").trim();
284
- return oneLine.length > 240 ? `${oneLine.slice(0, 237)}...` : oneLine;
285
- }