wotann 0.5.95 → 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)
@@ -50,6 +50,15 @@ export interface AppV3Props {
50
50
  readonly initialProvider?: ProviderName;
51
51
  readonly initialMessages?: readonly AgentMessage[];
52
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;
53
62
  /**
54
63
  * Optional capability-profile override — primarily for tests so the
55
64
  * tier is deterministic without manipulating `process.env`. When
@@ -107,4 +116,4 @@ export interface AppV3Props {
107
116
  * renders without a missing-style crash.
108
117
  */
109
118
  export declare function toTranscriptMessages(messages: readonly AgentMessage[]): readonly TranscriptMessageV3[];
110
- export declare function AppV3({ version: _version, providers, initialModel, initialProvider, initialMessages, runtime, profileOverride, columnsOverride, rowsOverride, uiStatePathOverride, statuslineConfigPathOverride, layoutConfigPathOverride, snippetDbPathOverride, }: AppV3Props): React.ReactElement;
119
+ export declare function AppV3({ version: _version, providers, initialModel, initialProvider, initialMessages, runtime, inline, profileOverride, columnsOverride, rowsOverride, uiStatePathOverride, statuslineConfigPathOverride, layoutConfigPathOverride, snippetDbPathOverride, }: AppV3Props): React.ReactElement;
@@ -224,7 +224,7 @@ function NoProviderHint() {
224
224
  const choices = useMemo(() => buildOnboardingAuthChoices().slice(0, 4), []);
225
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)))] }));
226
226
  }
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, }) {
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, }) {
228
228
  const { tone } = useThemeTone();
229
229
  const overlay = useOverlayManager();
230
230
  const { columns, rows } = useTerminalWidth();
@@ -237,6 +237,11 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
237
237
  const maxTranscriptScrollOffset = Math.max(0, transcriptMessages.length - transcriptVisibleCount);
238
238
  const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
239
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);
240
245
  const sideActiveRunRef = useRef(null);
241
246
  // Hermes Gap 2 — interrupt-and-redirect: persistent channel for the
242
247
  // host composer's mid-stream redirects. Ctrl+R captures the current
@@ -247,6 +252,13 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
247
252
  const redirectChannelRef = useRef(new InMemoryRedirectChannel());
248
253
  const [sidePaneDraft, setSidePaneDraft] = useState("");
249
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;
250
262
  useEffect(() => {
251
263
  setTranscriptScrollOffset(0);
252
264
  }, [messages.length]);
@@ -388,6 +400,12 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
388
400
  const turnContext = messages;
389
401
  const abortController = new AbortController();
390
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;
391
409
  setIsStreaming(true);
392
410
  setMessages((prev) => [...prev, userMessage]);
393
411
  void (async () => {
@@ -852,7 +870,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
852
870
  // "the UI isnt quite what I like… Minimal yet powerful. It's just a bit
853
871
  // too much." Provider status moved to the /providers slash command.
854
872
  const heavyChrome = process.env["WOTANN_TUI_HEAVY"] === "1";
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 }) }));
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 }) }));
856
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 }) }));
857
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) => {
858
876
  // Dispatch through the router with the empty arg + the real
@@ -901,10 +919,15 @@ export function AppV3({
901
919
  // (the gradient banner replaced the version-tagged splash). Prefixed
902
920
  // with `_` so the noUnusedParameters check is satisfied while the
903
921
  // prop stays in the public interface.
904
- 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, }) {
905
923
  const themeManager = useThemeManager(uiStatePathOverride);
906
924
  const [palette, setPalette] = useState(themeManager.getCurrent().colors);
907
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);
908
931
  const [isStreaming, setIsStreaming] = useState(false);
909
932
  const [draftValue, setDraftValue] = useState("");
910
933
  const [activeModel, setActiveModel] = useState(initialModel);
@@ -1021,7 +1044,13 @@ version: _version, providers, initialModel = "", initialProvider = "", initialMe
1021
1044
  providerRef.current = next;
1022
1045
  setActiveProvider(next);
1023
1046
  },
1024
- 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
+ },
1025
1054
  setVimMode: (enabled) => {
1026
1055
  vimModeRef.current = enabled;
1027
1056
  setVimMode(enabled);
@@ -1211,5 +1240,5 @@ version: _version, providers, initialModel = "", initialProvider = "", initialMe
1211
1240
  }, 2000);
1212
1241
  return () => clearTimeout(id);
1213
1242
  }, [escPrimed, quitConfirm]);
1214
- 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 }) }) }) }));
1215
1244
  }
@@ -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);
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wotann",
3
- "version": "0.5.95",
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
- }