wotann 0.5.95 → 0.5.97

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.
Files changed (40) hide show
  1. package/dist/index.js +68 -24
  2. package/dist/orchestration/proof-bundles.d.ts +8 -0
  3. package/dist/orchestration/proof-bundles.js +2 -0
  4. package/dist/security/approval-binding.d.ts +52 -0
  5. package/dist/security/approval-binding.js +57 -0
  6. package/dist/security/human-approval.d.ts +2 -0
  7. package/dist/security/human-approval.js +15 -24
  8. package/dist/ui/components/v3/AppV3.d.ts +10 -1
  9. package/dist/ui/components/v3/AppV3.js +34 -5
  10. package/dist/ui/components/v3/Transcript.d.ts +21 -1
  11. package/dist/ui/components/v3/Transcript.js +18 -58
  12. package/dist/ui/components/v3/TranscriptRow.d.ts +45 -0
  13. package/dist/ui/components/v3/TranscriptRow.js +102 -0
  14. package/dist/ui/inline-render.d.ts +28 -0
  15. package/dist/ui/inline-render.js +35 -0
  16. package/dist/verification/reproduction/autonomous-gate.d.ts +52 -0
  17. package/dist/verification/reproduction/autonomous-gate.js +71 -0
  18. package/dist/verification/reproduction/checkout-prep.d.ts +48 -0
  19. package/dist/verification/reproduction/checkout-prep.js +78 -0
  20. package/dist/verification/reproduction/diff-checker.d.ts +26 -0
  21. package/dist/verification/reproduction/diff-checker.js +33 -0
  22. package/dist/verification/reproduction/enforcement.d.ts +14 -0
  23. package/dist/verification/reproduction/enforcement.js +30 -0
  24. package/dist/verification/reproduction/exec-runner.d.ts +15 -0
  25. package/dist/verification/reproduction/exec-runner.js +47 -0
  26. package/dist/verification/reproduction/index.d.ts +10 -0
  27. package/dist/verification/reproduction/index.js +10 -0
  28. package/dist/verification/reproduction/mutation-gate.d.ts +42 -0
  29. package/dist/verification/reproduction/mutation-gate.js +43 -0
  30. package/dist/verification/reproduction/proof-artifact.d.ts +16 -0
  31. package/dist/verification/reproduction/proof-artifact.js +22 -0
  32. package/dist/verification/reproduction/replay-runner.d.ts +37 -0
  33. package/dist/verification/reproduction/replay-runner.js +28 -0
  34. package/dist/verification/reproduction/reproduce.d.ts +34 -0
  35. package/dist/verification/reproduction/reproduce.js +31 -0
  36. package/dist/verification/reproduction/verdict.d.ts +39 -0
  37. package/dist/verification/reproduction/verdict.js +40 -0
  38. package/package.json +1 -1
  39. package/dist/ui/opentui-chat.d.ts +0 -19
  40. 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 | 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)
@@ -5377,6 +5379,48 @@ program
5377
5379
  ? chalk.green(" Done — Task completed successfully")
5378
5380
  : chalk.red(" Failed — Task did not complete"));
5379
5381
  console.log();
