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.
- package/dist/index.js +68 -24
- package/dist/orchestration/proof-bundles.d.ts +8 -0
- package/dist/orchestration/proof-bundles.js +2 -0
- package/dist/security/approval-binding.d.ts +52 -0
- package/dist/security/approval-binding.js +57 -0
- package/dist/security/human-approval.d.ts +2 -0
- package/dist/security/human-approval.js +15 -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/dist/verification/reproduction/autonomous-gate.d.ts +52 -0
- package/dist/verification/reproduction/autonomous-gate.js +71 -0
- package/dist/verification/reproduction/checkout-prep.d.ts +48 -0
- package/dist/verification/reproduction/checkout-prep.js +78 -0
- package/dist/verification/reproduction/diff-checker.d.ts +26 -0
- package/dist/verification/reproduction/diff-checker.js +33 -0
- package/dist/verification/reproduction/enforcement.d.ts +14 -0
- package/dist/verification/reproduction/enforcement.js +30 -0
- package/dist/verification/reproduction/exec-runner.d.ts +15 -0
- package/dist/verification/reproduction/exec-runner.js +47 -0
- package/dist/verification/reproduction/index.d.ts +10 -0
- package/dist/verification/reproduction/index.js +10 -0
- package/dist/verification/reproduction/mutation-gate.d.ts +42 -0
- package/dist/verification/reproduction/mutation-gate.js +43 -0
- package/dist/verification/reproduction/proof-artifact.d.ts +16 -0
- package/dist/verification/reproduction/proof-artifact.js +22 -0
- package/dist/verification/reproduction/replay-runner.d.ts +37 -0
- package/dist/verification/reproduction/replay-runner.js +28 -0
- package/dist/verification/reproduction/reproduce.d.ts +34 -0
- package/dist/verification/reproduction/reproduce.js +31 -0
- package/dist/verification/reproduction/verdict.d.ts +39 -0
- package/dist/verification/reproduction/verdict.js +40 -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
}
|