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 +26 -24
- package/dist/ui/components/v3/AppV3.d.ts +10 -1
- package/dist/ui/components/v3/AppV3.js +34 -5
- package/dist/ui/components/v3/Transcript.d.ts +21 -1
- package/dist/ui/components/v3/Transcript.js +18 -58
- package/dist/ui/components/v3/TranscriptRow.d.ts +45 -0
- package/dist/ui/components/v3/TranscriptRow.js +102 -0
- package/dist/ui/inline-render.d.ts +28 -0
- package/dist/ui/inline-render.js +35 -0
- package/package.json +1 -1
- package/dist/ui/opentui-chat.d.ts +0 -19
- package/dist/ui/opentui-chat.js +0 -285
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 |
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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,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>;
|
package/dist/ui/opentui-chat.js
DELETED
|
@@ -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
|
-
}
|