5382
+ // Independent verify-by-reproduction (opt-in: WOTANN_REPRODUCE=1).
5383
+ // Default-off because re-running the suite in an isolated checkout
5384
+ // roughly doubles run time. Replays the agent's claimed checks in a
5385
+ // clean base+diff worktree (a separate trust boundary, deps symlinked)
5386
+ // and reports a verdict the agent cannot fake. Best-effort — never
5387
+ // breaks the run.
5388
+ if (process.env.WOTANN_REPRODUCE === "1") {
5389
+ try {
5390
+ const [repro, os, path] = await Promise.all([
5391
+ import("./verification/reproduction/index.js"),
5392
+ import("node:os"),
5393
+ import("node:path"),
5394
+ ]);
5395
+ const git = repro.buildExecGitRunner();
5396
+ const lastCycle = result.cycles[result.cycles.length - 1];
5397
+ const verdict = await repro.runWorkspaceReproduction({
5398
+ cwd: process.cwd(),
5399
+ claimed: {
5400
+ testsPass: lastCycle?.testsPass ?? false,
5401
+ typecheckPass: lastCycle?.typecheckPass ?? false,
5402
+ lintPass: lastCycle?.lintPass ?? false,
5403
+ },
5404
+ commands: { test: ["npm", "test"], typecheck: ["npm", "run", "typecheck"] },
5405
+ worktreeDir: path.join(os.tmpdir(), `wotann-verify-${Date.now()}`),
5406
+ linkFromRepo: ["node_modules"],
5407
+ }, { git, replay: repro.buildExecReplayRunner() });
5408
+ const tag = verdict.enforcement.action === "block"
5409
+ ? chalk.red(`⛔ ${verdict.result.verdict}`)
5410
+ : verdict.enforcement.action === "allow"
5411
+ ? chalk.green(`✓ ${verdict.result.verdict}`)
5412
+ : chalk.yellow(`⚠ ${verdict.result.verdict}`);
5413
+ console.log(chalk.bold("Independent reproduction (separate trust boundary):"));
5414
+ console.log(` ${tag} — ${verdict.enforcement.reason}`);
5415
+ for (const c of verdict.result.contradictions) {
5416
+ console.log(chalk.dim(` • ${c}`));
5417
+ }
5418
+ console.log();
5419
+ }
5420
+ catch (e) {
5421
+ console.log(chalk.dim(` Reproduction skipped: ${e instanceof Error ? e.message : String(e)}`));
5422
+ }
5423
+ }
5380
5424
  process.exit(result.success ? 0 : 1);
5381
5425
  }
