@opengeni/react 0.3.1 → 0.5.0

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 (42) hide show
  1. package/dist/index.d.ts +1055 -27
  2. package/dist/index.js +6993 -1954
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +22 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/fleet-tile.tsx +5 -0
  11. package/src/components/message-timeline.tsx +70 -196
  12. package/src/components/pierre-diff.tsx +140 -0
  13. package/src/components/pierre-file.tsx +142 -0
  14. package/src/components/sandbox-files.tsx +509 -0
  15. package/src/components/sandbox-terminal.tsx +425 -0
  16. package/src/components/workspace-dock.tsx +247 -0
  17. package/src/hooks/use-desktop-stream.ts +214 -0
  18. package/src/hooks/use-sandbox-files.ts +670 -0
  19. package/src/hooks/use-sandbox-git.ts +105 -0
  20. package/src/hooks/use-sandbox-terminal.ts +226 -0
  21. package/src/hooks/use-session-capabilities.ts +415 -0
  22. package/src/hooks/use-session.ts +80 -12
  23. package/src/hooks/use-terminal-stream.ts +207 -0
  24. package/src/index.ts +112 -3
  25. package/src/lib/cn.ts +20 -1
  26. package/src/lib/git-patch.ts +43 -0
  27. package/src/lib/use-theme-type.ts +40 -0
  28. package/src/lib/xterm-theme.ts +34 -0
  29. package/src/timeline/activity-rail.tsx +207 -0
  30. package/src/timeline/disclosure-context.tsx +34 -0
  31. package/src/timeline/index.ts +85 -0
  32. package/src/timeline/parsers.ts +253 -0
  33. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  34. package/src/timeline/registry.ts +96 -0
  35. package/src/timeline/screenshot-lightbox.tsx +152 -0
  36. package/src/timeline/shared.tsx +481 -0
  37. package/src/timeline/tool-diff.tsx +91 -0
  38. package/src/timeline/tool-renderers.tsx +882 -0
  39. package/src/timeline/turn-summary.tsx +125 -0
  40. package/src/timeline/types.ts +131 -0
  41. package/src/types/external.d.ts +7 -0
  42. package/styles/index.css +72 -0