5382
5426
  finally {
@@ -47,6 +47,14 @@ export interface AutonomousProofBundle {
47
47
  readonly visualVerificationEnabled: boolean;
48
48
  readonly visualExpectation?: string;
49
49
  readonly finalChecks: {
50
+ /**
51
+ * Provenance of these checks. "self-reported" = copied from the agent's
52
+ * own cycle result — a CLAIM, not an independently reproduced result. A
53
+ * green check is a claim, not proof (verify-by-reproduction, V7); a future
54
+ * verdict re-runs these in a separate trust boundary and reports a
55
+ * "reproduced" source instead.
56
+ */
57
+ readonly source: "self-reported";
50
58
  readonly testsPass: boolean;
51
59
  readonly typecheckPass: boolean;
52
60
  readonly lintPass: boolean;
@@ -44,6 +44,8 @@ export function writeAutonomousProofBundle(input) {
44
44
  visualExpectation: input.visualExpectation,
45
45
  finalChecks: lastCycle
46
46
  ? {
47
+ // Self-reported by the agent's own cycle — a claim, not proof (V7).
48
+ source: "self-reported",
47
49
  testsPass: lastCycle.testsPass,
48
50
  typecheckPass: lastCycle.typecheckPass,
49
51
  lintPass: lastCycle.lintPass,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Exact-bytes approval binding (corpus F4; defeats the OpenClaw CVE-2026-29607
3
+ * "approval persisted at the wrapper level, not the inner command" class and
4
+ * the TOCTOU race). Bind the canonicalized action's HMAC at approval time;
5
+ * re-pin at execution and abort on drift, replay (single-use nonce), or expiry.
6
+ */
7
+ export interface CanonicalAction {
8
+ readonly tool: string;
9
+ readonly args: readonly string[];
10
+ readonly cwd: string;
11
+ }
12
+ export interface ApprovalBinding {
13
+ readonly bindingId: string;
14
+ readonly actionHash: string;
15
+ readonly nonce: string;
16
+ readonly expiresAt: number;
17
+ }
18
+ export type VerifyResult = {
19
+ readonly ok: true;
20
+ } | {
21
+ readonly ok: false;
22
+ readonly reason: string;
23
+ };
24
+ /**
25
+ * Deterministic canonical form of an action. The CALLER must pre-resolve shell
26
+ * expansion / env-substitution / path resolution BEFORE binding, so the bound
27
+ * bytes ARE exactly what will execute (the literate wrapper is not what runs).
28
+ */
29
+ export declare function canonicalizeAction(action: CanonicalAction): string;
30
+ export interface ApprovalBinderOptions {
31
+ readonly ttlMs?: number;
32
+ readonly now?: () => number;
33
+ }
34
+ /**
35
+ * Stateful service: the consumed-nonce set lives inside the instance (QB#7
36
+ * per-call state, not module-global). Inject `now` for deterministic tests.
37
+ */
38
+ export declare class ApprovalBinder {
39
+ private readonly secret;
40
+ private readonly ttlMs;
41
+ private readonly now;
42
+ private readonly consumed;
43
+ private counter;
44
+ constructor(secret: Buffer | string, opts?: ApprovalBinderOptions);
45
+ bind(action: CanonicalAction, nonce?: string): ApprovalBinding;
46
+ /**
47
+ * Re-pin at execution: recompute the HMAC from the action that is ABOUT to
48
+ * run and reject on drift, expiry, or replay. Consumes the nonce on success.
49
+ * Order matters: replay > expiry > drift (cheapest, most-specific first).
50
+ */
51
+ verify(binding: ApprovalBinding, action: CanonicalAction): VerifyResult;
52
+ }
@@ -0,0 +1,57 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { computeBoundaryHmac } from "./prompt-injection-quarantine.js";
3
+ /**
4
+ * Deterministic canonical form of an action. The CALLER must pre-resolve shell
5
+ * expansion / env-substitution / path resolution BEFORE binding, so the bound
6
+ * bytes ARE exactly what will execute (the literate wrapper is not what runs).
7
+ */
8
+ export function canonicalizeAction(action) {
9
+ return JSON.stringify({ tool: action.tool.trim(), args: action.args, cwd: action.cwd });
10
+ }
11
+ /**
12
+ * Stateful service: the consumed-nonce set lives inside the instance (QB#7
13
+ * per-call state, not module-global). Inject `now` for deterministic tests.
14
+ */
15
+ export class ApprovalBinder {
16
+ secret;
17
+ ttlMs;
18
+ now;
19
+ consumed = new Set();
20
+ counter = 0;
21
+ constructor(secret, opts = {}) {
22
+ this.secret = secret;
23
+ this.ttlMs = opts.ttlMs ?? 5 * 60_000;
24
+ this.now = opts.now ?? (() => Date.now());
25
+ }
26
+ bind(action, nonce) {
27
+ const actionHash = computeBoundaryHmac(canonicalizeAction(action), this.secret);
28
+ const id = ++this.counter;
29
+ return {
30
+ bindingId: `bind-${id}`,
31
+ actionHash,
32
+ nonce: nonce ?? `n-${this.now()}-${id}`,
33
+ expiresAt: this.now() + this.ttlMs,
34
+ };
35
+ }
36
+ /**
37
+ * Re-pin at execution: recompute the HMAC from the action that is ABOUT to
38
+ * run and reject on drift, expiry, or replay. Consumes the nonce on success.
39
+ * Order matters: replay > expiry > drift (cheapest, most-specific first).
40
+ */
41
+ verify(binding, action) {
42
+ if (this.consumed.has(binding.nonce)) {
43
+ return { ok: false, reason: "replay: nonce already consumed" };
44
+ }
45
+ if (this.now() > binding.expiresAt) {
46
+ return { ok: false, reason: "expired: approval window elapsed" };
47
+ }
48
+ const recomputed = computeBoundaryHmac(canonicalizeAction(action), this.secret);
49
+ const a = Buffer.from(recomputed, "utf-8");
50
+ const b = Buffer.from(binding.actionHash, "utf-8");
51
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
52
+ return { ok: false, reason: "hash drift: action changed since approval" };
53
+ }
54
+ this.consumed.add(binding.nonce);
55
+ return { ok: true };
56
+ }
57
+ }
@@ -19,6 +19,8 @@ export interface ApprovalRequest {
19
19
  readonly tool: string;
20
20
  readonly args: string;
21
21
  readonly reason: string;
22
+ /** Optional exact-bytes approval-binding hash (see security/approval-binding.ts). */
23
+ readonly hash?: string;
22
24
  readonly risk: "low" | "medium" | "high" | "critical";
23
25
  readonly riskLevel: "moderate" | "high" | "critical";
24
26
  readonly createdAt: number;
@@ -26,7 +26,10 @@ const DEFAULT_POLICIES = [
26
26
  riskLevel: "moderate",
27
27
  channels: ["cli", "desktop"],
28
28
  timeoutMs: 60_000, // 1 minute
29
- defaultOnTimeout: "approve",
29
+ // Fail CLOSED: an absent/stalled human must never auto-approve a write or
30
+ // edit. An adversarial agent could otherwise stall past the timeout to win
31
+ // an automatic approval. (bash is already fail-closed; this matches it.)
32
+ defaultOnTimeout: "deny",
30
33
  },
31
34
  ];
32
35
  // ── Approval Manager ─────────────────────────────────────
@@ -71,7 +74,7 @@ export class HumanApprovalManager {
71
74
  feedback: `Approval timed out after ${timeoutMs}ms. Default: ${defaultOnTimeout}.`,
72
75
  respondedAt: Date.now(),
73
76
  };
74
- this.history.push(response);
77
+ this.history.push({ request, response });
75
78
  resolve(response);
76
79
  }
77
80
  }, timeoutMs);
@@ -80,7 +83,7 @@ export class HumanApprovalManager {
80
83
  request,
81
84
  resolve: (response) => {
82
85
  clearTimeout(timer);
83
- this.history.push(response);
86
+ this.history.push({ request, response });
84
87
  resolve(response);
85
88
  },
86
89
  });