@@ -0,0 +1,207 @@
1
+ import { BotIcon, BrainIcon, SquareTerminalIcon } from "lucide-react";
2
+ import { cn } from "../lib/cn";
3
+ import { truncate } from "../lib/format";
4
+ import { defaultToolRegistry } from "./tool-renderers";
5
+ import type { ToolRegistry } from "./registry";
6
+ import { PayloadBlock, ActivityDisclosure } from "./shared";
7
+ import { toolDisplayName } from "./projection";
8
+ import type { ActivityItem, ReasoningItem, SandboxItem, WorkerItem } from "./types";
9
+
10
+ /* ----------------------------------------------------------------------------
11
+ Activity rail
12
+
13
+ Renders a run of clustered activity items (reasoning, tool calls, workers,
14
+ sandbox ops) as the left-bordered column between chat messages. Tool calls
15
+ resolve through the renderer registry; everything else has a first-class row.
16
+
17
+ Shared by `MessageTimeline` and the component demo so both draw the exact
18
+ same rail — no divergence.
19
+ -------------------------------------------------------------------------- */
20
+
21
+ export type ActivityRailProps = {
22
+ items: ActivityItem[];
23
+ /** Renderer registry for tool calls. Defaults to {@link defaultToolRegistry}. */
24
+ toolRegistry?: ToolRegistry | undefined;
25
+ /** Drill into a spawned worker session. */
26
+ onOpenSession?: ((sessionId: string) => void) | undefined;
27
+ /** Drop the left rule + indent (used inside a folded turn summary). */
28
+ bare?: boolean | undefined;
29
+ className?: string | undefined;
30
+ };
31
+
32
+ /**
33
+ * The "family" a row belongs to, for light intra-rail grouping. Consecutive
34
+ * rows of the same family sit tight; a family change gets a little extra top
35
+ * margin so a long run reads as clusters rather than one undifferentiated wall.
36
+ */
37
+ function familyOf(item: ActivityItem): string {
38
+ if (item.kind === "tool-call") {
39
+ return item.name === "exec_command" || item.name === "write_stdin" ? "terminal" : item.name;
40
+ }
41
+ return item.kind;
42
+ }
43
+
44
+ export function ActivityRail({ items, toolRegistry = defaultToolRegistry, onOpenSession, bare, className }: ActivityRailProps) {
45
+ return (
46
+ <div
47
+ className={cn(
48
+ // Rows sit TIGHT by default (gap-0.5) so a same-family run reads as one
49
+ // calm cluster; a family change opens real breathing room (mt-3) below,
50
+ // so a long rail reads as a few clusters, not a metronome of rows.
51
+ "flex flex-col gap-0.5",
52
+ !bare && "animate-og-enter border-l-2 border-og-border pl-3 sm:pl-4",
53
+ className,
54
+ )}
55
+ >
56
+ {items.map((item, index) => {
57
+ const newFamily = index > 0 && familyOf(item) !== familyOf(items[index - 1]!);
58
+ const row = renderActivity(item, toolRegistry, onOpenSession);
59
+ return (
60
+ <div key={item.id} className={cn(newFamily && "mt-3")}>
61
+ {row}
62
+ </div>
63
+ );
64
+ })}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ /** A never-reachable guard: adding an `ActivityItem` kind is now a compile error. */
70
+ function assertNever(item: never): never {
71
+ throw new Error(`ActivityRail: unhandled activity item ${JSON.stringify(item)}`);
72
+ }
73
+
74
+ function renderActivity(
75
+ item: ActivityItem,
76
+ toolRegistry: ToolRegistry,
77
+ onOpenSession: ((sessionId: string) => void) | undefined,
78
+ ) {
79
+ switch (item.kind) {
80
+ case "reasoning":
81
+ return <ReasoningRow item={item} />;
82
+ case "tool-call": {
83
+ const Renderer = toolRegistry.resolve(item);
84
+ return <Renderer item={item} />;
85
+ }
86
+ case "worker":
87
+ return <WorkerRow item={item} onOpenSession={onOpenSession} />;
88
+ case "sandbox":
89
+ return <SandboxRow item={item} />;
90
+ default:
91
+ return assertNever(item);
92
+ }
93
+ }
94
+
95
+ function ReasoningRow({ item }: { item: ReasoningItem }) {
96
+ // Reasoning recedes: a dimmer, lighter-weight title so action rows lead and
97
+ // thought rows sit a half-step back in the hierarchy.
98
+ return (
99
+ <ActivityDisclosure
100
+ icon={<BrainIcon className="size-3.5" />}
101
+ iconTone="muted"
102
+ title={
103
+ item.streaming ? (
104
+ "Thinking"
105
+ ) : (
106
+ <span className="font-normal italic text-og-fg-subtle">Thought</span>
107
+ )
108
+ }
109
+ running={item.streaming}
110
+ preview={truncate(item.text, 110)}
111
+ >
112
+ <p className="whitespace-pre-wrap text-og-base leading-6 text-og-fg-muted">{item.text}</p>
113
+ </ActivityDisclosure>
114
+ );
115
+ }
116
+
117
+ function SandboxRow({ item }: { item: SandboxItem }) {
118
+ return (
119
+ <ActivityDisclosure
120
+ icon={<SquareTerminalIcon className="size-3.5" />}
121
+ iconTone={item.status === "failed" ? "failed" : item.status === "running" ? "running" : "muted"}
122
+ title={toolDisplayName(item.name)}
123
+ running={item.status === "running"}
124
+ failed={item.status === "failed"}
125
+ cancelled={item.status === "cancelled"}
126
+ preview={item.command ?? undefined}
127
+ >
128
+ {item.command ? <PayloadBlock label="Command" value={item.command} /> : null}
129
+ {item.output ? <PayloadBlock label="Output" value={item.output} /> : null}
130
+ </ActivityDisclosure>
131
+ );
132
+ }
133
+
134
+ /** Spawned/messaged worker sessions get a first-class card, not a tool row. */
135
+ function WorkerRow({ item, onOpenSession }: { item: WorkerItem; onOpenSession?: ((sessionId: string) => void) | undefined }) {
136
+ const running = item.status === "running";
137
+ const failed = item.status === "failed";
138
+ const cancelled = item.status === "cancelled";
139
+ const title =
140
+ item.action === "spawn"
141
+ ? running
142
+ ? "Spawning worker"
143
+ : failed
144
+ ? "Worker spawn failed"
145
+ : cancelled
146
+ ? "Worker interrupted"
147
+ : "Worker spawned"
148
+ : running
149
+ ? "Messaging worker"
150
+ : failed
151
+ ? "Worker message failed"
152
+ : cancelled
153
+ ? "Worker interrupted"
154
+ : "Worker messaged";
155
+ return (
156
+ <div
157
+ className={cn(
158
+ "my-0.5 flex items-start gap-3 rounded-og-md border bg-og-surface-1 p-3",
159
+ failed ? "border-og-status-failed/40" : "border-og-border",
160
+ )}
161
+ >
162
+ <span
163
+ className={cn(
164
+ "mt-0.5 inline-flex size-7 shrink-0 items-center justify-center",
165
+ failed ? "text-og-status-failed" : "text-og-accent",
166
+ )}
167
+ >
168
+ <BotIcon className="size-4" />
169
+ </span>
170
+ <div className="min-w-0 flex-1">
171
+ {/* In-flight state is carried ONLY by the shimmering title (no detached
172
+ pulse badge), matching every other running row in the rail. */}
173
+ <span className={cn("text-og-base font-medium", running ? "og-shimmer-text" : failed ? "text-og-status-failed" : "text-og-fg")}>
174
+ {title}
175
+ </span>
176
+ {item.prompt ? <p className="mt-0.5 truncate text-og-sm text-og-fg-muted">{truncate(item.prompt, 140)}</p> : null}
177
+ {item.workerSessionId ? (
178
+ <p className="mt-1 font-og-mono text-og-xs text-og-fg-subtle">{item.workerSessionId.slice(0, 8)}</p>
179
+ ) : null}
180
+ </div>
181
+ {/* Right gutter: failed gets a red chip; cancelled gets a calm "interrupted"
182
+ chip (no dot, no red); a live complete worker shows an "Open session" button. */}
183
+ {failed ? (
184
+ <span className="inline-flex shrink-0 self-center items-center gap-1.5 font-og-mono text-og-xs leading-none text-og-status-failed">
185
+ <span className="size-1.5 rounded-full bg-og-status-failed" />
186
+ failed
187
+ </span>
188
+ ) : cancelled ? (
189
+ <span className="og-cancelled-chip shrink-0 self-center font-og-mono text-og-xs leading-none text-og-fg-subtle">
190
+ interrupted
191
+ </span>
192
+ ) : item.workerSessionId && onOpenSession ? (
193
+ <button
194
+ type="button"
195
+ onClick={() => item.workerSessionId && onOpenSession(item.workerSessionId)}
196
+ className={cn(
197
+ "shrink-0 self-center rounded-og-sm border border-og-border px-2.5 py-1 text-og-sm font-medium text-og-fg-muted",
198
+ "outline-none transition-colors duration-150 hover:border-og-border-strong hover:text-og-fg",
199
+ "focus-visible:ring-2 focus-visible:ring-og-accent",
200
+ )}
201
+ >
202
+ Open session
203
+ </button>
204
+ ) : null}
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,34 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ /* ----------------------------------------------------------------------------
4
+ Disclosure defaults context
5
+
6
+ A tiny, opt-in context that lets an ancestor seed the INITIAL open state of
7
+ every collapsible in the timeline (ActivityDisclosure rows and TurnSummary
8
+ chips). Its sole intended use is deterministic screenshot capture: a tool can
9
+ force every card open so a headless render shows expanded bodies.
10
+
11
+ It is fully inert in normal app usage. With no provider, the hook returns
12
+ `undefined`, every collapsible keeps its own author-chosen default, and there
13
+ is zero change to how the components look or animate. Mounting the provider
14
+ only changes the SEED of the initial `open` state — Radix still owns the
15
+ open/close transition, so animations are untouched.
16
+ -------------------------------------------------------------------------- */
17
+
18
+ const DisclosureDefaultsContext = createContext<boolean | undefined>(undefined);
19
+
20
+ /**
21
+ * Seed the initial open state of every timeline collapsible below this node.
22
+ * Intended for screenshot/test instrumentation only; absent by default.
23
+ */
24
+ export function DisclosureDefaultsProvider({ defaultOpen, children }: { defaultOpen: boolean; children: ReactNode }) {
25
+ return <DisclosureDefaultsContext.Provider value={defaultOpen}>{children}</DisclosureDefaultsContext.Provider>;
26
+ }
27
+
28
+ /**
29
+ * The forced initial-open seed from an ancestor {@link DisclosureDefaultsProvider},
30
+ * or `undefined` when none is mounted (the inert, app-default case).
31
+ */
32
+ export function useForcedDefaultOpen(): boolean | undefined {
33
+ return useContext(DisclosureDefaultsContext);
34
+ }
@@ -0,0 +1,85 @@
1
+ /* ----------------------------------------------------------------------------
2
+ @opengeni/react — timeline module
3
+
4
+ The session timeline's data + rendering layer:
5
+ - projection.ts raw SessionEvents -> renderable TimelineItems (pure)
6
+ - types.ts the projected item shapes (the data contract)
7
+ - registry.ts the extensible, typed tool-renderer registry
8
+ - tool-renderers per-tool React renderers + the default registry
9
+ - parsers.ts pure provider-shape parsers (exec banner, V4A diff, …)
10
+ - shared.tsx the restraint primitives (ActivityDisclosure, TermBlock, …)
11
+ - tool-diff.tsx V4A GitFileDiff -> the real DiffView/PierreDiff stack
12
+ - screenshot-lightbox.tsx the app-level screenshot lightbox
13
+
14
+ This barrel is the module's public surface. The components here are the
15
+ single source of truth used by both the live app and the component demo.
16
+ -------------------------------------------------------------------------- */
17
+
18
+ // projection
19
+ export { buildTimeline, extractSessionRef, groupTimeline, sessionStatusFromEvents, toolDisplayName } from "./projection";
20
+
21
+ // item types
22
+ export type {
23
+ ActivityItem,
24
+ AgentMessageItem,
25
+ GoalItem,
26
+ NoticeItem,
27
+ ReasoningItem,
28
+ SandboxItem,
29
+ SessionStatusItem,
30
+ TimelineGroup,
31
+ TimelineItem,
32
+ ToolCallItem,
33
+ UserMessageItem,
34
+ WorkerItem,
35
+ } from "./types";
36
+
37
+ // renderer registry
38
+ export { createToolRegistry, rawTypeOf } from "./registry";
39
+ export type {
40
+ CreateToolRegistryOptions,
41
+ ToolRegistry,
42
+ ToolRegistryEntry,
43
+ ToolRenderer,
44
+ ToolRendererProps,
45
+ } from "./registry";
46
+
47
+ // default renderers + registry
48
+ export { createDefaultToolRegistry, defaultToolRegistry } from "./tool-renderers";
49
+
50
+ // the activity rail (the clustered reasoning/tool/worker/sandbox column)
51
+ export { ActivityRail } from "./activity-rail";
52
+ export type { ActivityRailProps } from "./activity-rail";
53
+
54
+ // shared primitives (extension authors compose these)
55
+ export { ActivityDisclosure, BodyNote, MediaEmpty, MediaSkeleton, PayloadBlock, ScreenshotFigure, TermBlock, Thumbnail } from "./shared";
56
+ export type { ActivityDisclosureProps, DisclosureChip } from "./shared";
57
+
58
+ // screenshot lightbox
59
+ export { LightboxProvider, useLightbox, useLightboxOptional } from "./screenshot-lightbox";
60
+
61
+ // disclosure defaults (opt-in initial-open seed; for screenshot/test instrumentation)
62
+ export { DisclosureDefaultsProvider, useForcedDefaultOpen } from "./disclosure-context";
63
+
64
+ // turn-collapse summary chip
65
+ export { TurnSummary } from "./turn-summary";
66
+ export type { TurnOutcome, TurnSummaryProps } from "./turn-summary";
67
+
68
+ // parsers (pure, reusable by custom renderers)
69
+ export {
70
+ applyPatchOps,
71
+ controlCaret,
72
+ execTruncated,
73
+ isApplyPatch,
74
+ isExecSessionLostBanner,
75
+ looksBinary,
76
+ parseExecBannerSessionId,
77
+ parseToolArgs,
78
+ redactSecrets,
79
+ sandboxCommandExitCode,
80
+ stripExecBanner,
81
+ tailPeek,
82
+ unwrapMcpOutput,
83
+ v4aToGitFileDiff,
84
+ } from "./parsers";
85
+ export type { ApplyPatchOperation } from "./parsers";
@@ -0,0 +1,253 @@
1
+ import type { GitFileDiff } from "@opengeni/sdk";
2
+ import { tryParseJson } from "../lib/format";
3
+
4
+ /* ----------------------------------------------------------------------------
5
+ Pure parsers for the provider-native tool shapes that the timeline renders.
6
+
7
+ These are intentionally browser-safe, dependency-free mirrors of the
8
+ server-side helpers in `@opengeni/runtime` (`sandboxCommandExitCode`,
9
+ `parseExecBannerSessionId`, `stripExecBanner`) plus the V4A diff parser the
10
+ apply-patch renderer needs. The SDK does not depend on `@opengeni/runtime` by
11
+ design (runtime is a heavy server package); these few regexes are cheap to
12
+ own here and keep the React surface free of a server dependency.
13
+
14
+ Every function is pure -- same input, same output -- so it can be
15
+ unit-tested and memoized.
16
+ -------------------------------------------------------------------------- */
17
+
18
+ /** Recover the exit code from a sandbox exec banner (`Process exited with code N`). */
19
+ export function sandboxCommandExitCode(out: unknown): number | null {
20
+ const match = String(out ?? "").match(/Process exited with code (-?\d+)/);
21
+ return match ? Number(match[1]) : null;
22
+ }
23
+
24
+ /**
25
+ * Recover the numeric exec-session id the sandbox embeds for a STILL-RUNNING
26
+ * (backgrounded) process (`Process running with session ID N`). A finished
27
+ * command emits `Process exited with code N` instead, which yields `null`.
28
+ */
29
+ export function parseExecBannerSessionId(out: unknown): number | null {
30
+ const text = String(out ?? "");
31
+ const outputIdx = text.indexOf("\nOutput:\n");
32
+ const banner = outputIdx >= 0 ? text.slice(0, outputIdx) : text.startsWith("Output:\n") ? "" : text;
33
+ const match = banner.match(/Process running with session ID (\d+)/);
34
+ if (!match) {
35
+ return null;
36
+ }
37
+ const n = Number.parseInt(match[1]!, 10);
38
+ return Number.isFinite(n) ? n : null;
39
+ }
40
+
41
+ /** Strip the exec banner (`Chunk ID ...\n...\nOutput:\n`) down to the command's stdout. */
42
+ export function stripExecBanner(out: unknown): string {
43
+ const text = String(out ?? "");
44
+ const marker = text.indexOf("\nOutput:\n");
45
+ if (marker >= 0) {
46
+ return text.slice(marker + "\nOutput:\n".length);
47
+ }
48
+ if (text.startsWith("Output:\n")) {
49
+ return text.slice("Output:\n".length);
50
+ }
51
+ return text;
52
+ }
53
+
54
+ /** The sandbox clamped the output (token/line truncation markers in the banner). */
55
+ export function execTruncated(out: unknown): boolean {
56
+ return /Total output lines:|\.{3}\d+ tokens truncated\.{3}|\[\.{3}\d+ characters truncated/.test(String(out ?? ""));
57
+ }
58
+
59
+ /** A `write_stdin` whose target PTY vanished (`write_stdin failed: session not found: N`). */
60
+ export function isExecSessionLostBanner(out: unknown): boolean {
61
+ return /write_stdin failed: session not found: \d+/.test(String(out ?? ""));
62
+ }
63
+
64
+ /** True when the exec stdout looks binary/garbled (a NUL byte or ELF magic). */
65
+ export function looksBinary(text: string): boolean {
66
+ return text.includes("\u0000") || text.startsWith("\u007fELF");
67
+ }
68
+
69
+ /**
70
+ * Render unprintable control characters as caret notation (0x03 -> `^C`) so a
71
+ * `write_stdin` keystroke payload reads cleanly in the row title.
72
+ */
73
+ export function controlCaret(printable: string): string {
74
+ return String(printable).replace(/[\u0000-\u001f]/g, (c) => `^${String.fromCharCode(c.charCodeAt(0) + 64)}`);
75
+ }
76
+
77
+ /* --- V4A apply_patch diff -> GitFileDiff ------------------------------------ */
78
+
79
+ /** One operation inside an `apply_patch_call` (a V4A file edit). */
80
+ export type ApplyPatchOperation = {
81
+ /**
82
+ * The V4A op kind. The three canonical values are `create_file`,
83
+ * `update_file`, and `delete_file`; the open `string` tail tolerates a
84
+ * forward-compatible/unknown op kind from the provider without a type error
85
+ * (it falls through to the "Edited" treatment).
86
+ */
87
+ type: "create_file" | "update_file" | "delete_file" | (string & {});
88
+ path: string;
89
+ /** Rename target -- when present the op is a move/rename. */
90
+ moveTo?: string | null | undefined;
91
+ /** The V4A hunk string (`@@ ...` lines with `+`/`-`/context prefixes). */
92
+ diff?: string | undefined;
93
+ };
94
+
95
+ /**
96
+ * Parse a single V4A `apply_patch` operation into the SDK's `GitFileDiff` shape
97
+ * so it can flow into the SAME `DiffView` / `PierreDiff` the Files tab uses.
98
+ * Throws on a hunk string it cannot structure (no `@@` anchor on an update); the
99
+ * renderer catches and falls back to a raw-patch view.
100
+ */
101
+ export function v4aToGitFileDiff(op: ApplyPatchOperation): GitFileDiff {
102
+ const status: GitFileDiff["status"] =
103
+ op.type === "create_file" ? "added" : op.type === "delete_file" ? "deleted" : op.moveTo ? "renamed" : "modified";
104
+ const oldPath = op.moveTo ? op.path : null;
105
+ const path = op.moveTo || op.path;
106
+
107
+ const hunks: GitFileDiff["hunks"] = [];
108
+ let additions = 0;
109
+ let deletions = 0;
110
+ let sawHunkAnchor = false;
111
+
112
+ if (op.type !== "delete_file") {
113
+ const lines = (op.diff ?? "").split("\n");
114
+ let cur: GitFileDiff["hunks"][number] | null = null;
115
+ let oldNo = 1;
116
+ let newNo = 1;
117
+ for (const raw of lines) {
118
+ if (raw.startsWith("@@")) {
119
+ sawHunkAnchor = true;
120
+ const match = raw.match(/-(\d+)(?:,\d+)?\s+\+(\d+)/);
121
+ oldNo = match ? Number(match[1]) : 1;
122
+ newNo = match ? Number(match[2]) : 1;
123
+ cur = {
124
+ oldStart: oldNo,
125
+ oldLines: 0,
126
+ newStart: newNo,
127
+ newLines: 0,
128
+ header: raw,
129
+ lines: [{ type: "meta", oldNo: null, newNo: null, text: raw }],
130
+ };
131
+ hunks.push(cur);
132
+ } else if (cur || op.type === "create_file") {
133
+ if (!cur) {
134
+ // No `@@` anchor on a create_file body: synthesize an add-only hunk.
135
+ // Leave `header` empty so `gitFileDiffToPatch` regenerates a valid
136
+ // `@@ -0,0 +1,N @@` from the range fields once newLines is counted — a
137
+ // pre-baked partial header (e.g. `@@ +1 @@`) renders zero lines in a
138
+ // generic unified-diff parser.
139
+ cur = { oldStart: 0, oldLines: 0, newStart: 1, newLines: 0, header: "", lines: [] };
140
+ hunks.push(cur);
141
+ oldNo = 0;
142
+ newNo = 1;
143
+ }
144
+ if (raw.startsWith("+")) {
145
+ cur.lines.push({ type: "add", oldNo: null, newNo: newNo++, text: raw.slice(1) });
146
+ cur.newLines += 1;
147
+ additions += 1;
148
+ } else if (raw.startsWith("-")) {
149
+ cur.lines.push({ type: "del", oldNo: oldNo++, newNo: null, text: raw.slice(1) });
150
+ cur.oldLines += 1;
151
+ deletions += 1;
152
+ } else {
153
+ cur.lines.push({ type: "context", oldNo: oldNo++, newNo: newNo++, text: raw.replace(/^ /, "") });
154
+ cur.oldLines += 1;
155
+ cur.newLines += 1;
156
+ }
157
+ }
158
+ }
159
+ // An update with content but no recognizable hunk anchor is malformed V4A;
160
+ // the caller falls back to the raw-patch view instead of a structured diff.
161
+ if (op.type === "update_file" && !sawHunkAnchor && lines.some((l) => l.trim().length > 0)) {
162
+ throw new Error("malformed V4A: no @@ hunk anchor");
163
+ }
164
+ }
165
+
166
+ return { path, oldPath, status, isBinary: false, isImage: false, additions, deletions, hunks, truncated: false };
167
+ }
168
+
169
+ /**
170
+ * Extract the `apply_patch` operations from a provider-native tool item's `raw`
171
+ * payload, normalizing the two wire shapes (`raw.operations[]` for a multi-file
172
+ * patch, `raw.operation` for a single op). The single owner of this shape so the
173
+ * renderer and the turn-summary facet counter never drift.
174
+ */
175
+ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
176
+ const r = (raw ?? {}) as { operation?: ApplyPatchOperation; operations?: ApplyPatchOperation[] };
177
+ if (Array.isArray(r.operations)) {
178
+ return r.operations;
179
+ }
180
+ return r.operation ? [r.operation] : [];
181
+ }
182
+
183
+ /**
184
+ * True when a tool item is an `apply_patch_call` — by its provider-native
185
+ * `raw.type` (the live-wire source of truth) or by tool `name` (first-party
186
+ * replays that omit `raw`). Centralizes the rawType-or-name check.
187
+ */
188
+ export function isApplyPatch(item: { name: string; raw: unknown }): boolean {
189
+ const type = item.raw && typeof item.raw === "object" ? (item.raw as { type?: unknown }).type : undefined;
190
+ return type === "apply_patch_call" || item.name === "apply_patch_call";
191
+ }
192
+
193
+ /* --- secret redaction ------------------------------------------------------- */
194
+
195
+ const SECRET_KEY = /^(value|secret|token|password|api[_-]?key|signing[_-]?key)$/i;
196
+
197
+ /** Deep-redact secret-looking values so arguments never leak a key into the UI. */
198
+ export function redactSecrets(value: unknown): unknown {
199
+ if (Array.isArray(value)) {
200
+ return value.map(redactSecrets);
201
+ }
202
+ if (value && typeof value === "object") {
203
+ const out: Record<string, unknown> = {};
204
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
205
+ out[k] = SECRET_KEY.test(k) ? "••••" : redactSecrets(v);
206
+ }
207
+ return out;
208
+ }
209
+ return value;
210
+ }
211
+
212
+ /** Parse tool arguments that may arrive as a JSON string or an object. */
213
+ export function parseToolArgs(args: unknown): Record<string, unknown> {
214
+ if (args == null) {
215
+ return {};
216
+ }
217
+ if (typeof args === "string") {
218
+ const parsed = tryParseJson(args);
219
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
220
+ }
221
+ return typeof args === "object" ? (args as Record<string, unknown>) : {};
222
+ }
223
+
224
+ /** The last non-empty line of a string -- the compact "what happened" peek. */
225
+ export function tailPeek(text: string): string {
226
+ const trimmed = text.trim();
227
+ if (!trimmed) {
228
+ return "";
229
+ }
230
+ const lines = trimmed.split("\n");
231
+ return lines[lines.length - 1] ?? "";
232
+ }
233
+
234
+ /**
235
+ * Unwrap an MCP tool result (`{ content: [{ type: "text", text }], isError? }`)
236
+ * into a flat `{ text, isError }`. Non-MCP outputs pass through as their string
237
+ * form.
238
+ */
239
+ export function unwrapMcpOutput(output: unknown): { text: string; isError: boolean } {
240
+ if (output && typeof output === "object" && "content" in output) {
241
+ const record = output as { content?: unknown; isError?: unknown };
242
+ const isError = Boolean(record.isError);
243
+ if (Array.isArray(record.content)) {
244
+ const textPart = record.content.find(
245
+ (part): part is { type: string; text: string } =>
246
+ !!part && typeof part === "object" && (part as { type?: unknown }).type === "text",
247
+ );
248
+ return { text: textPart ? String(textPart.text) : JSON.stringify(output), isError };
249
+ }
250
+ return { text: JSON.stringify(output), isError };
251
+ }
252
+ return { text: typeof output === "string" ? output : output == null ? "" : JSON.stringify(output), isError: false };
253
+ }