@@ -113,7 +116,7 @@ export class HumanApprovalManager {
113
116
  * Get approval history.
114
117
  */
115
118
  getHistory() {
116
- return this.history;
119
+ return this.history.map((record) => record.response);
117
120
  }
118
121
  /**
119
122
  * Add a custom policy.
@@ -146,30 +149,18 @@ export class HumanApprovalManager {
146
149
  * Get full audit log of all approval decisions (request + result pairs).
147
150
  */
148
151
  getAuditLog() {
149
- return this.history.map((response) => {
150
- // Look up the original request from pending or reconstruct from response
151
- const request = {
152
- id: response.requestId,
153
- action: "tool_call",
154
- description: response.feedback ?? "",
155
- tool: "",
156
- args: "",
157
- reason: "",
158
- risk: "medium",
159
- riskLevel: "moderate",
160
- createdAt: response.respondedAt,
161
- timestamp: response.respondedAt,
162
- timeoutMs: 0,
163
- channels: [],
164
- };
165
- const result = {
152
+ // The REAL request is recorded at decision time and read back verbatim —
153
+ // never reconstructed — so the audit trail can never drift from what was
154
+ // actually approved.
155
+ return this.history.map(({ request, response }) => ({
156
+ request,
157
+ result: {
166
158
  approved: response.decision === "approve",
167
159
  approvedBy: response.respondedBy,
168
160
  feedback: response.feedback,
169
161
  decidedAt: response.respondedAt,
170
- };
171
- return { request, result };
172
- });
162
+ },
163
+ }));
173
164
  }
174
165
  // ── Private ────────────────────────────────────────────
175
166
  assessRisk(tool, args) {
@@ -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
  }