diff --git a/docs/rendering/rendering-design-principles.md b/docs/rendering/rendering-design-principles.md index 8515b4ab..dc23f5d9 100644 --- a/docs/rendering/rendering-design-principles.md +++ b/docs/rendering/rendering-design-principles.md @@ -136,7 +136,7 @@ Two mechanisms, both surviving the cutover (they don't need the deleted legacy r - ...use `streamPhase` as a submit-ownership signal — ownership is renderable *content* (§7 rule 10). **Always:** -- Treat the ledger as the *single* decision point. If you find yourself deciding visibility in a feed component, stop — that decision belongs upstream, and putting it in the painter recreates the distributed-ownership bug class the ledger was built to end. +- Treat the ledger as the *single* decision point. If you find yourself deciding visibility in a feed component, stop — that decision belongs upstream, and putting it in the painter recreates the distributed-ownership bug class the ledger was built to end. The 2026-07 painter rewrite added an artifact layer (`features/feed/ui/resolve/` + `ui/artifacts/` — see `rendering-system.md` §5 and `docs/superpowers/specs/2026-07-11-feed-render-layer-rewrite-design.md`); that layer is *derivation*, not decision — resolvers must stay pure and total (never hide, never throw), and a card must never branch on `plane`. - Prefer showing-and-explaining over hiding. - Fix stale documentation you touch. The pipeline has a history of comments outliving the code they describe (`SemanticStreamingTurn`, `AGENT_CODE_RENDER_SHADOW`, `AGENT_CODE_RENDER_PIPELINE`, `deriveFeedRenderModel` are all **deleted** but still referenced in stray comments). A stale comment about a *rendering* decision is how the next person reintroduces the old model. diff --git a/docs/rendering/rendering-system.md b/docs/rendering/rendering-system.md index c8ed4349..f73e07aa 100644 --- a/docs/rendering/rendering-system.md +++ b/docs/rendering/rendering-system.md @@ -187,13 +187,20 @@ A discriminated union: `entry` (a committed/ghost/optimistic transcript entry) ### `Feed.tsx` — the painter -`Feed = memo(FeedImpl)`: the whole component is memoized so composer typing / focus / split-resize bail the entire markdown subtree. Its core is a single `{renderItems.map(renderFeedItem)}` — **the one-owner rule** — where `renderFeedItem` is a switch over the six item types. The container owns its own scroll listener; sticky-bottom follow, scroll-position persistence across unmount, lazy mounting (`LazyEntry` + `EAGER_TAIL`, keyed on committed ordinal so a busy live turn can't push the newest prompt into lazy-mount), older-history load, and the picker auto-scroll tweens all live here as independent effects. **Performance is an identity-stability story:** memo-by-`text` prose is the single biggest win, every row is individually memoized, and the tool-index context clones only on `toolIndexVersion` bump. +`Feed = memo(FeedImpl)`: the whole component is memoized so composer typing / focus / split-resize bail the entire markdown subtree. Its core is a single `{renderItems.map(renderFeedItem)}` — **the one-owner rule** — where `renderFeedItem` is a switch over the six item types. The scarred behaviors (sticky-bottom follow, scroll-position persistence, lazy mounting via `LazyEntry` + `EAGER_TAIL` keyed on committed ordinal, older-history load, both picker auto-scroll tweens, and the feed-debug emission) live in `features/feed/ui/hooks/` — ported verbatim in the 2026-07 painter rewrite, each hook header citing its Feed.tsx region of origin. **Performance is an identity-stability story:** every row is individually memoized, and the tool-index context clones only on `toolIndexVersion` bump. -### Row dispatch +### Row dispatch — the artifact layer (2026-07 RENDER rewrite) -`EntryRow` → (`CompactBoundaryRow` / `CompactSummaryRow` / `TaskNotificationRow` / `ConversationRow` / `SystemRow`). `ConversationRow` renders string content as one `MarkerRow`, array content as one `Block` per content-block (coalescing adjacent subagent spawns under a `SubagentGroupHeader`). **`Block`** is the per-content-block dispatcher — `text`/`thinking`/`image`/`tool_use`/`tool_result` — routing tools through `getRendererProviderCapabilities(provider).renderToolUse/renderToolResult` with a shared `JsonToolRow`/`ToolResultRow` fallback, plus git-widget interception (`GitCardRow`), subagent rows, and AskUserQuestion answered rows. +`EntryRow` → (`CompactBoundaryRow` / `CompactSummaryRow` / `TaskNotificationRow` / `ConversationRow` / `SystemRow`). `ConversationRow` renders array content as one `Block` per content-block. From `Block` (committed) and `SemanticLiveBlockRow` (live) down, dispatch is no longer two parallel ladders — the drift machine the 2026-07 audit flagged. Both planes normalize into **one `ArtifactVM` per logical artifact** and every family renders through **exactly one card**: -Live rows mirror committed ones so streaming ≈ final: **`SemanticLiveBlockRow`** dispatches by `block.kind` and *reuses the committed provider renderers* for live Edit/MultiEdit/Codex tools (same card, partial input). Everything hangs off **`MarkerRow`** — the universal `❯` (user) / `⏺` (assistant) / `⎿` (tool/sub-item) fixed-marker-column + hanging-indent primitive — and prose flows through `TextProse`/`StreamingProse` (react-markdown + remark-gfm, `remark-breaks` for the streaming screen buffer). The single "agent is working" affordance is **`WorkIndicator`**, driven solely by `streamPhase`. +- **`features/feed/ui/artifacts/types.ts`** — the `ArtifactVM` discriminated union. Every VM carries `status: streaming | running | complete | error` and `plane` (debug provenance ONLY — a card that branches on plane is reintroducing the fork). +- **`features/feed/ui/resolve/`** — pure, decision-free derivation. `registry.ts` is the ONE family-routing table (`routeFamily`, total — unknown names are `generic`, never hidden) plus `RESULT_CONSUMING_FAMILIES` (families whose card consumes the paired tool_result; the committed result block is suppressed for exactly these — one predicate, both branches) and `isLegacyProviderClaimed` (opencode `read` keeps its tag-soup result parser). `fromCommitted.ts` / `fromLive.ts` produce identical VMs from `ToolUseBlock`+result pairs and `SemanticLiveBlock`+snapshot respectively. +- **`features/feed/ui/artifacts/`** — one card per family: `CommandCard` (Bash/exec/local_shell/stdin — ANSI output, exit badges, Codex parsed-read summaries), `DiffCard` (Edit/MultiEdit/apply_patch over `DiffView`), `FileWriteCard` (live-highlighted streaming writes), `ReadCard` (Read/Grep/Glob/LS one-liners with expandable source), `TodoCard`, `WebCard`, `ImageGenCard`, `SlashCommandRow` (parses the `` envelope out of user text), `ThinkingBlock` (one renderer, both planes; redacted gets an explicit pill), and `GenericToolCard` — THE one fallback (MCP rides it with a server pill). +- **`features/feed/ui/kit/`** — the streaming-safe primitives underneath: `StreamingCodeBlock` (sealed-line hljs cache — a sealed line never re-tokenizes, only the live tail line re-renders per delta; NO Monaco on the live path, ever — the per-delta Monaco remount was the 2026-07 rewrite's headline defect), `SegmentedMarkdown` (the sealed prefix through the last CLOSED fence parses once; only the tail re-parses per delta), `AnsiText` (SGR subset on the terminal palette), `OutputWell` (the one collapsible output region, loud truncation), `DiffView` (per-line tint + syntax highlight, windowed for huge diffs), `StatusBadge`, `ExpandSection` (first-open Monaco gating). + +Provider knowledge lives in **extractors, not JSX**: `providers/{claude,codex}/renderer/extractors.ts` (slash envelope, edit partials, apply_patch grammar, exec/exit metadata). The capability table no longer has `renderToolUse`; `renderToolResult` survives only for unpaired results (Codex meta-keyed rows) and legacy-claimed tools. Pre-routing interceptions (git-intent cards, spawn tools → `TaskSubagentRow`, AskUserQuestion) stay ahead of the family table in `Block`/`BlockRow`. + +**Streaming ≈ final, by construction:** the same card renders `status:'streaming'` and `status:'complete'`; a tool finishing is a props flip, never a component swap or remount. Everything hangs off **`MarkerRow`** — the universal `❯` / `⏺` / `⎿` fixed-marker-column + hanging-indent primitive — and the single "agent is working" affordance is **`WorkIndicator`**, driven solely by `streamPhase`. --- @@ -227,6 +234,8 @@ This is why typing in the composer never re-parses the transcript's markdown, an | the painter | `features/feed/ui/Feed.tsx` | | the item contract | `features/feed/model/renderModel.ts` | | row dispatch | `features/feed/ui/rows/{EntryRow,ConversationRow,Block}.tsx` | +| the artifact layer | `features/feed/ui/artifacts/types.ts` + `features/feed/ui/resolve/registry.ts` | +| streaming primitives | `features/feed/ui/kit/` | | live rows | `features/feed/ui/semantic/BlockRow.tsx` | | the layout primitive | `features/feed/ui/MarkerRow.tsx` | | machine-checked invariants | `rendering/replay/invariants.ts` | diff --git a/docs/superpowers/plans/2026-07-11-feed-render-layer-rewrite.md b/docs/superpowers/plans/2026-07-11-feed-render-layer-rewrite.md new file mode 100644 index 00000000..054b10e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-feed-render-layer-rewrite.md @@ -0,0 +1,907 @@ +# Feed RENDER-Layer Rewrite — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Spec:** `docs/superpowers/specs/2026-07-11-feed-render-layer-rewrite-design.md` — read it first. This plan implements it phase by phase. All file:line references are as of commit `269f9fc`. + +**Goal:** Rebuild the feed's RENDER layer (painter + all row components) so streaming code paints highlighted line-by-line with zero remounts, every Claude/Codex tool renders through one purpose-built card that is identical live and committed, and command output is ANSI-aware with exit codes — outshining the native CLIs. + +**Architecture:** Providers extract data (`providers/*/renderer/extractors.ts`), a pure resolve layer normalizes committed entry blocks and live semantic blocks into one `ArtifactVM` per artifact (`features/feed/ui/resolve/`), one card per family renders each VM (`features/feed/ui/artifacts/`), and shared streaming-safe primitives sit underneath (`features/feed/ui/kit/`). The ownership ledger, view bridge, and `FeedRenderItem` contract are untouched. + +**Tech Stack:** React 18 + memo discipline, highlight.js v11 (static + sealed-line streaming), Monaco only behind desktop expand affordances, Tailwind v4 tokens, zustand app store (existing `customRendering` setting), existing ledger/bridge pipeline. + +## Global Constraints + +- **DO NOT touch the DECIDE layer:** nothing under `src/renderer/src/rendering/` changes. No new `RenderReason`, no visibility logic in components. If a change seems to need one, STOP and flag it — that is a separate fixture-gated PR. +- **No new test files** (standing repo rule). Verification = `npx tsc -p tsconfig.node.json --noEmit && npx tsc -p tsconfig.web.json --noEmit` (build/vitest do NOT type-check), `NODE_ENV=test npx vitest run` (existing suites incl. bundle/recording corpus must stay green; a corpus divergence must be triaged with a why, never blessed blind), and the per-phase live checklist. Do not add `test:*` scripts. +- **Thick WHY comments** on every new file/decision per `CLAUDE.md` — especially anything ported verbatim (cite the source region) and anything perf-load-bearing. +- **Identity stability (D11):** any new derivation must be reference-stable — same inputs by reference ⇒ same output by reference. Cloning-on-no-op is a bug. +- **Streaming ≈ final:** a tool finishing is a props flip on the same mounted component. Never swap component types or change keys at the streaming→complete boundary. +- **Browser-pure kit:** nothing under `features/feed/ui/kit/` or `ui/artifacts/` may import Node/Electron APIs — the remote phone client mounts the same ``. Monaco stays lazy (`import('@renderer/lib/code/monacoRuntime')`) and only behind expand affordances. +- **Debug == paint:** `DebugVisibleRow`/`VisibleDecision` emission (`Feed.tsx:845-900`, `features/feed/types.ts`) must keep working identically. +- **Worktree setup:** work happens on branch `feat/feed-render-rewrite` in `.worktrees/feed-render-rewrite`. Fresh worktrees fail tsc until: `git submodule update --init` and `ln -s ../../node_modules node_modules` (from the worktree root). The `hotkeyBinding.test.ts` failure is pre-existing — ignore it. +- **Commits:** one commit per task minimum, message format `feat(feed): …` / `refactor(feed): …`, ending with `Co-Authored-By: Claude Fable 5 `. +- **Each phase ends with the live checklist** (§Phase Verification at the bottom) run via `npm run dev` against a real Claude and a real Codex session. + +--- + +# PHASE 1 — Streaming primitives + +Phase outcome: live code streams highlighted line-by-line with no Monaco remounts; streaming markdown stops re-parsing the whole message; command output is ANSI-aware. All achieved by surgical swaps inside existing rows — no structural change yet. + +### Task 1: ANSI parser + `AnsiText` kit primitive + +**Files:** +- Create: `src/renderer/src/features/feed/ui/kit/ansi.ts` (pure parser, no React) +- Create: `src/renderer/src/features/feed/ui/kit/AnsiText.tsx` + +**Interfaces:** +- Produces: `parseAnsi(text: string, initial?: AnsiStyle): { spans: AnsiSpan[]; endStyle: AnsiStyle }`, `ANSI_INITIAL_STYLE: AnsiStyle`, `` +- Consumed by: Task 5 (`OutputWell`), Task 13 (`CommandCard`) + +- [ ] **Step 1: Write the parser** — `ansi.ts`: + +```ts +// ANSI SGR subset parser for tool/command output. +// +// WHY this exists: the feed renders command output verbatim into
,
+// so colored test runners / build tools show literal `\x1b[0m` garbage
+// (the single most-visible rendering gap in Bash-heavy sessions). This
+// module turns SGR-styled text into styled span descriptors.
+//
+// WHY a subset and not a terminal emulator: transcripts carry the raw
+// bytes a NON-interactive command wrote to a pipe. We honor SGR color/
+// weight codes and normalize carriage-return progress rewrites; cursor
+// movement / clear-screen sequences are stripped (they are meaningless
+// outside a real terminal grid — the PTY view exists for that).
+//
+// WHY \r collapses to "keep the last segment": progress bars emit
+// `50%\r75%\r100%`; rendering all three lines triples the output and
+// reads as garbage. Keeping the final rewrite per line is what the
+// user's terminal would have shown at rest.
+
+export type AnsiStyle = {
+  fg: number | string | null   // 0-15 palette index, '#rrggbb' for 24-bit/256, or null
+  bg: number | string | null
+  bold: boolean
+  dim: boolean
+  italic: boolean
+  underline: boolean
+  inverse: boolean
+}
+
+export type AnsiSpan = { text: string; style: AnsiStyle }
+
+export const ANSI_INITIAL_STYLE: AnsiStyle = {
+  fg: null, bg: null, bold: false, dim: false,
+  italic: false, underline: false, inverse: false,
+}
+
+// CSI sequences: keep SGR (`m`), strip everything else. Also strip
+// OSC (`\x1b]...\x07` / `\x1b]...\x1b\\`) — window titles etc.
+const CSI_RE = /\x1b\[([0-9;]*)([a-zA-Z])/g
+const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
+
+const XTERM_256 = (n: number): string => {
+  // 16-231: 6x6x6 cube; 232-255: grayscale ramp. 0-15 return as index.
+  if (n < 16) return String(n)
+  if (n >= 232) {
+    const v = 8 + (n - 232) * 10
+    const h = v.toString(16).padStart(2, '0')
+    return `#${h}${h}${h}`
+  }
+  const idx = n - 16
+  const steps = [0, 95, 135, 175, 215, 255]
+  const r = steps[Math.floor(idx / 36)]
+  const g = steps[Math.floor((idx % 36) / 6)]
+  const b = steps[idx % 6]
+  return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
+}
+
+function applySgr(style: AnsiStyle, params: number[]): AnsiStyle {
+  const s = { ...style }
+  for (let i = 0; i < params.length; i++) {
+    const p = params[i]
+    if (p === 0) return { ...ANSI_INITIAL_STYLE }
+    else if (p === 1) s.bold = true
+    else if (p === 2) s.dim = true
+    else if (p === 3) s.italic = true
+    else if (p === 4) s.underline = true
+    else if (p === 7) s.inverse = true
+    else if (p === 22) { s.bold = false; s.dim = false }
+    else if (p === 23) s.italic = false
+    else if (p === 24) s.underline = false
+    else if (p === 27) s.inverse = false
+    else if (p >= 30 && p <= 37) s.fg = p - 30
+    else if (p === 39) s.fg = null
+    else if (p >= 40 && p <= 47) s.bg = p - 40
+    else if (p === 49) s.bg = null
+    else if (p >= 90 && p <= 97) s.fg = p - 90 + 8
+    else if (p >= 100 && p <= 107) s.bg = p - 100 + 8
+    else if (p === 38 || p === 48) {
+      const target = p === 38 ? 'fg' as const : 'bg' as const
+      if (params[i + 1] === 5 && params[i + 2] !== undefined) {
+        const c = XTERM_256(params[i + 2]); s[target] = /^\d+$/.test(c) ? Number(c) : c
+        i += 2
+      } else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
+        s[target] = `#${[params[i + 2], params[i + 3], params[i + 4]]
+          .map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`
+        i += 4
+      }
+    }
+  }
+  return s
+}
+
+/** Collapse carriage-return rewrites per line: keep the final segment. */
+export function collapseCarriageReturns(text: string): string {
+  if (!text.includes('\r')) return text
+  return text
+    .split('\n')
+    .map(line => {
+      const at = line.lastIndexOf('\r')
+      return at === -1 ? line : line.slice(at + 1)
+    })
+    .join('\n')
+}
+
+export function parseAnsi(
+  text: string,
+  initial: AnsiStyle = ANSI_INITIAL_STYLE,
+): { spans: AnsiSpan[]; endStyle: AnsiStyle } {
+  const cleaned = collapseCarriageReturns(text).replace(OSC_RE, '')
+  const spans: AnsiSpan[] = []
+  let style = initial
+  let last = 0
+  CSI_RE.lastIndex = 0
+  let m: RegExpExecArray | null
+  while ((m = CSI_RE.exec(cleaned)) !== null) {
+    if (m.index > last) spans.push({ text: cleaned.slice(last, m.index), style })
+    if (m[2] === 'm') {
+      const params = m[1] === '' ? [0] : m[1].split(';').map(n => Number(n) || 0)
+      style = applySgr(style, params)
+    }
+    // non-SGR CSI: stripped (no span emitted for the sequence itself)
+    last = CSI_RE.lastIndex
+  }
+  if (last < cleaned.length) spans.push({ text: cleaned.slice(last), style })
+  return { spans, endStyle: style }
+}
+```
+
+- [ ] **Step 2: Write the component** — `AnsiText.tsx`. Palette indices 0–15 map through `readXtermTheme()` (`src/renderer/src/workspace/tile-tree/xtermTheme.ts:26` — returns `{ black, red, …, brightWhite }` already luminance-branched for light/dark). Re-read on `THEME_CHANGED_EVENT` (import from `@renderer/app-state/settings/theme`, same event `CodeBlock.tsx:207` uses).
+
+```tsx
+import { memo, useEffect, useMemo, useState } from 'react'
+import type { ITheme } from '@xterm/xterm'
+import { THEME_CHANGED_EVENT } from '@renderer/app-state/settings/theme'
+import { readXtermTheme } from '@renderer/workspace/tile-tree/xtermTheme'
+import { parseAnsi, type AnsiStyle } from './ansi'
+
+const PALETTE_KEYS = [
+  'black','red','green','yellow','blue','magenta','cyan','white',
+  'brightBlack','brightRed','brightGreen','brightYellow','brightBlue',
+  'brightMagenta','brightCyan','brightWhite',
+] as const
+
+function colorOf(v: number | string | null, theme: ITheme): string | undefined {
+  if (v === null) return undefined
+  if (typeof v === 'string') return v
+  return theme[PALETTE_KEYS[v]] as string | undefined
+}
+
+function styleOf(s: AnsiStyle, theme: ITheme): React.CSSProperties | undefined {
+  const fg = colorOf(s.inverse ? s.bg : s.fg, theme)
+  const bg = colorOf(s.inverse ? s.fg : s.bg, theme)
+  if (!fg && !bg && !s.bold && !s.dim && !s.italic && !s.underline) return undefined
+  return {
+    color: fg,
+    backgroundColor: bg,
+    fontWeight: s.bold ? 600 : undefined,
+    opacity: s.dim ? 0.65 : undefined,
+    fontStyle: s.italic ? 'italic' : undefined,
+    textDecoration: s.underline ? 'underline' : undefined,
+  }
+}
+
+/** ANSI-aware text renderer for command output. Memoized by text —
+ *  committed output never changes; live output grows, and parseAnsi
+ *  over a capped OutputWell payload is cheap enough per delta. */
+export const AnsiText = memo(function AnsiText({ text }: { text: string }) {
+  const [theme, setTheme] = useState(() => readXtermTheme())
+  useEffect(() => {
+    const onTheme = () => setTheme(readXtermTheme())
+    window.addEventListener(THEME_CHANGED_EVENT, onTheme)
+    return () => window.removeEventListener(THEME_CHANGED_EVENT, onTheme)
+  }, [])
+  const { spans } = useMemo(() => parseAnsi(text), [text])
+  return (
+    <>
+      {spans.map((span, i) => {
+        const style = styleOf(span.style, theme)
+        return style
+          ? {span.text}
+          : {span.text}
+      })}
+    
+  )
+})
+```
+
+- [ ] **Step 3: Typecheck** — `npx tsc -p tsconfig.web.json --noEmit`. Expected: clean (plus only pre-existing errors, if any — record them first with a pre-change run).
+- [ ] **Step 4: Scratch-verify the parser** in the scratchpad (temporary file, not committed): run `npx tsx` over a snippet feeding `parseAnsi('\x1b[31mFAIL\x1b[0m ok\r\x1b[32mdone\x1b[0m')` and assert spans/colors by eye. Delete the scratch file.
+- [ ] **Step 5: Commit** — `feat(feed): ANSI SGR parser + AnsiText kit primitive`
+
+### Task 2: `StreamingCodeBlock` — sealed-line highlighted streaming
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/kit/StreamingCodeBlock.tsx`
+
+**Interfaces:**
+- Produces: ``
+- Consumed by: Task 3 (live fence), Task 18 (`FileWriteCard`), Task 14 (`GenericToolCard` live JSON)
+
+- [ ] **Step 1: Write the component:**
+
+```tsx
+import { memo, useMemo, useRef } from 'react'
+import hljs from 'highlight.js'
+import { normalizeCodeLanguage } from '@shared/code/language'
+
+// Line-by-line streaming code renderer — THE replacement for the
+// per-delta Monaco remount that made live code fences jank (the old
+// path recreated editor+model+LSP on every token; see the deleted
+// engine="monaco" routing at BlockRow.tsx:605-611 @269f9fc).
+//
+// Contract:
+//   - `code` is APPEND-ONLY across renders for a given `blockKey`.
+//     Sealed lines (all but the last) are highlighted once and cached
+//     by line index; only the partial tail line re-tokenizes per delta.
+//   - `blockKey` identifies the stream. If it changes, the cache
+//     resets. It must NOT embed the language (the language often
+//     arrives a delta after the fence opens — embedding it in the key
+//     was remount bug #2 of the old path).
+//   - Language changing (late fence info-string, path resolution)
+//     invalidates the whole cache ONCE — cheap, happens within the
+//     first few deltas — never remounts the component.
+//
+// WHY stateless per-line highlight (no cross-line state): highlight.js
+// v11 removed the v10 `continuation` API. Per-line tokenization is
+// wrong only for multi-line constructs (template literals, block
+// comments) and self-repairs at finalize when the committed path does
+// its one-shot whole-text highlight. That trade buys us: a sealed line
+// literally never re-renders.
+//
+// WHY a cap: per-line spans over a pathological block (thousands of
+// lines) still accumulate DOM. Past MAX_HIGHLIGHT_LINES we stop
+// highlighting new sealed lines (plain text spans, still append-only).
+
+const MAX_HIGHLIGHT_LINES = 2000
+
+type Sealed = { text: string; html: string | null }
+
+export const StreamingCodeBlock = memo(function StreamingCodeBlock({
+  code,
+  language,
+  path,
+  blockKey,
+}: {
+  code: string
+  language?: string | null
+  path?: string | null
+  blockKey: string
+}) {
+  const normalized = normalizeCodeLanguage(language ?? null, path ?? null)
+  const canHighlight = normalized !== 'plaintext' && !!hljs.getLanguage(normalized)
+
+  // Cache lives in a ref — mutation, not state, because a sealed line
+  // must never trigger a render of its own. Keyed by blockKey+language
+  // so either changing resets it exactly once.
+  const cacheRef = useRef<{ key: string; lines: Sealed[] }>({ key: '', lines: [] })
+  const cacheId = `${blockKey}${canHighlight ? normalized : 'plain'}`
+  if (cacheRef.current.key !== cacheId) {
+    cacheRef.current = { key: cacheId, lines: [] }
+  }
+
+  const lines = useMemo(() => code.split('\n'), [code])
+  const sealedCount = lines.length - 1 // last line is the live tail
+  const cache = cacheRef.current.lines
+  for (let i = cache.length; i < sealedCount; i++) {
+    const text = lines[i]
+    let html: string | null = null
+    if (canHighlight && i < MAX_HIGHLIGHT_LINES && text.length > 0) {
+      try {
+        html = hljs.highlight(text, { language: normalized, ignoreIllegals: true }).value
+      } catch { html = null }
+    }
+    cache.push({ text, html })
+  }
+  // Defensive: if code SHRANK (should not happen — append-only contract),
+  // drop stale cache instead of painting ghost lines.
+  if (cache.length > sealedCount) cache.length = Math.max(0, sealedCount)
+
+  const tail = lines[lines.length - 1] ?? ''
+  const tailHtml = useMemo(() => {
+    if (!canHighlight || !tail) return null
+    try {
+      return hljs.highlight(tail, { language: normalized, ignoreIllegals: true }).value
+    } catch { return null }
+  }, [canHighlight, normalized, tail])
+
+  return (
+    
+      
+        {cache.map((line, i) =>
+          line.html !== null ? (
+            
+          ) : (
+            {line.text + '\n'}
+          ),
+        )}
+        {tailHtml !== null ? (
+          
+        ) : (
+          {tail}
+        )}
+      
+    
+ ) +}) +``` + +- [ ] **Step 2: Typecheck** — `npx tsc -p tsconfig.web.json --noEmit`. +- [ ] **Step 3: Commit** — `feat(feed): StreamingCodeBlock sealed-line streaming highlighter` + +### Task 3: Kill the live-fence Monaco remount + +**Files:** +- Modify: `src/renderer/src/features/feed/ui/semantic/BlockRow.tsx:598-615` (the fence branch) + +**Interfaces:** +- Consumes: `StreamingCodeBlock` (Task 2), existing `splitStreamingCodeFence` (`features/feed/lib/helpers.ts:179`) + +- [ ] **Step 1: Replace the fence branch.** Current code at `BlockRow.tsx:599-615` renders ``. Replace with: + +```tsx + const text = block.text ?? '' + const fence = text ? splitStreamingCodeFence(text) : null + if (fence) { + return ( + +
+ {fence.prose ? : null} + {/* Live open fence — sealed-line streaming highlight. blockKey + deliberately excludes the language: the info-string often + arrives a delta after the ``` and a language-bearing key + remounted the block (the old jank's second half). */} + +
+
+ ) + } +``` + +Update imports: add `StreamingCodeBlock`, and remove the `CodeBlock` import **only if** this was its last use in the file (it is not yet — the Write preview and live-tool-input branches still use it until Tasks 14/18; leave the import). + +- [ ] **Step 2: Typecheck + full vitest** — `npx tsc -p tsconfig.web.json --noEmit && NODE_ENV=test npx vitest run`. Corpus suites assert ledger/bridge output, not JSX, so expected green. +- [ ] **Step 3: Live-verify** — `npm run dev`, ask a Claude agent: *"Write me a 120-line TypeScript file, explain as you go"* and watch the fence: code must paint highlighted line-by-line, no flicker, no black-block flash, language arriving late must not restart the block. With proxy streaming OFF (settings), confirm nothing regresses (fence path only runs with semantic deltas). +- [ ] **Step 4: Commit** — `fix(feed): stream live code fences through StreamingCodeBlock, not per-delta Monaco` + +### Task 4: `SegmentedMarkdown` — stop re-parsing the whole streaming message + +**Files:** +- Create: `src/renderer/src/features/feed/ui/kit/SegmentedMarkdown.tsx` +- Modify: `src/renderer/src/features/feed/ui/semantic/BlockRow.tsx` (final text branch, `:630-634`) +- Modify: `src/renderer/src/features/feed/ui/Feed.tsx:948-954` (`semantic-text` case) + +**Interfaces:** +- Produces: `` and pure `splitSealedPrefix(text: string): { sealed: string; tail: string }` +- Consumes: `StreamingProse` (`ui/markdown/Prose.tsx:47`), `splitStreamingCodeFence`, `countFenceMarkers` (`lib/helpers.ts:168`), `StreamingCodeBlock` (Task 2) + +- [ ] **Step 1: Write the component.** Design: the *sealed prefix* is everything up to and including the END of the last **closed** fence. That string only changes identity when a fence closes (rare), so the memoized `StreamingProse` over it re-parses only then — killing both the O(len²) reparse and the "second fence re-parses the first" defect. The *tail* (small) streams: prose via `StreamingProse`, open fence via `StreamingCodeBlock`. + +```tsx +import { memo } from 'react' +import { + countFenceMarkers, + splitStreamingCodeFence, +} from '@renderer/features/feed/lib/helpers' +import { StreamingProse } from '@renderer/features/feed/ui/markdown' +import { StreamingCodeBlock } from './StreamingCodeBlock' + +// Streaming markdown without whole-message reparse. +// +// The old path fed the ENTIRE growing message through StreamingProse +// every delta; its memo keys on the full string so it never hit during +// streaming — O(len²) unified-pipeline work over a long message, and a +// message streaming its SECOND fence re-parsed the first through +// markdown each delta too (splitStreamingCodeFence only splits the +// last odd fence). +// +// Fix: split at the end of the last CLOSED fence. The sealed prefix +// string changes identity only when a fence closes, so the memoized +// StreamingProse over it is effectively parse-once. Only the tail +// (text since the last closed fence — bounded, usually small) +// re-parses per delta. + +export function splitSealedPrefix(text: string): { sealed: string; tail: string } { + const fences = countFenceMarkers(text) + if (fences < 2) return { sealed: '', tail: text } + const closedFences = fences % 2 === 0 ? fences : fences - 1 + // Find the index just past the Nth ``` marker (N = closedFences). + let idx = -1 + for (let n = 0; n < closedFences; n++) idx = text.indexOf('```', idx + 1) + // Seal through the end of the closing fence's line. + const lineEnd = text.indexOf('\n', idx + 3) + const sealedEnd = lineEnd === -1 ? text.length : lineEnd + 1 + return { sealed: text.slice(0, sealedEnd), tail: text.slice(sealedEnd) } +} + +export const SegmentedMarkdown = memo(function SegmentedMarkdown({ + text, + blockKey, +}: { + text: string + blockKey: string +}) { + if (!text) return null + const { sealed, tail } = splitSealedPrefix(text) + const fence = tail ? splitStreamingCodeFence(tail) : null + return ( +
+ {sealed ? : null} + {fence ? ( + <> + {fence.prose ? : null} + + + ) : tail ? ( + + ) : null} +
+ ) +}) +``` + +- [ ] **Step 2: Route the streaming text paths through it.** In `BlockRow.tsx`, the fence branch from Task 3 and the plain-text fallthrough (`:630-634`) merge into one: + +```tsx + const text = block.text ?? '' + // …citations branch unchanged, but its StreamingProse also becomes + // + return ( + + + + ) +``` + +(The Task-3 fence branch is subsumed: `SegmentedMarkdown` handles open fences itself. Remove the now-dead standalone fence branch.) In `Feed.tsx` case `'semantic-text'` (`:948-954`), replace `` with ``. + +- [ ] **Step 3: Typecheck + vitest + live-verify** — same commands. Live check: a long streaming answer with prose→code→prose→code; typing in the composer during streaming must stay smooth (Performance panel: no >16ms scripting bursts from markdown on every delta). +- [ ] **Step 4: Commit** — `perf(feed): segment streaming markdown — sealed prefix parses once, only the tail re-parses` + +### Task 5: `OutputWell`, `StatusBadge`, `ExpandSection` kit chrome + +**Files:** +- Create: `src/renderer/src/features/feed/ui/kit/OutputWell.tsx` +- Create: `src/renderer/src/features/feed/ui/kit/StatusBadge.tsx` +- Create: `src/renderer/src/features/feed/ui/kit/ExpandSection.tsx` + +**Interfaces:** +- Produces: + - `` + - `` + - `{children}` — lazy: children mount on first open, stay mounted after (ports the first-open gating pattern from `ToolResultRow.tsx:17-43`). +- Consumed by: Tasks 6, 13, 14, 17–23. + +- [ ] **Step 1: Write `OutputWell`** — behavior of `TruncatedOutputRow` (`ui/rows/TruncatedOutputRow.tsx`, quoted in full in the audit: 3-line preview, click-expand, 360px scroll cap, error tint) with two upgrades: `AnsiText` content when `ansi`, and an explicit truncation notice when a byte cap fires. Body content: `ansi ? : shown`, cap total rendered text at 200_000 chars with a trailing `… output truncated (N more lines)` line (explicit — never silent). Keep `MarkerRow marker="⎿" tone="muted"` as the outer layout. Keep the exact button copy (`… +N lines (click to expand)` / `collapse`) so muscle memory survives. +- [ ] **Step 2: Write `StatusBadge`** — one `` with the small-caps 11px style used today (`text-[11px] uppercase tracking-wider`): `streaming` → `streaming` (muted, subtle pulse via `animate-pulse`), `running` → `running` (muted), `complete` → `✓` + optional `durationMs` formatted `1.2s` (muted), `error` → `exit N` when `exitCode != null` else `failed` (`text-danger`). No layout shift between states (fixed line height). +- [ ] **Step 3: Write `ExpandSection`** — controlled `
` wrapper: `onToggle` sets `everOpened`; children render only when `everOpened || defaultOpen` (port the WHY comment from `ToolResultRow.tsx:17-43` about not mounting Monaco eagerly). +- [ ] **Step 4: Typecheck. Commit** — `feat(feed): OutputWell / StatusBadge / ExpandSection kit chrome` + +### Task 6: Replace both TruncatedOutputRows with OutputWell (ANSI on) + +**Files:** +- Modify: `src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx` (its `TruncatedOutputRow` uses) +- Modify: `src/providers/codex/renderer/rows/CodexRows.tsx:369-410` (delete the private copy) and its call sites (`CodexRows.tsx:505-620` result row) +- Modify: `src/renderer/src/features/feed/ui/semantic/BlockRow.tsx:289-313` and `:572-592` (live output `
`s → `OutputWell`)
+- Delete: `src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx` (after all uses migrate; `grep -rn "TruncatedOutputRow" src/` must be empty)
+
+- [ ] **Step 1: Swap call sites.** Each `` becomes ``. The live `
` blocks in `BlockRow.tsx` (function output `:299-312`, tool result `:578-591`) become ``.
+- [ ] **Step 2: Delete** the Codex private copy (`CodexRows.tsx:369-410`) and the shared file; fix imports.
+- [ ] **Step 3: Typecheck + vitest + live-verify** — run a Codex/Claude command with colored output (`npm test` in some repo): colors render, `[0m` garbage gone, expand/collapse works.
+- [ ] **Step 4: Commit** — `feat(feed): ANSI-aware OutputWell replaces both TruncatedOutputRow copies`
+- [ ] **Step 5: PHASE 1 GATE** — run the full Phase Verification checklist (bottom of this doc). Open a PR for phase 1 (do not merge without the user).
+
+---
+
+# PHASE 2 — Painter shell
+
+Phase outcome: `Feed.tsx` is a thin orchestrator; all scarred behaviors live in named hooks, ported verbatim. Pixel parity — zero visual change.
+
+### Task 7: Extract the behavior hooks
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/hooks/useStickyBottom.ts`
+- Create: `src/renderer/src/features/feed/ui/hooks/useScrollPersistence.ts` (mount restore — the `useLayoutEffect` at `Feed.tsx:393-421`)
+- Create: `src/renderer/src/features/feed/ui/hooks/useOlderHistory.ts`
+- Create: `src/renderer/src/features/feed/ui/hooks/usePickerAutoScroll.ts` (both picker tweens, `Feed.tsx:591-710`, sharing one `scrollAnimFrameRef`)
+- Create: `src/renderer/src/features/feed/ui/hooks/useFeedDebugEmission.ts` (`Feed.tsx:845-900`)
+- Modify: `src/renderer/src/features/feed/ui/Feed.tsx`
+
+**Interfaces (exact signatures the new Feed consumes):**
+
+```ts
+useScrollFeedBehaviors(args: {
+  scrollerRef: RefObject
+  sessionId: string
+  tailMode: boolean
+  bootstrapping: boolean
+  entriesLength: number
+  semanticTurnSignal: string
+  semanticHistorySignal: string
+  hasOlderHistory: boolean
+  loadingOlderHistory: boolean
+  onLoadOlderHistory?: () => Promise
+  onScrollInfo?: (info: ScrollInfo) => void
+  scrollToLatestRequest: number
+}): void   // composes useScrollPersistence + useStickyBottom + useOlderHistory internally
+
+usePickerAutoScroll(args: {
+  scrollerRef: RefObject
+  pickerSelectedUuid: string | null
+  codeBlockSelectedId: string | null
+}): void
+
+useFeedDebugEmission(args: {
+  onDebugLog: Props['onDebugLog']
+  entriesLength: number
+  visibleEntryCount: number
+  renderedRows: DebugVisibleRow[]
+  visibleDecisions: VisibleDecision[]
+  semanticTurnId: string | null
+  renderedSemanticHistoryTurnIds: string[]
+  streamPhase: StreamPhase
+}): void
+```
+
+- [ ] **Step 1: Move, don't rewrite.** Cut each effect + its refs into its hook file **verbatim**, with a header comment: `// PORTED VERBATIM from Feed.tsx: @269f9fc — this logic is scarred; see the WHY comments inline before changing anything.` Preserve every inline comment. The refs that multiple effects share (`stickyBottomRef`, `lastScrollTopRef`, `scrollAnimFrameRef`, `hadSavedPositionOnMountRef`, `loadingOlderRef`, `prevBootstrappingRef`) live inside whichever hook owns their lifecycle; `useScrollFeedBehaviors` owns all the scroll-family refs and composes the three scroll hooks so the sharing stays internal.
+- [ ] **Step 2: Rebuild `FeedImpl`** to: context providers (unchanged, `Feed.tsx:980-1030`), tool-index memos (unchanged, `:751-784`), `feedRenderModelFromItems` memo (unchanged, `:800-803`), the two hook calls, and `renderItems.map(renderFeedItem)` (`renderFeedItem` unchanged this phase). Target ≤300 lines.
+- [ ] **Step 3: Typecheck + vitest.** Then parity check: `npm run dev`, exercise tab-switch scroll restore, scroll-up-during-stream (must NOT yank down), scroll-back-to-bottom re-follow, older-history load preserving position, copy-assistant/copy-code pickers tween+outline, bootstrap resume landing at bottom. Save a debug bundle before and after (same session) and diff `render-diagnostics.json` row keys — must be identical.
+- [ ] **Step 4: Commit** — `refactor(feed): extract scarred Feed behaviors into ui/hooks (verbatim port)`
+- [ ] **Step 5: PHASE 2 GATE** — Phase Verification checklist; PR.
+
+---
+
+# PHASE 3 — Artifact layer + CommandCard + GenericToolCard
+
+Phase outcome: the normalization layer exists; commands and generic tools render through ONE card each, identical live and committed; slash commands parse; exit codes visible.
+
+### Task 8: `ArtifactVM` union
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/artifacts/types.ts`
+
+**Interfaces (produced — later tasks import these exact names):**
+
+```ts
+export type ArtifactStatus = 'streaming' | 'running' | 'complete' | 'error'
+
+export type ArtifactBase = {
+  id: string
+  provider: AgentProviderKind           // from '@shared/types/providerKind'
+  status: ArtifactStatus
+  plane: 'committed' | 'live'           // debug/provenance ONLY — cards must not branch on it
+  toolUseId: string | null
+  startedAt: number | null
+  endedAt: number | null
+}
+
+export type CommandArtifact = ArtifactBase & {
+  family: 'command'
+  command: string
+  cwd: string | null
+  description: string | null
+  sourceTool: 'Bash' | 'exec_command' | 'local_shell_call' | 'bash'
+  output: string | null                 // ANSI-preserved
+  exitCode: number | null
+  durationMs: number | null
+  stdinWrites: string[]                 // write_stdin attachments (Codex)
+}
+
+export type GenericToolArtifact = ArtifactBase & {
+  family: 'generic'
+  toolName: string
+  prettyName: string                    // MCP-aware pretty name
+  mcp: { server: string; tool: string } | null
+  headline: string | null
+  params: Record | null
+  paramsJson: string                    // raw (possibly partial) input JSON
+  parseError: string | null
+  resultText: string | null
+  resultIsError: boolean
+}
+
+export type SlashCommandArtifact = ArtifactBase & {
+  family: 'slash-command'
+  name: string
+  message: string | null
+  args: string | null
+  stdout: string | null
+}
+
+// Placeholder unions filled by Phases 4–5 (declare now so the resolver's
+// return type is stable): FileEditArtifact, FileWriteArtifact,
+// ReadArtifact, TodoArtifact, WebArtifact, AgentSpawnArtifact,
+// McpArtifact, ImageGenArtifact — each `ArtifactBase & { family: ''; … }`
+// with the exact payloads specified in spec §4. Declare ALL of them in
+// this task with their full spec §4 fields (they are types only — no
+// runtime cost, and later tasks then cannot drift from the contract).
+
+export type ArtifactVM =
+  | CommandArtifact | GenericToolArtifact | SlashCommandArtifact
+  | FileEditArtifact | FileWriteArtifact | ReadArtifact | TodoArtifact
+  | WebArtifact | AgentSpawnArtifact | McpArtifact | ImageGenArtifact
+```
+
+- [ ] **Step 1: Write the file** with the full spec-§4 payloads for every family (copy the field lists from the spec verbatim; `DiffLine` imports from `@shared/parsers/lineDiff`).
+- [ ] **Step 2: Typecheck. Commit** — `feat(feed): ArtifactVM discriminated union (artifact view-model contract)`
+
+### Task 9: The resolvers — committed + live → VM
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/resolve/registry.ts` (family routing)
+- Create: `src/renderer/src/features/feed/ui/resolve/fromCommitted.ts`
+- Create: `src/renderer/src/features/feed/ui/resolve/fromLive.ts`
+- Create: `src/providers/claude/renderer/extractors.ts`
+- Create: `src/providers/codex/renderer/extractors.ts`
+
+**Interfaces:**
+- Produces:
+  - `routeFamily(provider: AgentProviderKind, toolName: string): ArtifactVM['family']`
+  - `commandFromCommitted(tu: ToolUseBlock, result: ToolResultBlock | null, provider): CommandArtifact`
+  - `commandFromLive(block: SemanticLiveBlock, toolState: SemanticToolCallSnapshot | null, provider): CommandArtifact`
+  - `genericFromCommitted(...)`, `genericFromLive(...)` — same pattern
+  - Claude extractors: `parseSlashCommandEnvelope(text: string): { name: string; message: string | null; args: string | null; stdout: string | null } | null`
+  - Codex extractors: `execCommandInput(input: unknown): { command: string; cwd: string | null; yieldTimeMs: number | null; maxOutputTokens: number | null }`, `exitCodeFromResult(result: ToolResultBlock): number | null` (reads `result.codex` meta — shape at `providers/codex/renderer/transcript/rollout.ts:281-297`)
+- Consumes: `ToolUseBlock`/`ToolResultBlock` (`@shared/types/transcript`), `SemanticLiveBlock` = `SemanticLiveTurn['blocks'][number]` (`@renderer/session-runtime/state`), `extractToolCommand`/`toolResultText` (`features/feed/lib/helpers.ts:75-96`), headline heuristic merged from `helpers.ts` + `providers/shared/renderer/rows/jsonToolPresentation.ts:33-74` (move `prettifyToolName` from `jsonToolPresentation.ts:9-16` into `registry.ts`).
+
+Key rules (encode as WHY comments):
+- **Status derivation, live plane:** `block.finalized !== true && !block.resultAt` while input still streams → `'streaming'`; input finalized, no result (`toolState?.status === 'in_progress'`) → `'running'`; `toolState?.status === 'error' || block.resultIsError` → `'error'`; else with result → `'complete'`.
+- **Status derivation, committed plane:** result missing → `'running'` (a committed tool_use whose result hasn't landed yet — the GitCardRow "running…" case, `Block.tsx:149-151`); `is_error` or `exitCode !== 0` → `'error'`; else `'complete'`.
+- **Command output, live:** prefer `toolState?.resultContent ?? block.resultContent ?? null`. NOTE: verify during implementation whether `foldEvent.ts` accumulates Codex `tool_output_delta` into the block/lookup (`session-runtime/semantic/foldEvent.ts` — search `tool_output_delta`); if it does not, live output stays null until `tool_completed` and the card simply shows the running state — do NOT add fold logic in this PR (INGEST layer is out of scope; file a follow-up issue instead).
+- **Total function:** `routeFamily` returns `'generic'` for anything unrecognized. Never throw; never return "hidden".
+
+- [ ] **Step 1: Write `registry.ts`** — the routing table (`command`: Bash/bash/exec_command/local_shell_call; `slash-command` is routed by the *entry* text envelope, not tool name; everything else `'generic'` until Phases 4–5 extend it) + `prettifyToolName` move + merged `headlineFor(toolName, params)` (order: `command → file_path → path → notebook_path → pattern → query → url → description`, Bash capped via `truncateBashCommand`).
+- [ ] **Step 2: Write the four resolver functions** with the status rules above. `commandFromCommitted` pulls: command via `extractToolCommand`, `cwd` from `input.workdir ?? input.cwd ?? null`, description from `input.description`, output via `toolResultText(result)`, exitCode via the Codex extractor (Claude: null).
+- [ ] **Step 3: Write the Claude slash extractor.** Envelope grammar (verify against a real transcript row before finalizing the regexes — save one from a live session): `/foo`, ``, `` in one user text block; `` in a follow-up block/entry. Parser: three `RegExp` captures over the text, `null` if `` absent.
+- [ ] **Step 4: Typecheck. Commit** — `feat(feed): artifact resolvers — committed + live planes normalize to one VM`
+
+### Task 10: `CommandCard`
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/artifacts/command.tsx`
+- Modify: `src/renderer/src/features/feed/ui/rows/Block.tsx` (route committed Bash/exec_command through it)
+- Modify: `src/renderer/src/features/feed/ui/semantic/BlockRow.tsx` (route live exec/Bash/local_shell through it)
+- Modify: `src/providers/codex/renderer/rows/dispatch.tsx` (drop exec_command/write_stdin routing — CommandCard owns them)
+
+**Interfaces:**
+- Produces: ``
+- Consumes: `OutputWell`, `StatusBadge`, `MarkerRow`, `AnsiText` (via OutputWell), `truncateBashCommand`
+
+- [ ] **Step 1: Write the card.** Layout (all states, same mounted component):
+
+```tsx
+export const CommandCard = memo(function CommandCard({ vm }: { vm: CommandArtifact }) {
+  const [showFull, setShowFull] = useState(false)
+  const truncated = truncateBashCommand(vm.command)
+  const isTruncated = truncated !== vm.command
+  return (
+    
+      
+
+
 isTruncated && setShowFull(v => !v)}
+            title={isTruncated && !showFull ? 'click to expand full command' : undefined}
+          >
+            $ 
+            {showFull ? vm.command : truncated}
+          
+ +
+ {(vm.cwd || vm.description) && ( +
+ {vm.cwd ? cwd: {vm.cwd.split('/').pop()} : null} + {vm.description ? {vm.description} : null} +
+ )} + {vm.stdinWrites.map((chars, i) => ( +
stdin → {chars.slice(0, 120)}
+ ))} + {vm.output ? ( + + ) : vm.status === 'complete' ? null /* silent success: header-only row */ : null} +
+
+ ) +}) +``` + +- [ ] **Step 2: Route committed.** In `Block.tsx` `tool_use` branch, AFTER the git-intent interception (`Block.tsx:138-153` stays first — git cards win) and BEFORE provider dispatch: if `routeFamily(provider, tu.name) === 'command'`, resolve `commandFromCommitted(tu, toolResultIndex.get(tu.id) ?? null, provider)` inside a `useMemo`-equivalent (Block is already memoized per block; resolution is cheap — call directly) and return ``. In the `tool_result` branch, suppress results whose source tool routed to `'command'` (mirror the git suppression pattern at `Block.tsx:191-200` — same predicate both sides, hoisted, per the #442 lesson). Attach Codex `write_stdin` inputs to the preceding exec's card is **deferred**: for now `write_stdin` routes to `'command'` family as its own compact row (`stdinWrites` populated, `command` = `(stdin)`) — a follow-up may group them; do not build cross-block grouping in the painter (grouping is bridge/ledger territory). +- [ ] **Step 3: Route live.** In `BlockRow.tsx`: the `function_call` branch's `exec_command`/`write_stdin` special cases (`:271-276`) and the `local_shell_call` branch (`:362-382`) become `commandFromLive(block, toolState, provider)` → ``. Claude live Bash (currently the generic tool_use branch) is picked up when Task 11 replaces that branch — leave until then. +- [ ] **Step 4: Typecheck + vitest + live-verify:** Claude `Bash` (non-git) and Codex `exec_command` runs — committed and live must look identical modulo the status badge; failing command shows red `exit N`; colored output renders ANSI; git commands still show GitCardRow. +- [ ] **Step 5: Commit** — `feat(feed): CommandCard — one command surface, live + committed, ANSI + exit codes` + +### Task 11: `GenericToolCard` — the ONE fallback + +**Files:** +- Create: `src/renderer/src/features/feed/ui/artifacts/generic.tsx` +- Modify: `Block.tsx` (fallback `JsonToolRow` → `GenericToolCard`), `BlockRow.tsx` (the whole generic live tool branch `:487-596` → `GenericToolCard`; TodoWrite/Write/AskUserQuestion/Edit special cases stay until their phase-4/5 tasks) +- Modify: `src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx` call sites — generic results route through the card's result slot; Read/Grep special branches stay until Task 15 + +**Interfaces:** +- Produces: `` +- Consumes: kit primitives; `JsonResultSlab` (`@providers/shared/renderer/rows/JsonResultSlab`) for JSON-shaped results (keep it — it is data-shaped, not plane-shaped); `StreamingCodeBlock` (live partial JSON params, language `json`); `tryExtractJson` (`jsonToolPresentation.ts`) + +- [ ] **Step 1: Write the card.** Header: `prettyName` + MCP server pill when `vm.mcp` + `StatusBadge`. Headline line when present. Params: `ExpandSection summary="N params"` → parsed ? pretty JSON via `CodeBlock engine="static" language="json"` (committed/finalized) : `` (live partial — replaces the raw `
` dump at `BlockRow.tsx:558-563`). `parseError` renders the existing red line (`BlockRow.tsx:565-571`). Result: `tryExtractJson` → `JsonResultSlab`, else `OutputWell ansi`.
+- [ ] **Step 2: Wire both planes.** Committed: `Block.tsx:183` fallback becomes `genericFromCommitted(...)` → card. Live: `BlockRow.tsx` tool_use/server_tool_use/mcp_tool_use branch collapses to (in order): AskUserQuestion picker guard (`:409` unchanged) → Edit/MultiEdit reuse (unchanged until Task 16) → TodoWrite (unchanged until Task 18) → Write preview (unchanged until Task 17) → `genericFromLive(...)` → card. Same for the `function_call` non-command fallthrough (`CodexToolRow`/`JsonToolRow live` split at `:282-286` dies).
+- [ ] **Step 3: Typecheck + vitest + live-verify:** an MCP tool call (orchestration), a WebSearch, a Read — live card and committed card must be the same card; partial JSON params must render as growing highlighted JSON, not raw text.
+- [ ] **Step 4: Commit** — `feat(feed): GenericToolCard — single live+committed fallback, kills the raw-JSON live dump`
+
+### Task 12: Slash-command rendering + Codex silent-success rollout tweak
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/artifacts/slashCommand.tsx`
+- Modify: `src/renderer/src/features/feed/ui/rows/Block.tsx` text case (`:71-83`) — user text blocks matching the envelope route to `SlashCommandRow`
+- Modify: `src/providers/codex/renderer/transcript/rollout.ts:281-297` (stop dropping `exit 0` empty-output results — emit a minimal result block instead of `[]`)
+
+- [ ] **Step 1: `SlashCommandRow`.** `parseSlashCommandEnvelope(text)` non-null → render: `UserBand` + `MarkerRow marker="❯"` + `/name` pill (accent, monospace) + message text via `TextProse` + `args` inline + stdout in `` inside `ExpandSection summary="output"`. Null → existing user-text path unchanged.
+- [ ] **Step 2: Rollout tweak.** At `rollout.ts:283` the `if (!output.trim() && exitCode === 0) return []` drop makes silent successes invisible. Change to emit the normal tool_result block with empty content (keeping `codex.exitCode` meta) so the renderer's CommandCard can paint the header-only ✓ row. **This perturbs committed candidates → run the FULL corpus** (`NODE_ENV=test npx vitest run`): new rows should appear as additive divergences; triage each with `why: silent-success now rendered (spec §6 CommandCard)`. If anything NON-additive diverges, revert this step and ship it as its own PR with its own triage.
+- [ ] **Step 3: Typecheck + vitest (+ triage) + live-verify:** run `/help` or a custom slash command in Claude — pill + output, no raw XML. Codex `true`-style silent command shows the ✓ header row.
+- [ ] **Step 4: Commit** — `feat(feed): slash-command rows + render Codex silent-success commands`
+- [ ] **Step 5: PHASE 3 GATE** — Phase Verification checklist; PR.
+
+---
+
+# PHASE 4 — Code artifacts: DiffCard, FileWriteCard, ReadCard, TodoCard
+
+### Task 13: `DiffView` kit primitive + `DiffCard`
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/kit/DiffView.tsx` (successor to `providers/shared/renderer/rows/DiffSlab.tsx` — port its per-line hljs mechanism, `DiffSlab.tsx:37-48`, add: file header with action badge + move target + `+N −M` counts, per-file collapse for multi-file patches, first/last-K windowing (K=40) with an explicit expander for long diffs)
+- Create: `src/renderer/src/features/feed/ui/artifacts/fileEdit.tsx` (`fileEditFromCommitted` / `fileEditFromLive` + ``)
+- Modify: `src/providers/codex/renderer/extractors.ts` — move `parseApplyPatch` (grammar parser, `CodexRows.tsx:67-113`) and `partialApplyPatchInput` + `extractPartialJsonStringMember` (`BlockRow.tsx:106-157`) here
+- Modify: `Block.tsx` + `BlockRow.tsx` + both provider `dispatch.tsx` — Edit/MultiEdit/apply_patch route to `DiffCard`; `EditRow`/`MultiEditRow`/`CodexApplyPatchRow` die
+- Modify: Codex result path — `patch_apply_end` success renders compact `patch applied ✓ (N files)`; failure renders error + `unified_diff` through `DiffView` line-tinting (replacing the flat `CodeBlock language="diff"` at `CodexRows.tsx:583-589`; parse the unified diff with a tiny `+/-/@@`-prefix line classifier feeding `DiffLine[]` — do NOT import a diff library)
+
+Resolver rules: Claude Edit/MultiEdit → `diffLines(old,new)` from `@shared/parsers/lineDiff` per edit (as `ClaudeRows.tsx:98-99` does); live partial input goes through the ported `claudeLiveEditInput` logic (`BlockRow.tsx:72-93` — move to `providers/claude/renderer/extractors.ts`); while unparseable, `DiffCard` shows the file path (once closed) + raw streaming input via `StreamingCodeBlock` and flips to the diff on parse success (the ONE allowed internal body swap — a partial diff is unparseable by nature; the card and key stay stable).
+
+- [ ] Steps: write DiffView → write resolvers+card → reroute both planes → delete dead rows → typecheck + vitest + live-verify (Claude Edit + MultiEdit + Codex apply_patch, streaming and committed; multi-file patch collapse; failing patch shows tinted diff) → commit `feat(feed): DiffCard + DiffView — unified diff surface for Edit/MultiEdit/apply_patch`.
+
+### Task 14: `FileWriteCard` — highlighted streaming file writes
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/artifacts/fileWrite.tsx`
+- Modify: `BlockRow.tsx` Write preview (`:483-537`) and `ClaudeRows.tsx` `WriteRow` (`:182-206`) → both die, replaced by the card in both planes
+
+Resolver: live via `extractStreamingWriteInput` (`lib/streamingWriteInput.ts:134` — unchanged, it is already the right extractor); committed via parsed `{file_path, content}`. Card: header = path + language pill (`normalizeCodeLanguage(null, path)`) + growing line count + `StatusBadge`; body = `` — **this is the B-decision payoff: the live Write preview is now highlighted line-by-line** (the old preview was `highlight={false}`; sealed-line caching makes highlight affordable). Committed renders the same component (already-complete code seals in one pass; identical DOM). Desktop-only "open in Monaco" affordance inside an `ExpandSection` for files > 80 lines (lazy `CodeBlock engine="monaco"`, the `ToolResultRow.tsx:17-43` first-open pattern via `ExpandSection`).
+
+- [ ] Steps: resolver+card → reroute → delete old → typecheck + vitest + live-verify (ask Claude to Write a 200-line file: highlighted line-by-line growth, stable header, line counter ticking; committed view identical) → commit `feat(feed): FileWriteCard — live-highlighted streaming file writes`.
+
+### Task 15: `ReadCard` (Read/Grep/Glob/LS + Codex parsed reads)
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/artifacts/fileRead.tsx`
+- Modify: `ToolResultRow.tsx` — the hardcoded Read (`:114-147`) and Grep (`:152-172`) branches move into the resolver/card; `ToolResultRow` shrinks to the pure generic result renderer used by `GenericToolCard`
+- Modify: Codex `exec_command_end` parsed read/search results (`CodexRows.tsx:522-544`, `ExpandableCodeResult`) → route to `ReadCard`
+
+Card: collapsed one-liner (`Read src/foo.ts — 240 lines` / `Grep "pattern" in src/ — 12 matches` / Glob/LS file list count) + `ExpandSection` body: `CodeView`-style static `CodeBlock` (strip `N→` prefixes via `stripLineNumberPrefix`, `helpers.ts:132`), Monaco on expand for large reads (desktop). Glob/LS body: monospace file list with `vscode-icons-js` icons (`getIconForFile` — same dependency the editor explorer uses).
+
+- [ ] Steps → commit `feat(feed): ReadCard — Read/Grep/Glob/LS one-liners with expandable source`.
+
+### Task 16: `TodoCard`
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/artifacts/todo.tsx`
+- Modify: routes: Claude committed `TodoRow` (`ClaudeRows.tsx:241`) + live `SemanticTodoList` (`BlockRow.tsx:510` + `ui/semantic/TodoList.tsx`) → one card fed by `todoFromCommitted` (parsed input `todos[]`) / `todoFromLive` (`parseSemanticTodos(block.parsedInput)`, import from `@renderer/session-runtime/state`)
+
+Card: checklist rows — `pending` dim circle, `in_progress` accent spinner + `activeForm` text, `completed` strikethrough; counts chip in header. Keep OpenCode's reuse working: `registry.ts` routes `todowrite` (lowercase) to `'todo'` too (the OpenCode dispatch at `providers/opencode/renderer/rows/dispatch.tsx` reused Claude's TodoRow — after this task it can simply delete its special case and inherit, DO update it since it's a one-line deletion, not new OpenCode work).
+
+- [ ] Steps → commit `feat(feed): TodoCard — one todo surface, live + committed`.
+- [ ] **PHASE 4 GATE** — Phase Verification checklist; PR.
+
+---
+
+# PHASE 5 — Remaining families + legacy deletion + docs
+
+### Task 17: `WebCard` + `ImageGenCard`
+
+**Files:**
+- Create: `src/renderer/src/features/feed/ui/artifacts/web.tsx`, `imageGen.tsx`
+- Modify: `BlockRow.tsx` `web_search_call` (`:315-337`), `image_generation_call` (`:339-360`), `tool_search_call` (`:384-398`) live chips → cards; Claude WebSearch/WebFetch + Codex rollout-synthesized `web_search`/`image_generation`/`tool_search` committed rows (`rollout.ts:430-513` — do NOT change rollout; resolve from the synthesized tool_use inputs) → same cards
+
+WebCard: kind pill (Search/Open/Find) + query/url (clickable via the existing external-nav-safe link component used by `MarkdownComponents` — reuse, don't hand-roll ``) + `StatusBadge` + result excerpt in `ExpandSection`. ImageGenCard: status + revisedPrompt italic + result (if the result is an image payload, render through the upgraded `ImageBlockRow` from Task 19; else text).
+
+- [ ] Steps → commit `feat(feed): WebCard + ImageGenCard — committed parity for Codex special tools`.
+
+### Task 18: `AgentCard` re-skin + the `Task` spawn-name fix
+
+**Files:**
+- Modify: `src/providers/registry.renderer.capabilities.ts:201` — Claude `isSpawnTool`: `name === 'Agent' || name === 'Task' || name.endsWith('__orchestration_create_agent')` (**verify first**: grep a current live Claude transcript for the subagent tool name — `grep -o '"name":"[A-Za-z]*"' ~/.claude/projects//[…].jsonl | sort -u`; add what you actually find)
+- Modify: `src/renderer/src/features/feed/ui/rows/TaskSubagentRow.tsx` — re-skin on kit chrome (`StatusBadge`; meta as plain inline chips like CommandCard's cwd/description line) — behavior (live status glyph, elapsed, tool counts, `SubagentMiniFeed`, notification join) untouched
+- Create: `src/renderer/src/features/feed/ui/artifacts/agentSpawn.tsx` — thin: `agentSpawnFromCommitted` wraps the block + `SubAgentsContext` state; card delegates to the re-skinned `TaskSubagentRow` internals
+
+- [ ] Steps → live-verify a real `Task` spawn shows the card → commit `feat(feed): AgentCard — Task/Agent spawn fix + kit re-skin`.
+
+### Task 19: `McpCard`, thinking/image/system/compaction re-skins, usage hover
+
+**Files:**
+- Create: `artifacts/mcp.tsx` (route `mcp__*` from `'generic'` to `'mcp'` in `registry.ts`; card = GenericToolCard + server pill + params/result emphasis)
+- Create: `artifacts/thinking.tsx` — ONE renderer replacing the live (`BlockRow.tsx:206-245`) and committed (`Block.tsx:84-115`) duplicates; adds Codex summary/full tracks (in-place toggle) and an explicit `redacted by provider` pill for `redacted_thinking` (currently silently dropped)
+- Modify: `ui/rows/ImageBlockRow.tsx` — support `source.type === 'url'` (plain ``; `imageDataUrl` at `helpers.ts:210` stays for base64) + click-to-zoom overlay (simple fixed-position lightbox, ESC/click closes, no dependency)
+- Modify: `ui/rows/SystemRow.tsx` — hooks get name + collapsible payload via `ExpandSection` (still hidden by default per Feed rule 3)
+- Modify: `ui/rows/CompactBoundaryRow.tsx` / `CompactSummaryRow.tsx` — kit chrome pass only
+- Modify: `ui/rows/ConversationRow.tsx` — assistant marker gains an optional hover affordance showing `message.usage` token counts when the existing dev-debug setting is on (`features/debug/devDebugConfig.ts` store — add nothing new; gate on an existing flag)
+- Modify: `features/feed/workIndicatorHints.ts` — enrich the WorkIndicator tool-hint vocabulary with per-family verbs from `resolve/registry.ts` ("Running `npm test`", "Editing `foo.ts`", "Searching the web") — display-string change only, `streamPhase` machine untouched (spec §7)
+
+- [ ] Steps → commit `feat(feed): McpCard + thinking/image/system/compaction polish + usage hover`.
+
+### Task 20: Legacy deletion + capability-table cutover
+
+**Files:**
+- Modify: `src/providers/registry.renderer.capabilities.ts` — delete `renderToolUse`/`renderToolResult` from the capability type and all three providers (routing now lives in `resolve/registry.ts`; provider knowledge lives in `extractors.ts`)
+- Delete: `providers/shared/renderer/rows/JsonToolRow.tsx`, `features/feed/ui/rows/ToolUseRow.tsx`, `providers/shared/renderer/rows/DiffSlab.tsx`, the JSX remains of `providers/claude/renderer/rows/ClaudeRows.tsx` + `providers/codex/renderer/rows/CodexRows.tsx` (+ their `dispatch.tsx` files), `ui/semantic/TodoList.tsx`
+- Modify: `Block.tsx` → shrinks to: text/thinking/image dispatch + git interception + spawn/AskUserQuestion routing + `routeFamily` → card. `BlockRow.tsx` → shrinks to: thinking + AskUserQuestion picker + `routeFamily` → card + `SegmentedMarkdown` text tail.
+- Verify: `grep -rn "JsonToolRow\|ToolUseRow\|DiffSlab\|CodexToolRow\|TruncatedOutputRow\|EditRow\|MultiEditRow\|WriteRow\|renderToolUse\|renderToolResult" src/ packages/ --include='*.ts*'` → only hits in this plan/spec docs. OpenCode's dispatch must still compile — its `renderToolUse` usages are deleted with the capability; its tools route through `routeFamily` defaults (`'generic'`/`'todo'`/`'command'` for `bash`) — **behavioral upgrade for free, verify nothing worse**.
+
+- [ ] Steps → full typecheck both projects + full vitest + `npm run fixture:audit` → commit `refactor(feed): delete legacy dual-ladder tool rendering — resolve/artifacts owns the paint`.
+
+### Task 21: Documentation
+
+**Files:**
+- Modify: `docs/rendering/rendering-system.md` §5 (Stage RENDER) — rewrite to describe: view bridge unchanged → `renderFeedItem` → `resolveArtifact` → artifact cards → kit; the VM contract; the streaming primitives; update §7 "Where to look" table rows for the painter/row-dispatch/live-rows entries; delete stale references to removed components (per the doc's own rule: fix stale docs you touch, same PR)
+- Modify: `docs/rendering/rendering-design-principles.md` — one paragraph in §5 noting the painter's artifact layer must stay decision-free (pointer to spec)
+
+- [ ] Steps → commit `docs(rendering): update rendering-system.md for the artifact painter`.
+- [ ] **PHASE 5 GATE** — full Phase Verification below; final PR.
+
+---
+
+# Phase Verification checklist (run at every phase gate)
+
+1. `npx tsc -p tsconfig.node.json --noEmit && npx tsc -p tsconfig.web.json --noEmit` — clean (modulo recorded pre-existing errors).
+2. `NODE_ENV=test npx vitest run` — green; any corpus divergence triaged with a written `why`, never blessed blind.
+3. `npm run fixture:audit` — no regressions.
+4. `npm run dev`, one Claude + one Codex session, exercise and eyeball:
+   - long streaming answer with 2+ code fences (line-by-line highlight, no flicker/remount, late language OK)
+   - Edit + MultiEdit + apply_patch (streaming and committed)
+   - Write of a 200+ line file (highlighted growth, line counter)
+   - colored `npm test`-style command (ANSI + exit badge), a failing command (red `exit N`), a silent success (✓ header row)
+   - WebSearch/WebFetch, an MCP call, a subagent `Task` spawn, a slash command
+   - provider switch mid-session (committed path renders translated transcript)
+   - scroll: up-during-stream stays put; back-to-bottom re-follows; tab-switch restores; older-history preserves position
+   - phone client (`client:dev` remote): feed paints, no Monaco in bundle (`ls`+size-check the client build output before/after phase 1)
+5. Performance panel: streaming a long code block produces no per-delta long tasks; before/after comparison on the same recorded scenario.
+6. Save a debug bundle mid-stream; `render-diagnostics.json` and feed-debug rows still populate (debug == paint intact).
+
+# Task-order dependency notes
+
+- Tasks 1→2→3→4 are strictly ordered; 5–6 can follow in any order after 1.
+- Phase 2 (Task 7) can technically run before Phase 1 but do NOT — the primitive swaps land visible user value first and shrink the file Task 7 ports.
+- Task 8 blocks 9; 9 blocks 10–12; Phase 4 tasks are independent of each other after 9; Phase 5 Tasks 17–19 independent after 9; Task 20 strictly last-but-one; 21 last.
+
+# Self-review notes (already applied)
+
+- Spec §5.1's hljs `continuation` claim was corrected in the spec (v11 removed the API) — `StreamingCodeBlock` is stateless-per-line with finalize repair, documented in its header.
+- `write_stdin` grouping under the parent exec card was demoted to a follow-up (cross-block grouping in the painter would be a decision — ledger territory).
+- Live Codex `tool_output_delta` accumulation is verify-first (Task 9): if INGEST doesn't fold it, the card shows `running` until completion and a follow-up issue is filed — no INGEST changes in this plan.
diff --git a/docs/superpowers/specs/2026-07-11-feed-render-layer-rewrite-design.md b/docs/superpowers/specs/2026-07-11-feed-render-layer-rewrite-design.md
new file mode 100644
index 00000000..1804c1e5
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-feed-render-layer-rewrite-design.md
@@ -0,0 +1,269 @@
+# Feed RENDER-Layer Rewrite — Design Spec
+
+> **Date:** 2026-07-11
+> **Status:** approved design (user-approved in session; implementation plan follows in `docs/superpowers/plans/`)
+> **Scope decisions (user-locked):** hybrid visual direction (transcript skeleton + rich artifact cards) · live line-by-line highlighted code streaming (sealed-line cache, no live diff theatrics) · Claude + Codex only (OpenCode inherits shared primitives, gets no dedicated work)
+> **Companion reading:** `docs/rendering/rendering-system.md` (the pipeline this painter sits on), `docs/rendering/rendering-design-principles.md` (the discipline; the ledger is NOT touched by this work)
+
+---
+
+## 1. Why this rewrite exists
+
+The 2026-07 ownership-ledger rewrite fixed the *decision* layer — what is visible, who owns it, in what order. It deliberately carried the *paint* layer forward mostly unchanged, and the paint layer is now the weakest part of the app. The goal of this rewrite is blunt: **outshine the native Claude Code and Codex CLIs at rendering commands, code-edit streaming, file-write streaming, and the formatting of every artifact type.** We are a browser engine competing with TUIs; there is no excuse to be worse.
+
+### 1.1 The audit findings this design answers
+
+A four-way deep audit (components, git history, tool coverage, data shapes) established the following. Each finding is a design input; file:line references are as of `269f9fc`.
+
+**Streaming code (the headline regression):**
+
+- The live open code fence renders through Monaco, and `CodeBlock`'s Monaco effect lists `code` in its dependency array — **every streaming delta disposes and recreates the entire Monaco editor, model, and LSP document** (`src/renderer/src/lib/code/CodeBlock.tsx:104-260`, effect deps at `:260`; fence routing at `features/feed/ui/semantic/BlockRow.tsx:605-611`). There was never an incremental path; git history confirms Monaco was never incremental (original `13dfc22` already had `code` in deps).
+- The fence `CodeBlock` key embeds `fence.language` (`BlockRow.tsx:608`); models emit ``` then the language a delta later, so the key flips `plain → tsx` and React remounts the block a second time.
+- `StreamingProse` memoizes on the full growing string (`features/feed/ui/markdown/Prose.tsx:47-63`) — every delta re-parses the entire message's markdown AST (remark-gfm + remark-breaks): O(len²) over a message. `splitStreamingCodeFence` (`features/feed/lib/helpers.ts:179-197`) only splits the *last* odd fence, so a message streaming its second code block re-parses the first through markdown every delta too.
+- The "line-by-line like before" memory refers to the pre-semantic **screen-buffer era** (append-only plain text via `StreamingProse`) — removed by the 2026-04 semantic migration. The regression is the switch from append-only DOM to per-delta Monaco remount, not a lost incremental highlighter.
+- The one well-done streaming path is the **Write preview** (`BlockRow.tsx:483-537` + `features/feed/lib/streamingWriteInput.ts`): single-pass extractor, static append-only block, stable `write-live:${blockIndex}` key — but unhighlighted (`highlight={false}`).
+
+**Command/tool rendering coverage:**
+
+- **No ANSI handling anywhere in the feed** — command output goes verbatim into `
`; colored test/build output renders as literal `[0m` garbage (`features/feed/ui/rows/TruncatedOutputRow.tsx:32-39` and the near-identical private copy at `providers/codex/renderer/rows/CodexRows.tsx:369-410`).
+- **Exit codes are parsed but never displayed.** Codex stores `exit_code` in tool_result meta (`providers/codex/renderer/transcript/rollout.ts:281-297`) and uses it only to tint the pre red. Silent successes (`!output.trim() && exitCode === 0`) are dropped entirely (`rollout.ts:283`).
+- Claude's dispatch special-cases exactly four tools — Edit, MultiEdit, Write, TodoWrite (`providers/claude/renderer/rows/dispatch.tsx:11-28`). **Read, Grep, Glob, LS, Bash, WebSearch, WebFetch, NotebookEdit, and all MCP tools fall to the generic `JsonToolRow`** (name + headline + collapsed params `
`). +- **Live and committed views of the same tool diverge**: the live generic tool card is hand-rolled (`BlockRow.tsx:487-596`), doesn't reuse `JsonToolRow`, and dumps raw partial `block.inputJson` in a `
` (`:558-563`). Codex `web_search_call` / `image_generation_call` / `local_shell_call` / `tool_search_call` have bespoke live chips (`BlockRow.tsx:315-398`) but their rollout-synthesized committed twins fall to `JsonToolRow` (`rollout.ts:430-513`; the code admits the drift at `:417-428`).
+- **Slash commands render as raw XML** — nothing parses Claude's `` / `` / `` envelope; invocations paint as literal tags in user bubbles.
+- Claude subagent cards key on tool name `'Agent'` (`registry.renderer.capabilities.ts:201`), but modern Claude Code emits **`Task`** — the polished `TaskSubagentRow` likely never fires for current transcripts. (Verify against a live transcript during implementation; treat `Task` and `Agent` as synonyms.)
+- Two generic tool-use renderers coexist (`ToolUseRow.tsx`, `JsonToolRow.tsx`) with overlapping headline logic (`helpers.ts:44-56` vs `jsonToolPresentation.ts:33-74`); `TruncatedOutputRow` is duplicated (shared + Codex private copy). Codex successful `patch_apply_end` renders nothing (`CodexRows.tsx:561`), so a grammar-parse failure on the tool_use side leaves a successful patch with no visual confirmation at all. Claude Read/Grep specialness is hardcoded by tool name inside the shared `ToolResultRow` (`ToolResultRow.tsx:114,152`) instead of provider-owned.
+- Dropped shapes that reach the UI: url-sourced images (only base64 renders), `citations` content (only a count badge), `redacted_thinking`/`connector_text`/`document`/`container_upload`/`web_search_tool_result`/`code_execution_tool_result` block kinds (fall through to an empty text branch), `Message.usage`/`model` (never rendered), hooks/system payloads (one-line muted label).
+
+**Structure:**
+
+- Two parallel dispatch ladders — committed `features/feed/ui/rows/Block.tsx:119-212` and live `features/feed/ui/semantic/BlockRow.tsx` (a 635-line if-ladder) — that already drifted apart once and will again. Provider row modules mix extraction and JSX. The feed is not virtualized by explicit, documented decision (`LazyEntry.tsx:20-37`); this rewrite does not relitigate that.
+
+### 1.2 What this rewrite is NOT
+
+- **Not a DECIDE-layer change.** The ownership ledger, view bridge, `FeedRenderItem` union, ordering law, ghost predicate, and every `RenderReason` are untouched. No new suppression, no new visibility logic. If during implementation something seems to need a visibility decision, it goes to the ledger as a separate, fixture-gated change — not into the painter.
+- **Not a virtualization project.** `LazyEntry` + `EAGER_TAIL` policy is ported as-is.
+- **Not an OpenCode feature round.** OpenCode keeps its fallbacks; it benefits only through the shared kit.
+
+---
+
+## 2. Hard contracts the new painter must honor
+
+1. **Input contract:** the painter consumes `FeedRenderItem[]` from `useLedgerFeedItems` (`features/feed/ledger/useLedgerFeedItems.ts`) and paints every item it is handed. A ledger-selected candidate that cannot be resolved must land in `dropped[]` (bridge behavior, unchanged) — never silently unpainted.
+2. **Debug == paint:** the painter keeps emitting `DebugVisibleRow` / `VisibleDecision` (`features/feed/types.ts`) derived from what it actually painted. `saveDebugBundle` and feed-debug logging must keep working unmodified or with mechanical adaptation only.
+3. **Identity stability (D11):** all memoization keyed on object identity must survive. A row whose VM inputs did not change must not re-render. The artifact-resolution layer introduced by this design must be reference-stable: same `FeedRenderItem` + same tool-index version ⇒ same VM object by reference.
+4. **One Feed, two hosts:** desktop `TileLeaf` and the phone `remote-client/src/ui/SessionView.tsx` mount the same `` with `renderItemsOverride`. The kit must therefore stay browser-pure (no Node imports, no Electron), and Monaco must remain a lazy, desktop-expand-only concern so the phone bundle stays lean.
+5. **No new test files** (standing repo rule): verification rides on the existing corpus/replay suites (which assert ledger + bridge output and are structurally unaffected), `tsc` on both projects, the rendering-fixture audit script, and live eyeballing. Temporary fixtures during development are fine; they don't get committed.
+6. **Streaming ≈ final:** a tool/block finishing must be a **props flip on the same mounted component** (status `streaming` → `complete`), never a component swap or remount. This is the convergence rule the old code aimed at and only partially achieved; in the new design it holds by construction.
+
+---
+
+## 3. The architecture
+
+### 3.1 One sentence
+
+**Providers extract data, `resolve/` normalizes both planes (committed entry blocks and live semantic blocks) into one `ArtifactVM` per logical artifact, `artifacts/` renders each VM family with exactly one card, and `kit/` supplies the streaming-safe primitives underneath.**
+
+### 3.2 Directory structure
+
+```
+features/feed/
+  ui/
+    Feed.tsx                    # thin list orchestrator: map(item → row), target ~150 lines
+    hooks/                      # Feed.tsx's scarred behaviors, PORTED (logic verbatim), not re-derived
+      useStickyBottom.ts        #   sticky-bottom follow + scroll listener ownership
+      useScrollPersistence.ts   #   scroll position across unmount/remount
+      useLazyWindow.ts          #   LazyEntry + EAGER_TAIL keyed on committed ordinal
+      useOlderHistory.ts        #   older-history load trigger
+      useFeedDebugEmission.ts   #   DebugVisibleRow / VisibleDecision emission
+      usePickerAutoScroll.ts    #   picker auto-scroll tweens
+    kit/                        # visual primitives — provider-blind, decision-free, browser-pure
+      MarkerRow.tsx             #   ❯ ⏺ ⎿ transcript skeleton (kept, polished)
+      ArtifactCard.tsx          #   card chrome: header row, status strip, expand, meta chips
+      StatusBadge.tsx           #   streaming | running | done | error | exit-N badge
+      MetaChips.tsx             #   cwd / duration / description / counts chips
+      AnsiText.tsx              #   SGR subset parser → styled spans (colors, bold, dim, reset)
+      StreamingCodeBlock.tsx    #   sealed-line hljs cache + one live tail line (§5.1)
+      SegmentedMarkdown.tsx     #   fence-segmented streaming markdown (§5.2)
+      OutputWell.tsx            #   collapsible capped output region (kills both TruncatedOutputRows)
+      DiffView.tsx              #   DiffSlab successor: file header, ± stats, per-line syntax tint
+      CodeView.tsx              #   static hljs view; "open in Monaco" only on expand (desktop)
+      ExpandSection.tsx         #   lazy 
successor (first-open mount, preserved-open state) + artifacts/ # one file per family = VM type + derivation + card + types.ts # ArtifactVM discriminated union + shared status model + command.tsx # CommandArtifact + + fileEdit.tsx # FileEditArtifact + (Edit / MultiEdit / apply_patch) + fileWrite.tsx # FileWriteArtifact + + fileRead.tsx # ReadArtifact + (Read / Grep / Glob / LS) + web.tsx # WebArtifact + (WebSearch / WebFetch / web_search_call) + agentSpawn.tsx # AgentSpawnArtifact + (Task / Agent / spawn_agent / orchestration_create_agent) + todo.tsx # TodoArtifact + + mcp.tsx # McpArtifact + + slashCommand.tsx # SlashCommandArtifact + + imageGen.tsx # ImageGenArtifact + (Codex image_generation_call) + localShell.tsx # folded into CommandArtifact derivation (kept here only if shapes diverge) + generic.tsx # GenericToolArtifact + — THE one fallback + prose.tsx # assistant/user text (SegmentedMarkdown host) + thinking.tsx # one thinking renderer for live + committed (kills the duplicate) + image.tsx # base64 AND url image sources + system.tsx # hooks / permission-mode / snapshots (improved SystemRow) + compaction.tsx # CompactBoundaryRow / CompactSummaryRow re-skinned on kit + resolve/ + resolveArtifact.ts # FeedRenderItem → ArtifactVM (pure, memo-cached, reference-stable) + pairing.ts # committed tool_use + tool_result pairing via existing tool indices + registry.ts # family routing table: (provider, toolName) → family + extractor + +providers/claude/renderer/ + extractors.ts # pure data extraction only, NO JSX: Edit/Write/Read/etc. input shapes, + # slash-command envelope parser, git intent (existing detectGitIntent moves here) +providers/codex/renderer/ + extractors.ts # exec_command args, apply_patch grammar (parseApplyPatch moves here), + # exit codes, web_search/image_gen/local_shell metadata, write_stdin +``` + +Deleted at the end of the migration (each family cut-over deletes its slice): `features/feed/ui/rows/Block.tsx`'s dispatch ladder, `ui/semantic/BlockRow.tsx`'s if-ladder, `ToolUseRow.tsx`, `JsonToolRow.tsx` (+ `jsonToolPresentation.ts` merged into extractors/generic), both `TruncatedOutputRow`s, `DiffSlab.tsx` (superseded by `DiffView`), the JSX halves of `ClaudeRows.tsx` / `CodexRows.tsx`, `StreamingProse`'s whole-string path, and the live-fence Monaco routing in the old `BlockRow`. + +### 3.3 The capability-table seam moves up a level + +Today `registry.renderer.capabilities.ts` exposes `renderToolUse` / `renderToolResult` (JSX-returning). In the new design providers expose **extractors** (shape → typed data) and the artifact registry owns family routing. The capabilities table keeps everything else (mappers, fold policies, identity, `isSpawnTool` — with the `Task` fix). This keeps "adding a provider = one mapper + one fold policy + one condition policy + one extractor module," and it means a provider can never again fork the *visual* language — only feed it. + +--- + +## 4. The `ArtifactVM` union (the load-bearing type) + +All VMs share: + +```ts +type ArtifactStatus = 'streaming' | 'running' | 'complete' | 'error' +// streaming = tool input still arriving (tool_input_delta / partial inputJson) +// running = input finalized, awaiting result (in_progress in SemanticLookupSnapshot) +// complete = result attached (or committed pair resolved) without error +// error = is_error result / exitCode != 0 / parse-refusal + +interface ArtifactBase { + id: string // ledger candidate id when present; else `entry::` — NEVER visible index + family: ArtifactFamily + provider: AgentProviderKind + status: ArtifactStatus + plane: 'committed' | 'live' // provenance for debug emission only — cards MUST NOT branch on it + toolUseId?: string + startedAt?: number | null + endedAt?: number | null +} +``` + +Family payloads (fields sourced from the shape inventory — `SemanticLiveBlock` in `session-runtime/state.ts`, `ToolUseBlock`/`ToolResultBlock` in `shared/types/transcript.ts`, Codex rollout payloads in `providers/codex/renderer/transcript/rollout.ts`): + +- **`CommandArtifact`** — `{ command: string; cwd?: string; description?: string; source: 'Bash' | 'exec_command' | 'local_shell_call' | 'bash'; output: { text: string; ansi: boolean } | null; exitCode?: number | null; durationMs?: number | null; yieldTimeMs?: number; maxOutputTokens?: number; stdinWrites?: string[] }`. Live: command from partial JSON via closed-key extraction; output grows from `tool_output_delta` (Codex) or attaches at `tool_result` (Claude). Committed: paired result content + `codex.exitCode` meta. +- **`FileEditArtifact`** — `{ filePath: string | null; edits: Array<{ old: string; new: string }>; patchFiles?: Array<{ path: string; action: 'add'|'update'|'delete'; movedTo?: string; lines: DiffLine[] }>; diff: DiffLine[] | null }`. Sources: Claude `Edit` `{file_path, old_string, new_string}` / `MultiEdit` `{file_path, edits[]}` via `diffLines`; Codex `apply_patch` via the `*** Begin Patch` grammar parser. Live: filePath appears as soon as its JSON key closes; diff body renders when parseable, else the card shows the streaming raw input in a `StreamingCodeBlock` until it is. +- **`FileWriteArtifact`** — `{ filePath: string | null; content: string; language: string | null; lineCount: number; truncatedLive?: boolean }`. Live content via the generalized `extractStreamingWriteInput`. +- **`ReadArtifact`** — `{ kind: 'read'|'grep'|'glob'|'ls'; target: string | null; pattern?: string; resultText: string | null; resultLineCount?: number; language?: string | null }`. Codex `exec_command_end` parsed read/search results normalize here too (`parsedCmd[0].type`). +- **`WebArtifact`** — `{ kind: 'search'|'fetch'|'open_page'|'find_in_page'; query?: string; queries?: string[]; url?: string; pattern?: string; resultText: string | null }`. Sources: Claude WebSearch/WebFetch inputs; Codex `webSearchAction` live metadata AND the rollout-synthesized committed `web_search` tool_use — same VM, killing the live/committed drift. +- **`AgentSpawnArtifact`** — wraps the existing `SubAgentState` (unchanged — `TaskSubagentRow` is the one already-good card; it gets re-skinned, not redesigned) `+ { childProvider?: string }`. Spawn-tool matching fixed to include Claude `Task`. +- **`TodoArtifact`** — `{ todos: Array<{ content; status: 'pending'|'in_progress'|'completed'; activeForm }> }` (Claude TodoWrite + semantic `SemanticTaskSnapshot`). +- **`McpArtifact`** — `{ server: string; tool: string; params: unknown; paramsJson: string; resultText: string | null; resultJson?: unknown }` (parsed from `mcp__server__tool` names). +- **`SlashCommandArtifact`** — `{ name: string; message?: string; stdout?: string; args?: string }` parsed from the ``/``/`` envelope in user entries. +- **`ImageGenArtifact`** — Codex `imageGeneration` metadata `{ status; revisedPrompt?; result }`. +- **`GenericToolArtifact`** — `{ toolName: string; prettyName: string; headline: string | null; params: unknown; paramsJson: string; parseError?: string; resultText: string | null; resultIsJson: boolean }`. The ONE fallback, used identically for live and committed. Headline logic merged from `helpers.ts` + `jsonToolPresentation.ts`. +- **`ProseArtifact`**, **`ThinkingArtifact`** (`{ text; track?: 'summary'|'full'; redacted?: boolean }` — covers Codex reasoning summary/full and Claude `redacted_thinking` with an explicit "redacted" pill), **`ImageArtifact`** (`{ src: string; sourceKind: 'base64'|'url'; mediaType?: string }` — url sources become real ``s), **`SystemArtifact`**, **`CompactionArtifact`**. + +**Derivation rules:** + +- `resolveArtifact(item, ctx)` is pure and total: every `FeedRenderItem` yields exactly one VM (entry items may yield a list — one per content block, as `ConversationRow` does today). Unknown tool names yield `GenericToolArtifact`; unknown block kinds yield `ProseArtifact` with whatever text exists (mirroring the ledger's `rendered_fallback_dev_only` philosophy: show, never hide). +- **Reference stability:** resolution is cached per item — keyed on the `FeedRenderItem` object identity plus `toolIndexVersion` (committed pairing can move) plus the `SemanticLiveTurn` reference for live items. Same inputs ⇒ same VM by reference. This is the D11 obligation of the new layer and gets the same "no-op in ⇒ identical reference out" treatment as the adapter caches. +- **No decisions:** derivation never hides anything. Collapsed-activity units, work indicator, and empty states arrive pre-decided from the ledger/bridge and map to their own row types unchanged. + +--- + +## 5. Streaming primitives + +### 5.1 `StreamingCodeBlock` + +The centerpiece. Requirements: + +- **Sealed-line cache:** input text is split at `\n`. Every fully-received line is highlighted once via `hljs.highlight(line, { language, ignoreIllegals: true })` and cached by line index; a sealed line never re-tokenizes (streaming text is append-only). Rendered as one `` per line with `dangerouslySetInnerHTML`, inside a single `
`. *(Implementation note: highlight.js v11 removed the v10 `continuation` parameter, so per-line highlighting is stateless — multi-line constructs (template literals, block comments) may tint imperfectly mid-stream. This self-repairs at finalize, when the committed path does its one-shot whole-text highlight; the trade is documented in the component header.)*
+- **The live tail line** re-renders per delta (tiny — one line of tokenization).
+- **Stable identity:** keyed by `blockIndex` (or artifact id) alone. Language arriving late is a prop change: it invalidates the cache from line 0 once (cheap — happens within the first few deltas) but never remounts the component.
+- **Language resolution:** explicit fence/file-path language wins; else defer auto-detection until either 10 sealed lines or finalize, then run `highlightAuto` once and re-seal (one-time cost, no per-delta autodetect).
+- **Finalize is a no-op visually:** the last line seals; status flips on the parent card. No swap to a different component. Committed re-render of the same content produces identical DOM (streaming ≈ final).
+- **Caps:** live blocks over a threshold (e.g. 2,000 lines) stop sealing with highlight and fall back to plain sealed lines (still append-only) — matching the existing `highlight={false}` O(bytes²) caution; the committed/expanded view can still offer full highlight or Monaco.
+- Reused by: prose open fences, `FileWriteCard` live content, `FileEditCard` pre-parse raw input, `GenericToolCard` params-as-JSON live view (language `json`).
+
+### 5.2 `SegmentedMarkdown`
+
+- Splits streaming text at fence boundaries into **closed segments** + **open tail**. Each closed segment renders through the existing `ReactMarkdown` pipeline inside a `memo` whose key is the segment text — a closed segment never re-parses (fixes both the O(len²) reparse and the "second fence re-parses the first" defect in `splitStreamingCodeFence`).
+- The open tail: prose → lightweight streaming renderer (the `remark-breaks` behavior preserved for line-break fidelity); open fence → `StreamingCodeBlock`.
+- On finalize, the whole text renders once through the normal committed markdown path — which produces the same visual output by construction (same fence renderer underneath: `MarkdownCode` routes to `CodeView`, which shares highlight output with sealed `StreamingCodeBlock` lines).
+- Segment boundary detection must handle the `splitStreamingCodeFence` edge cases: fence markers inside inline code, `~~~` fences, indented fences (keep the current conservative triple-backtick-only heuristic — documented as such).
+
+### 5.3 `AnsiText`
+
+- A small SGR parser: supports color (16/256/24-bit), bold, dim, italic, underline, inverse, reset. NOT a terminal emulator: no cursor movement, no clear-line — those sequences are stripped. (Carriage-return progress bars collapse to their final line: on `\r` without `\n`, keep only the last segment — this makes spinners/progress output readable instead of garbage.)
+- Output: an array of styled ``s; colors map to the app's terminal theme tokens (`xtermTheme.ts` palette) so output matches the real terminal's look in both light/dark themes.
+- Used by `OutputWell` for all command output, live (per-delta append — parse is incremental, carrying open-style state exactly like the highlight continuation) and committed.
+
+### 5.4 `OutputWell`
+
+- The single collapsible output region replacing both `TruncatedOutputRow`s: first N lines visible (default 3, expandable), max-height with internal scroll when expanded, monospace, `AnsiText` content, error tint driven by the VM's status, byte/line caps with an explicit "… truncated (N more lines)" affordance — never a silent cut.
+
+### 5.5 `DiffView`
+
+- `DiffSlab` successor: per-file header (path, action badge Add/Update/Delete, move target, ± line counts), per-line red/green tint with per-line syntax highlight (keyed by file extension, same mechanism as today's `DiffSlab.tsx:37-48`), collapsible per file for multi-file patches, long-diff windowing (first/last K lines with an expander). Consumes precomputed `DiffLine[]` from extractors — the component never diffs.
+
+---
+
+## 6. Card specifications
+
+Shared chrome (`ArtifactCard`): marker column continuation (`⎿`-aligned), header row = icon + title + `StatusBadge` + `MetaChips`, body slot, expand affordance where applicable. Streaming cards show a subtle activity shimmer on the status badge only (no layout shift on completion). All cards render identically for `plane: 'committed' | 'live'` — provenance is debug metadata only.
+
+- **`CommandCard`** — header `$ ` (intelligent middle-truncation at 2 lines, click-expand for full, copy button), chips: cwd (basename with full-path tooltip), description, duration, yield/max-tokens (Codex). Body: `OutputWell` streaming ANSI output. Completion: `✓` or red `exit N` badge + duration. **Silent successes render as the compact header-only row** (command + ✓) instead of being dropped — the drop currently happens in Codex rollout synthesis (`rollout.ts:283`); the renderer requests the un-dropped form (see §8 note on the one upstream tweak this needs). Git-intent interception (`GitCardRow`) is preserved ahead of family routing, re-skinned on the kit. `write_stdin` inputs attach to the parent exec card as a "stdin →" chip-line rather than a separate naked row.
+- **`DiffCard`** — `DiffView` body; Claude Edit/MultiEdit (old/new string diff) and Codex apply_patch (patch grammar) look identical. Live: shows file path as soon as the key closes + raw streaming input in `StreamingCodeBlock` until the input parses, then flips to the real diff (same card, body swap gated on parse success — the one allowed internal transition, since a partial diff is unparseable by nature). Codex `patch_apply_end` success now renders a compact "patch applied ✓ (N files)" confirmation; failure renders the error + unified_diff through `DiffView`'s line tinting (fixing the flat-colored `CodeBlock language="diff"` path).
+- **`FileWriteCard`** — header: file path + language pill + growing line count. Body: `StreamingCodeBlock` (live-highlighted line-by-line — the upgraded Write preview). Committed: same component, `CodeView` semantics, expandable, "open in Monaco" affordance on desktop.
+- **`ReadCard`** — collapsed one-liner ("Read `src/foo.ts` — 240 lines" / "Grep `pattern` in `src/` — 12 matches") with `ExpandSection` body: `CodeView` with line numbers honoring the `N→` prefix stripping that exists today. Glob/LS: file-list rendering (monospace list, icons via `vscode-icons-js`).
+- **`WebCard`** — search: query pill + result summary text; fetch/open_page: URL (clickable, external-nav-safe per the existing link-safety rules) + result excerpt in `ExpandSection`. One card for Claude and Codex variants.
+- **`AgentCard`** — `TaskSubagentRow` re-skinned on kit chrome; behavior preserved (live status glyph, elapsed, tool counts, `SubagentMiniFeed`, notification join). Spawn matching fixed: Claude `Task` | `Agent`.
+- **`TodoCard`** — TodoRow re-skinned; live `SemanticTaskSnapshot` and committed TodoWrite render identically; completed-item strikethrough, in-progress spinner on the active item.
+- **`McpCard`** — server pill + tool name; params as collapsible pretty JSON (`StreamingCodeBlock` json while live); result: JSON → structured slab, text → `OutputWell`, honoring existing caps but with explicit truncation notices.
+- **`SlashCommandRow`** — user-plane row: `/name` pill + message text; `` in a collapsed `OutputWell`. No more raw XML.
+- **`GenericToolCard`** — the single fallback: pretty name (MCP-aware), headline (merged heuristic: `command → file_path → path → notebook_path → pattern → query → url → description`), params `ExpandSection` (pretty JSON, live-streaming safe), result via JSON slab or `OutputWell`. Identical live/committed by construction.
+- **`ThinkingBlock`** — one renderer for both planes (kills the live/committed duplicate JSX); Codex reasoning summary/full tracks as tabs-in-place; `redacted_thinking` gets an explicit "redacted by provider" pill instead of an empty fallthrough.
+- **`ImageBlockRow` successor** — base64 AND url sources; click-to-zoom lightbox (simple overlay, no dependency).
+- **`SystemRow` successor** — hooks (`PreToolUse`/`PostToolUse`) get name + matched-tool + collapsible payload; permission-mode switches get a one-line pill; remains hidden-by-default per current Feed rule, but *renders well* when shown.
+- **Usage/cost:** `Message.usage` renders as an optional per-turn hover affordance on the assistant marker (token counts, cache hits) — data currently dropped entirely; gated behind the existing debug/settings surface rather than always-on chrome.
+
+---
+
+## 7. The painter shell
+
+- `Feed.tsx` becomes a thin orchestrator: `renderItems.map` → row switch (`entry` → per-block VM rows, `semantic-block`/`semantic-text` → VM rows, `semantic-collapsed-activity` → receipt row unchanged, `work` → `WorkIndicator` unchanged, `empty` → placeholder). All scroll/lazy/sticky/debug behaviors move to `ui/hooks/` — **ported with logic verbatim** (each hook's header comment cites the Feed.tsx region it came from and why it's shaped that way; the sticky-bottom, scroll-persistence, and lazy-window logic are scarred code and must not be "improved" during the move).
+- `WorkIndicator` keeps its `streamPhase`-driven contract; its tool-hint vocabulary is enriched from the VM registry (per-family verbs: "Running `npm test`", "Editing `foo.ts`", "Searching the web") — display-only, no phase-machine changes.
+- Memo strategy: `Feed` stays whole-component memo; every artifact row is `memo` keyed on its VM reference; the VM cache (§4) makes those memos effective. The tool-index context clone-on-version-bump behavior is preserved.
+
+---
+
+## 8. Migration & rollout
+
+Each phase is a shippable PR that deletes what it replaces (opportunistic cleanup, no long-lived duplication). No feature flags — visual changes ship directly; the corpus gates structural regressions.
+
+1. **Phase 1 — primitives:** `kit/` lands with `StreamingCodeBlock`, `SegmentedMarkdown`, `AnsiText`, `OutputWell`, `StatusBadge`, `ExpandSection`. Immediate surgical swaps inside the existing rows: live fence Monaco → `StreamingCodeBlock`; `StreamingProse` internals → `SegmentedMarkdown`; both `TruncatedOutputRow`s → `OutputWell` (ANSI on). Fixes the worst jank before any structural change.
+2. **Phase 2 — painter shell:** new thin `Feed.tsx` + `ui/hooks/` ports. Pixel-parity goal; no card changes. Debug emission verified against a saved bundle before/after.
+3. **Phase 3 — artifact layer + the big two:** `resolve/` + `artifacts/types.ts` + `CommandCard` + `GenericToolCard`. Both dispatch ladders start draining; live/committed generic drift ends here. Includes the slash-command envelope parser and `SlashCommandRow` (it's user-plane, cheap, high-visibility).
+4. **Phase 4 — code artifacts:** `DiffCard`, `FileWriteCard`, `ReadCard`, `TodoCard`. `DiffSlab`/`ClaudeRows`/`CodexRows` JSX deleted; extractors move to `providers/*/renderer/extractors.ts`.
+5. **Phase 5 — the rest + deletion:** `WebCard`, `AgentCard` (with the `Task` fix), `McpCard`, `ImageGenCard`, thinking/image/system/compaction re-skins, usage hover. Delete `Block.tsx` ladder, `BlockRow.tsx` ladder, `JsonToolRow`, `ToolUseRow`, remaining legacy rows. Update `rendering-system.md` §5 (RENDER stage) in the same PR.
+
+**One acknowledged upstream tweak (not RENDER-layer):** rendering silent-success commands requires Codex rollout synthesis to stop dropping `exit 0` empty-output results (`rollout.ts:283`). That is a mapper change in `providers/codex/renderer/transcript/`, small and additive (emit a minimal result block instead of `[]`), and lands with Phase 3. It changes committed candidates, so it runs the corpus and any divergence is triaged honestly, per the discipline. If triage shows it perturbs ledger fixtures beyond additive rows, it ships as its own reviewed PR first.
+
+**Remote client:** phases 1–2 automatically apply (same ``). A phone-side visual pass happens at each phase's end (kit is browser-pure; Monaco affordances hidden on the phone host via the existing host distinction in `SessionFeed` context).
+
+---
+
+## 9. Verification
+
+Per phase: `tsc` on both projects (`tsconfig.node.json` + `tsconfig.web.json` — build/vitest don't type-check), full vitest run (unit + bundle corpus + recording corpus must stay green; any corpus divergence triaged with a `why`, never blessed blind), `npm run fixture:audit`, and a live `npm run dev` session against real Claude and Codex agents exercising: a long streaming assistant message with 2+ code fences, an Edit/MultiEdit/apply_patch burst, a Write of a 200+ line file, a colored `npm test` run (ANSI + exit code), a failing command, a web search, an MCP call, a subagent spawn, a slash command, and a provider switch mid-session (transcript translation renders through the same committed path). Performance check: the existing performance panel + a before/after on the "long session + active stream" scenario (frame drops during streaming are the metric that motivated the whole rewrite).
+
+## 10. Risks & mitigations
+
+- **hljs continuation-state API stability** — `continuation` is semi-documented; pin behavior with a scratch harness during Phase 1 development and encapsulate it entirely inside `StreamingCodeBlock` so a future engine swap (e.g. Shiki) is one file.
+- **Scroll-behavior fidelity** — mitigated by port-not-rewrite, hook-by-hook, with the region-of-origin cited; phase 2 is deliberately pixel-parity-only so scroll bugs are attributable to the move alone.
+- **Corpus perturbation from the rollout tweak** — isolated to one additive mapper change; own-PR escape hatch.
+- **Phone bundle growth** — kit is dependency-free (hljs already ships); Monaco stays desktop-lazy; verify remote-client bundle size in phase 1 CI output.
+- **Visual regressions invisible to tests** — accepted: visual quality is exactly what tests here don't gate (by repo rule, no new test files). The mitigation is the per-phase live checklist above and small, reviewable phases.
diff --git a/src/providers/claude/renderer/extractors.ts b/src/providers/claude/renderer/extractors.ts
new file mode 100644
index 00000000..4a12c512
--- /dev/null
+++ b/src/providers/claude/renderer/extractors.ts
@@ -0,0 +1,143 @@
+// Pure data extractors for Claude wire shapes — NO JSX. The artifact
+// resolve layer (src/renderer/src/features/feed/ui/resolve/) calls
+// these to normalize provider-specific payloads into ArtifactVMs; the
+// cards never see provider wire shapes. Keeping extraction here (and
+// JSX out) is the provider seam of the 2026-07 RENDER rewrite: a
+// provider can never fork the visual language again, only feed it.
+
+/** Parsed slash-command invocation envelope. Claude Code records a
+ *  slash command as a user text block containing XML-ish tags. */
+export type SlashCommandEnvelope = {
+  name: string
+  message: string | null
+  args: string | null
+}
+
+// WHY order-insensitive per-tag regexes instead of one combined match:
+// verified against real transcripts (2026-07-11) — the tag ORDER
+// VARIES between Claude Code versions: older sessions emit
+//  first, newer ones emit  first. A
+// positional grammar would silently stop matching on the next
+// upstream shuffle. Tags are single-occurrence per block in every
+// observed sample; [\s\S] because  carries raw user
+// text including newlines.
+const COMMAND_NAME_RE = /([\s\S]*?)<\/command-name>/
+const COMMAND_MESSAGE_RE = /([\s\S]*?)<\/command-message>/
+const COMMAND_ARGS_RE = /([\s\S]*?)<\/command-args>/
+const LOCAL_STDOUT_RE = /([\s\S]*?)<\/local-command-stdout>/
+
+/** Parse a user text block that is a slash-command invocation.
+ *  Returns null when the block carries no  tag —
+ *  callers fall through to normal user-text rendering, so this can
+ *  never make a prompt LESS readable. */
+export function parseSlashCommandEnvelope(text: string): SlashCommandEnvelope | null {
+  const name = COMMAND_NAME_RE.exec(text)?.[1]?.trim()
+  if (!name) return null
+  const message = COMMAND_MESSAGE_RE.exec(text)?.[1]?.trim() || null
+  const args = COMMAND_ARGS_RE.exec(text)?.[1]?.trim() || null
+  return { name, message, args }
+}
+
+/** Parse a user text block that is ONLY a local-command stdout record
+ *  (Claude Code emits it as a SEPARATE user entry following the
+ *  invocation). Content may carry ANSI escapes — real sample:
+ *  "Set model to \x1b[1mFable 5\x1b[22m…" — so render it through an
+ *  ANSI-aware surface. Returns null when the tag is absent. */
+export function parseLocalCommandStdout(text: string): string | null {
+  const m = LOCAL_STDOUT_RE.exec(text)
+  if (!m) return null
+  return m[1] ?? ''
+}
+
+/** Does this user text block belong to the slash-command surface at
+ *  all (invocation OR stdout record)? Cheap gate so the hot user-text
+ *  path doesn't run three regexes on every ordinary prompt. */
+export function isSlashCommandText(text: string): boolean {
+  return text.includes('') || text.includes('')
+}
+
+// ---------------------------------------------------------------------------
+// Edit / MultiEdit / Write input extraction (committed + live-partial)
+// ---------------------------------------------------------------------------
+
+import { parseJsonRecord } from '@shared/lib/asRecord'
+
+/** Committed Edit input — missing fields become empty strings so the
+ *  diff still renders (as all-added / all-removed) without crashing.
+ *  Moved from ClaudeRows.tsx editInput. */
+export function editInput(input: unknown): {
+  filePath: string
+  oldString: string
+  newString: string
+} {
+  const rec = (input ?? {}) as Record
+  return {
+    filePath: typeof rec.file_path === 'string' ? rec.file_path : '',
+    oldString: typeof rec.old_string === 'string' ? rec.old_string : '',
+    newString: typeof rec.new_string === 'string' ? rec.new_string : '',
+  }
+}
+
+export function multiEditInput(input: unknown): {
+  filePath: string
+  edits: Array<{ oldString: string; newString: string }>
+} {
+  const rec = (input ?? {}) as Record
+  const edits = Array.isArray(rec.edits) ? (rec.edits as Array>) : []
+  return {
+    filePath: typeof rec.file_path === 'string' ? rec.file_path : '',
+    edits: edits.map(e => ({
+      oldString: typeof e.old_string === 'string' ? e.old_string : '',
+      newString: typeof e.new_string === 'string' ? e.new_string : '',
+    })),
+  }
+}
+
+// [#285] Extract a CLOSED top-level JSON string field from a partial
+// inputJson buffer — one whose closing quote has already streamed. The
+// regex body `(?:[^"\\]|\\.)*` tolerates escaped quotes and embedded
+// newlines, so it only matches a fully-arrived value. Used ONLY during
+// the brief streaming window before the whole object is JSON-parseable;
+// the moment parseJsonRecord succeeds the authoritative parse takes
+// over. A value literally containing the key text mid-stream could
+// mis-match transiently, but it self-corrects on the next delta.
+// Moved from BlockRow.tsx.
+export function extractClosedJsonString(raw: string, key: string): string | null {
+  const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`)
+  const m = re.exec(raw)
+  if (!m) return null
+  try {
+    return JSON.parse(`"${m[1]}"`) as string
+  } catch {
+    return null
+  }
+}
+
+// [#285] Build the committed Edit/MultiEdit input object from a live
+// partial buffer, so the streaming path renders the SAME diff card the
+// committed transcript uses. Returns null until at least file_path has
+// streamed — never flash an empty "(no changes)" card. Moved from
+// BlockRow.tsx claudeLiveEditInput.
+export function partialEditInput(
+  raw: string,
+  parsed: Record | null,
+  toolName: string,
+): Record | null {
+  if (parsed) return parsed
+  const full = raw ? parseJsonRecord(raw) : null
+  if (full) return full
+  if (!raw) return null
+  const filePath = extractClosedJsonString(raw, 'file_path')
+  if (!filePath) return null
+  if (toolName === 'MultiEdit') {
+    // The edits array can't be reliably half-parsed; show the header
+    // now (file path) and let the authoritative parse fill in the
+    // per-edit diff chunks the instant the whole object completes.
+    return { file_path: filePath, edits: [] }
+  }
+  return {
+    file_path: filePath,
+    old_string: extractClosedJsonString(raw, 'old_string') ?? '',
+    new_string: extractClosedJsonString(raw, 'new_string') ?? '',
+  }
+}
diff --git a/src/providers/claude/renderer/rows/ClaudeRows.tsx b/src/providers/claude/renderer/rows/ClaudeRows.tsx
deleted file mode 100644
index cceafb79..00000000
--- a/src/providers/claude/renderer/rows/ClaudeRows.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-// Claude-specific tool row renderers extracted from Feed.tsx.
-//
-// Each component renders a specific Claude Code tool_use block with
-// rich formatting: Edit/MultiEdit show line-level diffs, Write shows
-// a green-tinted code slab, TodoWrite shows a checklist. They all
-// compose MarkerRow from the shared Feed framework and import
-// provider-agnostic helpers (diffLines, DiffLine) from core/parsers.
-//
-// Lives under feed/claude/ so codex can have its own sibling set
-// (feed/codex/CodexRows.tsx) without mixing provider logic.
-
-import { memo, useContext, useMemo } from 'react'
-
-import { diffLines } from '@shared/parsers/lineDiff'
-import { formatToolFilePath } from '@shared/paths/displayPath'
-import type { ToolUseBlock } from '@shared/types/transcript'
-import { CodeBlock } from '@renderer/lib/code/CodeBlock'
-import { CodeRenderContext } from '@renderer/features/feed/context'
-import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow'
-import { DiffSlab } from '@providers/shared/renderer/rows/DiffSlab'
-
-/* ---------- Shared helpers ---------- */
-
-/** Pull a file path and old/new strings out of a shape we don't fully
- *  trust — the transcript typing is `unknown`. Missing fields become
- *  empty strings so the diff still renders (as "everything added"
- *  or "everything removed") without crashing. */
-function editInput(
-  block: ToolUseBlock,
-): { filePath: string; oldString: string; newString: string } {
-  const input = (block.input ?? {}) as Record
-  return {
-    filePath: typeof input.file_path === 'string' ? input.file_path : '',
-    oldString: typeof input.old_string === 'string' ? input.old_string : '',
-    newString: typeof input.new_string === 'string' ? input.new_string : '',
-  }
-}
-
-/** Header row for file-tool blocks: "⏺ Edit  "
- *
- * WHY we show a workspace-relative path instead of the basename:
- * agents hand us absolute paths, and a repo can easily have a dozen
- * files called `index.tsx`; the basename alone is ambiguous. We pull
- * `workspaceRoot` from CodeRenderContext (= the session cwd) and
- * render the path relative to it. Paths outside the workspace stay
- * absolute so the user notices edits to tempfiles, dotfiles, or
- * files in another project. See shared/paths/displayPath.ts for the
- * formatting rule.
- *
- * The `title` attribute still carries the raw filePath so hover
- * always reveals the unambiguous absolute location regardless of
- * which form we render.
- */
-function FileToolHeader({
-  name,
-  filePath,
-  extra,
-}: {
-  name: string
-  filePath: string
-  extra?: string
-}) {
-  const { workspaceRoot } = useContext(CodeRenderContext)
-  const display = formatToolFilePath(filePath, workspaceRoot)
-  return (
-    
- {name} - {display && ( - // Left-side truncation so the filename stays visible when the - // pane is narrow. `text-overflow: ellipsis` only drops from the - // end of the text in the *writing direction*, so we flip the - // container to RTL and re-align to the left: overflow now - // collapses the leading `src/renderer/src/...` portion while - // the trailing `Feed.tsx` — the part the user actually wants - // to see — always remains on-screen. - // - // Caveat: RTL direction can reorder neutral characters (e.g. - // `/`, `.`) at the very start or end of the string. File - // paths are strong-LTR runs of ASCII letters with neutrals - // only between them, so in practice they render correctly - // without extra bidi isolates. - - {display} - - )} - {extra && {extra}} -
- ) -} - -/* ---------- Edit ---------- */ - -export const EditRow = memo(function EditRow({ block }: { block: ToolUseBlock }) { - const { filePath, oldString, newString } = editInput(block) - const lines = useMemo( - () => diffLines(oldString, newString), - [oldString, newString], - ) - return ( - -
- - -
-
- ) -}) - -/* ---------- MultiEdit ---------- */ - -export const MultiEditRow = memo(function MultiEditRow({ - block, -}: { - block: ToolUseBlock -}) { - const input = (block.input ?? {}) as Record - const filePath = - typeof input.file_path === 'string' ? input.file_path : '' - const edits = Array.isArray(input.edits) - ? (input.edits as Array>) - : [] - const normalized = edits.map(e => ({ - oldString: typeof e.old_string === 'string' ? e.old_string : '', - newString: typeof e.new_string === 'string' ? e.new_string : '', - })) - return ( - -
- -
- {normalized.map((e, i) => ( - - ))} -
-
-
- ) -}) - -const MultiEditChunk = memo(function MultiEditChunk({ - index, - total, - filePath, - edit, -}: { - index: number - total: number - filePath: string - edit: { oldString: string; newString: string } -}) { - const lines = useMemo( - () => diffLines(edit.oldString, edit.newString), - [edit.oldString, edit.newString], - ) - return ( -
- {total > 1 && ( -
- change {index + 1} / {total} -
- )} - -
- ) -}) - -/* ---------- Write ---------- */ - -export const WriteRow = memo(function WriteRow({ block }: { block: ToolUseBlock }) { - const input = (block.input ?? {}) as Record - const filePath = typeof input.file_path === 'string' ? input.file_path : '' - const content = typeof input.content === 'string' ? input.content : '' - const codeContext = useContext(CodeRenderContext) - const lineCount = useMemo(() => { - if (!content) return 0 - const normalized = content.endsWith('\n') ? content.slice(0, -1) : content - return normalized === '' ? 0 : normalized.split('\n').length - }, [content]) - return ( - -
- - -
-
- ) -}) - -/* ---------- TodoWrite ---------- */ - -type TodoItem = { - content: string - status: 'pending' | 'in_progress' | 'completed' - activeForm: string -} - -function parseTodos(block: ToolUseBlock): TodoItem[] { - const input = (block.input ?? {}) as Record - const raw = Array.isArray(input.todos) ? input.todos : [] - return raw.map(t => { - const item = (t ?? {}) as Record - const status = - item.status === 'in_progress' || item.status === 'completed' - ? item.status - : 'pending' - return { - content: typeof item.content === 'string' ? item.content : '', - status, - activeForm: typeof item.activeForm === 'string' ? item.activeForm : '', - } - }) -} - -// `label` exists for cross-provider reuse (2026-07-06): OpenCode's -// `todowrite` tool emits the same `{todos:[{content,status,…}]}` input shape -// this row already parses defensively, so its dispatch reuses TodoRow rather -// than cloning it — but the header must name the tool the AGENT actually -// called, not hardcode Claude's "TodoWrite". -export const TodoRow = memo(function TodoRow({ block, label = 'TodoWrite' }: { block: ToolUseBlock; label?: string }) { - const todos = useMemo(() => parseTodos(block), [block]) - const done = todos.filter(t => t.status === 'completed').length - return ( - -
-
- {label} - - {done} / {todos.length} done - -
- {todos.length === 0 ? ( -
(empty list)
- ) : ( -
    - {todos.map((t, i) => ( - - ))} -
- )} -
-
- ) -}) - -const TodoItemRow = memo(function TodoItemRow({ item }: { item: TodoItem }) { - const glyph = - item.status === 'completed' - ? '☑' - : item.status === 'in_progress' - ? '◐' - : '☐' - const textCls = - item.status === 'completed' - ? 'text-muted line-through' - : item.status === 'in_progress' - ? 'text-ink' - : 'text-ink-dim' - const glyphCls = - item.status === 'completed' - ? 'text-accent' - : item.status === 'in_progress' - ? 'text-accent' - : 'text-muted' - const label = - item.status === 'in_progress' && item.activeForm - ? item.activeForm - : item.content - return ( -
  • - - - {label} - -
  • - ) -}) diff --git a/src/providers/claude/renderer/rows/dispatch.tsx b/src/providers/claude/renderer/rows/dispatch.tsx index 3eb0839d..c8709cab 100644 --- a/src/providers/claude/renderer/rows/dispatch.tsx +++ b/src/providers/claude/renderer/rows/dispatch.tsx @@ -1,31 +1,6 @@ import type { ReactNode } from 'react' -import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' -import { - EditRow, - MultiEditRow, - TodoRow, - WriteRow, -} from '@providers/claude/renderer/rows/ClaudeRows' - -export function renderClaudeToolUse(block: ToolUseBlock): ReactNode | undefined { - // WHY this dispatch lives with the provider rows: these names are Claude Code - // transcript vocabulary, not feed vocabulary. Keeping the table beside the - // row components makes adding/removing a Claude tool a provider-local change - // and lets the shared feed keep one generic fallback for unknown tools. - switch (block.name) { - case 'Edit': - return - case 'MultiEdit': - return - case 'Write': - return - case 'TodoWrite': - return - default: - return undefined - } -} +import type { ToolResultBlock } from '@shared/types/transcript' export function renderClaudeToolResult( _block: ToolResultBlock, diff --git a/src/providers/codex/renderer/extractors.ts b/src/providers/codex/renderer/extractors.ts new file mode 100644 index 00000000..a54e38be --- /dev/null +++ b/src/providers/codex/renderer/extractors.ts @@ -0,0 +1,471 @@ +// Pure data extractors for Codex wire shapes — NO JSX. See the header +// of providers/claude/renderer/extractors.ts for why this seam exists. + +import { asRecord } from '@shared/lib/asRecord' +import type { ToolResultBlock } from '@shared/types/transcript' + +export type ExecCommandInput = { + command: string + workdir: string | null + yieldTimeMs: number | null + maxOutputTokens: number | null +} + +/** Parse an exec_command tool_use input. Codex passes the command as + * `cmd` (string OR pre-split argv array) or `command`; the metadata + * keys ride alongside. Canonical home — CodexRows.tsx imports this + * (it used to own a private copy). */ +export function execCommandInput(input: unknown): ExecCommandInput | null { + const rec = asRecord(input) + if (!rec) return null + const rawCommand = rec.cmd ?? rec.command + const command = Array.isArray(rawCommand) + ? rawCommand.map(String).join(' ') + : typeof rawCommand === 'string' + ? rawCommand + : '' + if (!command.trim()) return null + return { + command, + workdir: typeof rec.workdir === 'string' ? rec.workdir : null, + yieldTimeMs: typeof rec.yield_time_ms === 'number' ? rec.yield_time_ms : null, + maxOutputTokens: + typeof rec.max_output_tokens === 'number' ? rec.max_output_tokens : null, + } +} + +/** Codex exec metadata stamped on the tool_result block by the rollout + * mapper (codexToolResultEntry's `codex` extension — the source of + * truth is rollout.ts's exec_command_end branch). Claude has no + * equivalent; its results return nulls here. */ +export function codexResultMeta(result: ToolResultBlock | null): { + exitCode: number | null + cwd: string | null + durationMs: number | null +} { + const meta = asRecord(result?.codex) + if (!meta) return { exitCode: null, cwd: null, durationMs: null } + return { + exitCode: typeof meta.exitCode === 'number' ? meta.exitCode : null, + cwd: typeof meta.cwd === 'string' ? meta.cwd : null, + // Unified-exec wall time, captured when the output wrapper was + // stripped (entries.ts stripUnifiedExecWrapper). + durationMs: typeof meta.durationMs === 'number' ? meta.durationMs : null, + } +} + +/** Standalone patch_apply_end meta (unified-exec era: its call_id pairs + * with no tool_use — see rollout.ts). */ +export function patchResultMeta(result: ToolResultBlock): { + isPatchResult: boolean + success: boolean + files: string[] + diffs: Record +} { + const meta = asRecord(result.codex) + if (meta?.kind !== 'patch_apply_end') { + return { isPatchResult: false, success: false, files: [], diffs: {} } + } + const files = Array.isArray(meta.files) + ? meta.files.filter((f): f is string => typeof f === 'string') + : [] + const diffsRec = asRecord(meta.diffs) ?? {} + const diffs: Record = {} + for (const [k, v] of Object.entries(diffsRec)) { + if (typeof v === 'string') diffs[k] = v + } + return { isPatchResult: true, success: meta.success === true, files, diffs } +} + +/** Codex's exec classifier: parsed_cmd[0].type marks a command as a + * file read or search (the meta the old ExpandableCodeResult summary + * keyed on). */ +export function parsedReadFromResult( + result: ToolResultBlock | null, +): { kind: 'read' | 'search'; path: string | null } | null { + const meta = asRecord(result?.codex) + const parsedCmd = meta?.parsedCmd + if (!Array.isArray(parsedCmd)) return null + const first = asRecord(parsedCmd[0]) + const kind = first?.type + if (kind !== 'read' && kind !== 'search') return null + const path = + typeof first?.path === 'string' + ? first.path + : typeof first?.name === 'string' + ? first.name + : null + return { kind, path } +} + +// --------------------------------------------------------------------------- +// apply_patch — Codex's `*** Begin Patch` grammar (NOT unified diff) +// --------------------------------------------------------------------------- +// Moved from CodexRows.tsx so the artifact resolvers can parse patches +// without importing JSX. The grammar: `*** Begin Patch`, then per file +// `*** (Add|Update|Delete) File: ` (+ optional `*** Move to:`), +// body lines prefixed +/-/space, `@@` section markers, `*** End Patch`. + +import type { DiffLine } from '@shared/parsers/lineDiff' + +export type ApplyPatchFile = { + path: string + action: 'Add' | 'Update' | 'Delete' + movedTo?: string + lines: DiffLine[] +} + +export function applyPatchText(input: unknown): string { + if (typeof input === 'string') return input + const rec = asRecord(input) + if (typeof rec?.raw === 'string') return rec.raw + if (typeof rec?.arguments === 'string') return rec.arguments + if (typeof rec?.cmd === 'string') return rec.cmd + if (typeof rec?.patch === 'string') return rec.patch + if (typeof rec?.input === 'string') return rec.input + return '' +} + +export function parseApplyPatch(input: unknown): ApplyPatchFile[] { + const text = applyPatchText(input) + if (!text.includes('*** Begin Patch')) return [] + + const files: ApplyPatchFile[] = [] + let current: ApplyPatchFile | null = null + + for (const rawLine of text.split('\n')) { + const fileMatch = rawLine.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/) + if (fileMatch) { + current = { + action: fileMatch[1] as ApplyPatchFile['action'], + path: fileMatch[2] ?? '', + lines: [], + } + files.push(current) + continue + } + + if (!current) continue + + const moveMatch = rawLine.match(/^\*\*\* Move to: (.+)$/) + if (moveMatch) { + current.movedTo = moveMatch[1] ?? '' + continue + } + + if ( + rawLine === '*** Begin Patch' || + rawLine === '*** End Patch' || + rawLine === '*** End of File' || + rawLine.startsWith('@@') + ) { + continue + } + + if (rawLine.startsWith('+')) { + current.lines.push({ kind: '+', text: rawLine.slice(1) }) + } else if (rawLine.startsWith('-')) { + current.lines.push({ kind: '-', text: rawLine.slice(1) }) + } else if (rawLine.startsWith(' ')) { + current.lines.push({ kind: 'ctx', text: rawLine.slice(1) }) + } else if (rawLine === '') { + // Models frequently emit blank context lines WITHOUT the leading + // space; dropping them visually compressed rendered patches + // (PR524 review). + current.lines.push({ kind: 'ctx', text: '' }) + } + } + + return files +} + +/** Classify a unified diff (patch_apply_end error payloads carry one + * per file under codex.changes[path].unified_diff) into DiffLine[] + * for tinted rendering. Deliberately tiny: header/hunk lines are + * dropped, +/-/space prefixes map straight to line kinds — this is a + * display classifier, not a patch applier. */ +export function unifiedDiffToLines(diff: string): DiffLine[] { + const out: DiffLine[] = [] + for (const line of diff.split('\n')) { + if ( + line.startsWith('diff --git') || + line.startsWith('index ') || + // Header forms carry a space (`+++ b/x`) — matching the bare + // prefix dropped real content lines like a deletion of `--flag` + // (arrives as `---flag`; PR524 review). + line.startsWith('+++ ') || + line.startsWith('--- ') || + line === '+++' || + line === '---' || + line.startsWith('@@') + ) { + continue + } + if (line.startsWith('+')) out.push({ kind: '+', text: line.slice(1) }) + else if (line.startsWith('-')) out.push({ kind: '-', text: line.slice(1) }) + else out.push({ kind: 'ctx', text: line.startsWith(' ') ? line.slice(1) : line }) + } + // Trim trailing blank ctx line a trailing \n produces. + while (out.length > 0 && out[out.length - 1].kind === 'ctx' && out[out.length - 1].text === '') out.pop() + return out +} + +/** Per-file unified diffs from a patch_apply_end result's codex meta. */ +export function patchChangesFromResult( + result: ToolResultBlock | null, +): Array<{ path: string; lines: DiffLine[] }> { + const meta = asRecord(result?.codex) + const changes = asRecord(meta?.changes) + if (!changes) return [] + return Object.entries(changes).map(([path, change]) => { + const rec = asRecord(change) + const diff = typeof rec?.unified_diff === 'string' ? rec.unified_diff : '' + return { path, lines: diff ? unifiedDiffToLines(diff) : [] } + }) +} + +// --------------------------------------------------------------------------- +// Live-partial apply_patch payloads (moved from BlockRow.tsx) +// --------------------------------------------------------------------------- +// While an apply_patch call streams, the payload may be a JSON wrapper +// whose patch text sits inside a STILL-OPEN string literal +// (`{"cmd": "*** Begin Patch\n..."`). JSON.parse can't touch it, so +// this decodes the partial string member by hand — same fixed-spec +// escape table as streamingWriteInput's scanner. + +const SIMPLE_JSON_ESCAPES: Record = { + '"': '"', + '\\': '\\', + '/': '/', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', +} + +function decodePartialJsonStringBody(raw: string, start: number): string { + let out = '' + let i = start + while (i < raw.length) { + const ch = raw[i] + if (ch === '"') return out + if (ch === '\\') { + if (i + 1 >= raw.length) return out + const esc = raw[i + 1] + if (esc === 'u') { + const hex = raw.slice(i + 2, i + 6) + if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) return out + out += String.fromCharCode(parseInt(hex, 16)) + i += 6 + continue + } + out += SIMPLE_JSON_ESCAPES[esc] ?? esc + i += 2 + continue + } + out += ch + i += 1 + } + return out +} + +function extractPartialJsonStringMember(raw: string, keys: string[]): string | null { + for (const key of keys) { + const marker = `"${key}"` + const keyAt = raw.indexOf(marker) + if (keyAt === -1) continue + const colonAt = raw.indexOf(':', keyAt + marker.length) + if (colonAt === -1) continue + let valueAt = colonAt + 1 + while (valueAt < raw.length && /\s/.test(raw[valueAt] ?? '')) valueAt += 1 + if (raw[valueAt] !== '"') continue + return decodePartialJsonStringBody(raw, valueAt + 1) + } + return null +} + +/** Normalize a live (possibly partial) apply_patch buffer into the + * `{ raw: }` shape parseApplyPatch understands — + * bare grammar, complete JSON wrapper, or partial JSON wrapper. */ +export function partialApplyPatchInput(raw: string): Record { + // Bare grammar ONLY when the buffer actually starts with the patch + // header. `includes` alone fires on a JSON wrapper whose ESCAPED + // patch contains the marker literally (`{"cmd": "*** Begin Patch\n…`), + // returning the escaped buffer — whose newlines are two-char \n + // sequences parseApplyPatch can't split on — and making the partial + // string-member decode below unreachable (found by PR524 review). + if (raw.trimStart().startsWith('*** Begin Patch')) return { raw } + const patch = extractPartialJsonStringMember(raw, [ + 'cmd', + 'patch', + 'input', + 'raw', + 'arguments', + ]) + return patch && patch.includes('*** Begin Patch') ? { raw: patch } : { raw, arguments: raw } +} + +// --------------------------------------------------------------------------- +// Unified exec — Codex's modern `exec` tool (custom_tool_call, name "exec") +// --------------------------------------------------------------------------- +// The input is not JSON: it is a JavaScript SCRIPT the model writes, e.g. +// const r = await tools.exec_command({"cmd":"sed -n '1,40p' x.ts", ...}); +// text(r.output); +// or +// const patch = "*** Begin Patch\n*** Update File: x.ts\n@@\n-a\n+b\n*** End Patch"; +// text(await tools.apply_patch(patch)); +// Codex's own TUI renders the script's INTENT ("Edited x.ts (+7 -3)"); +// rendering the raw script as a generic card is exactly the regression a +// 2026-07-12 debug bundle caught. This classifier extracts the intent so +// the artifact layer can route to DiffCard / CommandCard. It must be +// PARTIAL-SAFE: the script streams, so the embedded string literal / +// JSON argument may be unterminated at any prefix. + +export type UnifiedExecAction = + | { kind: 'apply_patch'; patchText: string } + | { kind: 'exec_command'; input: ExecCommandInput } + | { kind: 'write_stdin'; chars: string } + /** tools.wait(...) — waiting on a background terminal session. */ + | { kind: 'wait' } + | null + +/** Decode a JS double-quoted string literal starting just AFTER the + * opening quote, tolerating an unterminated (still-streaming) tail. + * JS escapes overlap JSON's for everything models emit here. */ +function decodeJsStringLiteral(script: string, start: number): string { + return decodePartialJsonStringBody(script, start) +} + +/** Extract the first balanced {...} JSON object argument following + * `marker` in the script, searching from `from`. Returns the parsed + * object when complete; null while the braces are still streaming + * (caller falls back to partial string-member extraction). Brace + * matching skips string literals so a "}" inside a cmd string can't + * end the scan early. */ +function jsonObjectArgAfter( + script: string, + marker: string, + from = 0, +): Record | null { + const at = script.indexOf(marker, from) + if (at === -1) return null + const open = script.indexOf('{', at + marker.length) + if (open === -1) return null + let depth = 0 + let inString = false + for (let i = open; i < script.length; i++) { + const ch = script[i] + if (inString) { + if (ch === '\\') i += 1 + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === '{') depth += 1 + else if (ch === '}') { + depth -= 1 + if (depth === 0) { + try { + return JSON.parse(script.slice(open, i + 1)) as Record + } catch { + return null + } + } + } + } + return null +} + +export function classifyUnifiedExecScript(script: string): UnifiedExecAction { + if (!script) return null + + // apply_patch first: an edit is the story even if the script also + // echoes output. The patch text lives in a string literal (either + // inline in the call or bound to a const) — find the literal that + // contains the patch header and decode it, partial-safe. + if (script.includes('tools.apply_patch')) { + const headerAt = script.indexOf('*** Begin Patch') + if (headerAt !== -1) { + const quoteAt = script.lastIndexOf('"', headerAt) + if (quoteAt !== -1) { + const patchText = decodeJsStringLiteral(script, quoteAt + 1) + if (patchText.includes('*** Begin Patch')) { + return { kind: 'apply_patch', patchText } + } + } + } + // Header hasn't streamed in yet — signal apply_patch with an empty + // patch so the card can show its header instead of a raw script. + return { kind: 'apply_patch', patchText: '' } + } + + if (script.includes('tools.wait(')) { + return { kind: 'wait' } + } + + if (script.includes('tools.write_stdin')) { + const arg = jsonObjectArgAfter(script, 'tools.write_stdin') + const chars = + typeof arg?.chars === 'string' + ? arg.chars + : extractPartialJsonStringMember(script, ['chars']) ?? '' + // Empty stdin IS a wait: Codex polls a background terminal with + // chars:"" while output drains, and its native TUI renders exactly + // these as "Waited for background terminal". + if (chars === '') return { kind: 'wait' } + return { kind: 'write_stdin', chars } + } + + if (script.includes('tools.exec_command')) { + // Collect EVERY exec_command call in the script — Promise.all + // fan-outs run several commands from one script, and the native + // TUI shows one cell per inner call. We render them as command + // lines in one card (the rollout gives us one tool_use to hang + // them on), which reads the same. + const inputs: ExecCommandInput[] = [] + let from = 0 + for (;;) { + const at = script.indexOf('tools.exec_command', from) + if (at === -1) break + const arg = jsonObjectArgAfter(script, 'tools.exec_command', at) + const parsed = arg ? execCommandInput(arg) : null + if (parsed) inputs.push(parsed) + from = at + 'tools.exec_command'.length + } + if (inputs.length > 0) { + const first = inputs[0] + const input: ExecCommandInput = + inputs.length === 1 + ? first + : { + ...first, + command: inputs.map(i => i.command).join('\n'), + } + return { kind: 'exec_command', input } + } + // Partial braces: pull what has streamed so far (cmd is the one + // that matters for the header; the metadata chips fill in later). + const cmd = extractPartialJsonStringMember(script, ['cmd', 'command']) + if (cmd !== null) { + return { + kind: 'exec_command', + input: { command: cmd, workdir: null, yieldTimeMs: null, maxOutputTokens: null }, + } + } + return { kind: 'exec_command', input: { command: '…', workdir: null, yieldTimeMs: null, maxOutputTokens: null } } + } + + return null +} + +/** The unified-exec script text from a tool input, both planes: + * committed rollout synthesis wraps the non-JSON script as { raw }; + * the live plane hands the raw argumentsJson buffer straight in. */ +export function unifiedExecScript(input: unknown): string { + if (typeof input === 'string') return input + const rec = asRecord(input) + if (typeof rec?.raw === 'string') return rec.raw + if (typeof rec?.input === 'string') return rec.input + return '' +} diff --git a/src/providers/codex/renderer/rows/CodexRows.tsx b/src/providers/codex/renderer/rows/CodexRows.tsx index 95c7de12..8db320ce 100644 --- a/src/providers/codex/renderer/rows/CodexRows.tsx +++ b/src/providers/codex/renderer/rows/CodexRows.tsx @@ -1,16 +1,15 @@ -import { memo, useContext, useMemo, useState } from 'react' +import { memo, useContext, useState } from 'react' -import type { DiffLine } from '@shared/parsers/lineDiff' import { CodeBlock } from '@renderer/lib/code/CodeBlock' import { CodeRenderContext } from '@renderer/features/feed/context' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' import { formatToolFilePath } from '@shared/paths/displayPath' -import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import type { ToolResultBlock } from '@shared/types/transcript' import { JsonResultSlab } from '@providers/shared/renderer/rows/JsonResultSlab' import { tryExtractJson } from '@providers/shared/renderer/rows/jsonToolPresentation' import { asRecord } from '@shared/lib/asRecord' -import { DiffSlab } from '@providers/shared/renderer/rows/DiffSlab' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' // WHY the import switch matters here: the local copy this replaced // did NOT exclude arrays — it returned `value as Record<...>` for // any non-null object including arrays. The shared helper rejects @@ -34,244 +33,6 @@ function textFromContent(content: unknown): string { return String(content ?? '') } -function summarizePatchTargets(input: unknown): string[] { - const text = - typeof input === 'string' - ? input - : typeof asRecord(input)?.raw === 'string' - ? String(asRecord(input)?.raw) - : '' - if (!text) return [] - const matches = [...text.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)] - return matches.map(match => match[1]).slice(0, 6) -} - -type ApplyPatchFile = { - path: string - action: 'Add' | 'Update' | 'Delete' - movedTo?: string - lines: DiffLine[] -} - -function applyPatchText(input: unknown): string { - if (typeof input === 'string') return input - const rec = asRecord(input) - if (typeof rec?.raw === 'string') return rec.raw - if (typeof rec?.arguments === 'string') return rec.arguments - if (typeof rec?.cmd === 'string') return rec.cmd - if (typeof rec?.patch === 'string') return rec.patch - if (typeof rec?.input === 'string') return rec.input - return '' -} - -function parseApplyPatch(input: unknown): ApplyPatchFile[] { - const text = applyPatchText(input) - if (!text.includes('*** Begin Patch')) return [] - - const files: ApplyPatchFile[] = [] - let current: ApplyPatchFile | null = null - - for (const rawLine of text.split('\n')) { - const fileMatch = rawLine.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/) - if (fileMatch) { - current = { - action: fileMatch[1] as ApplyPatchFile['action'], - path: fileMatch[2] ?? '', - lines: [], - } - files.push(current) - continue - } - - if (!current) continue - - const moveMatch = rawLine.match(/^\*\*\* Move to: (.+)$/) - if (moveMatch) { - current.movedTo = moveMatch[1] ?? '' - continue - } - - if ( - rawLine === '*** Begin Patch' || - rawLine === '*** End Patch' || - rawLine === '*** End of File' || - rawLine.startsWith('@@') - ) { - continue - } - - if (rawLine.startsWith('+')) { - current.lines.push({ kind: '+', text: rawLine.slice(1) }) - } else if (rawLine.startsWith('-')) { - current.lines.push({ kind: '-', text: rawLine.slice(1) }) - } else if (rawLine.startsWith(' ')) { - current.lines.push({ kind: 'ctx', text: rawLine.slice(1) }) - } - } - - return files -} - -function PatchFileHeader({ - action, - path, - movedTo, -}: { - action: ApplyPatchFile['action'] - path: string - movedTo?: string -}) { - const { workspaceRoot } = useContext(CodeRenderContext) - const display = formatToolFilePath(path, workspaceRoot) - const movedDisplay = movedTo ? formatToolFilePath(movedTo, workspaceRoot) : null - const extra = movedDisplay - ? `${action.toLowerCase()} -> ${movedDisplay}` - : action.toLowerCase() - return ( -
    - ApplyPatch - {display && ( - - {display} - - )} - {extra} -
    - ) -} - -function headlineForTool(block: ToolUseBlock): string | null { - const input = asRecord(block.input) - if (!input) return null - - if (block.name === 'write_stdin') { - const chars = input.chars - if (typeof chars === 'string' && chars.length > 0) return chars - return null - } - - if (block.name === 'exec_command') { - const cmd = input.cmd - if (typeof cmd === 'string') return cmd - if (Array.isArray(cmd)) return cmd.join(' ') - } - - if (block.name === 'apply_patch') { - const targets = summarizePatchTargets(block.input) - if (targets.length > 0) return targets.join('\n') - } - - if (typeof input.command === 'string') return input.command - // Path BEFORE description (corpus bug, plan-json-tool-rows §1b): - // ai_workspace_attach_file showed its description gloss while hiding - // the actual file path. A path is an identifier; a description is - // commentary. - if (typeof input.path === 'string') return input.path - if (typeof input.description === 'string') return input.description - if (typeof input.arguments === 'string') return input.arguments.slice(0, 160) - if (typeof input.raw === 'string' && block.name !== 'apply_patch') return input.raw.slice(0, 160) - return null -} - -const MAX_COMMAND_DISPLAY_LINES = 2 -const MAX_COMMAND_DISPLAY_CHARS = 160 -const RESULT_MAX_LINES = 3 - -type ExecCommandInput = { - command: string - workdir: string | null - yieldTimeMs: number | null - maxOutputTokens: number | null -} - -function execCommandInput(input: unknown): ExecCommandInput | null { - const rec = asRecord(input) - if (!rec) return null - const rawCommand = rec.cmd ?? rec.command - const command = Array.isArray(rawCommand) - ? rawCommand.map(String).join(' ') - : typeof rawCommand === 'string' - ? rawCommand - : '' - if (!command.trim()) return null - return { - command, - workdir: typeof rec.workdir === 'string' ? rec.workdir : null, - yieldTimeMs: typeof rec.yield_time_ms === 'number' ? rec.yield_time_ms : null, - maxOutputTokens: - typeof rec.max_output_tokens === 'number' ? rec.max_output_tokens : null, - } -} - -function truncateCommand(text: string): string { - const lines = text.split('\n') - const needsLineTruncation = lines.length > MAX_COMMAND_DISPLAY_LINES - const needsCharTruncation = text.length > MAX_COMMAND_DISPLAY_CHARS - if (!needsLineTruncation && !needsCharTruncation) return text - - let truncated = text - if (needsLineTruncation) { - truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n') - } - if (truncated.length > MAX_COMMAND_DISPLAY_CHARS) { - truncated = truncated.slice(0, MAX_COMMAND_DISPLAY_CHARS) - } - return truncated.trimEnd() + '…' -} - -export const CodexExecCommandRow = memo(function CodexExecCommandRow({ - block, -}: { - block: ToolUseBlock -}) { - const { workspaceRoot } = useContext(CodeRenderContext) - const parsed = useMemo(() => execCommandInput(block.input), [block.input]) - if (!parsed) return - - const displayWorkdir = parsed.workdir - ? formatToolFilePath(parsed.workdir, workspaceRoot) - : null - const meta = [ - displayWorkdir ? `cwd ${displayWorkdir}` : null, - parsed.yieldTimeMs !== null ? `yield ${parsed.yieldTimeMs}ms` : null, - parsed.maxOutputTokens !== null ? `max ${parsed.maxOutputTokens} tokens` : null, - ].filter((item): item is string => item !== null) - - return ( - -
    -
    - Run - {meta.length > 0 && ( - - {meta.join(' · ')} - - )} -
    - {/* WHY this is a command-specific row instead of the generic - `exec_command` tool card: - - Codex's semantic name is an implementation detail. The - user-facing object is the shell command, and the 2026-05-16 - HTML bundle showed the old card wasting the first line on - `exec_command` while burying the actual command in a nested - child row. That made dense debug sessions scan like a list - of identical provider internals. Keeping the marker row but - promoting the command to the primary surface makes live and - committed command calls readable without changing ownership - or result pairing. */} -
    -          $ 
    -          {truncateCommand(parsed.command)}
    -        
    -
    -
    - ) -}) - function detectDiff(text: string): boolean { return text.startsWith('diff --git ') || text.startsWith('@@ ') } @@ -366,142 +127,6 @@ function ExpandableCodeResult({ ) } -function TruncatedOutputRow({ - content, - isError, -}: { - content: string - isError: boolean -}) { - const [expanded, setExpanded] = useState(false) - const lines = content.length === 0 ? [] : content.split('\n') - const needsTruncation = lines.length > RESULT_MAX_LINES - const shown = expanded || !needsTruncation - ? content - : lines.slice(0, RESULT_MAX_LINES).join('\n') - const hiddenCount = needsTruncation ? lines.length - RESULT_MAX_LINES : 0 - - return ( - -
    -
    -          {shown || '(no output)'}
    -        
    - {needsTruncation && ( - - )} -
    -
    - ) -} - -export const CodexToolRow = memo(function CodexToolRow({ - block, -}: { - block: ToolUseBlock -}) { - const headline = useMemo(() => { - const raw = headlineForTool(block) - if (!raw) return null - if (block.name === 'exec_command') return truncateCommand(raw) - return raw - }, [block]) - - return ( - -
    -
    - {block.name} -
    - {headline && ( - -
    -              {headline}
    -            
    -
    - )} -
    -
    - ) -}) - -export const CodexWriteStdinRow = memo(function CodexWriteStdinRow({ - block, -}: { - block: ToolUseBlock -}) { - const input = asRecord(block.input) - const chars = typeof input?.chars === 'string' ? input.chars : '' - - // WHY empty write_stdin renders nothing: - // Codex uses write_stdin for two very different things: real input - // into an ongoing command, and empty/poll continuation calls while - // a long-running PTY command is still draining output. The latter - // created the ugly one-line `write_stdin` rows visible in the - // 2026-05-16T18:54 bundle: no path, no command, no payload, just a - // provider implementation detail. Empty stdin has no user-visible - // content, so the command/result row remains the owner of the UI. - if (!chars) return null - - return ( - -
    -
    - stdin -
    - -
    -            {truncateCommand(chars)}
    -          
    -
    -
    -
    - ) -}) - -export const CodexApplyPatchRow = memo(function CodexApplyPatchRow({ - block, -}: { - block: ToolUseBlock -}) { - const files = useMemo(() => parseApplyPatch(block.input), [block.input]) - - if (files.length === 0) { - return - } - - return ( - -
    - {files.map((file, index) => ( -
    - - -
    - ))} -
    -
    - ) -}) - export const CodexToolResultRow = memo(function CodexToolResultRow({ block, }: { @@ -557,53 +182,10 @@ export const CodexToolResultRow = memo(function CodexToolResultRow({ } } - if (kind === 'patch_apply_end') { - if (!isError) return null - - const changes = asRecord(meta?.changes) - const items = changes ? Object.entries(changes) : [] - if (items.length > 0) { - return ( - -
    - {items.map(([filePath, change]) => { - const rec = asRecord(change) - const diff = typeof rec?.unified_diff === 'string' ? rec.unified_diff : '' - return ( -
    -
    - {formatToolFilePath(filePath, codeContext.workspaceRoot)} -
    - {diff ? ( - - ) : text ? ( - - ) : null} -
    - ) - })} -
    -
    - ) - } - } + // NOTE: the patch_apply_end branch that used to live here is GONE on + // purpose — file-edit results are suppressed upstream in Block.tsx + // (RESULT_CONSUMING_FAMILIES) and rendered inside DiffCard, including + // the per-file tinted unified_diffs on failure. if (!text && !isError) return null @@ -616,5 +198,5 @@ export const CodexToolResultRow = memo(function CodexToolResultRow({ return } - return + return }) diff --git a/src/providers/codex/renderer/rows/dispatch.tsx b/src/providers/codex/renderer/rows/dispatch.tsx index 733275e2..85c03500 100644 --- a/src/providers/codex/renderer/rows/dispatch.tsx +++ b/src/providers/codex/renderer/rows/dispatch.tsx @@ -1,30 +1,7 @@ import type { ReactNode } from 'react' import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' -import { - CodexApplyPatchRow, - CodexExecCommandRow, - CodexToolResultRow, - CodexToolRow, - CodexWriteStdinRow, -} from '@providers/codex/renderer/rows/CodexRows' - -export function renderCodexToolUse(block: ToolUseBlock): ReactNode | undefined { - // WHY Codex falls back to CodexToolRow here instead of shared ToolUseRow: - // the Codex row understands Codex's function-call payload conventions and - // has provider-specific headline extraction for arguments/raw patches. The - // shared fallback remains for providers that do not claim a tool name. - if (block.name === 'apply_patch') return - if (block.name === 'exec_command') return - if (block.name === 'write_stdin') return - // Unknown names deliberately fall through to the SHARED fallback - // (JsonToolRow via Block.tsx) — the residue-plan P1 convergence. Codex - // used to claim everything with CodexToolRow, which is why its MCP / - // orchestration tools drifted into "name + one headline over a raw - // blob" while claude's fell to a different fallback with different - // gaps. One fallback, one behavior, all providers. - return undefined -} +import { CodexToolResultRow } from '@providers/codex/renderer/rows/CodexRows' export function renderCodexToolResult( block: ToolResultBlock, diff --git a/src/providers/codex/renderer/transcript/entries.ts b/src/providers/codex/renderer/transcript/entries.ts index b8032a93..7d5e7951 100644 --- a/src/providers/codex/renderer/transcript/entries.ts +++ b/src/providers/codex/renderer/transcript/entries.ts @@ -82,6 +82,30 @@ export function stripCodexExecWrapper(output: string): string { return output.slice(idx + marker.length) } +/** Codex's unified-exec (`exec` code-mode tool) wraps its output as + * "Script completed\nWall time {N} seconds\nOutput:\n{real output}". + * The wrapper burned the entire collapsed preview (the user saw three + * lines of boilerplate and never the output — 2026-07-12 bundle). + * Strip it and surface the wall time as structured duration so the + * CommandCard shows it as a chip instead. */ +const SCRIPT_WRAPPER_RE = + /^Script (completed|failed[^\n]*|running with cell ID \d+)\nWall time ([0-9.]+) seconds\nOutput:\n?/ +export function stripUnifiedExecWrapper(output: string): { + output: string + durationMs: number | null + scriptFailed: boolean + stillRunning: boolean +} { + const m = SCRIPT_WRAPPER_RE.exec(output) + if (!m) return { output, durationMs: null, scriptFailed: false, stillRunning: false } + return { + output: output.slice(m[0].length), + durationMs: Math.round(parseFloat(m[2]) * 1000), + scriptFailed: m[1].startsWith('failed'), + stillRunning: m[1].startsWith('running'), + } +} + /** True when the output is ONLY the exec wrapper with a trailing * "Process exited with code …" line and nothing else — i.e. no * stdout/stderr worth surfacing. Callers filter these out so the diff --git a/src/providers/codex/renderer/transcript/rollout.ts b/src/providers/codex/renderer/transcript/rollout.ts index a98dc63f..32faa49c 100644 --- a/src/providers/codex/renderer/transcript/rollout.ts +++ b/src/providers/codex/renderer/transcript/rollout.ts @@ -9,6 +9,7 @@ import { isCodexExecWrapperOutput, parseCodexJson, stripCodexExecWrapper, + stripUnifiedExecWrapper, } from '@providers/codex/renderer/transcript/entries' // Codex rollout → feed entry mapping. @@ -280,7 +281,12 @@ export function mapCodexRolloutToFeedEntries(entry: Record): En ) const exitCode = typeof payload.exit_code === 'number' ? payload.exit_code : 0 - if (!output.trim() && exitCode === 0) return [] + // Silent successes (exit 0, no output) used to be dropped here, + // which made them INVISIBLE in the feed — no record the command + // ran at all. The CommandCard now renders a compact header-only + // "$ cmd ✓" row from exactly this result (spec §6), so emit the + // normal tool_result with empty content and the exit meta instead + // of returning []. return [ codexToolResultEntry( uuid, @@ -306,17 +312,41 @@ export function mapCodexRolloutToFeedEntries(entry: Record): En const stdout = typeof payload.stdout === 'string' ? payload.stdout : '' const stderr = typeof payload.stderr === 'string' ? payload.stderr : '' const content = stdout || stderr + // `changes` has TWO observed wire shapes: the protocol source + // (vendor/codex-src protocol.rs PatchApplyEndEvent) declares a + // HashMap — but real + // 2026-07 binaries emit a plain ARRAY of file paths. Normalize to + // { files: string[], diffs: Record } so the + // renderer can paint "Edited (+N −M)" when diffs exist and + // a path list when they don't. NOTE: since unified-exec, this + // event's call_id is a fresh `exec-` pairing with NO + // tool_use — it renders as a standalone patch-result row. + const files: string[] = [] + const diffs: Record = {} + if (Array.isArray(payload.changes)) { + for (const f of payload.changes) { + if (typeof f === 'string') files.push(f) + } + } else { + const rec = asRecord(payload.changes) + for (const [path, change] of Object.entries(rec ?? {})) { + files.push(path) + const c = asRecord(change) + if (typeof c?.unified_diff === 'string') diffs[path] = c.unified_diff + } + } return [ codexToolResultEntry( uuid, timestamp, payload.call_id, content, - payload.success !== true, + payload.success !== true && payload.status !== 'completed', { kind: 'patch_apply_end', - success: payload.success === true, - changes: asRecord(payload.changes) ?? {}, + success: payload.success === true || payload.status === 'completed', + files, + diffs, }, ), ] @@ -390,10 +420,14 @@ export function mapCodexRolloutToFeedEntries(entry: Record): En } if (payload.type === 'custom_tool_call_output' && typeof payload.call_id === 'string') { - const output = codexOutputText(payload.output) - const parsed = parseCodexJson(output) + const raw = codexOutputText(payload.output) + // Unified-exec scripts wrap real output in a "Script completed / + // Wall time / Output:" preamble — strip it so the collapsed preview + // shows OUTPUT, and keep the wall time as structured duration. + const { output: unwrapped, durationMs, scriptFailed } = stripUnifiedExecWrapper(raw) + const parsed = parseCodexJson(unwrapped) const normalized = - typeof parsed?.output === 'string' ? parsed.output : output + typeof parsed?.output === 'string' ? parsed.output : unwrapped const metadata = parsed?.metadata const exitCode = numberField(asRecord(metadata), 'exit_code') ?? 0 if ( @@ -408,8 +442,8 @@ export function mapCodexRolloutToFeedEntries(entry: Record): En timestamp, payload.call_id, normalized, - exitCode !== 0, - { kind: 'custom_tool_call_output' }, + scriptFailed || exitCode !== 0, + { kind: 'custom_tool_call_output', durationMs }, ), ] } diff --git a/src/providers/opencode/renderer/rows/dispatch.tsx b/src/providers/opencode/renderer/rows/dispatch.tsx index 95e432b5..e251edd3 100644 --- a/src/providers/opencode/renderer/rows/dispatch.tsx +++ b/src/providers/opencode/renderer/rows/dispatch.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react' import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' -import { TodoRow } from '@providers/claude/renderer/rows/ClaudeRows' import { renderOpencodeReadResult } from '@providers/opencode/renderer/rows/OpencodeReadResult' // OpenCode committed/live tool rows. @@ -22,24 +21,11 @@ import { renderOpencodeReadResult } from '@providers/opencode/renderer/rows/Open // are plain path/pattern/command payloads the generic rows present fine. // Specialized rows (diff-style edit rendering etc.) should be added here one // evidence-backed tool at a time, not speculatively. -export function renderOpencodeToolUse(block: ToolUseBlock): ReactNode | undefined { - switch (block.name) { - case 'todowrite': - return - default: - return undefined - } -} - export function renderOpencodeToolResult( block: ToolResultBlock, context: { sourceTool?: ToolUseBlock | null }, ): ReactNode | undefined { const source = context.sourceTool?.name - // todowrite results echo the JSON checklist that TodoRow ALREADY - // rendered from the tool_use above — painting it again is the raw-blob - // noise the 07-06 bundle shows. State lives in the row; drop the echo. - if (source === 'todowrite') return null // read results are a tagged text document (// // soup). Parse and present as a code slab with the real path; fall // through to the generic row when the shape doesn't match. diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts index 4937a1eb..09a5be07 100644 --- a/src/providers/registry.renderer.capabilities.ts +++ b/src/providers/registry.renderer.capabilities.ts @@ -6,11 +6,9 @@ import { CLAUDE_VIEWS } from '@providers/claude/renderer/conditions/views' import { CODEX_VIEWS } from '@providers/codex/renderer/conditions/views' import { renderClaudeToolResult, - renderClaudeToolUse, } from '@providers/claude/renderer/rows/dispatch' import { renderCodexToolResult, - renderCodexToolUse, } from '@providers/codex/renderer/rows/dispatch' import type { SemanticFoldPolicy, TranscriptEntryMapper } from '@shared/types/providerConfig' import { CLAUDE_SEMANTIC_FOLD_POLICY } from '@providers/claude/renderer/semanticFoldPolicy' @@ -29,7 +27,6 @@ import { } from '@providers/opencode/renderer/transcript/mapper' import { opencodeComposerSubmit } from '@providers/opencode/renderer/composerSubmit' import { - renderOpencodeToolUse, renderOpencodeToolResult, } from '@providers/opencode/renderer/rows/dispatch' import { codexComposerSubmit } from '@providers/codex/renderer/composerSubmit' @@ -66,7 +63,6 @@ export type RendererProviderCapabilities = { */ splitShortcutKey?: string conditionViews: Record - renderToolUse?: (block: ToolUseBlock) => ReactNode | undefined renderToolResult?: ( block: ToolResultBlock, context: { sourceTool?: ToolUseBlock | null }, @@ -193,12 +189,15 @@ const claudeCapabilities: RendererProviderCapabilities = { name: 'Claude Code', ...CLAUDE_IDENTITY, conditionViews: CLAUDE_VIEWS, - renderToolUse: renderClaudeToolUse, renderToolResult: renderClaudeToolResult, // Claude fanout: `Agent` tool_use, plus MCP-orchestrated spawns that arrive // prefixed. The bare `orchestration_create_agent` is codex's (see below), so // the fleet-row union still covers it. - isSpawnTool: (name) => name === 'Agent' || isMcpOrchestrationCreateAgent(name), + // 'Agent' is what this CLI generation records (verified: 184 hits in + // recent local transcripts, zero 'Task'); 'Task' is the newer upstream + // vocabulary for the same subagent fanout — accept both so a CLI + // update can't silently kill every subagent card again. + isSpawnTool: (name) => name === 'Agent' || name === 'Task' || isMcpOrchestrationCreateAgent(name), createTranscriptEntryMapper: () => createClaudeTranscriptEntryMapper(), extractProviderSessionId: extractClaudeProviderSessionId, composerSubmit: claudeComposerSubmit, @@ -213,7 +212,6 @@ const codexCapabilities: RendererProviderCapabilities = { name: 'Codex', ...CODEX_IDENTITY, conditionViews: CODEX_VIEWS, - renderToolUse: renderCodexToolUse, renderToolResult: renderCodexToolResult, // Codex fanout: `spawn_agent` function_call, plus the MCP orchestration spawn // whose `mcp__` prefix codex strips on the wire (so it arrives bare). @@ -235,7 +233,6 @@ const opencodeCapabilities: RendererProviderCapabilities = { conditionViews: OPENCODE_VIEWS, // Evidence-backed rows only (live probe 2026-07-06): todowrite renders as // a real todo list; everything else falls through to the generic rows. - renderToolUse: renderOpencodeToolUse, renderToolResult: renderOpencodeToolResult, // opencode has no subagent-spawn tool yet (no fleet fanout on this backend). isSpawnTool: () => false, diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index c6a7881a..5874df7b 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -6,11 +6,8 @@ import { TaskNotificationsContext } from '@renderer/features/feed/context' import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { memo, - useEffect, - useLayoutEffect, useMemo, useRef, - useState, } from 'react' import { @@ -36,11 +33,9 @@ import { type ScrollInfo, type DebugVisibleRow, } from '@renderer/features/feed/types' -import { scrollPositions } from '@renderer/features/feed/scroll' import { buildToolUseIndex, buildToolResultIndex, - debugLabelForEntry, } from '@renderer/features/feed/lib/helpers' import { feedRenderModelFromItems, @@ -51,8 +46,11 @@ import { SemanticCollapsedActivityRow, } from '@renderer/features/feed/ui/semantic' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' -import { StreamingProse } from '@renderer/features/feed/ui/markdown' +import { SegmentedMarkdown } from '@renderer/features/feed/ui/kit/SegmentedMarkdown' import { semanticTurnScrollSignal } from '@renderer/session-runtime/semantic/helpers' +import { useFeedDebugEmission } from '@renderer/features/feed/ui/hooks/useFeedDebugEmission' +import { usePickerAutoScroll } from '@renderer/features/feed/ui/hooks/usePickerAutoScroll' +import { useScrollFeedBehaviors } from '@renderer/features/feed/ui/hooks/useScrollFeedBehaviors' import { EAGER_TAIL, EntryRow, @@ -61,7 +59,6 @@ import { import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' import type { SubAgentState } from '@renderer/session-runtime/state' import type { ClaudeAskUserQuestionState } from '@shared/types/providerConditions' -import * as perf from '@renderer/performance/client' // Re-export — many external callers import these types from Feed // directly rather than reaching into ../types/../context. Keep the @@ -121,7 +118,7 @@ export type { AgentProvider, ScrollInfo } from '@renderer/features/feed/types' type Props = { /** Session identity — used as the key for per-session scroll * position persistence across Feed unmount/remount (tab switches). - * See `scrollPositions` below. */ + * See ui/hooks/useScrollFeedBehaviors.ts (`scrollPositions`). */ sessionId: string /** Which provider's row renderers to use. Default 'claude'. */ provider?: AgentProvider @@ -263,7 +260,7 @@ type Props = { // compare bails the entire subtree out. Zero markdown work happens. // // 2. Every row component (`EntryRow`, `ConversationRow`, `TextProse`, -// `ToolUseRow`, `ToolResultRow`) is individually memoized. Even when +// artifact cards, `ToolResultRow`) is individually memoized. Even when // Feed DOES need to re-render (new entry lands, streaming frame // ticks), existing rows receive the exact same entry/block/text // reference they had last time and skip. Only the genuinely new @@ -312,420 +309,47 @@ function FeedImpl({ onDebugLog, }: Props) { // Scroll container owned by Feed itself — not by TileLeaf — so the - // sticky-bottom logic below can own its own scroll listener without + // sticky-bottom logic can own its own scroll listener without // reaching up the tree. TileLeaf's wrapper is just a flex cell and // no longer sets overflow-auto; see TileLeaf.tsx for the pair. // - // Why this is load-bearing: active semantic turns can update many - // times per second. A naive "scroll to bottom on every render" effect - // yanks the viewport down whenever text/tool deltas arrive, making it - // impossible for the user to scroll up and read earlier content. - // Worth preserving the original production diagnosis: "scrolling - // doesn't even work, it snaps me back to the bottom super glitchly." - // That's exactly the behavior the old unconditional effect produced. - // - // The fix is two-part: - // 1. Track "is the user near the bottom right now" in a ref - // that's updated by a scroll listener. "Near" = within 48px - // of the bottom; the pad absorbs sub-pixel rounding and the - // natural momentum overshoot when new rows land. - // 2. Only auto-scroll when the ref is true. If the user has - // scrolled up, stickyBottom becomes false, and subsequent - // updates stop forcing the viewport down. When the user - // scrolls back to the bottom, the ref flips true again and - // auto-scroll resumes. - // - // Using a ref (not state) for stickyBottom is deliberate: we don't - // want a React re-render on every scroll tick, only a read in the - // auto-scroll effect. Scroll events fire on EVERY pixel, so this - // matters. + // The scarred scroll/picker behaviors (sticky-bottom follow, mount + // restore, older-history load, bootstrap pin-once, scroll-to-latest, + // both picker tweens) are PORTED VERBATIM into ui/hooks/ — see + // useScrollFeedBehaviors.ts and usePickerAutoScroll.ts for every + // original WHY comment. Feed owns only the refs the JSX needs. const scrollerRef = useRef(null) const endRef = useRef(null) - // Restore stickyBottom from the persisted map on first render so - // the auto-scroll effect below makes the right decision without - // needing to wait for the scroll listener to run. Defaults to - // true for brand-new sessions (no saved position yet). - const stickyBottomRef = useRef( - scrollPositions.get(sessionId)?.stickyBottom ?? true, - ) - const loadingOlderRef = useRef(false) - // Was there an existing saved scroll position for this session when - // this Feed instance mounted? Used to distinguish "restore the - // user's deliberate scrolled-up position" from "brand-new/resumed - // session should land at latest content even if stickyBottom got - // transiently knocked false during bootstrap." - const hadSavedPositionOnMountRef = useRef(scrollPositions.has(sessionId)) - // Previous scrollTop, used to distinguish "the user started - // scrolling upward" from incidental near-bottom jitter. This is - // load-bearing during active turns: with the old "gap < 48" - // heuristic alone, a tiny upward wheel tick still counted as - // sticky, and the next ~60 Hz screen update snapped the feed right - // back down before the user could accumulate enough distance to - // escape. Any real upward movement should break follow - // immediately; re-follow only when the user intentionally returns - // near the bottom. - const lastScrollTopRef = useRef(0) - - // Restore the saved scroll position on mount — synchronously, via - // useLayoutEffect, so the browser never paints the scroller at - // scrollTop=0 before we restore. Using useEffect here would flash - // the top of the feed for one frame before the restore landed. - // - // Three cases: - // 1. No saved entry → this is the first time we've mounted for - // this session (or the map was just cleared). Default to - // "stuck at bottom" — for a freshly-opened feed the user - // wants to see the most recent content, just like opening a - // terminal or a chat window. - // 2. Saved stickyBottom: true → the user was at the bottom when - // they left. Content may have grown while we were unmounted - // (new entries appended to runtime.entries even though Feed - // wasn't rendering), so we pin to the NEW scrollHeight, not - // the old scrollTop. - // 3. Saved stickyBottom: false → restore the exact saved - // scrollTop. Content height on remount matches save time - // (because unmount freezes new entries from growing the - // unmounted scroller) so this is pixel-accurate. - // - // The sessionId dep is load-bearing: if the user resumes a - // different session in the same pane slot, we need to re-restore - // from the new key. Today it's effectively a mount-only effect. - useLayoutEffect(() => { - const el = scrollerRef.current - if (!el) return - hadSavedPositionOnMountRef.current = scrollPositions.has(sessionId) - if (tailMode) { - el.scrollTop = el.scrollHeight - stickyBottomRef.current = true - lastScrollTopRef.current = el.scrollTop - return - } - const saved = scrollPositions.get(sessionId) - if (!saved || saved.stickyBottom) { - // Case 1 or 2: pin to bottom. We do this synchronously in - // useLayoutEffect so the browser commits the scrollTop change - // in the SAME paint as the initial content render. Without - // that, the first paint shows scrollTop=0 (top) and the next - // tick scrolls down — visibly a "starts at top, jumps to - // bottom" flash on every tab switch, which is the exact bug - // the user flagged. - el.scrollTop = el.scrollHeight - stickyBottomRef.current = true - lastScrollTopRef.current = el.scrollTop - } else { - // Case 3: restore exact position. - el.scrollTop = saved.scrollTop - stickyBottomRef.current = false - lastScrollTopRef.current = el.scrollTop - } - }, [sessionId, tailMode]) - // One scroll listener for the container. Updates stickyBottomRef - // imperatively AND persists the position into the module-level - // map so a later unmount/remount can restore it. - // - // CRITICAL: we DO NOT call onScroll() synchronously on mount. - // That was the original bug — at mount time the scroller has - // scrollTop=0 and scrollHeight=full-content, so gap > 48 and the - // handler would stamp stickyBottom=false INTO THE REF AND THE - // PERSISTED MAP before the layout effect above had a chance to - // scroll to the bottom. Then the auto-scroll effect below would - // see stickyBottom=false and skip, leaving the viewport stuck at - // the top. The layout effect sets stickyBottomRef explicitly, so - // the scroll listener only needs to react to actual user scrolls. - useEffect(() => { - const el = scrollerRef.current - if (!el) return - const onScroll = () => { - if (tailMode) { - el.scrollTop = el.scrollHeight - stickyBottomRef.current = true - lastScrollTopRef.current = el.scrollTop - scrollPositions.set(sessionId, { - scrollTop: el.scrollTop, - stickyBottom: true, - }) - if (onScrollInfo) onScrollInfo({ fraction: 0 }) - return - } - const gap = el.scrollHeight - (el.scrollTop + el.clientHeight) - const scrollingUp = el.scrollTop < lastScrollTopRef.current - const nearBottom = gap < 48 - stickyBottomRef.current = - scrollingUp && gap > 0 ? false : nearBottom - lastScrollTopRef.current = el.scrollTop - scrollPositions.set(sessionId, { - scrollTop: el.scrollTop, - stickyBottom: stickyBottomRef.current, - }) - // Push scroll position to parent for the scroll indicator. - // fraction=0 at bottom, fraction=1 at top. - if (onScrollInfo) { - const maxScroll = el.scrollHeight - el.clientHeight - const fraction = maxScroll > 0 - ? 1 - (el.scrollTop / maxScroll) - : 0 - onScrollInfo({ fraction }) - } + // Cheap fingerprints of the current semantic turn and bounded + // semantic history so the sticky-bottom effect re-runs when semantic + // deltas land, not only when committed entries append (feed audit + // Finding 2 — per-block growth folded in via semanticTurnScrollSignal). + const semanticTurnSignal = semanticTurn ? semanticTurnScrollSignal(semanticTurn) : '' + const semanticHistorySignal = semanticHistory + .map(semanticTurnScrollSignal) + .join('|') - if ( - el.scrollTop < 160 && - hasOlderHistory && - !loadingOlderHistory && - !loadingOlderRef.current && - !tailMode && - onLoadOlderHistory - ) { - loadingOlderRef.current = true - const beforeHeight = el.scrollHeight - const beforeTop = el.scrollTop - void onLoadOlderHistory() - .then(() => { - requestAnimationFrame(() => { - const next = scrollerRef.current - if (!next) return - const delta = next.scrollHeight - beforeHeight - next.scrollTop = beforeTop + Math.max(0, delta) - lastScrollTopRef.current = next.scrollTop - }) - }) - .finally(() => { - loadingOlderRef.current = false - }) - } - } - el.addEventListener('scroll', onScroll, { passive: true }) - return () => el.removeEventListener('scroll', onScroll) - }, [ + const { cancelTween } = usePickerAutoScroll({ + scrollerRef, + pickerSelectedUuid, + codeBlockSelectedId, + }) + useScrollFeedBehaviors({ + scrollerRef, sessionId, - onScrollInfo, tailMode, + bootstrapping, + entriesLength: entries.length, + semanticTurnSignal, + semanticHistorySignal, hasOlderHistory, loadingOlderHistory, onLoadOlderHistory, - ]) - - // Auto-scroll on content changes, but ONLY when sticky. The effect - // runs on every change that would grow the feed (a new committed - // entry) or move the semantic streaming tail. If the - // user is scrolled up, we skip — they're reading earlier content - // and we don't want to yank them back. - // - // Include cheap fingerprints of the current semantic turn and - // bounded semantic history so the effect re-runs when semantic - // deltas land, not only when committed entries append. - // WHY semanticTurnScrollSignal and not just turnId:text.length:blockCount - // (feed audit Finding 2): the old signal missed per-block streaming growth - // (tool output / thinking / reasoning deltas inside an existing block), so the - // sticky-bottom effect below would not re-pin while a row visibly grew. The - // helper folds in each block's content lengths and status so this effect fires - // on the common streaming path. It is a scroll-invalidation token only. - const semanticTurnSignal = semanticTurn ? semanticTurnScrollSignal(semanticTurn) : '' - const semanticHistorySignal = semanticHistory - .map(semanticTurnScrollSignal) - .join('|') - useEffect(() => { - // During a bulk bootstrap burst we skip per-append auto-scroll. - // The pin-once-on-transition effect below lands us at the bottom - // in a single operation after the burst ends — otherwise every - // entry appended during the burst would pin-scroll and wake up - // the LazyEntry observer cascade. See docs/superpowers/plans/ - // 2026-04-15-bootstrap-replay-perf.md. - if (bootstrapping) return - if (!tailMode && !stickyBottomRef.current) return - // scrollTop = scrollHeight pins to bottom without the smooth-scroll - // overshoot scrollIntoView sometimes produces. Direct, instant, - // no animation frames. - const el = scrollerRef.current - if (el) el.scrollTop = el.scrollHeight - }, [entries.length, tailMode, semanticTurnSignal, semanticHistorySignal, bootstrapping]) - - // Pin-once on the bootstrap → live transition. Runs exactly once per - // transition thanks to the previous-value ref: we read the prior - // value, compare, pin if we just left bootstrap mode, then store the - // new value for next time. No dependency on `entries.length` so the - // effect does not fire on subsequent live appends — those go - // through the regular auto-scroll effect above. - const prevBootstrappingRef = useRef(false) - useEffect(() => { - if (prevBootstrappingRef.current && !bootstrapping) { - const el = scrollerRef.current - // Fresh/resumed sessions with no saved scroll position should - // ALWAYS land on the latest content after the bootstrap burst. - // Relying purely on stickyBottomRef here is fragile because the - // initial mount/placeholder/lazy-load sequence can transiently - // mark the feed non-sticky before the first real user scroll. - // That leaves the viewport stranded above the eager tail: the - // exact "blank until I scroll down a couple pages" symptom. - const shouldForceInitialBottom = - !hadSavedPositionOnMountRef.current && !tailMode - if (el && (tailMode || stickyBottomRef.current || shouldForceInitialBottom)) { - el.scrollTop = el.scrollHeight - stickyBottomRef.current = true - lastScrollTopRef.current = el.scrollTop - scrollPositions.set(sessionId, { - scrollTop: el.scrollTop, - stickyBottom: true, - }) - if (onScrollInfo) onScrollInfo({ fraction: 0 }) - } - } - prevBootstrappingRef.current = bootstrapping - }, [bootstrapping, onScrollInfo, sessionId, tailMode]) - - // When the picker selection changes, smoothly tween the scroller - // so the highlighted entry centers in the viewport. - // - // We do NOT use native scrollIntoView({behavior:'smooth'}) because: - // - With block:'nearest' it no-ops when the target is already on - // screen, so rapid arrow presses after a manual scroll land on - // already-visible entries and produce zero motion (user report: - // "my arrow key does nothing after I scroll"). - // - With block:'center' native smooth scroll was observably shaky - // here — the feed's own scroll listener fires on every frame of - // the animation and was fighting the browser's scroll queue. - // - // A custom rAF tween is ~20 lines, interrupt-safe (new target - // cancels any in-flight animation), and completely independent of - // whatever the scroll listener is doing. - const scrollAnimFrameRef = useRef(null) - useEffect(() => { - if (!pickerSelectedUuid) return - const root = scrollerRef.current - if (!root) return - const target = root.querySelector( - `[data-entry-uuid="${pickerSelectedUuid}"]`, - ) as HTMLElement | null - if (!target) return - - // Compute desired scrollTop so the target's vertical center aligns - // with the scroller's vertical center. Clamp to the scroller's - // scrollable range so we don't try to scroll past start/end. - const targetCenter = target.offsetTop + target.offsetHeight / 2 - const desired = targetCenter - root.clientHeight / 2 - const maxScroll = root.scrollHeight - root.clientHeight - const to = Math.max(0, Math.min(maxScroll, desired)) - const from = root.scrollTop - const distance = to - from - if (Math.abs(distance) < 1) return - - // Cancel any in-flight animation so rapid Up/Down presses don't - // compound into a runaway scroll. - if (scrollAnimFrameRef.current !== null) { - cancelAnimationFrame(scrollAnimFrameRef.current) - scrollAnimFrameRef.current = null - } - - const duration = 180 - const startTime = performance.now() - const ease = (t: number) => 1 - Math.pow(1 - t, 3) // easeOutCubic - - const step = (now: number) => { - const elapsed = now - startTime - const t = Math.min(1, elapsed / duration) - root.scrollTop = from + distance * ease(t) - if (t < 1) { - scrollAnimFrameRef.current = requestAnimationFrame(step) - } else { - scrollAnimFrameRef.current = null - } - } - scrollAnimFrameRef.current = requestAnimationFrame(step) - - return () => { - if (scrollAnimFrameRef.current !== null) { - cancelAnimationFrame(scrollAnimFrameRef.current) - scrollAnimFrameRef.current = null - } - } - }, [pickerSelectedUuid]) - - // Copy Code Block picker — highlight + scroll the selected block. - // - // WHY a separate effect from the assistant-entry one above, and why - // the outline is applied imperatively here rather than as a JSX - // class: the assistant picker highlights an ENTRY, and Feed already - // renders a wrapper
    per entry that it can add - // an outline class to in JSX. A code block is a `CodeBlock` buried - // deep inside rendered markdown / tool rows — there is no per-block - // wrapper Feed controls. So we locate the block by its - // `data-code-block-id` and toggle the outline classes on the node - // directly; the cleanup removes them. - // - // The outline class list is the SAME tokens the entry picker uses - // (line ~881) — keeping them identical means Tailwind's static - // scan already emits the CSS, so `classList.add` is safe. - // - // Shares `scrollAnimFrameRef` with the assistant-picker scroll so a - // tween from one picker is cancelled if the other starts; only one - // picker is ever active at a time, so they never genuinely race. - useEffect(() => { - if (!codeBlockSelectedId) return - const root = scrollerRef.current - if (!root) return - const target = root.querySelector( - `[data-code-block-id="${codeBlockSelectedId}"]`, - ) as HTMLElement | null - if (!target) return - - const outline = ['outline', 'outline-2', 'outline-accent', 'outline-offset-2'] - target.classList.add(...outline) - - // Center the block in the scroller — same rAF tween as the - // assistant-picker scroll effect (native smooth-scroll fought the - // feed's own scroll listener; see the long note on that effect). - const targetCenter = target.offsetTop + target.offsetHeight / 2 - const desired = targetCenter - root.clientHeight / 2 - const maxScroll = root.scrollHeight - root.clientHeight - const to = Math.max(0, Math.min(maxScroll, desired)) - const from = root.scrollTop - const distance = to - from - if (Math.abs(distance) >= 1) { - if (scrollAnimFrameRef.current !== null) { - cancelAnimationFrame(scrollAnimFrameRef.current) - scrollAnimFrameRef.current = null - } - const duration = 180 - const startTime = performance.now() - const ease = (t: number) => 1 - Math.pow(1 - t, 3) - const step = (now: number) => { - const t = Math.min(1, (now - startTime) / duration) - root.scrollTop = from + distance * ease(t) - if (t < 1) { - scrollAnimFrameRef.current = requestAnimationFrame(step) - } else { - scrollAnimFrameRef.current = null - } - } - scrollAnimFrameRef.current = requestAnimationFrame(step) - } - - return () => { - target.classList.remove(...outline) - if (scrollAnimFrameRef.current !== null) { - cancelAnimationFrame(scrollAnimFrameRef.current) - scrollAnimFrameRef.current = null - } - } - }, [codeBlockSelectedId]) - - useEffect(() => { - if (scrollToLatestRequest === 0) return - const el = scrollerRef.current - if (!el) return - if (scrollAnimFrameRef.current !== null) { - cancelAnimationFrame(scrollAnimFrameRef.current) - scrollAnimFrameRef.current = null - } - el.scrollTop = el.scrollHeight - stickyBottomRef.current = true - lastScrollTopRef.current = el.scrollTop - scrollPositions.set(sessionId, { - scrollTop: el.scrollTop, - stickyBottom: true, - }) - if (onScrollInfo) onScrollInfo({ fraction: 0 }) - }, [onScrollInfo, scrollToLatestRequest, sessionId]) + onScrollInfo, + scrollToLatestRequest, + cancelPickerTween: cancelTween, + }) // Index EVERY tool_use block (not just the visible set) so tool_result // lookups still resolve even when some synthetic entries have been @@ -827,7 +451,6 @@ function FeedImpl({ } return ids }, [renderItems]) - const renderedSemanticHistorySignature = renderedSemanticHistoryTurnIds.join('\u0000') // The current live turn IS rendered iff any block item carries owner // 'semantic-current'. WorkIndicator's tool-hint reads the full turn, which // Feed already has as the `semanticTurn` prop — return that when present. @@ -842,62 +465,18 @@ function FeedImpl({ return currentOnScreen ? semanticTurn : null }, [renderItems, semanticTurn]) - const previousRenderedRowsRef = useRef(null) - const previousRenderDebugSignatureRef = useRef(null) - - useEffect(() => { - if (!onDebugLog) return - const previous = previousRenderedRowsRef.current - const renderDebugSignature = JSON.stringify({ - entryCount: entries.length, - visibleEntryCount, - itemKeys: renderedRows.map(row => row.key), - semanticTurnId: semanticTurn?.turnId ?? null, - semanticHistoryTurnIds: renderedSemanticHistoryTurnIds, - streamPhase, - }) - const prevKeys = new Set(previous?.map(row => row.key) ?? []) - const nextKeys = new Set(renderedRows.map(row => row.key)) - const added = renderedRows.filter(row => !prevKeys.has(row.key)) - const removed = (previous ?? []).filter(row => !nextKeys.has(row.key)) - const hidden = visibleDecisions - .filter(item => !item.visible) - .slice(-12) - .map(item => ({ - key: item.key, - label: debugLabelForEntry(item.entry), - reason: item.reason, - })) - const changed = - previous === null || - previousRenderDebugSignatureRef.current !== renderDebugSignature || - added.length > 0 || - removed.length > 0 || - previous.length !== renderedRows.length || - previous.some((row, index) => row.key !== renderedRows[index]?.key) - if (!changed) return - onDebugLog({ - layer: 'RENDER', - kind: 'visible_rows', - summary: - previous === null - ? `initial rows ${renderedRows.length}` - : `rows ${previous.length} -> ${renderedRows.length} (+${added.length} -${removed.length})`, - data: { - rows: renderedRows, - added, - removed, - hidden, - entryCount: entries.length, - visibleEntryCount, - semanticTurnId: semanticTurn?.turnId ?? null, - semanticHistoryTurnIds: renderedSemanticHistoryTurnIds, - streamPhase, - }, - }) - previousRenderedRowsRef.current = renderedRows - previousRenderDebugSignatureRef.current = renderDebugSignature - }, [entries.length, onDebugLog, renderedRows, renderedSemanticHistorySignature, renderedSemanticHistoryTurnIds, semanticTurn?.turnId, streamPhase, visibleDecisions, visibleEntryCount]) + // Feed-debug emission — the "debug == paint" half of the painter, + // ported verbatim into ui/hooks/useFeedDebugEmission.ts. + useFeedDebugEmission({ + onDebugLog, + entriesLength: entries.length, + visibleEntryCount, + renderedRows, + visibleDecisions, + semanticTurnId: semanticTurn?.turnId ?? null, + renderedSemanticHistoryTurnIds, + streamPhase, + }) const renderFeedItem = (item: FeedRenderItem) => { switch (item.type) { @@ -947,9 +526,11 @@ function FeedImpl({ return case 'semantic-text': // Blockless Codex/opencode turn text — the legacy no-blocks path. + // SegmentedMarkdown so a growing turn doesn't re-parse its whole + // markdown per delta and open fences stream highlighted. return ( - + ) case 'work': @@ -1044,7 +625,7 @@ function FeedImpl({ // // The entire row surface (LazyEntry, EntryRow, ConversationRow, Block, // ImageBlockRow, CompactBoundaryRow, CompactSummaryRow, SystemRow, -// ToolUseRow, ToolResultRow, TruncatedOutputRow, UserBand, +// ToolResultRow, UserBand, // plus the EAGER_TAIL constant) moved to ./rows/. Each component lives // in its own file, and the long WHY comments (lazy mount rationale, // the "CRITICAL: don't wrap tool_results in UserBand" gotcha, the diff --git a/src/renderer/src/features/feed/ui/artifacts/command.tsx b/src/renderer/src/features/feed/ui/artifacts/command.tsx new file mode 100644 index 00000000..43a76354 --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/command.tsx @@ -0,0 +1,176 @@ +import { memo, useMemo, useState } from 'react' +import hljs from 'highlight.js' + +import { useContext } from 'react' +import { formatToolFilePath } from '@shared/paths/displayPath' +import { CodeRenderContext } from '@renderer/features/feed/context' +import { truncateBashCommand } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { ExpandSection } from '@renderer/features/feed/ui/kit/ExpandSection' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' + +import type { CommandArtifact } from './types' + +// CommandCard — the ONE command surface (spec §6). Claude Bash, Codex +// exec_command, Codex local_shell_call, and write_stdin all render +// here, live and committed, from the same CommandArtifact VM: +// +// $ npm test ✓ 1.2s +// cwd: agent-code run the unit tests +// ⎿ +// +// Replaces: the JsonToolRow fallback Claude Bash fell into (2-line +// headline, params-in-details, output rendered separately by +// ToolResultRow), CodexExecCommandRow + CodexWriteStdinRow, and the +// bespoke live local_shell_call chip in BlockRow. What those never +// showed and this does: exit codes (parsed since forever, displayed +// never), ANSI colors, and live output streaming into the card as +// tool_output_delta arrives. +// +// Silent successes (exit 0, no output) render as the header-only row — +// command + ✓ — instead of being invisible. The header IS the record +// that the command ran. + +export const CommandCard = memo(function CommandCard({ vm }: { vm: CommandArtifact }) { + const [showFull, setShowFull] = useState(false) + const { workspaceRoot } = useContext(CodeRenderContext) + + // Empty write_stdin renders NOTHING — preserved verbatim from the + // CodexWriteStdinRow this card replaced. Codex emits empty stdin + // writes as poll/continuation calls while a long PTY command drains; + // they carry no user-visible content. renderUnits.ts's + // isInvisibleWriteStdinBlock mirrors this so the render model and + // the DOM agree the block owns no screen real estate — if you change + // this, change that selector in the same commit. (Unified-exec + // scripts polling with chars:"" classify as 'wait' instead and DO + // render — see below — matching the native "Waited for background + // terminal" rows; that path never enters this selector.) + if (vm.sourceTool === 'write_stdin' && vm.stdinWrites.every(s => s.length === 0)) { + return null + } + + const truncated = truncateBashCommand(vm.command) + const isTruncated = truncated !== vm.command + const cwdBase = vm.cwd ? vm.cwd.split('/').filter(Boolean).pop() ?? vm.cwd : null + const shown = showFull ? vm.command : truncated + + // Bash-highlight the command line, like the native TUI's + // highlight_bash_to_lines — a shell pipeline reads far better with + // its strings/flags tinted. Memoized on the shown text; commands are + // short so this is micro-cost. Falls back to plain text on hljs + // failure or for the wait pseudo-command. + const commandHtml = useMemo(() => { + if (vm.sourceTool === 'wait') return null + try { + return hljs.highlight(shown, { language: 'bash', ignoreIllegals: true }).value + } catch { + return null + } + }, [shown, vm.sourceTool]) + + // Native-style slim wait row: "• Waited for background terminal" + // (codex history_cell/exec.rs) — dim, no output well, duration chip + // when known. These are frequent while long commands drain; a full + // command card per poll would drown the feed. + if (vm.sourceTool === 'wait') { + return ( + +
    + Waited for background terminal + {vm.durationMs != null && vm.durationMs > 0 ? ( + + {(vm.durationMs / 1000).toFixed(1)}s + + ) : null} +
    +
    + ) + } + + // Verb header, native vocabulary: Running while the input streams / + // awaits a result, Ran when done (exec_cell/render.rs). The ✓/exit + // badge still carries success; the verb carries tense. + const verb = vm.status === 'streaming' || vm.status === 'running' ? 'Running' : 'Ran' + + return ( + +
    +
    + + {verb} + +
     isTruncated && setShowFull(v => !v)}
    +            title={isTruncated && !showFull ? 'click to expand full command' : undefined}
    +          >
    +            {commandHtml !== null ? (
    +              
    +            ) : (
    +              shown
    +            )}
    +          
    + +
    + {(cwdBase || vm.description || vm.yieldTimeMs != null) && ( +
    + {cwdBase ? cwd: {cwdBase} : null} + {vm.description ? {vm.description} : null} + {vm.yieldTimeMs != null ? yield {vm.yieldTimeMs}ms : null} + {vm.maxOutputTokens != null ? max {vm.maxOutputTokens} tokens : null} +
    + )} + {vm.stdinWrites.map((chars, i) => ( +
    + stdin → {chars.slice(0, 120)} + {chars.length > 120 ? '…' : ''} +
    + ))} + {vm.output && vm.parsedRead ? ( + // Codex classified this exec as a file read/search — a + // one-line summary with the source behind a lazy expand reads + // far better than N raw lines (ported from the old + // ExpandableCodeResult; Monaco stays first-open gated). + + { + const lineCount = vm.output.trim() ? vm.output.split('\n').length : 0 + const noun = lineCount === 1 ? 'line' : 'lines' + const displayPath = vm.parsedRead.path + ? formatToolFilePath(vm.parsedRead.path, workspaceRoot) + : null + return vm.parsedRead.kind === 'read' + ? displayPath + ? `Read ${lineCount} ${noun} from ${displayPath}` + : `Read ${lineCount} ${noun}` + : displayPath + ? `Search results: ${lineCount} ${noun} in ${displayPath}` + : `Search results: ${lineCount} ${noun}` + })()} + > + + + + ) : vm.output ? ( + + ) : null} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/fileEdit.tsx b/src/renderer/src/features/feed/ui/artifacts/fileEdit.tsx new file mode 100644 index 00000000..52cd7f1f --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/fileEdit.tsx @@ -0,0 +1,395 @@ +import { memo, useContext, useMemo } from 'react' + +import { diffLines } from '@shared/parsers/lineDiff' +import { formatToolFilePath } from '@shared/paths/displayPath' +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import type { AgentProviderKind } from '@shared/types/providerKind' + +import { + editInput, + multiEditInput, + partialEditInput, +} from '@providers/claude/renderer/extractors' +import { + classifyUnifiedExecScript, + parseApplyPatch, + partialApplyPatchInput, + patchChangesFromResult, + unifiedDiffToLines, + unifiedExecScript, +} from '@providers/codex/renderer/extractors' + +import { CodeRenderContext } from '@renderer/features/feed/context' +import { toolResultText } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { DiffView, type DiffViewFile } from '@renderer/features/feed/ui/kit/DiffView' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import { StreamingCodeBlock } from '@renderer/features/feed/ui/kit/StreamingCodeBlock' +import type { SemanticLiveTurn } from '@renderer/session-runtime/state' + +import type { ArtifactStatus, FileEditArtifact } from './types' + +type SemanticLiveBlock = SemanticLiveTurn['blocks'][number] +type SemanticToolCallSnapshot = SemanticLiveTurn['lookups']['toolCallsById'][string] + +// DiffCard — the ONE file-edit surface (spec §6): Claude Edit, +// MultiEdit, and Codex apply_patch, live and committed, through the +// same card and the same DiffView body. +// +// Live behavior (the [#285] convergence, generalized): the file path +// appears the moment its JSON string literal closes; the diff fills in +// as old_string/new_string stream (each render re-derives the partial +// diff — diffLines over partial strings is cheap and self-corrects); +// an apply_patch payload renders its parsed files as the grammar +// streams. Until even the path has arrived, the raw streaming input +// shows in a StreamingCodeBlock — never a blank card, never worse than +// the raw-JSON preview this replaces. +// +// Result handling: success stubs ("file updated successfully") are +// consumed silently — the diff IS the story and the ✓ badge is the +// confirmation (this also finally gives Codex patch successes a +// visible confirmation; they used to render literally nothing). +// ERRORS render loudly: the error text in a red OutputWell plus, for +// Codex patch failures, the per-file unified_diff tinted through +// DiffView (replacing the flat `language="diff"` CodeBlock path). + +function editStatus( + result: ToolResultBlock | null, +): ArtifactStatus { + if (!result) return 'running' + if (result.is_error === true) return 'error' + return 'complete' +} + +export function fileEditFromCommitted( + tu: ToolUseBlock, + result: ToolResultBlock | null, + provider: AgentProviderKind, +): FileEditArtifact { + let filePath: string | null = null + let diffs: FileEditArtifact['diffs'] = [] + let patchFiles: FileEditArtifact['patchFiles'] = [] + + if (tu.name === 'Edit') { + const input = editInput(tu.input) + filePath = input.filePath || null + diffs = [diffLines(input.oldString, input.newString)] + } else if (tu.name === 'MultiEdit') { + const input = multiEditInput(tu.input) + filePath = input.filePath || null + diffs = input.edits.map(e => diffLines(e.oldString, e.newString)) + } else { + // apply_patch — either the classic tool or a unified-exec script + // wrapping tools.apply_patch("*** Begin Patch…") (modern Codex). + const source = + tu.name === 'exec' + ? (() => { + const action = classifyUnifiedExecScript(unifiedExecScript(tu.input)) + return action?.kind === 'apply_patch' ? { raw: action.patchText } : tu.input + })() + : tu.input + patchFiles = parseApplyPatch(source).map(f => ({ + path: f.path, + action: f.action.toLowerCase() as 'add' | 'update' | 'delete', + movedTo: f.movedTo ?? null, + lines: f.lines, + })) + } + + const isError = result?.is_error === true + // Codex patch failure: prefer the per-file unified diffs from the + // result meta over the tool_use grammar (the meta reflects what the + // apply actually did/failed on). + if (isError && tu.name === 'apply_patch') { + const changes = patchChangesFromResult(result) + if (changes.length > 0) { + patchFiles = changes.map(c => ({ + path: c.path, + action: 'update' as const, + movedTo: null, + lines: c.lines, + })) + } + } + + return { + family: 'file-edit', + id: `edit:${tu.id}`, + provider, + status: editStatus(result), + plane: 'committed', + toolUseId: tu.id, + startedAt: null, + endedAt: null, + filePath, + diffs, + patchFiles, + rawStreamingInput: null, + resultError: isError && result ? toolResultText(result) : null, + } +} + +export function fileEditFromLive( + block: SemanticLiveBlock, + toolState: SemanticToolCallSnapshot | null, + provider: AgentProviderKind, +): FileEditArtifact { + const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}` + const name = block.toolName ?? '' + const raw = block.argumentsJson ?? block.inputJson ?? '' + + let filePath: string | null = null + let diffs: FileEditArtifact['diffs'] = [] + let patchFiles: FileEditArtifact['patchFiles'] = [] + let rawStreamingInput: string | null = null + + if (name === 'apply_patch' || name === 'exec') { + // The payload may be the grammar directly or a JSON wrapper around + // it; parseApplyPatch handles both via applyPatchText. A partial + // grammar parses to however many complete file sections have + // arrived — the card grows file by file. + // Unified exec: pull the patch literal out of the streaming JS + // script (partial-safe — the diff grows file by file as the + // literal streams). Classic apply_patch keeps the wrapper decode. + const source = + name === 'exec' + ? (() => { + const action = classifyUnifiedExecScript(raw) + return action?.kind === 'apply_patch' ? { raw: action.patchText } : { raw: '' } + })() + : block.parsedInput ?? partialApplyPatchInput(raw) + patchFiles = parseApplyPatch(source).map(f => ({ + path: f.path, + action: f.action.toLowerCase() as 'add' | 'update' | 'delete', + movedTo: f.movedTo ?? null, + lines: f.lines, + })) + if (patchFiles.length === 0 && raw) rawStreamingInput = raw + } else { + const partial = partialEditInput(raw, block.parsedInput ?? null, name) + if (partial) { + if (name === 'MultiEdit') { + const input = multiEditInput(partial) + filePath = input.filePath || null + diffs = input.edits.map(e => diffLines(e.oldString, e.newString)) + } else { + const input = editInput(partial) + filePath = input.filePath || null + diffs = [diffLines(input.oldString, input.newString)] + } + } else if (raw) { + rawStreamingInput = raw + } + } + + const hasResult = block.resultAt != null || block.resultContent != null + const status: ArtifactStatus = + toolState?.status === 'error' || block.resultIsError === true + ? 'error' + : hasResult + ? 'complete' + : block.finalized === true + ? 'running' + : 'streaming' + + return { + family: 'file-edit', + id: `edit:${id}`, + provider, + status, + plane: 'live', + toolUseId: block.toolUseId ?? block.callId ?? null, + startedAt: null, + endedAt: block.resultAt ?? null, + filePath, + diffs, + patchFiles, + rawStreamingInput, + resultError: + block.resultIsError === true ? (block.resultContent ?? '(error)') : null, + } +} + +const TOOL_LABEL: Record = { + Edit: 'Edit', + MultiEdit: 'MultiEdit', + apply_patch: 'ApplyPatch', +} + +export const DiffCard = memo(function DiffCard({ + vm, + toolName, +}: { + vm: FileEditArtifact + toolName: string +}) { + const { workspaceRoot } = useContext(CodeRenderContext) + const label = TOOL_LABEL[toolName] ?? toolName + const display = vm.filePath ? formatToolFilePath(vm.filePath, workspaceRoot) : null + + const files: DiffViewFile[] = useMemo(() => { + if (vm.patchFiles.length > 0) { + return vm.patchFiles.map(f => ({ + path: f.path, + action: f.action, + movedTo: f.movedTo, + lines: f.lines, + })) + } + return vm.diffs.map((lines, i) => ({ + // The card header already names the (single) file for Edit/ + // MultiEdit — per-chunk headers only label the chunk. + path: null, + action: null, + movedTo: null, + lines, + chunkLabel: vm.diffs.length > 1 ? `change ${i + 1} / ${vm.diffs.length}` : null, + })) + }, [vm.diffs, vm.patchFiles]) + + return ( + +
    +
    + {label} + {display && ( + // RTL truncation — filename stays visible (FileToolHeader port). + + {display} + + )} + {vm.diffs.length > 1 ? ( + + {vm.diffs.length} changes + + ) : vm.patchFiles.length > 1 ? ( + + {vm.patchFiles.length} files + + ) : null} + {(() => { + // Codex-native (+N −M) header totals (diff_render.rs + // render_line_count_summary): green adds, red removes. + const all = [...vm.diffs.flat(), ...vm.patchFiles.flatMap(f => f.lines)] + const added = all.filter(l => l.kind === '+').length + const removed = all.filter(l => l.kind === '-').length + if (added === 0 && removed === 0) return null + return ( + + (+{added}{' '} + −{removed}) + + ) + })()} + +
    + {vm.rawStreamingInput !== null ? ( + // Nothing parseable yet — show the raw streaming input rather + // than a blank card. Flips to the real diff on parse success + // (same mounted card; the body swap is unavoidable because a + // partial diff is unparseable by nature — spec §6). + + ) : ( + 0 || vm.diffs.length > 1} + /> + )} + {vm.resultError ? ( + + ) : null} +
    +
    + ) +}) + +// PatchResultCard — the standalone "Edited …" confirmation row for a +// unified-exec-era patch_apply_end event. Its call_id pairs with NO +// tool_use (fresh `exec-`; the DiffCard for the same edit renders +// from the SCRIPT's patch text on the exec tool_use), so this event +// paints Codex-native style: `• Edited (+N −M)` with the tinted +// diff when the event carried per-file unified_diffs, or a compact +// per-file list when the binary only sent paths — and a loud +// `✘ Failed to apply patch` + stderr on failure (mirrors codex TUI +// patches.rs). Committed-plane only by nature. +export const PatchResultCard = memo(function PatchResultCard({ + files, + diffs, + success, + stderr, +}: { + files: string[] + diffs: Record + success: boolean + stderr: string +}) { + const { workspaceRoot } = useContext(CodeRenderContext) + const diffFiles: DiffViewFile[] = Object.entries(diffs).map(([path, diff]) => ({ + path, + action: 'update' as const, + movedTo: null, + lines: unifiedDiffToLines(diff), + })) + + if (!success) { + return ( + +
    + + ✘ Failed to apply patch + + {diffFiles.length > 0 ? : null} + {stderr ? : null} +
    +
    + ) + } + + return ( + +
    +
    + Edited + {files.length === 1 ? ( + + {formatToolFilePath(files[0], workspaceRoot)} + + ) : ( + + {files.length} files + + )} + +
    + {diffFiles.length > 0 ? ( + + ) : files.length > 1 ? ( + +
    + {files.map(f => ( +
    + {formatToolFilePath(f, workspaceRoot)} +
    + ))} +
    +
    + ) : null} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/fileRead.tsx b/src/renderer/src/features/feed/ui/artifacts/fileRead.tsx new file mode 100644 index 00000000..7c36a6d6 --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/fileRead.tsx @@ -0,0 +1,203 @@ +import { memo, useContext } from 'react' + +import { formatToolFilePath } from '@shared/paths/displayPath' +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import type { AgentProviderKind } from '@shared/types/providerKind' + +import { CodeRenderContext } from '@renderer/features/feed/context' +import { + stripLineNumberPrefix, + toolResultText, +} from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { ExpandSection } from '@renderer/features/feed/ui/kit/ExpandSection' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import type { SemanticLiveTurn } from '@renderer/session-runtime/state' + +import type { ArtifactStatus, ReadArtifact } from './types' + +type SemanticLiveBlock = SemanticLiveTurn['blocks'][number] +type SemanticToolCallSnapshot = SemanticLiveTurn['lookups']['toolCallsById'][string] + +// ReadCard — the ONE lookup surface (spec §6): Read, Grep, Glob, LS. +// The collapsed one-liner IS the design: lookups are churn, and the +// old rendering split them across a generic tool_use row (bare name + +// params slab) and a special-cased result branch buried inside the +// shared ToolResultRow (hardcoded by tool name — audit gap #9). One +// card now owns both halves: +// +// ⏺ Read src/foo.ts ✓ +// ⎿ ▸ Read 240 lines ← ExpandSection, Monaco on first open +// +// Behavior ported from ToolResultRow's Read/Grep branches: the `N→` +// line-number prefix is stripped before display (the code renderer +// numbers lines itself), Monaco mounts only behind first-open, Grep +// results keep language-aware coloring via path detection. Glob/LS +// results render as a monospace file list. + +function toolKind(name: string): ReadArtifact['kind'] { + if (name === 'Grep') return 'grep' + if (name === 'Glob') return 'glob' + if (name === 'LS') return 'ls' + return 'read' +} + +function readTarget(input: Record | null): string | null { + if (!input) return null + if (typeof input.file_path === 'string') return input.file_path + if (typeof input.path === 'string') return input.path + return null +} + +function readPattern(input: Record | null): string | null { + if (!input) return null + if (typeof input.pattern === 'string') return input.pattern + if (typeof input.glob === 'string') return input.glob + return null +} + +export function readFromCommitted( + tu: ToolUseBlock, + result: ToolResultBlock | null, + provider: AgentProviderKind, +): ReadArtifact { + const input = (tu.input ?? null) as Record | null + const kind = toolKind(tu.name) + const rawText = result ? toolResultText(result).replace(/\s+$/, '') : null + return { + family: 'file-read', + id: `read:${tu.id}`, + provider, + status: !result ? 'running' : result.is_error === true ? 'error' : 'complete', + plane: 'committed', + toolUseId: tu.id, + startedAt: null, + endedAt: null, + kind, + target: readTarget(input), + pattern: readPattern(input), + resultText: + rawText !== null && kind === 'read' ? stripLineNumberPrefix(rawText) : rawText, + resultIsError: result?.is_error === true, + } +} + +export function readFromLive( + block: SemanticLiveBlock, + toolState: SemanticToolCallSnapshot | null, + provider: AgentProviderKind, +): ReadArtifact { + const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}` + const kind = toolKind(block.toolName ?? '') + const input = block.parsedInput ?? null + const raw = toolState?.resultContent ?? block.resultContent ?? null + const hasResult = block.resultAt != null || raw != null + const status: ArtifactStatus = + toolState?.status === 'error' || block.resultIsError === true + ? 'error' + : hasResult + ? 'complete' + : block.finalized === true + ? 'running' + : 'streaming' + return { + family: 'file-read', + id: `read:${id}`, + provider, + status, + plane: 'live', + toolUseId: block.toolUseId ?? block.callId ?? null, + startedAt: null, + endedAt: block.resultAt ?? null, + kind, + target: readTarget(input), + pattern: readPattern(input), + resultText: raw !== null && kind === 'read' ? stripLineNumberPrefix(raw) : raw, + resultIsError: block.resultIsError === true, + } +} + +const KIND_LABEL: Record = { + read: 'Read', + grep: 'Grep', + glob: 'Glob', + ls: 'LS', +} + +export const ReadCard = memo(function ReadCard({ vm }: { vm: ReadArtifact }) { + const { workspaceRoot } = useContext(CodeRenderContext) + const display = vm.target ? formatToolFilePath(vm.target, workspaceRoot) : null + const lineCount = vm.resultText ? vm.resultText.split('\n').filter(Boolean).length : 0 + + return ( + +
    +
    + + {KIND_LABEL[vm.kind]} + + {vm.pattern ? ( + + "{vm.pattern}" + + ) : null} + {display ? ( + + {display} + + ) : null} + +
    + {vm.resultIsError && vm.resultText ? ( + + ) : vm.resultText ? ( + + + {lineCount}{' '} + {lineCount === 1 ? 'match line' : 'match lines'} + + ) : vm.kind === 'glob' || vm.kind === 'ls' ? ( + <> + {lineCount}{' '} + {lineCount === 1 ? 'entry' : 'entries'} + + ) : ( + <> + Read {lineCount}{' '} + {lineCount === 1 ? 'line' : 'lines'} + + ) + } + > + {vm.kind === 'glob' || vm.kind === 'ls' ? ( +
    +                  {vm.resultText}
    +                
    + ) : ( + + )} +
    +
    + ) : null} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/fileWrite.tsx b/src/renderer/src/features/feed/ui/artifacts/fileWrite.tsx new file mode 100644 index 00000000..4a9a0a8c --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/fileWrite.tsx @@ -0,0 +1,175 @@ +import { memo, useContext } from 'react' + +import { normalizeCodeLanguage } from '@shared/code/language' +import { formatToolFilePath } from '@shared/paths/displayPath' +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import type { AgentProviderKind } from '@shared/types/providerKind' + +import { CodeRenderContext } from '@renderer/features/feed/context' +import { toolResultText } from '@renderer/features/feed/lib/helpers' +import { extractStreamingWriteInput } from '@renderer/features/feed/lib/streamingWriteInput' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { ExpandSection } from '@renderer/features/feed/ui/kit/ExpandSection' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import { StreamingCodeBlock } from '@renderer/features/feed/ui/kit/StreamingCodeBlock' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import type { SemanticLiveTurn } from '@renderer/session-runtime/state' + +import type { ArtifactStatus, FileWriteArtifact } from './types' + +type SemanticLiveBlock = SemanticLiveTurn['blocks'][number] +type SemanticToolCallSnapshot = SemanticLiveTurn['lookups']['toolCallsById'][string] + +// FileWriteCard — the ONE file-write surface (spec §6), and the payoff +// of the streaming-ambition decision: the live Write preview is now +// HIGHLIGHTED line-by-line (the old preview was highlight={false} +// because whole-string hljs per delta was O(bytes²); sealed-line +// caching makes highlight affordable). Committed renders the same +// StreamingCodeBlock — already-complete code seals in one pass and +// produces identical DOM, so the commit boundary is invisible except +// the badge flip and the line counter settling. +// +// Large committed files additionally offer "open in Monaco" behind a +// first-open ExpandSection (desktop affordance; the phone host simply +// never expands it — Monaco stays lazy). + +const MONACO_OFFER_LINES = 80 + +function countLines(content: string): number { + if (!content) return 0 + const normalized = content.endsWith('\n') ? content.slice(0, -1) : content + return normalized === '' ? 0 : normalized.split('\n').length +} + +export function fileWriteFromCommitted( + tu: ToolUseBlock, + result: ToolResultBlock | null, + provider: AgentProviderKind, +): FileWriteArtifact & { resultError: string | null } { + const input = (tu.input ?? {}) as Record + const filePath = typeof input.file_path === 'string' ? input.file_path : null + const content = typeof input.content === 'string' ? input.content : '' + const isError = result?.is_error === true + const status: ArtifactStatus = !result ? 'running' : isError ? 'error' : 'complete' + return { + family: 'file-write', + id: `write:${tu.id}`, + provider, + status, + plane: 'committed', + toolUseId: tu.id, + startedAt: null, + endedAt: null, + filePath, + content, + lineCount: countLines(content), + resultError: isError && result ? toolResultText(result) : null, + } +} + +export function fileWriteFromLive( + block: SemanticLiveBlock, + toolState: SemanticToolCallSnapshot | null, + provider: AgentProviderKind, +): (FileWriteArtifact & { resultError: string | null }) | null { + const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}` + // Prefer the authoritative parse once the input finalizes; fall back + // to the single-pass partial scanner while streaming. + const parsed = block.parsedInput + let filePath: string | null = null + let content = '' + if (parsed && typeof parsed.file_path === 'string') { + filePath = parsed.file_path + content = typeof parsed.content === 'string' ? parsed.content : '' + } else { + const stream = extractStreamingWriteInput(block.inputJson ?? '') + // No path yet → caller falls through to the generic card (raw + // preview) — never a blank Write card (the old preview's rule). + if (!stream.filePath) return null + filePath = stream.filePath + content = stream.partialContent ?? '' + } + const hasResult = block.resultAt != null || block.resultContent != null + const status: ArtifactStatus = + toolState?.status === 'error' || block.resultIsError === true + ? 'error' + : hasResult + ? 'complete' + : block.finalized === true + ? 'running' + : 'streaming' + return { + family: 'file-write', + id: `write:${id}`, + provider, + status, + plane: 'live', + toolUseId: block.toolUseId ?? block.callId ?? null, + startedAt: null, + endedAt: block.resultAt ?? null, + filePath, + content, + lineCount: countLines(content), + resultError: + block.resultIsError === true ? (block.resultContent ?? '(error)') : null, + } +} + +export const FileWriteCard = memo(function FileWriteCard({ + vm, +}: { + vm: FileWriteArtifact & { resultError: string | null } +}) { + const { workspaceRoot } = useContext(CodeRenderContext) + const display = vm.filePath ? formatToolFilePath(vm.filePath, workspaceRoot) : null + const language = normalizeCodeLanguage(null, vm.filePath) + + return ( + +
    +
    + Write + {display && ( + + {display} + + )} + {language !== 'plaintext' ? ( + + {language} + + ) : null} + + {vm.lineCount} line{vm.lineCount === 1 ? '' : 's'} + + +
    + + {(vm.status === 'complete' || vm.status === 'error') && + vm.lineCount > MONACO_OFFER_LINES ? ( + + + + ) : null} + {vm.resultError ? : null} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/generic.tsx b/src/renderer/src/features/feed/ui/artifacts/generic.tsx new file mode 100644 index 00000000..618d71e0 --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/generic.tsx @@ -0,0 +1,183 @@ +import { memo } from 'react' + +import { formatToolFilePath } from '@shared/paths/displayPath' +import { + isAbsolutePathLike, + isHttpUrl, + slabEntries, + smartHeadline, + tryExtractJson, +} from '@providers/shared/renderer/rows/jsonToolPresentation' +import { JsonResultSlab } from '@providers/shared/renderer/rows/JsonResultSlab' +import { SLAB_MAX_CHARS } from '@providers/shared/renderer/rows/JsonToolRow' + +import { truncateBashCommand } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import { StreamingCodeBlock } from '@renderer/features/feed/ui/kit/StreamingCodeBlock' + +import type { GenericToolArtifact } from './types' + +// GenericToolCard — THE one fallback for tools without a dedicated +// family card, identical live and committed (spec §6). Successor to +// the committed JsonToolRow AND the hand-rolled live generic branch in +// BlockRow (which dumped raw partial inputJson into a
     — audit
    +// finding 7). The per-param presentation (path shortening, URL links,
    +// 400-char scalar clamp, nested-JSON slab with the 16KB cap) is
    +// preserved from JsonToolRow because the corpus work behind it
    +// (plan-json-tool-rows.md) is exactly what made MCP/orchestration
    +// payloads readable.
    +//
    +// The live upgrade over the old raw dump: a PARTIAL params buffer
    +// renders as growing highlighted JSON through StreamingCodeBlock
    +// (sealed-line cache — cheap per delta), flipping to the structured
    +// per-param slab the moment the JSON parses. Same card, body fills in.
    +
    +function ParamValue({ value }: { value: unknown }) {
    +  if (typeof value === 'string') {
    +    if (isHttpUrl(value)) {
    +      return (
    +        
    +          {value}
    +        
    +      )
    +    }
    +    if (isAbsolutePathLike(value)) {
    +      return (
    +        
    +          {formatToolFilePath(value, null)}
    +        
    +      )
    +    }
    +    // 400-char clamp: a scalar param is a one-line hint in a collapsed
    +    // slab, not a document (see JsonToolRow's original rationale).
    +    return (
    +      
    +        {value.length > 400 ? `${value.slice(0, 400)}…` : value}
    +      
    +    )
    +  }
    +  if (value === null || typeof value !== 'object') {
    +    return {String(value)}
    +  }
    +  return {Array.isArray(value) ? `[${value.length}]` : '{…}'}
    +}
    +
    +export const GenericToolCard = memo(function GenericToolCard({
    +  vm,
    +}: {
    +  vm: GenericToolArtifact
    +}) {
    +  const headline = smartHeadline(vm.params)
    +  const headlineText = (() => {
    +    if (!headline) return null
    +    if (vm.toolName === 'Bash' && headline.key === 'command') {
    +      return truncateBashCommand(headline.value)
    +    }
    +    if (isAbsolutePathLike(headline.value)) return formatToolFilePath(headline.value, null)
    +    return headline.value
    +  })()
    +
    +  const params = slabEntries(vm.params, headline?.key ?? null)
    +  const nested = params.filter(([, v]) => typeof v === 'object' && v !== null)
    +  const nestedJson = (() => {
    +    if (nested.length === 0) return null
    +    try {
    +      const json = JSON.stringify(Object.fromEntries(nested), null, 2)
    +      return json.length > SLAB_MAX_CHARS ? `${json.slice(0, SLAB_MAX_CHARS)}\n…` : json
    +    } catch {
    +      return null
    +    }
    +  })()
    +
    +  // Partial live input: the JSON hasn't parsed yet, so there is no
    +  // params record — show the raw buffer as growing highlighted JSON.
    +  // Capped like every slab; the moment vm.params materializes this
    +  // branch is skipped and the structured slab takes over.
    +  const showStreamingParams = vm.params === null && vm.paramsJson.length > 0
    +
    +  const parsedResult =
    +    vm.resultText !== null ? tryExtractJson(vm.resultText) : null
    +
    +  return (
    +    
    +      
    +
    + {vm.prettyName} + {vm.mcp && ( + + MCP · {vm.mcp.server} + + )} + +
    + {headlineText && ( + +
    +              {headlineText}
    +            
    +
    + )} + {showStreamingParams ? ( + + SLAB_MAX_CHARS + ? `${vm.paramsJson.slice(0, SLAB_MAX_CHARS)}\n…` + : vm.paramsJson + } + language="json" + blockKey={`params:${vm.id}`} + /> + + ) : params.length > 0 ? ( + +
    + + {params.length} param{params.length === 1 ? '' : 's'} + +
    + {params + .filter(([, v]) => typeof v !== 'object' || v === null) + .map(([k, v]) => ( +
    + {k}: +
    + ))} + {nestedJson && ( + + )} +
    +
    +
    + ) : null} + {vm.parseError && ( + +
    + invalid tool input: {vm.parseError} +
    +
    + )} + {vm.resultText !== null ? ( + parsedResult !== null && typeof parsedResult === 'object' ? ( + + ) : ( + + ) + ) : null} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/imageGen.tsx b/src/renderer/src/features/feed/ui/artifacts/imageGen.tsx new file mode 100644 index 00000000..c076f14f --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/imageGen.tsx @@ -0,0 +1,95 @@ +import { memo } from 'react' + +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import type { AgentProviderKind } from '@shared/types/providerKind' + +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import type { SemanticLiveTurn } from '@renderer/session-runtime/state' + +import type { ImageGenArtifact } from './types' + +type SemanticLiveBlock = SemanticLiveTurn['blocks'][number] +type SemanticToolCallSnapshot = SemanticLiveTurn['lookups']['toolCallsById'][string] + +// ImageGenCard — Codex image_generation_call (live) and the rollout- +// synthesized `image_generation` committed twin. The result payload is +// a provider-side handle (not inline image bytes), so the card is a +// status surface: label + revised prompt + badge. If Codex ever +// surfaces inline image data here, route it through ImageBlockRow. + +export function imageGenFromCommitted( + tu: ToolUseBlock, + result: ToolResultBlock | null, + provider: AgentProviderKind, +): ImageGenArtifact { + const input = (tu.input ?? {}) as Record + const genStatus = typeof input.status === 'string' ? input.status : null + return { + family: 'image-gen', + id: `img:${tu.id}`, + provider, + status: + result?.is_error === true + ? 'error' + : genStatus === 'completed' || result + ? 'complete' + : 'running', + plane: 'committed', + toolUseId: tu.id, + startedAt: null, + endedAt: null, + genStatus, + revisedPrompt: + typeof input.revisedPrompt === 'string' ? input.revisedPrompt : null, + result: null, + } +} + +export function imageGenFromLive( + block: SemanticLiveBlock, + _toolState: SemanticToolCallSnapshot | null, + provider: AgentProviderKind, +): ImageGenArtifact { + const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}` + const img = block.imageGeneration + const genStatus = img?.status ?? block.status ?? null + return { + family: 'image-gen', + id: `img:${id}`, + provider, + status: genStatus === 'completed' ? 'complete' : 'running', + plane: 'live', + toolUseId: block.toolUseId ?? block.callId ?? null, + startedAt: null, + endedAt: block.resultAt ?? null, + genStatus, + revisedPrompt: img?.revisedPrompt ?? null, + result: img?.result ?? null, + } +} + +export const ImageGenCard = memo(function ImageGenCard({ vm }: { vm: ImageGenArtifact }) { + return ( + +
    +
    + 🖼 Image generation + {vm.genStatus && vm.status !== 'complete' ? ( + + {vm.genStatus.replace(/_/g, ' ')} + + ) : null} + +
    + {vm.revisedPrompt ? ( + +
    + {vm.revisedPrompt} +
    +
    + ) : null} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/slashCommand.tsx b/src/renderer/src/features/feed/ui/artifacts/slashCommand.tsx new file mode 100644 index 00000000..ecff7e56 --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/slashCommand.tsx @@ -0,0 +1,67 @@ +import { memo } from 'react' + +import { + isSlashCommandText, + parseLocalCommandStdout, + parseSlashCommandEnvelope, +} from '@providers/claude/renderer/extractors' + +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { TextProse } from '@renderer/features/feed/ui/markdown' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { UserBand } from '@renderer/features/feed/ui/rows/primitives' + +// Slash-command rendering (spec §6). Claude Code records a slash +// invocation as a user text block full of XML-ish tags — +// /foo etc. — which the feed used to +// paint as literal tag soup inside the user's prompt bubble (audit +// gap #6, one of the ugliest everyday surfaces). Two row shapes: +// +// ❯ /model model ← invocation: name pill + message/args +// ⎿ Set model to Fable 5… ← stdout record (SEPARATE user entry, +// may carry ANSI — real transcripts do) +// +// The stdout row is muted and band-less on purpose: it is command +// OUTPUT the CLI echoed, not something the user typed — wrapping it +// in the user band would misattribute it. + +export const SlashCommandRow = memo(function SlashCommandRow({ text }: { text: string }) { + const envelope = parseSlashCommandEnvelope(text) + if (envelope) { + return ( + + +
    +
    + + {envelope.name} + + {envelope.message && envelope.message !== envelope.name.replace(/^\//, '') ? ( + {envelope.message} + ) : null} +
    + {envelope.args ? : null} +
    +
    +
    + ) + } + + const stdout = parseLocalCommandStdout(text) + if (stdout !== null) { + if (!stdout.trim()) return null + return + } + + // Defensive: isSlashCommandText matched but neither tag parsed — + // fall back to plain user text so nothing ever vanishes. + return ( + + + + + + ) +}) + +export { isSlashCommandText } diff --git a/src/renderer/src/features/feed/ui/artifacts/thinking.tsx b/src/renderer/src/features/feed/ui/artifacts/thinking.tsx new file mode 100644 index 00000000..7eaa7f29 --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/thinking.tsx @@ -0,0 +1,61 @@ +import { memo } from 'react' + +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { StreamingProse, TextProse } from '@renderer/features/feed/ui/markdown' + +// ThinkingBlock — the ONE thinking/reasoning surface, replacing the +// near-identical JSX that lived twice (live: BlockRow's thinking +// branch; committed: Block.tsx's thinking case) and drifted in small +// ways. Design rules preserved from the 2026-04-18 rework: +// +// - Empty thinking renders NOTHING. The WorkIndicator owns the +// "Thinking · Ns" affordance; a static placeholder row read as +// "hung" when reasoning was encrypted. +// - Non-empty thinking is a collapsed
    , closed by default. +// +// New: `redacted` renders an explicit "redacted by provider" pill — +// redacted_thinking blocks used to fall through to an empty text +// branch and silently vanish (audit dropped-shape #1). Showing that +// something WAS there but is unreadable beats pretending it never +// happened (the pipeline's show-and-explain ethos). +// +// `streaming` picks the markdown flavor: StreamingProse keeps hard +// newlines as
    (live reasoning arrives line-oriented), TextProse +// is standard committed markdown. A status flag, not a plane flag — +// finalized live thinking renders exactly like its committed twin. + +export const ThinkingBlock = memo(function ThinkingBlock({ + text, + streaming = false, + redacted = false, +}: { + text: string + streaming?: boolean + redacted?: boolean +}) { + if (redacted) { + return ( + + + ∴ thinking redacted by provider + + + ) + } + if (!text) return null + return ( + +
    + + ∴ Thinking{streaming ? '…' : ''} + + (click to expand) + + +
    + {streaming ? : } +
    +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/todo.tsx b/src/renderer/src/features/feed/ui/artifacts/todo.tsx new file mode 100644 index 00000000..6751fb69 --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/todo.tsx @@ -0,0 +1,150 @@ +import { memo } from 'react' + +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import type { AgentProviderKind } from '@shared/types/providerKind' + +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import { parseSemanticTodos, type SemanticLiveTurn } from '@renderer/session-runtime/state' + +import type { ArtifactStatus, TodoArtifact } from './types' + +type SemanticLiveBlock = SemanticLiveTurn['blocks'][number] +type SemanticToolCallSnapshot = SemanticLiveTurn['lookups']['toolCallsById'][string] + +// TodoCard — the ONE checklist surface (spec §6), unifying the +// committed TodoRow (ClaudeRows) and the live SemanticTodoList that +// had near-identical but separately-maintained JSX. Glyph vocabulary +// preserved from TodoRow: ☑ done (accent, strikethrough), ◐ in +// progress (accent, activeForm label), ☐ pending (muted). +// +// `label` names the tool the agent actually called — OpenCode's +// lowercase `todowrite` reuses this card (the 2026-07-06 cross-provider +// reuse), so the header must not hardcode Claude's "TodoWrite". + +function parseTodos(input: unknown): TodoArtifact['todos'] { + const rec = (input ?? {}) as Record + const raw = Array.isArray(rec.todos) ? rec.todos : [] + return raw.map(t => { + const item = (t ?? {}) as Record + const status = + item.status === 'in_progress' || item.status === 'completed' + ? item.status + : 'pending' + return { + content: typeof item.content === 'string' ? item.content : '', + status, + activeForm: typeof item.activeForm === 'string' ? item.activeForm : '', + } + }) +} + +export function todoFromCommitted( + tu: ToolUseBlock, + result: ToolResultBlock | null, + provider: AgentProviderKind, +): TodoArtifact { + return { + family: 'todo', + id: `todo:${tu.id}`, + provider, + status: !result ? 'running' : result.is_error === true ? 'error' : 'complete', + plane: 'committed', + toolUseId: tu.id, + startedAt: null, + endedAt: null, + todos: parseTodos(tu.input), + } +} + +export function todoFromLive( + block: SemanticLiveBlock, + toolState: SemanticToolCallSnapshot | null, + provider: AgentProviderKind, +): TodoArtifact { + const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}` + const hasResult = block.resultAt != null || block.resultContent != null + const status: ArtifactStatus = + toolState?.status === 'error' || block.resultIsError === true + ? 'error' + : hasResult + ? 'complete' + : block.finalized === true + ? 'running' + : 'streaming' + return { + family: 'todo', + id: `todo:${id}`, + provider, + status, + plane: 'live', + toolUseId: block.toolUseId ?? block.callId ?? null, + startedAt: null, + endedAt: block.resultAt ?? null, + todos: parseSemanticTodos(block.parsedInput), + } +} + +const TodoItemRow = memo(function TodoItemRow({ + item, +}: { + item: TodoArtifact['todos'][number] +}) { + const glyph = + item.status === 'completed' ? '☑' : item.status === 'in_progress' ? '◐' : '☐' + const textCls = + item.status === 'completed' + ? 'text-muted line-through' + : item.status === 'in_progress' + ? 'text-ink' + : 'text-ink-dim' + const glyphCls = + item.status === 'completed' || item.status === 'in_progress' + ? 'text-accent' + : 'text-muted' + const label = + item.status === 'in_progress' && item.activeForm ? item.activeForm : item.content + return ( +
  • + + {label} +
  • + ) +}) + +export const TodoCard = memo(function TodoCard({ + vm, + label = 'TodoWrite', +}: { + vm: TodoArtifact + label?: string +}) { + const done = vm.todos.filter(t => t.status === 'completed').length + return ( + +
    +
    + {label} + + {done} / {vm.todos.length} done + + +
    + {vm.todos.length === 0 ? ( +
    (empty list)
    + ) : ( +
      + {vm.todos.map((t, i) => ( + + ))} +
    + )} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/artifacts/types.ts b/src/renderer/src/features/feed/ui/artifacts/types.ts new file mode 100644 index 00000000..dae23dee --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/types.ts @@ -0,0 +1,199 @@ +import type { DiffLine } from '@shared/parsers/lineDiff' +import type { AgentProviderKind } from '@shared/types/providerKind' + +// ArtifactVM — the normalized view-model every artifact card renders. +// +// WHY this layer exists (spec §3/§4, +// docs/superpowers/specs/2026-07-11-feed-render-layer-rewrite-design.md): +// the same logical artifact (a Bash run, an Edit, a web search) reaches +// React through TWO different shapes — committed ToolUseBlock/ +// ToolResultBlock pairs and live SemanticLiveBlock — and historically +// each shape grew its own renderer. The two dispatch ladders (committed +// Block.tsx vs live BlockRow.tsx) drifted apart repeatedly: live MCP +// dumps looked nothing like their committed twins, Codex web_search had +// a bespoke live chip and an ugly generic committed row. Normalizing +// both planes into ONE VM per artifact family makes "live ≈ committed" +// true BY CONSTRUCTION: the same card renders status:'streaming' and +// status:'complete', and finishing a tool is a props flip, never a +// component swap. +// +// Rules for this file: +// - Types only. No functions, no React. The resolvers live in +// ui/resolve/, the cards in ui/artifacts/.tsx. +// - Every family payload carries data BOTH planes can populate; +// fields a plane can't know yet are null, never fabricated. A +// card must render sensibly with every nullable field null. +// - `plane` is provenance for debug emission ONLY. A card that +// branches on it is reintroducing the live/committed fork this +// layer exists to kill. + +export type ArtifactStatus = + /** tool input still arriving (tool_input_delta / partial inputJson) */ + | 'streaming' + /** input finalized, awaiting result */ + | 'running' + /** result attached (or committed pair resolved) without error */ + | 'complete' + /** is_error result / non-zero exit code / parse refusal */ + | 'error' + +export type ArtifactBase = { + /** Stable identity for React keys and memo caches. Derived from + * toolUseId/callId/entry uuid — NEVER the visible index (that + * produced the React-subtree-reuse phantom-duplicate class). */ + id: string + provider: AgentProviderKind + status: ArtifactStatus + /** Provenance for debug emission only — cards MUST NOT branch on it. */ + plane: 'committed' | 'live' + toolUseId: string | null + startedAt: number | null + endedAt: number | null +} + +// --------------------------------------------------------------------------- +// Families +// --------------------------------------------------------------------------- + +export type CommandArtifact = ArtifactBase & { + family: 'command' + command: string + cwd: string | null + description: string | null + /** Which wire-level tool produced this — display metadata (the $ card + * is identical for all of them), kept for debugging provenance. */ + sourceTool: 'Bash' | 'exec_command' | 'local_shell_call' | 'bash' | 'write_stdin' | 'wait' + /** Raw output, ANSI escapes preserved (OutputWell renders them). */ + output: string | null + exitCode: number | null + durationMs: number | null + /** Codex exec metadata chips. */ + yieldTimeMs: number | null + maxOutputTokens: number | null + /** Codex write_stdin payloads attached to this command surface. */ + stdinWrites: string[] + /** Codex classified this exec as a file read / search (parsed_cmd + * meta) — the card shows a "Read N lines from " summary with + * expandable source instead of a raw OutputWell. */ + parsedRead: { kind: 'read' | 'search'; path: string | null } | null +} + +export type FileEditArtifact = ArtifactBase & { + family: 'file-edit' + filePath: string | null + /** One DiffLine[] per edit chunk (Claude Edit = 1, MultiEdit = N). + * Empty while the live input is still unparseable. */ + diffs: DiffLine[][] + /** Codex apply_patch: per-file parsed patch. Mutually exclusive with + * `diffs` in practice (Claude fills diffs, Codex fills patchFiles); + * the card renders whichever is non-empty. */ + patchFiles: Array<{ + path: string + action: 'add' | 'update' | 'delete' + movedTo: string | null + lines: DiffLine[] + }> + /** Raw streaming input to show until parseable (live plane only). */ + rawStreamingInput: string | null + resultError: string | null +} + +export type FileWriteArtifact = ArtifactBase & { + family: 'file-write' + filePath: string | null + content: string + lineCount: number +} + +export type ReadArtifact = ArtifactBase & { + family: 'file-read' + kind: 'read' | 'grep' | 'glob' | 'ls' + /** file path (read) or directory/scope path (grep/glob/ls) */ + target: string | null + pattern: string | null + resultText: string | null + resultIsError: boolean +} + +export type TodoArtifact = ArtifactBase & { + family: 'todo' + todos: Array<{ + content: string + status: 'pending' | 'in_progress' | 'completed' + activeForm: string + }> +} + +export type WebArtifact = ArtifactBase & { + family: 'web' + kind: 'search' | 'fetch' | 'open_page' | 'find_in_page' + query: string | null + queries: string[] + url: string | null + pattern: string | null + resultText: string | null +} + +export type AgentSpawnArtifact = ArtifactBase & { + family: 'agent-spawn' + /** The raw tool_use block — AgentCard delegates to the existing + * TaskSubagentRow machinery (SubAgentsContext keyed by tool_use id), + * which already renders live status/elapsed/mini-feed well. */ + toolName: string + input: Record | null +} + +export type McpArtifact = ArtifactBase & { + family: 'mcp' + server: string + tool: string + params: Record | null + paramsJson: string + resultText: string | null + resultIsError: boolean +} + +export type SlashCommandArtifact = ArtifactBase & { + family: 'slash-command' + name: string + message: string | null + args: string | null + stdout: string | null +} + +export type ImageGenArtifact = ArtifactBase & { + family: 'image-gen' + genStatus: string | null + revisedPrompt: string | null + result: string | null +} + +export type GenericToolArtifact = ArtifactBase & { + family: 'generic' + toolName: string + /** MCP-aware pretty name (mcp__srv__tool → "tool (srv)"). */ + prettyName: string + mcp: { server: string; tool: string } | null + headline: string | null + params: Record | null + /** Raw (possibly partial mid-stream) input JSON. */ + paramsJson: string + parseError: string | null + resultText: string | null + resultIsError: boolean +} + +export type ArtifactVM = + | CommandArtifact + | FileEditArtifact + | FileWriteArtifact + | ReadArtifact + | TodoArtifact + | WebArtifact + | AgentSpawnArtifact + | McpArtifact + | SlashCommandArtifact + | ImageGenArtifact + | GenericToolArtifact + +export type ArtifactFamily = ArtifactVM['family'] diff --git a/src/renderer/src/features/feed/ui/artifacts/web.tsx b/src/renderer/src/features/feed/ui/artifacts/web.tsx new file mode 100644 index 00000000..b0c57ab1 --- /dev/null +++ b/src/renderer/src/features/feed/ui/artifacts/web.tsx @@ -0,0 +1,157 @@ +import { memo } from 'react' + +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import type { AgentProviderKind } from '@shared/types/providerKind' +import { isHttpUrl } from '@providers/shared/renderer/rows/jsonToolPresentation' + +import { toolResultText } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { ExpandSection } from '@renderer/features/feed/ui/kit/ExpandSection' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { StatusBadge } from '@renderer/features/feed/ui/kit/StatusBadge' +import type { SemanticLiveTurn } from '@renderer/session-runtime/state' + +import type { ArtifactStatus, WebArtifact } from './types' + +type SemanticLiveBlock = SemanticLiveTurn['blocks'][number] +type SemanticToolCallSnapshot = SemanticLiveTurn['lookups']['toolCallsById'][string] + +// WebCard — the ONE web surface (spec §6): Claude WebSearch/WebFetch, +// Codex web_search_call (live) and its rollout-synthesized `web_search` +// committed twin, plus tool_search. This kills audit gap #3's ugliest +// case: Codex web searches had a nice live chip (🌐 Search: …) and an +// unrecognizable generic committed row — same action, two looks. +// +// Links open through a plain target=_blank anchor gated by isHttpUrl — +// the same pattern JsonToolRow's ParamValue used (length-capped, http(s) +// only), so no new navigation surface is introduced. + +const KIND_ICON: Record = { + search: '🌐', + fetch: '🌐', + open_page: '🌐', + find_in_page: '🔎', +} + +const KIND_LABEL: Record = { + search: 'Search', + fetch: 'Fetch', + open_page: 'Open', + find_in_page: 'Find in page', +} + +function kindFromString(kind: string | null, toolName: string): WebArtifact['kind'] { + if (kind === 'open_page' || kind === 'find_in_page' || kind === 'search') return kind + if (toolName === 'WebFetch') return 'fetch' + return 'search' +} + +export function webFromCommitted( + tu: ToolUseBlock, + result: ToolResultBlock | null, + provider: AgentProviderKind, +): WebArtifact { + const input = (tu.input ?? {}) as Record + return { + family: 'web', + id: `web:${tu.id}`, + provider, + status: !result ? 'running' : result.is_error === true ? 'error' : 'complete', + plane: 'committed', + toolUseId: tu.id, + startedAt: null, + endedAt: null, + kind: kindFromString( + typeof input.kind === 'string' ? input.kind : null, + tu.name, + ), + query: typeof input.query === 'string' ? input.query : null, + queries: Array.isArray(input.queries) + ? input.queries.filter((q): q is string => typeof q === 'string') + : [], + url: typeof input.url === 'string' ? input.url : null, + pattern: typeof input.pattern === 'string' ? input.pattern : null, + resultText: result ? toolResultText(result).replace(/\s+$/, '') || null : null, + } +} + +export function webFromLive( + block: SemanticLiveBlock, + toolState: SemanticToolCallSnapshot | null, + provider: AgentProviderKind, +): WebArtifact { + const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}` + const action = block.webSearchAction + const hasResult = block.resultAt != null || block.resultContent != null + const status: ArtifactStatus = + toolState?.status === 'error' || block.resultIsError === true + ? 'error' + : hasResult || block.status === 'completed' + ? 'complete' + : 'running' + return { + family: 'web', + id: `web:${id}`, + provider, + status, + plane: 'live', + toolUseId: block.toolUseId ?? block.callId ?? null, + startedAt: null, + endedAt: block.resultAt ?? null, + kind: kindFromString(action?.kind ?? null, block.toolName ?? ''), + query: action?.query ?? null, + queries: action?.queries ?? [], + url: action?.url ?? null, + pattern: action?.pattern ?? null, + resultText: toolState?.resultContent ?? block.resultContent ?? null, + } +} + +export const WebCard = memo(function WebCard({ + vm, + toolLabel, +}: { + vm: WebArtifact + /** 'Tool search' for tool_search rows; default derives from kind. */ + toolLabel?: string +}) { + const label = toolLabel ?? KIND_LABEL[vm.kind] + const subject = vm.query ?? (vm.queries.length > 0 ? vm.queries.join(', ') : null) + return ( + +
    +
    + + {KIND_ICON[vm.kind]} {label} + + {subject ? {subject} : null} + {vm.pattern ? ( + "{vm.pattern}" + ) : null} + {vm.url ? ( + isHttpUrl(vm.url) ? ( + + {vm.url} + + ) : ( + {vm.url} + ) + ) : null} + +
    + {vm.resultText ? ( + + + + + + ) : null} +
    +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/hooks/useFeedDebugEmission.ts b/src/renderer/src/features/feed/ui/hooks/useFeedDebugEmission.ts new file mode 100644 index 00000000..792a40b5 --- /dev/null +++ b/src/renderer/src/features/feed/ui/hooks/useFeedDebugEmission.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'react' + +import type { StreamPhase } from '@renderer/session-runtime/state' +import type { DebugVisibleRow, VisibleDecision } from '@renderer/features/feed/types' +import { debugLabelForEntry } from '@renderer/features/feed/lib/helpers' + +// PORTED VERBATIM from Feed.tsx:845-900 @269f9fc — the RENDER-layer +// feed-debug emission. This is one half of the "debug == paint" +// contract (rendering-design-principles.md P4): what lands in the +// feed-debug log is derived from the rows the painter ACTUALLY put on +// screen (DebugVisibleRow, built off the same renderItems the JSX +// maps), never a second visibility derivation that could disagree +// with the paint. Do not "optimize" this into reading the ledger +// directly — the point is that it records the paint. + +export function useFeedDebugEmission({ + onDebugLog, + entriesLength, + visibleEntryCount, + renderedRows, + visibleDecisions, + semanticTurnId, + renderedSemanticHistoryTurnIds, + streamPhase, +}: { + onDebugLog?: (entry: { + layer: 'RENDER' + kind: string + summary: string + data?: unknown + }) => void + entriesLength: number + visibleEntryCount: number + renderedRows: DebugVisibleRow[] + visibleDecisions: VisibleDecision[] + semanticTurnId: string | null + renderedSemanticHistoryTurnIds: string[] + streamPhase: StreamPhase +}): void { + const previousRenderedRowsRef = useRef(null) + const previousRenderDebugSignatureRef = useRef(null) + const renderedSemanticHistorySignature = renderedSemanticHistoryTurnIds.join('\u0000') + + useEffect(() => { + if (!onDebugLog) return + const previous = previousRenderedRowsRef.current + const renderDebugSignature = JSON.stringify({ + entryCount: entriesLength, + visibleEntryCount, + itemKeys: renderedRows.map(row => row.key), + semanticTurnId, + semanticHistoryTurnIds: renderedSemanticHistoryTurnIds, + streamPhase, + }) + const prevKeys = new Set(previous?.map(row => row.key) ?? []) + const nextKeys = new Set(renderedRows.map(row => row.key)) + const added = renderedRows.filter(row => !prevKeys.has(row.key)) + const removed = (previous ?? []).filter(row => !nextKeys.has(row.key)) + const hidden = visibleDecisions + .filter(item => !item.visible) + .slice(-12) + .map(item => ({ + key: item.key, + label: debugLabelForEntry(item.entry), + reason: item.reason, + })) + const changed = + previous === null || + previousRenderDebugSignatureRef.current !== renderDebugSignature || + added.length > 0 || + removed.length > 0 || + previous.length !== renderedRows.length || + previous.some((row, index) => row.key !== renderedRows[index]?.key) + if (!changed) return + onDebugLog({ + layer: 'RENDER', + kind: 'visible_rows', + summary: + previous === null + ? `initial rows ${renderedRows.length}` + : `rows ${previous.length} -> ${renderedRows.length} (+${added.length} -${removed.length})`, + data: { + rows: renderedRows, + added, + removed, + hidden, + entryCount: entriesLength, + visibleEntryCount, + semanticTurnId, + semanticHistoryTurnIds: renderedSemanticHistoryTurnIds, + streamPhase, + }, + }) + previousRenderedRowsRef.current = renderedRows + previousRenderDebugSignatureRef.current = renderDebugSignature + }, [entriesLength, onDebugLog, renderedRows, renderedSemanticHistorySignature, renderedSemanticHistoryTurnIds, semanticTurnId, streamPhase, visibleDecisions, visibleEntryCount]) +} diff --git a/src/renderer/src/features/feed/ui/hooks/usePickerAutoScroll.ts b/src/renderer/src/features/feed/ui/hooks/usePickerAutoScroll.ts new file mode 100644 index 00000000..81440f6d --- /dev/null +++ b/src/renderer/src/features/feed/ui/hooks/usePickerAutoScroll.ts @@ -0,0 +1,122 @@ +import { useEffect, useRef, type RefObject } from 'react' + +// PORTED VERBATIM from Feed.tsx:591-710 @269f9fc — the two picker +// auto-scroll effects (Copy Assistant Message + Copy Code Block). This +// logic is scarred; read the WHY comments before changing anything. +// +// WHY the tween is custom rAF and not native scrollIntoView: +// - With block:'nearest' scrollIntoView no-ops when the target is +// already on screen, so rapid arrow presses after a manual scroll +// land on already-visible entries and produce zero motion (user +// report: "my arrow key does nothing after I scroll"). +// - With block:'center' native smooth scroll was observably shaky — +// the feed's own scroll listener fires on every frame of the +// animation and was fighting the browser's scroll queue. +// A custom rAF tween is ~20 lines, interrupt-safe (new target +// cancels any in-flight animation), and completely independent of +// whatever the scroll listener is doing. +// +// WHY this hook RETURNS cancelTween: the scroll-to-latest behavior in +// useScrollFeedBehaviors must cancel an in-flight picker tween before +// snapping to the bottom (Feed.tsx:712-728 did this by sharing the +// ref). Returning a stable canceller keeps the animation-frame ref +// owned HERE while giving the scroll hook its one legitimate touch +// point — the alternative (a shared ref threaded through Feed) spreads +// tween ownership across files, which is how the two pickers drifted +// apart in the first place. + +const OUTLINE_CLASSES = ['outline', 'outline-2', 'outline-accent', 'outline-offset-2'] + +export function usePickerAutoScroll({ + scrollerRef, + pickerSelectedUuid, + codeBlockSelectedId, +}: { + scrollerRef: RefObject + pickerSelectedUuid: string | null + codeBlockSelectedId: string | null +}): { cancelTween: () => void } { + const scrollAnimFrameRef = useRef(null) + // Stable canceller — identity never changes, safe as an effect dep. + const cancelTweenRef = useRef(() => { + if (scrollAnimFrameRef.current !== null) { + cancelAnimationFrame(scrollAnimFrameRef.current) + scrollAnimFrameRef.current = null + } + }) + + // Shared tween runner for both pickers. Duration/easing preserved + // exactly from the original (180ms easeOutCubic). + const tweenTo = (root: HTMLElement, target: HTMLElement) => { + const targetCenter = target.offsetTop + target.offsetHeight / 2 + const desired = targetCenter - root.clientHeight / 2 + const maxScroll = root.scrollHeight - root.clientHeight + const to = Math.max(0, Math.min(maxScroll, desired)) + const from = root.scrollTop + const distance = to - from + if (Math.abs(distance) < 1) return + + cancelTweenRef.current() + + const duration = 180 + const startTime = performance.now() + const ease = (t: number) => 1 - Math.pow(1 - t, 3) // easeOutCubic + + const step = (now: number) => { + const elapsed = now - startTime + const t = Math.min(1, elapsed / duration) + root.scrollTop = from + distance * ease(t) + if (t < 1) { + scrollAnimFrameRef.current = requestAnimationFrame(step) + } else { + scrollAnimFrameRef.current = null + } + } + scrollAnimFrameRef.current = requestAnimationFrame(step) + } + + // Copy Assistant Message picker — center the selected entry. + useEffect(() => { + if (!pickerSelectedUuid) return + const root = scrollerRef.current + if (!root) return + const target = root.querySelector( + `[data-entry-uuid="${pickerSelectedUuid}"]`, + ) as HTMLElement | null + if (!target) return + tweenTo(root, target) + return () => cancelTweenRef.current() + // eslint-disable-next-line react-hooks/exhaustive-deps -- tweenTo reads refs only + }, [pickerSelectedUuid]) + + // Copy Code Block picker — outline + center the selected block. + // + // WHY the outline is applied imperatively rather than as a JSX class: + // the assistant picker highlights an ENTRY, and Feed renders a + // wrapper
    per entry it can class in JSX. A code + // block is buried deep inside rendered markdown / tool rows — there + // is no per-block wrapper Feed controls. So we locate the block by + // `data-code-block-id` and toggle classes on the node directly. + // The class list matches the entry picker's outline exactly, so + // Tailwind's static scan already emits the CSS. + useEffect(() => { + if (!codeBlockSelectedId) return + const root = scrollerRef.current + if (!root) return + const target = root.querySelector( + `[data-code-block-id="${codeBlockSelectedId}"]`, + ) as HTMLElement | null + if (!target) return + + target.classList.add(...OUTLINE_CLASSES) + tweenTo(root, target) + + return () => { + target.classList.remove(...OUTLINE_CLASSES) + cancelTweenRef.current() + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- tweenTo reads refs only + }, [codeBlockSelectedId]) + + return { cancelTween: cancelTweenRef.current } +} diff --git a/src/renderer/src/features/feed/ui/hooks/useScrollFeedBehaviors.ts b/src/renderer/src/features/feed/ui/hooks/useScrollFeedBehaviors.ts new file mode 100644 index 00000000..b1abf2ee --- /dev/null +++ b/src/renderer/src/features/feed/ui/hooks/useScrollFeedBehaviors.ts @@ -0,0 +1,286 @@ +import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react' + +import { scrollPositions } from '@renderer/features/feed/scroll' +import type { ScrollInfo } from '@renderer/features/feed/types' + +// PORTED VERBATIM from Feed.tsx @269f9fc — the entire scroll-behavior +// family: mount restore (:393-421), the scroll listener (:436-506), +// sticky-bottom auto-scroll (:527-541), the bootstrap pin-once +// transition (:549-574), and scroll-to-latest (:712-728). This logic +// is scarred production code; every WHY comment travelled with it. +// Read them before changing ANYTHING here. +// +// WHY one hook and not five: these effects share five refs +// (stickyBottom, lastScrollTop, hadSavedPositionOnMount, loadingOlder, +// prevBootstrapping) whose lifecycles are one story — "where is the +// user in the scroller and did they put themselves there". Splitting +// per-effect would force a ref-bag argument that obscures the port +// and invites drift between the halves. The plan's +// useStickyBottom/useScrollPersistence/useOlderHistory decomposition +// lives as the section headers below instead. + +export function useScrollFeedBehaviors({ + scrollerRef, + sessionId, + tailMode, + bootstrapping, + entriesLength, + semanticTurnSignal, + semanticHistorySignal, + hasOlderHistory, + loadingOlderHistory, + onLoadOlderHistory, + onScrollInfo, + scrollToLatestRequest, + cancelPickerTween, +}: { + scrollerRef: RefObject + sessionId: string + tailMode: boolean + bootstrapping: boolean + entriesLength: number + semanticTurnSignal: string + semanticHistorySignal: string + hasOlderHistory: boolean + loadingOlderHistory: boolean + onLoadOlderHistory?: () => Promise + onScrollInfo?: (info: ScrollInfo) => void + scrollToLatestRequest: number + /** From usePickerAutoScroll — scroll-to-latest must cancel an + * in-flight picker tween before snapping (the tween would fight + * the snap for the next 180ms otherwise). */ + cancelPickerTween: () => void +}): void { + // Restore stickyBottom from the persisted map on first render so + // the auto-scroll effect below makes the right decision without + // needing to wait for the scroll listener to run. Defaults to + // true for brand-new sessions (no saved position yet). + const stickyBottomRef = useRef( + scrollPositions.get(sessionId)?.stickyBottom ?? true, + ) + const loadingOlderRef = useRef(false) + // Was there an existing saved scroll position for this session when + // this Feed instance mounted? Used to distinguish "restore the + // user's deliberate scrolled-up position" from "brand-new/resumed + // session should land at latest content even if stickyBottom got + // transiently knocked false during bootstrap." + const hadSavedPositionOnMountRef = useRef(scrollPositions.has(sessionId)) + // Previous scrollTop, used to distinguish "the user started + // scrolling upward" from incidental near-bottom jitter. This is + // load-bearing during active turns: with the old "gap < 48" + // heuristic alone, a tiny upward wheel tick still counted as + // sticky, and the next ~60 Hz screen update snapped the feed right + // back down before the user could accumulate enough distance to + // escape. Any real upward movement should break follow + // immediately; re-follow only when the user intentionally returns + // near the bottom. + const lastScrollTopRef = useRef(0) + + // ---- useScrollPersistence: mount restore ------------------------- + // Restore the saved scroll position on mount — synchronously, via + // useLayoutEffect, so the browser never paints the scroller at + // scrollTop=0 before we restore. Using useEffect here would flash + // the top of the feed for one frame before the restore landed. + // + // Three cases: + // 1. No saved entry → first mount for this session. Default to + // "stuck at bottom" — a freshly-opened feed should show the + // most recent content, like opening a terminal or chat window. + // 2. Saved stickyBottom: true → user was at the bottom when they + // left. Content may have grown while unmounted, so pin to the + // NEW scrollHeight, not the old scrollTop. + // 3. Saved stickyBottom: false → restore the exact saved + // scrollTop. Content height on remount matches save time. + // + // The sessionId dep is load-bearing: if the user resumes a different + // session in the same pane slot, we re-restore from the new key. + useLayoutEffect(() => { + const el = scrollerRef.current + if (!el) return + hadSavedPositionOnMountRef.current = scrollPositions.has(sessionId) + if (tailMode) { + el.scrollTop = el.scrollHeight + stickyBottomRef.current = true + lastScrollTopRef.current = el.scrollTop + return + } + const saved = scrollPositions.get(sessionId) + if (!saved || saved.stickyBottom) { + // Case 1 or 2: pin to bottom, synchronously in useLayoutEffect + // so the scrollTop change commits in the SAME paint as the + // initial content render. Without that, the first paint shows + // scrollTop=0 and the next tick scrolls down — a visible + // "starts at top, jumps to bottom" flash on every tab switch. + el.scrollTop = el.scrollHeight + stickyBottomRef.current = true + lastScrollTopRef.current = el.scrollTop + } else { + // Case 3: restore exact position. + el.scrollTop = saved.scrollTop + stickyBottomRef.current = false + lastScrollTopRef.current = el.scrollTop + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollerRef is a stable ref + }, [sessionId, tailMode]) + + // ---- useStickyBottom: the scroll listener ------------------------ + // One scroll listener for the container. Updates stickyBottomRef + // imperatively AND persists the position into the module-level map + // so a later unmount/remount can restore it. + // + // CRITICAL: we DO NOT call onScroll() synchronously on mount. That + // was the original bug — at mount time the scroller has scrollTop=0 + // and scrollHeight=full-content, so gap > 48 and the handler would + // stamp stickyBottom=false INTO THE REF AND THE PERSISTED MAP before + // the layout effect above had a chance to scroll to the bottom. The + // layout effect sets stickyBottomRef explicitly; the scroll listener + // only reacts to actual user scrolls. + useEffect(() => { + const el = scrollerRef.current + if (!el) return + const onScroll = () => { + if (tailMode) { + el.scrollTop = el.scrollHeight + stickyBottomRef.current = true + lastScrollTopRef.current = el.scrollTop + scrollPositions.set(sessionId, { + scrollTop: el.scrollTop, + stickyBottom: true, + }) + if (onScrollInfo) onScrollInfo({ fraction: 0 }) + return + } + const gap = el.scrollHeight - (el.scrollTop + el.clientHeight) + const scrollingUp = el.scrollTop < lastScrollTopRef.current + const nearBottom = gap < 48 + stickyBottomRef.current = + scrollingUp && gap > 0 ? false : nearBottom + lastScrollTopRef.current = el.scrollTop + scrollPositions.set(sessionId, { + scrollTop: el.scrollTop, + stickyBottom: stickyBottomRef.current, + }) + // Push scroll position to parent for the scroll indicator. + // fraction=0 at bottom, fraction=1 at top. + if (onScrollInfo) { + const maxScroll = el.scrollHeight - el.clientHeight + const fraction = maxScroll > 0 + ? 1 - (el.scrollTop / maxScroll) + : 0 + onScrollInfo({ fraction }) + } + + // ---- useOlderHistory: top-edge load trigger ------------------ + if ( + el.scrollTop < 160 && + hasOlderHistory && + !loadingOlderHistory && + !loadingOlderRef.current && + !tailMode && + onLoadOlderHistory + ) { + loadingOlderRef.current = true + const beforeHeight = el.scrollHeight + const beforeTop = el.scrollTop + void onLoadOlderHistory() + .then(() => { + requestAnimationFrame(() => { + const next = scrollerRef.current + if (!next) return + const delta = next.scrollHeight - beforeHeight + next.scrollTop = beforeTop + Math.max(0, delta) + lastScrollTopRef.current = next.scrollTop + }) + }) + .finally(() => { + loadingOlderRef.current = false + }) + } + } + el.addEventListener('scroll', onScroll, { passive: true }) + return () => el.removeEventListener('scroll', onScroll) + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollerRef is a stable ref + }, [ + sessionId, + onScrollInfo, + tailMode, + hasOlderHistory, + loadingOlderHistory, + onLoadOlderHistory, + ]) + + // ---- useStickyBottom: auto-scroll on content changes ------------- + // Only when sticky. If the user is scrolled up, skip — they're + // reading earlier content and we don't want to yank them back. + // + // The semantic signals are cheap fingerprints (turnId + per-block + // content lengths + status — see semanticTurnScrollSignal) so this + // effect re-runs when semantic deltas land, not only when committed + // entries append. It is a scroll-invalidation token only. + useEffect(() => { + // During a bulk bootstrap burst we skip per-append auto-scroll. + // The pin-once-on-transition effect below lands us at the bottom + // in a single operation after the burst ends — otherwise every + // entry appended during the burst would pin-scroll and wake up + // the LazyEntry observer cascade. See docs/superpowers/plans/ + // 2026-04-15-bootstrap-replay-perf.md. + if (bootstrapping) return + if (!tailMode && !stickyBottomRef.current) return + // scrollTop = scrollHeight pins to bottom without the smooth-scroll + // overshoot scrollIntoView sometimes produces. Direct, instant, + // no animation frames. + const el = scrollerRef.current + if (el) el.scrollTop = el.scrollHeight + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollerRef is a stable ref + }, [entriesLength, tailMode, semanticTurnSignal, semanticHistorySignal, bootstrapping]) + + // ---- Pin-once on the bootstrap → live transition ------------------ + // Runs exactly once per transition thanks to the previous-value ref. + // No dependency on entriesLength so the effect does not fire on + // subsequent live appends — those go through the regular auto-scroll + // effect above. + const prevBootstrappingRef = useRef(false) + useEffect(() => { + if (prevBootstrappingRef.current && !bootstrapping) { + const el = scrollerRef.current + // Fresh/resumed sessions with no saved scroll position should + // ALWAYS land on the latest content after the bootstrap burst. + // Relying purely on stickyBottomRef here is fragile because the + // initial mount/placeholder/lazy-load sequence can transiently + // mark the feed non-sticky before the first real user scroll. + // That leaves the viewport stranded above the eager tail: the + // exact "blank until I scroll down a couple pages" symptom. + const shouldForceInitialBottom = + !hadSavedPositionOnMountRef.current && !tailMode + if (el && (tailMode || stickyBottomRef.current || shouldForceInitialBottom)) { + el.scrollTop = el.scrollHeight + stickyBottomRef.current = true + lastScrollTopRef.current = el.scrollTop + scrollPositions.set(sessionId, { + scrollTop: el.scrollTop, + stickyBottom: true, + }) + if (onScrollInfo) onScrollInfo({ fraction: 0 }) + } + } + prevBootstrappingRef.current = bootstrapping + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollerRef is a stable ref + }, [bootstrapping, onScrollInfo, sessionId, tailMode]) + + // ---- Scroll-to-latest (explicit user request) ---------------------- + useEffect(() => { + if (scrollToLatestRequest === 0) return + const el = scrollerRef.current + if (!el) return + cancelPickerTween() + el.scrollTop = el.scrollHeight + stickyBottomRef.current = true + lastScrollTopRef.current = el.scrollTop + scrollPositions.set(sessionId, { + scrollTop: el.scrollTop, + stickyBottom: true, + }) + if (onScrollInfo) onScrollInfo({ fraction: 0 }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollerRef/canceller are stable + }, [onScrollInfo, scrollToLatestRequest, sessionId]) +} diff --git a/src/renderer/src/features/feed/ui/kit/AnsiText.tsx b/src/renderer/src/features/feed/ui/kit/AnsiText.tsx new file mode 100644 index 00000000..6473c910 --- /dev/null +++ b/src/renderer/src/features/feed/ui/kit/AnsiText.tsx @@ -0,0 +1,88 @@ +import type { CSSProperties } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' +import type { ITheme } from '@xterm/xterm' + +import { THEME_CHANGED_EVENT } from '@renderer/app-state/settings/theme' +import { readXtermTheme } from '@renderer/workspace/tile-tree/xtermTheme' + +import { hasAnsi, parseAnsi, type AnsiStyle } from './ansi' + +// ANSI-aware text renderer for command output. +// +// WHY the palette comes from readXtermTheme(): the terminal panes +// already solved "ANSI colors readable on both light and dark canvas" +// (xtermTheme.ts branches the 16-color table by background luminance). +// Feed output using the SAME table means a `git diff` looks identical +// in the feed card and in the raw terminal — one fewer visual dialect. +// +// WHY a state+event subscription instead of reading the theme per +// render: readXtermTheme() calls getComputedStyle — cheap but not +// free, and theme changes are rare. THEME_CHANGED_EVENT is the same +// signal CodeBlock/monacoRuntime already listen to. +// +// Memoized by text: committed output never changes; live output grows, +// and parseAnsi over a capped OutputWell payload is cheap per delta. + +const PALETTE_KEYS = [ + 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', + 'brightMagenta', 'brightCyan', 'brightWhite', +] as const satisfies readonly (keyof ITheme)[] + +function colorOf(v: number | string | null, theme: ITheme): string | undefined { + if (v === null) return undefined + if (typeof v === 'string') return v + const key = PALETTE_KEYS[v] + return key ? (theme[key] as string | undefined) : undefined +} + +function styleOf(s: AnsiStyle, theme: ITheme): CSSProperties | undefined { + // Inverse swaps fg/bg; with no explicit colors set, fall back to the + // theme's foreground/background so inverse is still visible. + const fg = s.inverse + ? (colorOf(s.bg, theme) ?? theme.background) + : colorOf(s.fg, theme) + const bg = s.inverse + ? (colorOf(s.fg, theme) ?? theme.foreground) + : colorOf(s.bg, theme) + if (!fg && !bg && !s.bold && !s.dim && !s.italic && !s.underline) return undefined + return { + color: fg, + backgroundColor: bg, + fontWeight: s.bold ? 600 : undefined, + opacity: s.dim ? 0.65 : undefined, + fontStyle: s.italic ? 'italic' : undefined, + textDecoration: s.underline ? 'underline' : undefined, + } +} + +export const AnsiText = memo(function AnsiText({ text }: { text: string }) { + const [theme, setTheme] = useState(() => readXtermTheme()) + useEffect(() => { + const onTheme = () => setTheme(readXtermTheme()) + window.addEventListener(THEME_CHANGED_EVENT, onTheme) + return () => window.removeEventListener(THEME_CHANGED_EVENT, onTheme) + }, []) + + const plain = !hasAnsi(text) + const spans = useMemo( + () => (plain ? null : parseAnsi(text).spans), + [plain, text], + ) + + // Fast path: no escape sequences → plain text node, zero span DOM. + if (plain || !spans) return <>{text} + + return ( + <> + {spans.map((span, i) => { + const style = styleOf(span.style, theme) + return style ? ( + {span.text} + ) : ( + {span.text} + ) + })} + + ) +}) diff --git a/src/renderer/src/features/feed/ui/kit/DiffView.tsx b/src/renderer/src/features/feed/ui/kit/DiffView.tsx new file mode 100644 index 00000000..cf87b5a7 --- /dev/null +++ b/src/renderer/src/features/feed/ui/kit/DiffView.tsx @@ -0,0 +1,216 @@ +import { memo, useContext, useMemo, useState } from 'react' +import hljs from 'highlight.js' + +import { escapeHtml, toHighlightLanguage } from '@shared/code/htmlHighlight' +import { normalizeCodeLanguage } from '@shared/code/language' +import { formatToolFilePath } from '@shared/paths/displayPath' +import type { DiffLine } from '@shared/parsers/lineDiff' + +import { CodeRenderContext } from '@renderer/features/feed/context' + +// DiffView — the kit diff surface (DiffSlab's successor, spec §5.5). +// Consumes precomputed DiffLine[] (the component NEVER diffs); adds on +// top of the slab: an optional per-file header (tool label + RTL- +// truncated path + action/± stats) and long-diff windowing. +// +// The line-rendering core (per-line hljs tint inside red/green rows) +// and the `w-max min-w-full` sizer wrapper are PORTED VERBATIM from +// DiffSlab.tsx — the sizer is a shipped-bug tombstone: without it the +// +/- tint stopped at the visible pane's right edge when scrolling +// horizontally. The RTL path-truncation trick is ported from +// ClaudeRows.FileToolHeader (leading directories collapse, the +// filename stays visible). +// +// WHY windowing: a 2,000-line generated-file diff used to paint 2,000 +// highlighted rows eagerly. Past WINDOW_THRESHOLD we render the first/ +// last WINDOW_EDGE lines with an explicit "+N more lines" expander — +// explicit, never silent (the unknowns-contract ethos: a silent cut +// reads as "that was the whole diff"). + +const WINDOW_THRESHOLD = 96 +const WINDOW_EDGE = 40 + +function DiffLines({ lines, filePath }: { lines: DiffLine[]; filePath?: string | null }) { + const [expanded, setExpanded] = useState(false) + + const highlightLanguage = useMemo( + () => toHighlightLanguage(normalizeCodeLanguage(undefined, filePath ?? undefined)), + [filePath], + ) + + const windowed = !expanded && lines.length > WINDOW_THRESHOLD + const visible = useMemo(() => { + if (!windowed) return lines.map((line, i) => ({ line, key: i })) + return [ + ...lines.slice(0, WINDOW_EDGE).map((line, i) => ({ line, key: i })), + ...lines.slice(lines.length - WINDOW_EDGE).map((line, i) => ({ + line, + key: lines.length - WINDOW_EDGE + i, + })), + ] + }, [lines, windowed]) + + const renderedLines = useMemo( + () => + visible.map(({ line }) => { + if (line.text === '') return '\u200b' + if (!highlightLanguage) return escapeHtml(line.text) + try { + return hljs.highlight(line.text, { language: highlightLanguage, ignoreIllegals: true }).value + } catch { + return escapeHtml(line.text) + } + }), + [highlightLanguage, visible], + ) + + const hiddenCount = windowed ? lines.length - WINDOW_EDGE * 2 : 0 + const expanderAt = windowed ? WINDOW_EDGE : -1 + + return ( +
    + {/* Sizer wrapper — ported verbatim from DiffSlab (see header). */} +
    + {visible.map(({ line, key }, index) => { + const bg = + line.kind === '+' + ? 'bg-diff-add-bg' + : line.kind === '-' + ? 'bg-diff-remove-bg' + : '' + const fg = + line.kind === '+' + ? 'text-diff-add-fg' + : line.kind === '-' + ? 'text-diff-remove-fg' + : 'text-code-ink-dim' + const bodyTone = line.kind === 'ctx' ? 'text-code-ink-dim' : 'text-code-ink' + const row = ( +
    + + +
    + ) + if (index === expanderAt) { + return ( +
    + + {row} +
    + ) + } + return row + })} +
    +
    + ) +} + +export type DiffViewFile = { + path: string | null + /** Codex apply_patch action badge; null for plain Edit diffs. */ + action: 'add' | 'update' | 'delete' | null + movedTo: string | null + lines: DiffLine[] + /** Chunk label for multi-chunk single-file diffs (MultiEdit). */ + chunkLabel?: string | null +} + +export const DiffView = memo(function DiffView({ + files, + emptyLabel = '(no changes)', + showHeaders = true, +}: { + files: DiffViewFile[] + emptyLabel?: string + showHeaders?: boolean +}) { + const { workspaceRoot } = useContext(CodeRenderContext) + + if (files.length === 0 || files.every(f => f.lines.length === 0 && !f.path)) { + return ( +
    + {emptyLabel} +
    + ) + } + + return ( +
    + {files.map((file, index) => { + const display = file.path ? formatToolFilePath(file.path, workspaceRoot) : null + const added = file.lines.filter(l => l.kind === '+').length + const removed = file.lines.filter(l => l.kind === '-').length + return ( +
    + {showHeaders && (display || file.action || file.chunkLabel) ? ( +
    + {file.action ? ( + + {file.action} + + ) : null} + {display ? ( + // RTL truncation: leading directories collapse, the + // filename stays visible (ported from FileToolHeader — + // see ClaudeRows history for the bidi caveat). + + {display} + + ) : null} + {file.movedTo ? ( + + → {formatToolFilePath(file.movedTo, workspaceRoot)} + + ) : null} + {file.chunkLabel ? ( + + {file.chunkLabel} + + ) : null} + {(added > 0 || removed > 0) && ( + + {added > 0 ? +{added} : null} + {added > 0 && removed > 0 ? ' ' : null} + {removed > 0 ? −{removed} : null} + + )} +
    + ) : null} + {file.lines.length > 0 ? ( + + ) : ( +
    + {emptyLabel} +
    + )} +
    + ) + })} +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/kit/ExpandSection.tsx b/src/renderer/src/features/feed/ui/kit/ExpandSection.tsx new file mode 100644 index 00000000..bb13f032 --- /dev/null +++ b/src/renderer/src/features/feed/ui/kit/ExpandSection.tsx @@ -0,0 +1,37 @@ +import { useState, type ReactNode } from 'react' + +// Lazy
    — the kit-wide successor to ToolResultRow's private +// LazyDetails (ported from ToolResultRow.tsx:17-43 @269f9fc). +// +// WHY children gate on first-open: a closed
    hides its +// contents visually but React still mounts them. The expensive child +// here is usually a Monaco CodeBlock — editor creation, model +// allocation, sometimes an LSP document lifecycle. Gating on +// first-open keeps dense restored feeds cheap until the user +// explicitly drills in. Once opened, children STAY mounted (collapse +// hides, doesn't unmount) so re-expanding is instant and any editor +// state survives. + +export function ExpandSection({ + summary, + defaultOpen = false, + children, +}: { + summary: ReactNode + defaultOpen?: boolean + children: ReactNode +}) { + const [everOpened, setEverOpened] = useState(defaultOpen) + return ( +
    { + if (event.currentTarget.open) setEverOpened(true) + }} + > + {summary} + {everOpened ?
    {children}
    : null} +
    + ) +} diff --git a/src/renderer/src/features/feed/ui/kit/OutputWell.tsx b/src/renderer/src/features/feed/ui/kit/OutputWell.tsx new file mode 100644 index 00000000..e2033fec --- /dev/null +++ b/src/renderer/src/features/feed/ui/kit/OutputWell.tsx @@ -0,0 +1,113 @@ +import { memo, useMemo, useState } from 'react' + +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' + +import { AnsiText } from './AnsiText' + +// The single collapsible output region for command/tool output — the +// kit successor to BOTH TruncatedOutputRow copies (the shared one at +// features/feed/ui/rows/TruncatedOutputRow.tsx and Codex's private +// byte-similar twin that lived in CodexRows.tsx @269f9fc; two copies = +// guaranteed drift, and neither was ANSI-aware). +// +// Behavior preserved from the originals (users have muscle memory): +// first 3 lines visible, click-to-expand button with the exact same +// copy, 360px scroll cap when expanded, error tint. Upgrades: +// - ANSI-aware content (opt-out via ansi={false} for payloads known +// to be markup-free where the probe cost isn't worth it). +// - An explicit byte-cap notice. The old rows rendered unbounded +// content once expanded; a whole-file `cat` could paint megabytes. +// Truncation is LOUD (`… output truncated`) because a silent cut +// reads as "that was all the output" — the same lie the rendering +// pipeline's unknowns contract exists to prevent. + +const RESULT_MAX_LINES = 3 +// Generous — a long test run is ~100KB. The cap defends against +// pathological whole-file dumps, not normal output. +const MAX_RENDER_CHARS = 200_000 + +export const OutputWell = memo(function OutputWell({ + text, + isError = false, + ansi = true, + previewLines = RESULT_MAX_LINES, +}: { + text: string + isError?: boolean + ansi?: boolean + previewLines?: number +}) { + const [expanded, setExpanded] = useState(false) + + // Memoized: slicing + line-counting the dropped remainder of an + // over-cap payload is O(dropped bytes) — running it on EVERY render + // (including per-delta live renders) was a review perf finding. + const { capped, cappedNotice } = useMemo(() => { + if (text.length <= MAX_RENDER_CHARS) { + return { capped: text, cappedNotice: null as string | null } + } + const dropped = text.slice(MAX_RENDER_CHARS).split('\n').length + return { + capped: text.slice(0, MAX_RENDER_CHARS), + cappedNotice: `… output truncated (${dropped} more ${dropped === 1 ? 'line' : 'lines'})`, + } + }, [text]) + + const lines = capped.length === 0 ? [] : capped.split('\n') + // Head+tail preview, matching Codex's native output clipping + // (exec_cell/render.rs output_lines): the START shows what ran and + // the END shows how it finished — the tail is usually the part that + // matters (test summary, error, exit line). Head-only previews hid + // exactly that. Collapse only when it hides at least one line beyond + // the head+tail window. + const needsTruncation = lines.length > previewLines * 2 + 1 + const head = needsTruncation ? lines.slice(0, previewLines).join('\n') : capped + const tail = needsTruncation ? lines.slice(-previewLines).join('\n') : '' + const shown = expanded || !needsTruncation ? capped : head + const hiddenCount = needsTruncation ? lines.length - previewLines * 2 : 0 + + return ( + +
    +        {ansi ?  : shown}
    +        {expanded && cappedNotice ? (
    +          {`\n${cappedNotice}`}
    +        ) : null}
    +      
    + {needsTruncation && !expanded && ( + <> + +
    +            {ansi ?  : tail}
    +          
    + + )} + {needsTruncation && expanded && ( + + )} +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/kit/SegmentedMarkdown.tsx b/src/renderer/src/features/feed/ui/kit/SegmentedMarkdown.tsx new file mode 100644 index 00000000..77087ad2 --- /dev/null +++ b/src/renderer/src/features/feed/ui/kit/SegmentedMarkdown.tsx @@ -0,0 +1,93 @@ +import { memo } from 'react' + +import { + countFenceMarkers, + splitStreamingCodeFence, +} from '@renderer/features/feed/lib/helpers' +import { StreamingProse } from '@renderer/features/feed/ui/markdown' + +import { StreamingCodeBlock } from './StreamingCodeBlock' + +// Streaming markdown without whole-message reparse. +// +// The old path fed the ENTIRE growing message through StreamingProse +// every delta. StreamingProse memoizes on the full text string, so the +// memo never hit while streaming — the whole unified pipeline +// (remark-parse → remark-gfm → remark-breaks → rehype-highlight) re-ran +// per token batch: O(len²) work over a long message. Worse, a message +// streaming its SECOND code fence re-parsed the first (now-closed) +// fence through markdown on every delta too, because +// splitStreamingCodeFence only splits at the LAST odd fence. +// +// Fix: split at the end of the last CLOSED fence. +// - The `sealed` prefix string changes identity only when a fence +// CLOSES (rare — a handful of times per message), so the memoized +// StreamingProse over it is effectively parse-once. +// - Only the `tail` (text since the last closed fence — bounded, +// usually a paragraph or one growing code block) re-parses per +// delta; an open fence in the tail streams through +// StreamingCodeBlock (sealed-line highlight, no reparse at all). +// +// WHY the boundary is "last closed fence" and not "last completed +// paragraph": fences are the only markdown construct here whose parse +// cost is both heavy and REGION-STABLE — prose before a closed fence +// cannot be re-interpreted by later text (a fence is a hard block +// boundary in commonmark). Sealing at paragraph boundaries would be +// wrong: a later delta can turn a paragraph into a list continuation +// or setext heading. Sealing at closed fences is conservative and +// always safe. +// +// Fence detection intentionally mirrors splitStreamingCodeFence's +// conservative triple-backtick-only heuristic (no ~~~ fences, no +// indented fences) so the two agree on what is "open". If that helper +// evolves, this must evolve with it — they share countFenceMarkers. + +export function splitSealedPrefix(text: string): { sealed: string; tail: string } { + const fences = countFenceMarkers(text) + if (fences < 2) return { sealed: '', tail: text } + const closedFences = fences % 2 === 0 ? fences : fences - 1 + // Index just past the Nth ``` marker (N = closedFences). The walk + // must advance by a FULL marker (+3), not +1 — overlapping search + // finds two "markers" inside one ````-run while the counting regex + // sees one, desynchronizing the seal boundary (PR524 review: a + // 4-backtick fence rendered its whole body as prose). + let idx = -1 + for (let n = 0; n < closedFences; n++) { + idx = text.indexOf('```', idx === -1 ? 0 : idx + 3) + } + // Seal through the end of the closing fence's line so the fence's + // trailing newline stays inside the sealed segment (a closing ``` + // may be followed by an info-string-less newline or EOF). + const lineEnd = text.indexOf('\n', idx + 3) + const sealedEnd = lineEnd === -1 ? text.length : lineEnd + 1 + return { sealed: text.slice(0, sealedEnd), tail: text.slice(sealedEnd) } +} + +export const SegmentedMarkdown = memo(function SegmentedMarkdown({ + text, + blockKey, +}: { + text: string + blockKey: string +}) { + if (!text) return null + const { sealed, tail } = splitSealedPrefix(text) + const fence = tail ? splitStreamingCodeFence(tail) : null + return ( +
    + {sealed ? : null} + {fence ? ( + <> + {fence.prose ? : null} + + + ) : tail ? ( + + ) : null} +
    + ) +}) diff --git a/src/renderer/src/features/feed/ui/kit/StatusBadge.tsx b/src/renderer/src/features/feed/ui/kit/StatusBadge.tsx new file mode 100644 index 00000000..c071b4e6 --- /dev/null +++ b/src/renderer/src/features/feed/ui/kit/StatusBadge.tsx @@ -0,0 +1,58 @@ +import { memo } from 'react' + +// The single status vocabulary for artifact cards. One component so +// every tool surface flips through the SAME states with the SAME look — +// the old rows each hand-rolled their own "running/failed/done" spans +// (BlockRow's toolState badge, Codex status chips, GitCardRow's +// placeholder) and drifted. +// +// Design constraints: +// - No layout shift between states: fixed line-height inline span, +// text-only. A card completing must not move its neighbors. +// - `streaming` pulses subtly (input still arriving); `running` is +// static (input done, awaiting result) — the WorkIndicator at the +// feed foot owns the loud "agent is busy" affordance, this badge +// is per-card context only. +// - Error shows the exit code when known (`exit 1`) because that is +// the single most useful diagnostic bit a command can surface; +// the old rows parsed it and then threw it away. + +export type BadgeStatus = 'streaming' | 'running' | 'complete' | 'error' + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + const min = Math.floor(ms / 60_000) + const sec = Math.round((ms % 60_000) / 1000) + return `${min}m${sec ? ` ${sec}s` : ''}` +} + +export const StatusBadge = memo(function StatusBadge({ + status, + exitCode = null, + durationMs = null, +}: { + status: BadgeStatus + exitCode?: number | null + durationMs?: number | null +}) { + const base = 'text-[11px] uppercase tracking-wider select-none' + switch (status) { + case 'streaming': + return streaming + case 'running': + return running + case 'error': + return ( + + {exitCode != null ? `exit ${exitCode}` : 'failed'} + + ) + case 'complete': + return ( + + ✓{durationMs != null ? ` ${formatDuration(durationMs)}` : ''} + + ) + } +}) diff --git a/src/renderer/src/features/feed/ui/kit/StreamingCodeBlock.tsx b/src/renderer/src/features/feed/ui/kit/StreamingCodeBlock.tsx new file mode 100644 index 00000000..0c91ac79 Binary files /dev/null and b/src/renderer/src/features/feed/ui/kit/StreamingCodeBlock.tsx differ diff --git a/src/renderer/src/features/feed/ui/kit/ansi.ts b/src/renderer/src/features/feed/ui/kit/ansi.ts new file mode 100644 index 00000000..051267fb --- /dev/null +++ b/src/renderer/src/features/feed/ui/kit/ansi.ts @@ -0,0 +1,183 @@ +// ANSI SGR subset parser for tool/command output. +// +// WHY this exists: the feed used to render command output verbatim into +//
    , so colored test runners / build tools showed literal `\x1b[0m`
    +// garbage — the single most-visible rendering gap in Bash-heavy
    +// sessions. This module turns SGR-styled text into styled span
    +// descriptors that AnsiText.tsx paints.
    +//
    +// WHY a subset and not a terminal emulator: transcripts carry the raw
    +// bytes a NON-interactive command wrote to a pipe. We honor SGR color/
    +// weight codes and normalize carriage-return progress rewrites; cursor
    +// movement / clear-line sequences are stripped (they are meaningless
    +// outside a real terminal grid — the raw PTY view exists for that).
    +//
    +// WHY \r collapses to "keep the last segment": progress bars emit
    +// `50%\r75%\r100%`; rendering all three rewrites triples the output and
    +// reads as garbage. Keeping the final rewrite per line is what the
    +// user's terminal would have shown at rest.
    +//
    +// WHY the parser is pure and React-free: OutputWell re-parses on each
    +// growing live payload; keeping this allocation-light and testable in
    +// isolation matters. It also lets the remote phone client share it —
    +// nothing in here may touch Node/Electron APIs.
    +
    +export type AnsiStyle = {
    +  /** 0-15 = terminal palette index (resolved against the xterm theme
    +   *  at paint time so light/dark both work); '#rrggbb' for 256/24-bit
    +   *  colors; null = default ink. */
    +  fg: number | string | null
    +  bg: number | string | null
    +  bold: boolean
    +  dim: boolean
    +  italic: boolean
    +  underline: boolean
    +  inverse: boolean
    +}
    +
    +export type AnsiSpan = { text: string; style: AnsiStyle }
    +
    +export const ANSI_INITIAL_STYLE: AnsiStyle = {
    +  fg: null,
    +  bg: null,
    +  bold: false,
    +  dim: false,
    +  italic: false,
    +  underline: false,
    +  inverse: false,
    +}
    +
    +// CSI sequences: keep SGR (final byte `m`), strip everything else
    +// (cursor movement, erase, etc.). The param class includes private-mode
    +// and extension bytes (`?=><:`) so `\x1b[?25l` (cursor hide — spinners
    +// emit it constantly) and colon-form SGR (`38:5:196`, ITU T.416) are
    +// STRIPPED instead of surviving as literal garbage (PR524 review).
    +// Colon/private params never reach applySgr — strip-only. OSC sequences
    +// (window titles, hyperlinks) strip whole; `\x1b(B`-style charset
    +// designators (tput sgr0 emits them) strip too — the `(B` was visible.
    +// eslint-disable-next-line no-control-regex
    +const CSI_RE = /\x1b\[([0-9;:?=><]*)([a-zA-Z])/g
    +// eslint-disable-next-line no-control-regex
    +const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
    +// eslint-disable-next-line no-control-regex
    +const CHARSET_RE = /\x1b[()][A-Za-z0-9]/g
    +
    +/** xterm 256-color index → palette index (0-15) or '#rrggbb'.
    + *  16-231 is the 6×6×6 color cube; 232-255 the grayscale ramp. */
    +function xterm256(n: number): number | string {
    +  if (n < 16) return n
    +  if (n >= 232) {
    +    const v = 8 + (n - 232) * 10
    +    const h = v.toString(16).padStart(2, '0')
    +    return `#${h}${h}${h}`
    +  }
    +  const idx = n - 16
    +  const steps = [0, 95, 135, 175, 215, 255]
    +  const r = steps[Math.floor(idx / 36)]
    +  const g = steps[Math.floor((idx % 36) / 6)]
    +  const b = steps[idx % 6]
    +  return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
    +}
    +
    +function applySgr(style: AnsiStyle, params: number[]): AnsiStyle {
    +  const s = { ...style }
    +  for (let i = 0; i < params.length; i++) {
    +    const p = params[i]
    +    if (p === 0) {
    +      // Full reset replaces the whole style — but keep scanning: a
    +      // sequence like `0;31` resets THEN sets red.
    +      s.fg = null; s.bg = null; s.bold = false; s.dim = false
    +      s.italic = false; s.underline = false; s.inverse = false
    +    } else if (p === 1) s.bold = true
    +    else if (p === 2) s.dim = true
    +    else if (p === 3) s.italic = true
    +    else if (p === 4) s.underline = true
    +    else if (p === 7) s.inverse = true
    +    else if (p === 22) { s.bold = false; s.dim = false }
    +    else if (p === 23) s.italic = false
    +    else if (p === 24) s.underline = false
    +    else if (p === 27) s.inverse = false
    +    else if (p >= 30 && p <= 37) s.fg = p - 30
    +    else if (p === 39) s.fg = null
    +    else if (p >= 40 && p <= 47) s.bg = p - 40
    +    else if (p === 49) s.bg = null
    +    else if (p >= 90 && p <= 97) s.fg = p - 90 + 8
    +    else if (p >= 100 && p <= 107) s.bg = p - 100 + 8
    +    else if (p === 38 || p === 48) {
    +      // Extended color: `38;5;` (256-color) or `38;2;;;`.
    +      const target: 'fg' | 'bg' = p === 38 ? 'fg' : 'bg'
    +      if (params[i + 1] === 5 && params[i + 2] !== undefined) {
    +        s[target] = xterm256(params[i + 2])
    +        i += 2
    +      } else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
    +        s[target] = `#${[params[i + 2], params[i + 3], params[i + 4]]
    +          .map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0'))
    +          .join('')}`
    +        i += 4
    +      }
    +      // Malformed extended sequence: ignore (stop consuming params).
    +    }
    +    // Unknown SGR codes: ignored, style unchanged.
    +  }
    +  return s
    +}
    +
    +/** Collapse carriage-return rewrites per line: keep only the final
    + *  segment after the last `\r`. See module header for WHY. */
    +export function collapseCarriageReturns(text: string): string {
    +  if (!text.includes('\r')) return text
    +  return text
    +    .split('\n')
    +    .map(line => {
    +      // CRLF line endings: the \r is a line TERMINATOR, not a rewrite —
    +      // strip it instead of collapsing, or every CRLF line (curl
    +      // headers, Windows-origin output) collapses to the empty string
    +      // after it (PR524 review, HIGH-1). Likewise a line that ENDS in
    +      // \r mid-stream is a rewrite that hasn't arrived yet — keep the
    +      // text before it visible, as a real terminal would.
    +      const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line
    +      const at = trimmed.lastIndexOf('\r')
    +      return at === -1 ? trimmed : trimmed.slice(at + 1)
    +    })
    +    .join('\n')
    +}
    +
    +// DOM-span ceiling: adjacent-escape payloads (`x\x1b[m` repeated) can
    +// mint one span per character — ~50k spans under the byte cap froze the
    +// renderer in review testing. Past the ceiling, the tail renders as one
    +// unstyled span: content always survives, only styling degrades.
    +const MAX_SPANS = 4000
    +
    +export function parseAnsi(
    +  text: string,
    +  initial: AnsiStyle = ANSI_INITIAL_STYLE,
    +): { spans: AnsiSpan[]; endStyle: AnsiStyle } {
    +  const cleaned = collapseCarriageReturns(text).replace(OSC_RE, '').replace(CHARSET_RE, '')
    +  const spans: AnsiSpan[] = []
    +  let style = initial
    +  let last = 0
    +  CSI_RE.lastIndex = 0
    +  let m: RegExpExecArray | null
    +  while ((m = CSI_RE.exec(cleaned)) !== null) {
    +    if (spans.length >= MAX_SPANS) {
    +      spans.push({ text: cleaned.slice(last).replace(CSI_RE, ''), style: ANSI_INITIAL_STYLE })
    +      return { spans, endStyle: style }
    +    }
    +    if (m.index > last) spans.push({ text: cleaned.slice(last, m.index), style })
    +    if (m[2] === 'm' && !/[?:=><]/.test(m[1])) {
    +      const params = m[1] === '' ? [0] : m[1].split(';').map(n => Number(n) || 0)
    +      style = applySgr(style, params)
    +    }
    +    // Non-SGR CSI: stripped — no span emitted for the sequence itself.
    +    last = CSI_RE.lastIndex
    +  }
    +  if (last < cleaned.length) spans.push({ text: cleaned.slice(last), style })
    +  return { spans, endStyle: style }
    +}
    +
    +/** Fast probe: does this text contain any escape sequence at all?
    + *  OutputWell uses it to skip span-building for the overwhelmingly
    + *  common plain-output case. */
    +export function hasAnsi(text: string): boolean {
    +  return text.includes('\x1b[') || text.includes('\x1b]') || text.includes('\r')
    +}
    diff --git a/src/renderer/src/features/feed/ui/resolve/fromCommitted.ts b/src/renderer/src/features/feed/ui/resolve/fromCommitted.ts
    new file mode 100644
    index 00000000..85473e04
    --- /dev/null
    +++ b/src/renderer/src/features/feed/ui/resolve/fromCommitted.ts
    @@ -0,0 +1,171 @@
    +import { asRecord, parseJsonRecord } from '@shared/lib/asRecord'
    +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript'
    +import type { AgentProviderKind } from '@shared/types/providerKind'
    +
    +import {
    +  classifyUnifiedExecScript,
    +  codexResultMeta,
    +  parsedReadFromResult,
    +  unifiedExecScript,
    +} from '@providers/codex/renderer/extractors'
    +import {
    +  prettifyToolName,
    +  smartHeadline,
    +} from '@providers/shared/renderer/rows/jsonToolPresentation'
    +
    +import {
    +  extractToolCommand,
    +  toolResultText,
    +} from '@renderer/features/feed/lib/helpers'
    +import type {
    +  ArtifactStatus,
    +  CommandArtifact,
    +  GenericToolArtifact,
    +} from '@renderer/features/feed/ui/artifacts/types'
    +
    +// Committed-plane resolvers: ToolUseBlock (+ paired ToolResultBlock,
    +// looked up by the caller via the runtime tool indices) → ArtifactVM.
    +//
    +// Status derivation, committed plane (spec §4):
    +//   - result missing        → 'running' (a committed tool_use whose
    +//     result hasn't landed yet — the same window the GitCardRow
    +//     "running…" placeholder covers)
    +//   - is_error / exit != 0  → 'error'
    +//   - else                  → 'complete'
    +// 'streaming' never occurs on this plane: a committed tool_use always
    +// carries its full input.
    +
    +function committedStatus(
    +  result: ToolResultBlock | null,
    +  exitCode: number | null,
    +): ArtifactStatus {
    +  if (!result) return 'running'
    +  if (result.is_error === true) return 'error'
    +  if (exitCode !== null && exitCode !== 0) return 'error'
    +  return 'complete'
    +}
    +
    +export function commandFromCommitted(
    +  tu: ToolUseBlock,
    +  result: ToolResultBlock | null,
    +  provider: AgentProviderKind,
    +): CommandArtifact {
    +  // Codex unified `exec`: the input is a JS script; the command (or
    +  // stdin write) lives inside it. Classify once and synthesize the
    +  // same VM a plain exec_command would produce — the card must not
    +  // know the wire changed.
    +  if (tu.name === 'exec') {
    +    const action = classifyUnifiedExecScript(unifiedExecScript(tu.input))
    +    const { exitCode, cwd: metaCwd, durationMs } = codexResultMeta(result)
    +    const exec = action?.kind === 'exec_command' ? action.input : null
    +    return {
    +      family: 'command',
    +      id: `cmd:${tu.id}`,
    +      provider,
    +      status: committedStatus(result, exitCode),
    +      plane: 'committed',
    +      toolUseId: tu.id,
    +      startedAt: null,
    +      endedAt: null,
    +      command:
    +        action?.kind === 'wait'
    +          ? 'Waited for background terminal'
    +          : action?.kind === 'write_stdin'
    +            ? '(stdin)'
    +            : exec?.command ?? '(no command)',
    +      cwd: metaCwd ?? exec?.workdir ?? null,
    +      description: null,
    +      sourceTool:
    +        action?.kind === 'wait'
    +          ? 'wait'
    +          : action?.kind === 'write_stdin'
    +            ? 'write_stdin'
    +            : 'exec_command',
    +      output: result ? toolResultText(result) : null,
    +      exitCode,
    +      durationMs,
    +      yieldTimeMs: exec?.yieldTimeMs ?? null,
    +      maxOutputTokens: exec?.maxOutputTokens ?? null,
    +      stdinWrites: action?.kind === 'write_stdin' ? [action.chars] : [],
    +      parsedRead: parsedReadFromResult(result),
    +    }
    +  }
    +
    +  const input = asRecord(tu.input)
    +  const { exitCode, cwd: metaCwd, durationMs } = codexResultMeta(result)
    +  const stdin =
    +    tu.name === 'write_stdin' && typeof input?.chars === 'string'
    +      ? [input.chars]
    +      : []
    +  return {
    +    family: 'command',
    +    id: `cmd:${tu.id}`,
    +    provider,
    +    status: committedStatus(result, exitCode),
    +    plane: 'committed',
    +    toolUseId: tu.id,
    +    startedAt: null,
    +    endedAt: null,
    +    command:
    +      tu.name === 'write_stdin'
    +        ? '(stdin)'
    +        : extractToolCommand(tu) ?? '(no command)',
    +    cwd:
    +      metaCwd ??
    +      (typeof input?.workdir === 'string'
    +        ? input.workdir
    +        : typeof input?.cwd === 'string'
    +          ? input.cwd
    +          : null),
    +    description:
    +      typeof input?.description === 'string' ? input.description : null,
    +    sourceTool: (tu.name === 'Bash' ||
    +      tu.name === 'bash' ||
    +      tu.name === 'exec_command' ||
    +      tu.name === 'local_shell_call' ||
    +      tu.name === 'write_stdin'
    +      ? tu.name
    +      : 'Bash') as CommandArtifact['sourceTool'],
    +    output: result ? toolResultText(result) : null,
    +    exitCode,
    +    durationMs,
    +    yieldTimeMs:
    +      typeof input?.yield_time_ms === 'number' ? input.yield_time_ms : null,
    +    maxOutputTokens:
    +      typeof input?.max_output_tokens === 'number'
    +        ? input.max_output_tokens
    +        : null,
    +    stdinWrites: stdin,
    +    parsedRead: parsedReadFromResult(result),
    +  }
    +}
    +
    +export function genericFromCommitted(
    +  tu: ToolUseBlock,
    +  result: ToolResultBlock | null,
    +  provider: AgentProviderKind,
    +): GenericToolArtifact {
    +  const params =
    +    asRecord(tu.input) ??
    +    (typeof tu.input === 'string' ? parseJsonRecord(tu.input) : null)
    +  const pretty = prettifyToolName(tu.name)
    +  return {
    +    family: 'generic',
    +    id: `gen:${tu.id}`,
    +    provider,
    +    status: committedStatus(result, null),
    +    plane: 'committed',
    +    toolUseId: tu.id,
    +    startedAt: null,
    +    endedAt: null,
    +    toolName: tu.name,
    +    prettyName: pretty.display,
    +    mcp: pretty.mcpServer ? { server: pretty.mcpServer, tool: pretty.display } : null,
    +    headline: smartHeadline(params)?.value ?? null,
    +    params,
    +    paramsJson: params ? JSON.stringify(params, null, 2) : String(tu.input ?? ''),
    +    parseError: null,
    +    resultText: result ? toolResultText(result) : null,
    +    resultIsError: result?.is_error === true,
    +  }
    +}
    diff --git a/src/renderer/src/features/feed/ui/resolve/fromLive.ts b/src/renderer/src/features/feed/ui/resolve/fromLive.ts
    new file mode 100644
    index 00000000..b0270996
    --- /dev/null
    +++ b/src/renderer/src/features/feed/ui/resolve/fromLive.ts
    @@ -0,0 +1,164 @@
    +import { asRecord, parseJsonRecord } from '@shared/lib/asRecord'
    +import type { AgentProviderKind } from '@shared/types/providerKind'
    +
    +import {
    +  classifyUnifiedExecScript,
    +  execCommandInput,
    +} from '@providers/codex/renderer/extractors'
    +import {
    +  prettifyToolName,
    +  smartHeadline,
    +} from '@providers/shared/renderer/rows/jsonToolPresentation'
    +
    +import type { SemanticLiveTurn } from '@renderer/session-runtime/state'
    +import type {
    +  ArtifactStatus,
    +  CommandArtifact,
    +  GenericToolArtifact,
    +} from '@renderer/features/feed/ui/artifacts/types'
    +
    +type SemanticLiveBlock = SemanticLiveTurn['blocks'][number]
    +type SemanticToolCallSnapshot = SemanticLiveTurn['lookups']['toolCallsById'][string]
    +
    +// Live-plane resolvers: SemanticLiveBlock (+ the turn's per-tool
    +// snapshot) → the SAME ArtifactVM the committed plane produces. This
    +// is the convergence seam: whatever a live card can't know yet stays
    +// null and fills in by props flip when the data arrives; when the
    +// committed row replaces the live one, it renders the same card from
    +// the same VM shape.
    +//
    +// Status derivation, live plane (spec §4):
    +//   - input still streaming (not finalized, no result) → 'streaming'
    +//   - input finalized, snapshot in_progress / no result → 'running'
    +//   - snapshot error / resultIsError                    → 'error'
    +//   - result attached                                   → 'complete'
    +
    +function liveStatus(
    +  block: SemanticLiveBlock,
    +  toolState: SemanticToolCallSnapshot | null,
    +): ArtifactStatus {
    +  if (toolState?.status === 'error' || block.resultIsError === true) return 'error'
    +  const hasResult = block.resultAt != null || block.resultContent != null
    +  if (hasResult && toolState?.status !== 'in_progress') return 'complete'
    +  if (block.finalized !== true && !hasResult) return 'streaming'
    +  return 'running'
    +}
    +
    +/** Best-effort parsed input from a live block: prefer the finalized
    + *  parse, then a full parse of the (possibly complete) buffer. Null
    + *  while the JSON is still partial. */
    +export function liveParsedInput(block: SemanticLiveBlock): Record | null {
    +  if (block.parsedInput) return block.parsedInput
    +  const raw = block.argumentsJson ?? block.inputJson ?? ''
    +  return raw ? parseJsonRecord(raw) : null
    +}
    +
    +export function commandFromLive(
    +  block: SemanticLiveBlock,
    +  toolState: SemanticToolCallSnapshot | null,
    +  provider: AgentProviderKind,
    +): CommandArtifact {
    +  const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}`
    +  const parsed = liveParsedInput(block)
    +
    +  // Three live command sources, one VM:
    +  //   - Claude Bash: { command, description } (partial mid-stream —
    +  //     command may be absent until its JSON string closes)
    +  //   - Codex exec_command: { cmd|command, workdir, yield_time_ms, … }
    +  //   - Codex local_shell_call: block.localShellCall metadata (argv
    +  //     array + workingDirectory), no JSON input at all
    +  const shell = block.localShellCall
    +  // Unified `exec` (modern Codex): the streaming buffer is a JS script,
    +  // not JSON — classify it for the embedded exec_command/write_stdin.
    +  const unified =
    +    block.toolName === 'exec'
    +      ? classifyUnifiedExecScript(block.argumentsJson ?? block.inputJson ?? '')
    +      : null
    +  const exec =
    +    unified?.kind === 'exec_command'
    +      ? unified.input
    +      : parsed
    +        ? execCommandInput(parsed)
    +        : null
    +  const command =
    +    unified?.kind === 'wait'
    +      ? 'Waited for background terminal'
    +      : shell?.command?.join(' ') ??
    +        exec?.command ??
    +        (typeof parsed?.command === 'string' ? parsed.command : null)
    +
    +  // Live output: tool_output_delta accumulates into block.resultContent
    +  // (foldEvent tool_output_delta branch), and the lookup snapshot
    +  // mirrors it — prefer the snapshot (kept current across block moves).
    +  const output = toolState?.resultContent ?? block.resultContent ?? null
    +
    +  return {
    +    family: 'command',
    +    id: `cmd:${id}`,
    +    provider,
    +    status: liveStatus(block, toolState),
    +    plane: 'live',
    +    toolUseId: block.toolUseId ?? block.callId ?? null,
    +    startedAt: null,
    +    endedAt: block.resultAt ?? null,
    +    command: command ?? '…',
    +    cwd: shell?.workingDirectory ?? exec?.workdir ?? null,
    +    description: typeof parsed?.description === 'string' ? parsed.description : null,
    +    sourceTool:
    +      unified?.kind === 'wait'
    +        ? 'wait'
    +        : block.kind === 'local_shell_call'
    +          ? 'local_shell_call'
    +          : block.toolName === 'write_stdin' || unified?.kind === 'write_stdin'
    +            ? 'write_stdin'
    +            : block.toolName === 'Bash'
    +              ? 'Bash'
    +              : 'exec_command',
    +    output,
    +    // Live exit code is not stored on the block (tool_completed folds
    +    // it into resultIsError only) — the committed row adds the number.
    +    exitCode: null,
    +    durationMs: null,
    +    yieldTimeMs: exec?.yieldTimeMs ?? null,
    +    maxOutputTokens: exec?.maxOutputTokens ?? null,
    +    stdinWrites:
    +      unified?.kind === 'write_stdin'
    +        ? [unified.chars]
    +        : block.toolName === 'write_stdin' && typeof parsed?.chars === 'string'
    +          ? [parsed.chars]
    +          : [],
    +    // parsed_cmd classification only exists on committed result meta.
    +    parsedRead: null,
    +  }
    +}
    +
    +export function genericFromLive(
    +  block: SemanticLiveBlock,
    +  toolState: SemanticToolCallSnapshot | null,
    +  provider: AgentProviderKind,
    +): GenericToolArtifact {
    +  const id = block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}`
    +  const name = block.toolName ?? block.kind
    +  const parsed = liveParsedInput(block)
    +  const pretty = prettifyToolName(name)
    +  const rawJson = block.argumentsJson ?? block.inputJson ?? ''
    +  return {
    +    family: 'generic',
    +    id: `gen:${id}`,
    +    provider,
    +    status: liveStatus(block, toolState),
    +    plane: 'live',
    +    toolUseId: block.toolUseId ?? block.callId ?? null,
    +    startedAt: null,
    +    endedAt: block.resultAt ?? null,
    +    toolName: name,
    +    prettyName: pretty.display,
    +    mcp: pretty.mcpServer ? { server: pretty.mcpServer, tool: pretty.display } : null,
    +    headline: smartHeadline(parsed ?? asRecord(block.parsedInput))?.value ?? null,
    +    params: parsed,
    +    paramsJson: parsed ? JSON.stringify(parsed, null, 2) : rawJson,
    +    parseError: block.parseError ?? null,
    +    resultText: toolState?.resultContent ?? block.resultContent ?? null,
    +    resultIsError: block.resultIsError === true,
    +  }
    +}
    diff --git a/src/renderer/src/features/feed/ui/resolve/registry.ts b/src/renderer/src/features/feed/ui/resolve/registry.ts
    new file mode 100644
    index 00000000..67d003a1
    --- /dev/null
    +++ b/src/renderer/src/features/feed/ui/resolve/registry.ts
    @@ -0,0 +1,111 @@
    +import type { AgentProviderKind } from '@shared/types/providerKind'
    +
    +import type { ArtifactFamily } from '@renderer/features/feed/ui/artifacts/types'
    +
    +// Family routing — the ONE table that says which card a tool renders
    +// through, for BOTH planes. The old system had this knowledge smeared
    +// across four places (committed Block.tsx interception order, the
    +// provider dispatch.tsx files, BlockRow.tsx's live if-ladder, and
    +// ToolResultRow's hardcoded Read/Grep branches) and they drifted.
    +//
    +// Routing is total: anything unrecognized is 'generic' — never hidden,
    +// never thrown. Provider-specific interceptions that are NOT plain
    +// family routing (git-intent widgets, spawn tools, AskUserQuestion)
    +// stay ahead of this table in Block/BlockRow.
    +//
    +// MIGRATION STATE (2026-07 RENDER rewrite, spec §8): families whose
    +// dedicated card has LANDED are listed in CARD_LANDED_FAMILIES; the
    +// rest still fall through to the legacy provider-dispatch path until
    +// their phase-4/5 task ships the card. Routing the name now but
    +// gating on landed-ness keeps this table the single source of truth
    +// while the migration is in flight — delete the gate when the last
    +// card lands.
    +
    +// 'local_shell' is the rollout-synthesized COMMITTED name for the live
    +// 'local_shell_call' kind — both are the command family.
    +const COMMAND_TOOLS = new Set(['Bash', 'bash', 'exec_command', 'local_shell_call', 'local_shell', 'write_stdin'])
    +const READ_TOOLS = new Set(['Read', 'FileRead', 'Grep', 'Glob', 'LS'])
    +const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'apply_patch'])
    +const WRITE_TOOLS = new Set(['Write'])
    +const TODO_TOOLS = new Set(['TodoWrite', 'todowrite'])
    +const WEB_TOOLS = new Set(['WebSearch', 'WebFetch', 'web_search', 'web_search_call', 'tool_search', 'tool_search_call'])
    +const IMAGE_GEN_TOOLS = new Set(['image_generation', 'image_generation_call'])
    +
    +/** Tool names a provider's LEGACY dispatch still claims with bespoke
    + *  parsing the family cards don't replicate. OpenCode's `read` result
    + *  is / tag soup parsed by OpencodeReadResult; its
    + *  `todowrite` reuses TodoRow. Family routing + result suppression
    + *  must skip these or the tag soup paints raw (the exact regression
    + *  the GenericToolCard cutover briefly introduced for opencode reads).
    + *  Scope note: OpenCode gets no dedicated cards this rewrite (spec §1.2)
    + *  — this carve-out IS its integration. */
    +export function isLegacyProviderClaimed(
    +  provider: AgentProviderKind,
    +  toolName: string,
    +): boolean {
    +  // todowrite left this list when TodoCard landed (it takes a label,
    +  // so the cross-provider reuse survives); `read` stays until someone
    +  // teaches a card to parse OpenCode's / envelope.
    +  return provider === 'opencode' && toolName === 'read'
    +}
    +
    +/** Modern Codex `exec` (unified script tool): the family depends on
    + *  what the SCRIPT does, not the tool name — tools.apply_patch inside
    + *  the script is a file edit, tools.exec_command/write_stdin are
    + *  commands. Codex's own TUI renders the intent ("Edited x (+7 -3)");
    + *  routing by name alone painted every edit as a raw-script card (the
    + *  2026-07-12 debug-bundle regression). */
    +export function isUnifiedExecTool(toolName: string): boolean {
    +  return toolName === 'exec'
    +}
    +
    +export function routeFamily(
    +  _provider: AgentProviderKind,
    +  toolName: string,
    +): ArtifactFamily {
    +  if (COMMAND_TOOLS.has(toolName)) return 'command'
    +  if (READ_TOOLS.has(toolName)) return 'file-read'
    +  if (EDIT_TOOLS.has(toolName)) return 'file-edit'
    +  if (WRITE_TOOLS.has(toolName)) return 'file-write'
    +  if (TODO_TOOLS.has(toolName)) return 'todo'
    +  if (WEB_TOOLS.has(toolName)) return 'web'
    +  if (IMAGE_GEN_TOOLS.has(toolName)) return 'image-gen'
    +  if (toolName.startsWith('mcp__')) return 'mcp'
    +  return 'generic'
    +}
    +
    +/** Families whose dedicated card exists. 'mcp' rides on
    + *  GenericToolCard (the VM carries the server badge) until Task 19
    + *  upgrades it. */
    +export const CARD_LANDED_FAMILIES: ReadonlySet = new Set([
    +  'command',
    +  'generic',
    +  'mcp',
    +  'file-edit',
    +  'file-write',
    +  'file-read',
    +  'todo',
    +  'web',
    +  'image-gen',
    +] satisfies ArtifactFamily[])
    +
    +/** Families whose committed card CONSUMES the paired tool_result
    + *  (renders output/exit inside the card) — the tool_result block must
    + *  be suppressed for these or the output paints twice. MUST stay a
    + *  subset of CARD_LANDED_FAMILIES: suppressing a result whose card
    + *  hasn't landed is data loss (e.g. apply_patch error results are
    + *  still rendered by CodexToolResultRow until the DiffCard task). */
    +export const RESULT_CONSUMING_FAMILIES: ReadonlySet = new Set([
    +  'command',
    +  'generic',
    +  'mcp',
    +  // DiffCard consumes edit results: success stubs vanish (the diff is
    +  // the story, ✓ is the confirmation), errors render inside the card —
    +  // including Codex patch failures' tinted unified_diffs.
    +  'file-edit',
    +  'file-write',
    +  'file-read',
    +  'todo',
    +  'web',
    +  'image-gen',
    +] satisfies ArtifactFamily[])
    diff --git a/src/renderer/src/features/feed/ui/rows/Block.tsx b/src/renderer/src/features/feed/ui/rows/Block.tsx
    index 5ed8a4f4..e2918375 100644
    --- a/src/renderer/src/features/feed/ui/rows/Block.tsx
    +++ b/src/renderer/src/features/feed/ui/rows/Block.tsx
    @@ -24,10 +24,49 @@ import { TextProse } from '@renderer/features/feed/ui/markdown'
     import { ImageBlockRow } from '@renderer/features/feed/ui/rows/ImageBlockRow'
     import { UserBand } from '@renderer/features/feed/ui/rows/primitives'
     import { ToolResultRow } from '@renderer/features/feed/ui/rows/ToolResultRow'
    -import { ToolUseRow } from '@renderer/features/feed/ui/rows/ToolUseRow'
     import { isAgentSpawnToolName } from '@providers/registry.renderer.capabilities'
    -import { JsonToolRow } from '@providers/shared/renderer/rows/JsonToolRow'
     import { TaskSubagentRow } from '@renderer/features/feed/ui/rows/TaskSubagentRow'
    +import { CommandCard } from '@renderer/features/feed/ui/artifacts/command'
    +import {
    +  DiffCard,
    +  PatchResultCard,
    +  fileEditFromCommitted,
    +} from '@renderer/features/feed/ui/artifacts/fileEdit'
    +import {
    +  ReadCard,
    +  readFromCommitted,
    +} from '@renderer/features/feed/ui/artifacts/fileRead'
    +import {
    +  FileWriteCard,
    +  fileWriteFromCommitted,
    +} from '@renderer/features/feed/ui/artifacts/fileWrite'
    +import { GenericToolCard } from '@renderer/features/feed/ui/artifacts/generic'
    +import {
    +  SlashCommandRow,
    +  isSlashCommandText,
    +} from '@renderer/features/feed/ui/artifacts/slashCommand'
    +import { ThinkingBlock } from '@renderer/features/feed/ui/artifacts/thinking'
    +import { TodoCard, todoFromCommitted } from '@renderer/features/feed/ui/artifacts/todo'
    +import { WebCard, webFromCommitted } from '@renderer/features/feed/ui/artifacts/web'
    +import {
    +  ImageGenCard,
    +  imageGenFromCommitted,
    +} from '@renderer/features/feed/ui/artifacts/imageGen'
    +import {
    +  commandFromCommitted,
    +  genericFromCommitted,
    +} from '@renderer/features/feed/ui/resolve/fromCommitted'
    +import {
    +  RESULT_CONSUMING_FAMILIES,
    +  isLegacyProviderClaimed,
    +  isUnifiedExecTool,
    +  routeFamily,
    +} from '@renderer/features/feed/ui/resolve/registry'
    +import {
    +  classifyUnifiedExecScript,
    +  patchResultMeta,
    +  unifiedExecScript,
    +} from '@providers/codex/renderer/extractors'
     
     /* ---------- Block dispatcher ---------- */
     
    @@ -40,9 +79,8 @@ import { TaskSubagentRow } from '@renderer/features/feed/ui/rows/TaskSubagentRow
     //   - text under role='assistant' → TextProse with `⏺` marker
     //   - thinking → collapsed 
    if non-empty, else nothing // - image → ImageBlockRow -// - tool_use → provider-specific renderer (Claude: Edit/MultiEdit/ -// Write/TodoWrite rich rows; Codex: CodexToolRow; everything else: -// generic ToolUseRow). Plus the git-widget interception. +// - tool_use → routeFamily → the family's artifact card (see +// ui/resolve/registry.ts). Plus the git-widget interception. // - tool_result → provider-specific result renderer, with the git // widget suppression mirrored here. // #442 finding-21: the git-widget card renders for a shell tool_use, and the @@ -69,6 +107,15 @@ export const Block = memo(function Block({ const customRendering = useAppStore(state => state.settings.customRendering) switch (block.type) { case 'text': { + const text = (block as { text: string }).text + // Slash-command surface: an invocation envelope or a + // local-command-stdout record renders through SlashCommandRow + // (name pill + output well) instead of painting the raw + // … tag soup into the prompt. Cheap string gate so + // ordinary prompts never pay the regex cost. + if (role === 'user' && isSlashCommandText(text)) { + return + } // Only text blocks under a user role represent an actual user // prompt. A sibling tool_result block in the same message is // NOT a user prompt (it's tool output), and must not get the @@ -76,42 +123,21 @@ export const Block = memo(function Block({ // the whole ConversationRow. const row = ( - + ) return role === 'user' ? {row} : row } case 'thinking': { - // Persisted thinking block. Anthropic strips the plaintext from - // the final message (only `signature` ciphertext survives), so - // text is ALMOST ALWAYS empty in committed transcripts. Old - // behaviour was to render a placeholder `∴ Thinking` row; now - // we render nothing and let the WorkIndicator (while live) and - // the absence of content (after the fact) speak for themselves. - // - // Non-empty thinking on a committed block does still exist - // (older sessions, non-Opus-4 models, synthetic entries). Keep - // the expandable surface for those — aligned with the live - // branch above, `
    ` closed by default. - // - // See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. + // Persisted thinking is ALMOST ALWAYS empty (Anthropic strips the + // plaintext; only signature ciphertext survives) — ThinkingBlock + // renders nothing for empty text. Non-empty committed thinking + // (older sessions, synthetic entries) keeps the collapsed details. const text = (block as { thinking?: string }).thinking ?? '' - if (!text) return null - return ( - -
    - - ∴ Thinking - - (click to expand) - - -
    - -
    -
    -
    - ) + return + } + case 'redacted_thinking': { + return } case 'image': { return @@ -175,12 +201,86 @@ export const Block = memo(function Block({ ) } - const providerRow = getRendererProviderCapabilities(currentProvider).renderToolUse?.(tu) - // Shared fallback is the generic JSON tool row (residue plan P1): - // it degrades to the old ToolUseRow look for headline-only inputs - // (Bash keeps its 2-line cap) and gives MCP/orchestration payloads - // a real rendering instead of a bare name over raw JSON. - return providerRow !== undefined ? providerRow : + // Command family — one card for Bash / exec_command / write_stdin / + // local_shell_call, live-identical (spec §6). Routed AFTER the + // git-intent interception above (git cards win for recognized git + // commands) and after spawn/AskUserQuestion. The paired result is + // consumed INTO the card (output + exit code); the tool_result + // branch below suppresses it with the same routeFamily predicate — + // the #442 lesson: whatever the card renders for, the result + // suppresses for, one predicate, both branches. + // Legacy provider claims (opencode read): the tool_use side gets + // the generic card WITHOUT consuming the paired result — the + // provider's renderToolResult (OpencodeReadResult tag-soup + // parser) still owns the result row. See isLegacyProviderClaimed. + if (isLegacyProviderClaimed(currentProvider, tu.name)) { + return + } + const family = routeFamily(currentProvider, tu.name) + if (family === 'command') { + const paired = toolResultIndex.get(tu.id) ?? null + return + } + // generic + mcp go straight to the one fallback card (no provider + // dispatch ever claimed these names — verified across all three + // dispatch.tsx files at the time of the rewrite). The card + // consumes the paired result (suppressed below via + // RESULT_CONSUMING_FAMILIES), so live and committed MCP/unknown + // tools finally render identically. + if (family === 'generic' || family === 'mcp') { + const paired = toolResultIndex.get(tu.id) ?? null + return + } + if (family === 'file-edit') { + const paired = toolResultIndex.get(tu.id) ?? null + return ( + + ) + } + if (family === 'file-write') { + const paired = toolResultIndex.get(tu.id) ?? null + return + } + if (family === 'file-read') { + const paired = toolResultIndex.get(tu.id) ?? null + return + } + if (family === 'todo') { + const paired = toolResultIndex.get(tu.id) ?? null + return ( + + ) + } + if (family === 'web') { + const paired = toolResultIndex.get(tu.id) ?? null + return ( + + ) + } + if (family === 'image-gen') { + const paired = toolResultIndex.get(tu.id) ?? null + return + } + + // Unreachable in practice — every family above has a landed card + // and routeFamily is total — but a silent non-render on a future + // family addition would be the #239 class, so fall back loudly + // to the generic card. + const paired = toolResultIndex.get(tu.id) ?? null + return } case 'tool_result': { const tr = block as ToolResultBlock @@ -198,7 +298,41 @@ export const Block = memo(function Block({ return null } } + // Standalone patch confirmations (unified-exec patch_apply_end — + // its call_id pairs with nothing) paint the Codex-native + // "Edited path" row instead of a floating raw-output well. + const patchMeta = patchResultMeta(tr) + if (patchMeta.isPatchResult) { + return ( + + ) + } const sourceTool = toolUseIndex.get(tr.tool_use_id) + // Results consumed INTO a card on the tool_use row (command output, + // generic/mcp result slots) — painting them again here duplicates + // the output below the card. Same routing table as the tool_use + // branch (the #442 lesson: one predicate, both branches). + // RESULT_CONSUMING_FAMILIES is deliberately a subset of the landed + // cards — suppressing a result whose card hasn't landed is data loss. + if ( + sourceTool && + // Spawn results carry the subagent's FINAL REPORT (Claude) or the + // join payload (Codex). TaskSubagentRow renders neither, so family + // suppression here silently destroyed Claude subagent reports + // (PR524 review, HIGH). Spawns fall through to provider dispatch: + // Codex drops its join-payload noise there on purpose; Claude's + // report renders via the generic result row, as pre-rewrite. + !isAgentSpawnToolName(sourceTool.name) && + !isLegacyProviderClaimed(currentProvider, sourceTool.name) && + RESULT_CONSUMING_FAMILIES.has(routeFamily(currentProvider, sourceTool.name)) + ) { + return null + } // #442 finding-C2: an answered AskUserQuestion renders the picked answer // inside AskUserQuestionAnsweredRow on the tool_use row (it reads the // paired tool_result). Painting the tool_result again here shows the same diff --git a/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx b/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx index d2e48ba4..21dc1487 100644 --- a/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx @@ -1,6 +1,7 @@ -import { memo } from 'react' +import { memo, useEffect, useState } from 'react' import type { ContentBlock } from '@shared/types/transcript' +import { asRecord } from '@shared/lib/asRecord' import { imageDataUrl } from '@renderer/features/feed/lib/helpers' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' @@ -10,11 +11,44 @@ import { UserBand } from '@renderer/features/feed/ui/rows/primitives' // Image content-block renderer. Handles both directions of the // conversation: an inline image pasted by the user (`❯` marker + // UserBand highlight) or an image emitted by an assistant tool call -// (`⏺` marker, no band). The image itself is rendered from a base64 -// data: URL — the transcript wire format never carries remote URLs -// for inline images, only inline bytes. When the data is missing -// (malformed block), we render a small "image" placeholder pill -// instead of failing silently. +// (`⏺` marker, no band). +// +// Sources: base64 inline bytes (the common case — CC-pasted +// screenshots) AND the spec's `url` shape (previously dropped to a +// bare "image" placeholder — audit dropped-shape #4; http(s) only, so +// a malformed source can't smuggle an arbitrary scheme into ). +// Click-to-zoom: a dependency-free fixed-overlay lightbox — feed +// images are capped at 28rem, which makes real screenshots unreadable +// without it. ESC or click closes. + +function imageSrc(block: ContentBlock): string | null { + const inline = imageDataUrl(block) + if (inline) return inline + const source = asRecord(asRecord(block)?.source) + if (source?.type === 'url' && typeof source.url === 'string' && /^https?:\/\//.test(source.url)) { + return source.url + } + return null +} + +function Lightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + return ( +
    + {alt} +
    + ) +} + export const ImageBlockRow = memo(function ImageBlockRow({ block, role, @@ -22,7 +56,8 @@ export const ImageBlockRow = memo(function ImageBlockRow({ block: ContentBlock role: 'user' | 'assistant' }) { - const src = imageDataUrl(block) + const [zoomed, setZoomed] = useState(false) + const src = imageSrc(block) const source = (block as { source?: { media_type?: unknown } }).source const mediaType = typeof source?.media_type === 'string' @@ -33,12 +68,16 @@ export const ImageBlockRow = memo(function ImageBlockRow({
    {src ? ( - {alt} + <> + {alt} setZoomed(true)} + /> + {zoomed ? setZoomed(false)} /> : null} + ) : (
    image diff --git a/src/renderer/src/features/feed/ui/rows/SystemRow.tsx b/src/renderer/src/features/feed/ui/rows/SystemRow.tsx index a2413ad9..baf2b67f 100644 --- a/src/renderer/src/features/feed/ui/rows/SystemRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/SystemRow.tsx @@ -1,16 +1,26 @@ import { memo } from 'react' import type { Entry } from '@shared/types/transcript' +import { asRecord } from '@shared/lib/asRecord' import { attachmentLabel } from '@renderer/features/feed/lib/helpers' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { ExpandSection } from '@renderer/features/feed/ui/kit/ExpandSection' // System-entry renderer — the hidden-by-default row that shows // meta-entries from the transcript (permission-mode switches, -// file-history snapshots, hook attachments). These don't -// contribute to the conversation; they're diagnostic surfaces the -// user opts into via settings. Rendered muted with the `·` marker -// so when visible they're clearly secondary to real turns. +// file-history snapshots, hook attachments). These don't contribute +// to the conversation; they're diagnostic surfaces the user opts into +// via settings. Rendered muted with the `·` marker so when visible +// they're clearly secondary to real turns. +// +// Upgrade over the old one-line label: hook attachments carry a real +// payload (matched tool, hook output) that used to be unreachable — +// when a payload exists it sits behind a collapsed ExpandSection, so +// the row stays one muted line until deliberately opened. + +const PAYLOAD_CAP = 8 * 1024 + export const SystemRow = memo(function SystemRow({ entry }: { entry: Entry }) { const label = entry.type === 'attachment' @@ -20,10 +30,31 @@ export const SystemRow = memo(function SystemRow({ entry }: { entry: Entry }) { : entry.type === 'file-history-snapshot' ? 'file history snapshot' : entry.type + + const attachment = entry.type === 'attachment' ? asRecord(asRecord(entry)?.attachment) : null + const payloadJson = (() => { + if (!attachment) return null + try { + const json = JSON.stringify(attachment, null, 2) + if (json === '{}') return null + return json.length > PAYLOAD_CAP ? `${json.slice(0, PAYLOAD_CAP)}\n…` : json + } catch { + return null + } + })() + return (
    - {label} + {payloadJson ? ( + +
    +              {payloadJson}
    +            
    +
    + ) : ( + label + )}
    ) diff --git a/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx b/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx index b5ef55ac..9129af23 100644 --- a/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx @@ -1,67 +1,20 @@ -import { memo, useContext, useState, type ReactNode } from 'react' +import { memo, useContext } from 'react' import type { ToolResultBlock } from '@shared/types/transcript' -import { CodeBlock } from '@renderer/lib/code/CodeBlock' - -import { stripLineNumberPrefix } from '@renderer/features/feed/lib/helpers' -import { CodeRenderContext, ToolUseIndexContext } from '@renderer/features/feed/context' -import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { ToolUseIndexContext } from '@renderer/features/feed/context' import { JsonResultSlab } from '@providers/shared/renderer/rows/JsonResultSlab' import { tryExtractJson } from '@providers/shared/renderer/rows/jsonToolPresentation' -import { TruncatedOutputRow } from '@renderer/features/feed/ui/rows/TruncatedOutputRow' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' /* ---------- Tool result: "⎿ (lines of output)" ---------- */ -function LazyDetails({ - summary, - children, -}: { - summary: ReactNode - children: ReactNode -}) { - const [opened, setOpened] = useState(false) - return ( - // Closed
    hides its contents visually but React still mounts - // children. The expensive child here is usually a Monaco CodeBlock, - // which means editor creation, model allocation, and sometimes LSP - // document lifecycle. Gate it on first-open so dense restored feeds - // stay cheap until the user explicitly drills into the raw file output. -
    { - if (event.currentTarget.open) setOpened(true) - }} - > - - {summary} - - {opened ?
    {children}
    : null} -
    - ) -} - /** - * Look at the tool_use this result came from (via the feed-level - * index in context) and decide how to render the result: - * - * Read → strip the "N→" line-number prefix CC's Read tool emits, - * and render the contents as a preformatted code slab. We - * deliberately skip markdown parsing here because source - * code frequently contains triple-backticks and unbalanced - * emphasis that would wreck the markdown AST. For full - * syntax highlighting later we can feed the stripped text - * through highlight.js directly. - * - * Edit / MultiEdit / Write → the diff/content already rendered on - * the preceding tool_use row tells the story. The terse - * "has been updated successfully" message is pure noise - * next to it; suppress for non-errors. - * - * everything else (Bash, Glob, Grep, …) → keep the existing - * plain-pre rendering. The content IS the interesting part - * for those tools. + * Generic tool-result renderer — the LEGACY fallback for families whose + * dedicated card hasn't landed (web, image-gen) and for results whose + * source tool the index can't resolve. Everything else is suppressed + * upstream in Block.tsx and rendered inside its family card. */ export const ToolResultRow = memo(function ToolResultRow({ block, @@ -69,7 +22,6 @@ export const ToolResultRow = memo(function ToolResultRow({ block: ToolResultBlock }) { const toolUseIndex = useContext(ToolUseIndexContext) - const codeContext = useContext(CodeRenderContext) const sourceTool = toolUseIndex.get(block.tool_use_id)?.name const text = @@ -84,92 +36,9 @@ export const ToolResultRow = memo(function ToolResultRow({ const isError = block.is_error === true const trimmed = text.replace(/\s+$/, '') - // File-write tools AND TodoWrite: the rendered diff/content/checklist - // on the preceding tool_use row already tells the story. The result - // in all four cases is a stub success string that would just clutter - // the feed. Errors still fall through to the normal result renderer - // so failures remain visible. - if ( - !isError && - (sourceTool === 'Edit' || - sourceTool === 'MultiEdit' || - sourceTool === 'Write' || - sourceTool === 'TodoWrite') - ) { - return null - } - - // Read tool result — show a one-line summary only, not the file - // contents. Mirrors claude-code-src/full/tools/FileReadTool/UI.tsx - // `renderToolResultMessage` which renders "Read lines" at - // height={1} and never echoes the file bytes into the feed. The - // user already knows which file was read (it's on the tool-use - // row); dumping its contents below pushes the next assistant - // message off-screen for no gain. - // - // A click-to-expand
    keeps the raw content one - // interaction away for when you actually need it (debugging, - // code review). Syntax highlighting happens inside CodeBlock - // only when expanded. - if (sourceTool === 'Read' && !isError) { - const stripped = stripLineNumberPrefix(trimmed) - const numLines = stripped ? stripped.split('\n').length : 0 - const sourceInput = toolUseIndex.get(block.tool_use_id)?.input as - | Record - | undefined - const filePath = - typeof sourceInput?.file_path === 'string' - ? sourceInput.file_path - : typeof sourceInput?.path === 'string' - ? sourceInput.path - : null - return ( - - - Read {numLines}{' '} - {numLines === 1 ? 'line' : 'lines'} - - )} - > - - - - ) - } - - // Grep tool result: render with CodeBlock so results get syntax - // highlighting based on the file pattern / path. Grep output is - // already formatted text but benefits from language-aware coloring. - if (sourceTool === 'Grep' && !isError) { - const sourceInput = toolUseIndex.get(block.tool_use_id)?.input as - | Record - | undefined - const filePath = - typeof sourceInput?.path === 'string' - ? sourceInput.path - : null - return ( - - - - ) - } + // NOTE: the Read and Grep special branches that lived here are GONE + // on purpose — the file-read family is suppressed upstream in + // Block.tsx and rendered by ReadCard (one card owns both halves). // JSON-shaped results (MCP envelope / plain JSON) get a collapsed // pretty rendering instead of an escaped one-liner (residue plan P1). @@ -185,5 +54,6 @@ export const ToolResultRow = memo(function ToolResultRow({ // claude-code's OutputLine + renderTruncatedContent (MAX_LINES_TO_SHOW // = 3). The collapsed view keeps the feed dense so a long `find .` // or noisy test run doesn't push the assistant's next message off. - return + // OutputWell is the kit successor — same behavior, ANSI-aware. + return }) diff --git a/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx b/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx deleted file mode 100644 index 4c6b746b..00000000 --- a/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { memo } from 'react' - -import type { ToolUseBlock } from '@shared/types/transcript' - -import { truncateBashCommand } from '@renderer/features/feed/lib/helpers' -import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' - -/* ---------- Tool use: "⏺ Bash ⎿ $ command" ---------- */ - -// Generic tool_use row — the fallback renderer when the tool name -// doesn't match one of the per-tool rich rows (Edit / MultiEdit / -// Write / TodoWrite for Claude; provider dispatchers for Codex). -// Shows "⏺ " followed by a muted `⎿ ` line with -// the command / description / path pulled from the tool's input -// object. -// -// Bash commands get the 2-line / 160-char cap claude-code's Ink UI -// enforces (see claude-code-src/full/tools/BashTool/UI.tsx). The -// truncateBashCommand helper applies the same caps so the feed reads -// like the upstream TUI — long / multiline invocations collapse with -// a trailing `…`. The whole command remains in the transcript; the -// collapse is purely a density choice so a 20-line heredoc doesn't -// push the next assistant message below the fold. -export const ToolUseRow = memo(function ToolUseRow({ block }: { block: ToolUseBlock }) { - // Headline lookup order mirrors `workIndicatorHints.toolHintFromBlock` - // so the row's "⎿ …" line carries the same identifier the work - // indicator showed while the tool was running. - // - // The historic chain was `command → description → path`, written when - // this row was Bash-only. That left every file-op tool (Read, - // NotebookRead, etc.) with a blank body because Read's argument is - // `file_path`, not `path` — observed in 2026-04-25T10-10-36 debug - // bundle as ~75 orphan-ghost rows reading just "Read" with no - // argument. We now check `command` (Bash), then the path-shaped - // fields any file-op might use, then search/network identifiers, - // then `description` as the last-ditch free-form fallback. - // - // `description` deliberately moves to the bottom: Bash's - // `description` is a redundant gloss of `command`, and other tools - // that accept a description usually also have a more specific - // identifier. If we hit `description` we've exhausted everything - // else. - const input = block.input as Record | undefined - const pickString = (key: string): string | null => { - const v = input?.[key] - return typeof v === 'string' && v.length > 0 ? v : null - } - const rawHeadline = - pickString('command') ?? - pickString('file_path') ?? - pickString('path') ?? - pickString('notebook_path') ?? - pickString('pattern') ?? - pickString('query') ?? - pickString('url') ?? - pickString('description') - - // Bash commands get the 2-line / 160-char cap claude-code's Ink UI - // enforces. `description` and `path` headlines are already one-line- - // ish so we only truncate when the headline came from `command`. - const headline = (() => { - if (!rawHeadline) return null - if (block.name === 'Bash' && typeof input?.command === 'string') { - return truncateBashCommand(input.command) - } - return rawHeadline - })() - - return ( - -
    -
    - {block.name} -
    - {headline && ( - -
    -              {headline}
    -            
    -
    - )} -
    -
    - ) -}) diff --git a/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx b/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx deleted file mode 100644 index cff734be..00000000 --- a/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useState } from 'react' - -import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' - -// MAX_LINES_TO_SHOW in claude-code-src. Hoisted so the memo'd row -// component doesn't re-create the constant every render. -const RESULT_MAX_LINES = 3 - -// Collapsed output view for tool_result content that isn't worth a -// full dump (Bash, Glob, LS, error stacks). Shows the first 3 lines -// and offers a click-to-expand button revealing the full output -// inside a 360px scroll cap. Mirrors claude-code's OutputLine + -// renderTruncatedContent from claude-code-src — keeping the feed -// dense so a long `find .` or noisy test run doesn't push the -// assistant's next message off-screen. -export function TruncatedOutputRow({ - content, - isError, -}: { - content: string - isError: boolean -}) { - const [expanded, setExpanded] = useState(false) - const lines = content.length === 0 ? [] : content.split('\n') - const needsTruncation = lines.length > RESULT_MAX_LINES - const shown = expanded || !needsTruncation - ? content - : lines.slice(0, RESULT_MAX_LINES).join('\n') - const hiddenCount = needsTruncation ? lines.length - RESULT_MAX_LINES : 0 - return ( - -
    -        {shown}
    -      
    - {needsTruncation && ( - - )} -
    - ) -} diff --git a/src/renderer/src/features/feed/ui/rows/index.ts b/src/renderer/src/features/feed/ui/rows/index.ts index 77be1154..53a02412 100644 --- a/src/renderer/src/features/feed/ui/rows/index.ts +++ b/src/renderer/src/features/feed/ui/rows/index.ts @@ -15,7 +15,5 @@ export { CompactBoundaryRow } from '@renderer/features/feed/ui/rows/CompactBound export { CompactSummaryRow } from '@renderer/features/feed/ui/rows/CompactSummaryRow' export { SystemRow } from '@renderer/features/feed/ui/rows/SystemRow' export { ImageBlockRow } from '@renderer/features/feed/ui/rows/ImageBlockRow' -export { ToolUseRow } from '@renderer/features/feed/ui/rows/ToolUseRow' export { ToolResultRow } from '@renderer/features/feed/ui/rows/ToolResultRow' -export { TruncatedOutputRow } from '@renderer/features/feed/ui/rows/TruncatedOutputRow' export { UserBand } from '@renderer/features/feed/ui/rows/primitives' diff --git a/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx b/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx index 5527dcab..20317b8e 100644 --- a/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx +++ b/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx @@ -1,189 +1,52 @@ -import { JsonResultSlab } from '@providers/shared/renderer/rows/JsonResultSlab' -import { JsonToolRow, SLAB_MAX_CHARS } from '@providers/shared/renderer/rows/JsonToolRow' -import { tryExtractJson } from '@providers/shared/renderer/rows/jsonToolPresentation' +import { SLAB_MAX_CHARS } from '@providers/shared/renderer/rows/JsonToolRow' import { memo } from 'react' -import { - CodexApplyPatchRow, - CodexExecCommandRow, - CodexToolRow, - CodexWriteStdinRow, -} from '@providers/codex/renderer/rows/CodexRows' -import { - EditRow, - MultiEditRow, -} from '@providers/claude/renderer/rows/ClaudeRows' import type { ToolUseBlock } from '@shared/types/transcript' import { parseJsonRecord } from '@shared/lib/asRecord' -import { CodeBlock } from '@renderer/lib/code/CodeBlock' -import { - parseSemanticTodos, - type SemanticLiveTurn, -} from '@renderer/session-runtime/state' +import type { SemanticLiveTurn } from '@renderer/session-runtime/state' -import { splitStreamingCodeFence } from '@renderer/features/feed/lib/helpers' -import { extractStreamingWriteInput } from '@renderer/features/feed/lib/streamingWriteInput' +import { CommandCard } from '@renderer/features/feed/ui/artifacts/command' +import { + DiffCard, + fileEditFromLive, +} from '@renderer/features/feed/ui/artifacts/fileEdit' +import { + ReadCard, + readFromLive, +} from '@renderer/features/feed/ui/artifacts/fileRead' +import { + FileWriteCard, + fileWriteFromLive, +} from '@renderer/features/feed/ui/artifacts/fileWrite' +import { GenericToolCard } from '@renderer/features/feed/ui/artifacts/generic' +import { + commandFromLive, + genericFromLive, +} from '@renderer/features/feed/ui/resolve/fromLive' +import { classifyUnifiedExecScript } from '@providers/codex/renderer/extractors' +import { OutputWell } from '@renderer/features/feed/ui/kit/OutputWell' +import { SegmentedMarkdown } from '@renderer/features/feed/ui/kit/SegmentedMarkdown' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' import { StreamingProse } from '@renderer/features/feed/ui/markdown' -import { AskUserQuestionRow } from '@renderer/features/feed/ui/semantic/AskUserQuestionRow' -import { SemanticTodoList } from '@renderer/features/feed/ui/semantic/TodoList' +import { ThinkingBlock } from '@renderer/features/feed/ui/artifacts/thinking' +import { TodoCard, todoFromLive } from '@renderer/features/feed/ui/artifacts/todo' +import { WebCard, webFromLive } from '@renderer/features/feed/ui/artifacts/web' +import { + ImageGenCard, + imageGenFromLive, +} from '@renderer/features/feed/ui/artifacts/imageGen' -// [#285] Extract a CLOSED top-level JSON string field from a partial inputJson -// buffer — i.e. one whose closing quote has already streamed. The regex body -// `(?:[^"\\]|\\.)*` tolerates escaped quotes and embedded newlines, so it only -// matches a fully-arrived value. Used ONLY during the brief streaming window -// before the whole object is JSON-parseable; the moment `parseJsonRecord` -// succeeds (below) the authoritative parse takes over. A value that literally -// contains the key text mid-stream could mis-match transiently, but it -// self-corrects on the next delta / final parse — strictly better than the raw -// JSON blob this replaces. -function extractClosedJsonString(raw: string, key: string): string | null { - const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`) - const m = re.exec(raw) - if (!m) return null - try { - return JSON.parse(`"${m[1]}"`) as string - } catch { - return null - } -} +import { AskUserQuestionRow } from '@renderer/features/feed/ui/semantic/AskUserQuestionRow' // Cap any pretty-printed JSON slab on the LIVE path the same way JsonToolRow -// caps its committed slabs. WHY: these two call sites stringify tool output / -// parsed input straight into the DOM every render while the block streams. An -// unbounded payload (a whole-file read result, a giant orchestration graph) is -// the O(bytes²) highlight/paint trap on the hottest path in the feed. We reuse -// JsonToolRow's exported SLAB_MAX_CHARS instead of a fresh magic number so the -// live and committed previews truncate identically (they used to only be -// capped on the committed side, so a live row could balloon). +// caps its committed slabs — the live and committed previews must truncate +// identically (they used to drift; see SLAB_MAX_CHARS' rationale). function cappedJson(value: unknown): string { const json = JSON.stringify(value, null, 2) return json.length > SLAB_MAX_CHARS ? `${json.slice(0, SLAB_MAX_CHARS)}\n…` : json } -// [#285] Build the committed Edit/MultiEdit input object from a live semantic -// block, so the streaming path can render the SAME rich EditRow/MultiEditRow -// (FileToolHeader + line-level DiffSlab) the committed transcript uses — -// mirroring how Codex reuses its committed rows on the live path (see the -// function_call branch below). Returns null until at least `file_path` has -// streamed so we never flash an empty "Edit · (no changes)" card; the caller -// then falls through to the raw preview until the path arrives. -function claudeLiveEditInput( - block: SemanticLiveTurn['blocks'][number], -): Record | null { - if (block.parsedInput) return block.parsedInput - const raw = block.inputJson ?? '' - const full = raw ? parseJsonRecord(raw) : null - if (full) return full - if (!raw) return null - const filePath = extractClosedJsonString(raw, 'file_path') - if (!filePath) return null - if (block.toolName === 'MultiEdit') { - // The `edits` array can't be reliably half-parsed; show the header now - // (file path) and let the authoritative parse above fill in the per-edit - // diff chunks the instant the whole object completes. - return { file_path: filePath, edits: [] } - } - return { - file_path: filePath, - old_string: extractClosedJsonString(raw, 'old_string') ?? '', - new_string: extractClosedJsonString(raw, 'new_string') ?? '', - } -} - -const SIMPLE_JSON_ESCAPES: Record = { - '"': '"', - '\\': '\\', - '/': '/', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t', -} - -function decodePartialJsonStringBody(raw: string, start: number): string { - let out = '' - let i = start - while (i < raw.length) { - const ch = raw[i] - if (ch === '"') return out - if (ch === '\\') { - if (i + 1 >= raw.length) return out - const esc = raw[i + 1] - if (esc === 'u') { - const hex = raw.slice(i + 2, i + 6) - if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) return out - out += String.fromCharCode(parseInt(hex, 16)) - i += 6 - continue - } - out += SIMPLE_JSON_ESCAPES[esc] ?? esc - i += 2 - continue - } - out += ch - i += 1 - } - return out -} - -function extractPartialJsonStringMember(raw: string, keys: string[]): string | null { - for (const key of keys) { - const marker = `"${key}"` - const keyAt = raw.indexOf(marker) - if (keyAt === -1) continue - const colonAt = raw.indexOf(':', keyAt + marker.length) - if (colonAt === -1) continue - let valueAt = colonAt + 1 - while (valueAt < raw.length && /\s/.test(raw[valueAt] ?? '')) valueAt += 1 - if (raw[valueAt] !== '"') continue - return decodePartialJsonStringBody(raw, valueAt + 1) - } - return null -} - -function partialApplyPatchInput(raw: string): Record { - if (raw.includes('*** Begin Patch')) return { raw } - const patch = extractPartialJsonStringMember(raw, [ - 'cmd', - 'patch', - 'input', - 'raw', - 'arguments', - ]) - return patch && patch.includes('*** Begin Patch') ? { raw: patch } : { raw, arguments: raw } -} - -function codexLiveToolInput(block: SemanticLiveTurn['blocks'][number]): unknown { - const raw = block.argumentsJson ?? block.inputJson ?? '' - if (block.parsedInput) return block.parsedInput - const parsed = raw ? parseJsonRecord(raw) : null - if (parsed) return parsed - - // WHY apply_patch keeps a raw fallback: - // Codex can surface patch application as a custom/freeform tool - // call where the payload is the patch grammar itself, not a JSON - // object. The committed Codex renderer already knows how to parse - // `{ raw: "*** Begin Patch..." }`; feeding the live block through - // the same shape gives streaming patch calls the same file/diff - // card as committed transcript rows instead of showing a giant raw - // preformatted argument blob. - if (block.toolName === 'apply_patch' && raw) return partialApplyPatchInput(raw) - - return raw ? { raw, arguments: raw } : {} -} - -function codexLiveToolUseBlock(block: SemanticLiveTurn['blocks'][number]): ToolUseBlock { - return { - type: 'tool_use', - id: block.callId ?? block.toolUseId ?? block.itemId ?? `live:${block.blockIndex}`, - name: block.toolName ?? block.kind, - input: codexLiveToolInput(block), - } -} - // Single live-block renderer — this is the big dispatch for the // semantic streaming path. Each SemanticLiveTurn block is one of a // dozen kinds (thinking, function_call, tool_use, web_search_call, @@ -203,58 +66,26 @@ export const SemanticLiveBlockRow = memo(function SemanticLiveBlockRow({ block: SemanticLiveTurn['blocks'][number] toolState: SemanticLiveTurn['lookups']['toolCallsById'][string] | null }) { + if (block.kind === 'redacted_thinking') { + return + } + if (block.kind === 'thinking' || block.kind === 'reasoning') { // Live thinking — for Claude this is the ONLY time the plaintext is // available (`thinking` is stripped on the final message before - // persisting; only signature ciphertext survives). For Codex the - // `reasoning` block works similarly, and plaintext is frequently - // empty because ChatGPT delivers reasoning encrypted. - // - // Design (2026-04-18 rework): - // - Empty thinking → render NOTHING. The WorkIndicator at the - // foot of the feed already shows "Thinking · Ns" with a - // pulsing dot, so the old static `∴ Thinking…` row was - // redundant noise that actively looked "hung" when encrypted. - // - Non-empty thinking → collapsed `
    ` (closed by - // default). Users who want to read reasoning click to expand; - // nobody sees a flood of italic prose they didn't ask for. - // - // See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. + // persisting; only signature ciphertext survives). Codex `reasoning` + // may carry a summary track, a full track, or neither (encrypted). + // Both tracks show when distinct — summary first, full below it. + const summary = block.reasoningSummary ?? '' + const full = block.thinking || block.reasoningText || '' const text = - block.thinking || - block.reasoningSummary || - block.reasoningText || - '' - if (!text) return null - const isStreaming = !block.finalized - return ( - -
    - - ∴ Thinking{isStreaming ? '…' : ''} - - (click to expand) - - -
    - -
    -
    -
    - ) + summary && full && summary !== full + ? `${summary}\n\n---\n\n${full}` + : full || summary + return } - // Codex-specific variants — minimal first-class rendering so tool - // calls, searches, shell commands, and image generations show up - // live from the proxy stream instead of waiting for rollout to - // catch up. Each variant shows what it IS (tool name / command / - // query / status) without trying to reinvent the full rollout- - // rendered card; rollout's reducer writes the canonical final - // version to the feed, and these live rows fill in the "right now" - // gap. Ordered from highest-frequency (function_call) to lowest. - if (block.kind === 'function_call' || block.kind === 'custom_tool_call') { - const liveTool = codexLiveToolUseBlock(block) // WHY live Codex calls reuse committed Codex row renderers: // The broken 18:54 transcript showed the live plane rendering @@ -265,25 +96,48 @@ export const SemanticLiveBlockRow = memo(function SemanticLiveBlockRow({ // ToolUseBlock shape the committed transcript uses, then delegate // to the committed Codex card. Streaming now means "same card // with partial input" instead of a separate raw-JSON UI. - if (liveTool.name === 'apply_patch') { - return - } - if (liveTool.name === 'exec_command') { - return + // Modern Codex unified `exec`: the streaming input is a JS script — + // classify its intent so an edit paints the SAME DiffCard (growing + // as the patch literal streams) and a command paints the CommandCard, + // exactly like the classic tool names. Raw-script fallback only for + // unrecognized shapes. + if (block.toolName === 'exec') { + const action = classifyUnifiedExecScript( + block.argumentsJson ?? block.inputJson ?? '', + ) + if (action?.kind === 'apply_patch') { + return ( + + ) + } + if (action) { + return + } + return } - if (liveTool.name === 'write_stdin') { - return + + if (block.toolName === 'apply_patch') { + return ( + + ) } - // Parse-gated convergence with the committed fallback (residue plan - // P1): a fully-parsed live payload renders through the same shared - // JsonToolRow the committed row will use; raw/partial payloads keep - // CodexToolRow's degraded look until the JSON completes. THIN glue on - // purpose — this whole bypass dies at Stage 3. - const liveInput = liveTool.input as Record | null - if (liveInput && !('raw' in liveInput)) { - return + // Command family — the SAME CommandCard the committed plane renders + // (spec §6 convergence). Live output streams into the card as + // tool_output_delta accumulates on the block; exit tint arrives via + // resultIsError on tool_completed. + if (block.toolName === 'exec_command' || block.toolName === 'write_stdin') { + return } - return + // Everything else — the SAME GenericToolCard the committed plane + // renders (spec §6). Partial JSON streams as growing highlighted + // params inside the card; the structured slab takes over on parse. + return } if ( @@ -303,109 +157,33 @@ export const SemanticLiveBlockRow = memo(function SemanticLiveBlockRow({ : raw === undefined ? '(no output)' : cappedJson(raw) - return ( - -
    -          {outputText}
    -        
    -
    - ) + // OutputWell so live function output matches the committed result + // surface — ANSI-aware, collapsed to 3 lines, loud truncation. + return } if (block.kind === 'web_search_call') { - const action = block.webSearchAction - const label = - action?.kind === 'search' - ? `Search: ${action.query ?? action.queries?.join(', ') ?? '…'}` - : action?.kind === 'open_page' - ? `Open: ${action.url ?? '?'}` - : action?.kind === 'find_in_page' - ? `Find "${action.pattern ?? '?'}" in ${action.url ?? '?'}` - : 'Web search' - return ( - -
    - 🌐 {label} - {block.status ? ( - - {block.status.replace(/_/g, ' ')} - - ) : null} -
    -
    - ) + // Same WebCard as the committed rollout-synthesized web_search row + // — the bespoke live chip this replaces was audit gap #3's poster + // child (nice live, generic committed). + return } if (block.kind === 'image_generation_call') { - const img = block.imageGeneration - return ( - -
    -
    - 🖼 Image generation - - {img?.status ?? block.status ?? 'running'} - -
    - {img?.revisedPrompt ? ( - -
    - {img.revisedPrompt} -
    -
    - ) : null} -
    -
    - ) + return } if (block.kind === 'local_shell_call') { - const shell = block.localShellCall - const command = shell?.command.join(' ') ?? '(no command)' - return ( - -
    -
    - $ Shell - - {shell?.status ?? block.status ?? 'running'} - -
    - -
    -              {command}
    -            
    -
    -
    -
    - ) + // Same CommandCard as committed local_shell rows — the bespoke + // "$ Shell" chip this replaces was live-only and drifted from the + // committed rendering (audit gap #3). + return } if (block.kind === 'tool_search_call') { - const label = block.toolName ?? 'Tool search' - return ( - -
    - 🔎 {label} - {block.status ? ( - - {block.status.replace(/_/g, ' ')} - - ) : null} -
    -
    - ) + return } - // AskUserQuestion gets a dedicated native picker BEFORE the generic - // tool_use handler. An unresolved AskUserQuestion block (`!resultAt`) - // is a LIVE picker blocking the agent on user input; rendering it as - // the usual "AskUserQuestion · running" tool row (with a raw-JSON - // input dump) left the user no way to answer except via the terminal. - // The guard mirrors BlockRow's route-in condition exactly: once the - // tool_result lands and sets `resultAt`, we fall through to the normal - // tool_use branch so the answered question renders as a plain - // committed-style row instead of a stale clickable picker. if (block.toolName === 'AskUserQuestion' && !block.resultAt) { return } @@ -422,203 +200,82 @@ export const SemanticLiveBlockRow = memo(function SemanticLiveBlockRow({ // are one unit of work. Nesting the result here preserves that // mental model during live streaming and avoids another round of // "find the matching tool later in the feed" bookkeeping. - // [#285] Live Edit / MultiEdit reuse the COMMITTED renderers (rich - // FileToolHeader + line-level DiffSlab) instead of dumping raw JSON — the - // exact convergence Codex already does in the function_call branch above. - // We return the committed row directly (no live wrapper / status badge) so - // the streaming card is visually identical to its committed form, and the - // diff fills in as old_string/new_string stream. Until `file_path` has - // arrived, claudeLiveEditInput returns null and we fall through to the - // existing raw preview — never worse than before. + // Live Edit / MultiEdit — the SAME DiffCard the committed plane + // renders ([#285] convergence, now VM-driven). The path shows the + // moment its JSON literal closes; the diff fills in as old/new + // stream; until the path arrives the raw input streams in the card + // body — never worse than before. if (block.toolName === 'Edit' || block.toolName === 'MultiEdit') { - const liveEditInput = claudeLiveEditInput(block) - if (liveEditInput) { - const liveBlock: ToolUseBlock = { - type: 'tool_use', - id: - block.toolUseId ?? - block.callId ?? - block.itemId ?? - `live:${block.blockIndex}`, - name: block.toolName, - input: liveEditInput, - } - return block.toolName === 'Edit' ? ( - - ) : ( - - ) - } + return ( + + ) } - const todos = - block.toolName === 'TodoWrite' - ? parseSemanticTodos(block.parsedInput) - : [] - const hasResult = block.resultAt != null || block.resultContent != null + // Claude live Bash — same CommandCard as its committed row. The + // command string appears once its JSON literal closes (partial + // buffers keep the streaming placeholder); the git-intent widget is + // a committed-plane concern (detectGitIntent needs the full + // command; by the time a git card matters the committed row owns it). + if (block.toolName === 'Bash') { + return + } - // Live `Write` preview. While a Write tool_use streams, the only - // data we have is `block.inputJson` — partial, unparseable JSON. - // Dumping it raw means the user watches a 200-line file scroll by - // as one escaped JSON blob (`{"file_path":"…","content":"# …\n\n…`). - // `extractStreamingWriteInput` does a single linear scan of that - // buffer and pulls out the path + the in-flight content, decoded. - // When it yields a filePath we render the path + a plain code - // preview of the content as it arrives. - // - // This is a LIVE preview, deliberately NOT pixel-identical to the - // committed WriteRow that replaces it once the block finalizes: - // - the committed row uses `FileToolHeader` with a line count; - // the live row shows just the path on a `⎿` marker line. - // - the live preview passes `highlight={false}` (see below); - // the committed row is syntax-highlighted. - // So there IS a one-time visual change at the commit boundary — - // the header gains a line count and the code gains highlighting. - // The content text is identical across the transition; the goal - // here is "show the file taking shape", not a frozen final card. - // - // If the buffer doesn't match Write's expected shape the - // extractor returns nulls and we fall through to the raw
     —
    -    // never worse than the pre-feature behaviour.
    -    const writeStream =
    -      block.toolName === 'Write'
    -        ? extractStreamingWriteInput(block.inputJson ?? '')
    -        : null
    -    return (
    -      
    -        
    -
    - - {block.toolName ?? block.kind} - - {toolState ? ( - - {toolState.status === 'in_progress' - ? 'running' - : toolState.status === 'error' - ? 'failed' - : 'done'} - - ) : null} -
    - {block.toolName === 'TodoWrite' ? ( - - ) : writeStream && writeStream.filePath ? ( -
    - - - {writeStream.filePath} - - - {/* - `highlight={false}` is load-bearing for performance. - highlight.js re-highlights the WHOLE code string on - every change; this CodeBlock is fed a growing buffer - that re-renders on every `input_json_delta`, so - highlighting here would cost O(streamed bytes²) over - a long write. The plain preview is cheap; the - committed WriteRow does the one-shot highlight after - the stream ends. `codeId` is keyed by blockIndex so - the component stays mounted across the many delta - re-renders rather than remounting. - */} - -
    - ) : block.parsedInput && block.inputJsonValid !== false ? ( - // Parse-gated pretty params (residue plan P1). Partial JSON - // keeps the raw stream below — pretty-printing half a JSON - // string is worse than showing it verbatim. - -
    - - {Object.keys(block.parsedInput).length} param - {Object.keys(block.parsedInput).length === 1 ? '' : 's'} - -
    - -
    -
    -
    - ) : ( - -
    -                {block.inputJson || '(waiting for input…)'}
    -              
    -
    - )} - {block.parseError ? ( - -
    - invalid tool input: {block.parseError} -
    -
    - ) : null} - {hasResult ? ( - (() => { - const parsed = block.resultContent ? tryExtractJson(block.resultContent) : null - if (parsed !== null && typeof parsed === 'object') { - return - } - return ( - -
    -                    {block.resultContent || '(empty result)'}
    -                  
    -
    - ) - })() - ) : null} -
    -
    - ) + if (block.toolName === 'TodoWrite') { + return + } + + // Live Write — the SAME FileWriteCard as committed, and the point + // of the whole streaming exercise: the tool input is PARTIAL, + // unparseable JSON until the last delta, so fileWriteFromLive runs + // extractStreamingWriteInput's single-pass scanner to pull the + // file_path (once its string literal closes) and the JSON-unescaped + // content-so-far out of the incomplete buffer — the file visibly + // takes shape, highlighted line by line. Returns null until the + // path has closed; fall through to the generic card's raw preview + // until then — never a blank Write card. + if (block.toolName === 'Write') { + const writeVm = fileWriteFromLive(block, toolState, 'claude') + if (writeVm) return + } + // Live Read/Grep/Glob/LS with real output — the same ReadCard as + // committed. (Most live lookups collapse into the churn receipt via + // groupSemanticActivity; only finished-with-output ones reach here.) + if ( + block.toolName === 'Read' || + block.toolName === 'FileRead' || + block.toolName === 'Grep' || + block.toolName === 'Glob' || + block.toolName === 'LS' + ) { + return + } + + // Everything else — the SAME GenericToolCard the committed plane + // renders. This replaces the hand-rolled live card that dumped raw + // partial inputJson into a
     (audit finding 7).
    +    return 
       }
     
    +  // Streaming assistant text — prose AND code fences, open or closed.
    +  // SegmentedMarkdown owns the whole surface: the sealed prefix (through
    +  // the last CLOSED fence) parses once, the tail streams cheaply, and an
    +  // open fence in the tail paints highlighted line-by-line through
    +  // StreamingCodeBlock (which replaced the per-delta Monaco remount that
    +  // used to live right here — see kit/StreamingCodeBlock.tsx for the
    +  // full history). The old whole-message StreamingProse call re-parsed
    +  // the entire markdown AST on every delta: O(len²) per message.
       const text = block.text ?? ''
    -  const fence = text ? splitStreamingCodeFence(text) : null
    -  if (fence) {
    -    return (
    -      
    -        
    - {fence.prose ? : null} - -
    -
    - ) - } if (block.citations && block.citations.length > 0) { return (
    - {text ? : null} + {text ? ( + + ) : null}
    {block.citations.length} citation{block.citations.length === 1 ? '' : 's'}
    @@ -629,7 +286,7 @@ export const SemanticLiveBlockRow = memo(function SemanticLiveBlockRow({ return ( - + ) }) diff --git a/src/renderer/src/features/feed/ui/semantic/TodoList.tsx b/src/renderer/src/features/feed/ui/semantic/TodoList.tsx deleted file mode 100644 index ccf47c90..00000000 --- a/src/renderer/src/features/feed/ui/semantic/TodoList.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { memo } from 'react' - -import type { SemanticTodoItem } from '@renderer/session-runtime/state' - -// Inline todo-list rendering for the semantic-streaming TodoWrite -// tool row. Shows the list with status glyphs (☑ done, ◐ in-progress, -// ☐ pending) and a "N/M done" header. The `activeForm` override for -// in-progress todos matches upstream Claude's "I am doing X" live -// phrasing — the todo's permanent content is a planning-tense -// sentence ("Fix the login flow"), the activeForm is the live-tense -// progress sentence ("Fixing the login flow"), and the latter reads -// better while a turn is in-flight. -export const SemanticTodoList = memo(function SemanticTodoList({ - todos, -}: { - todos: SemanticTodoItem[] -}) { - const done = todos.filter(todo => todo.status === 'completed').length - return ( -
    -
    - TodoWrite - - {done} / {todos.length} done - -
    - {todos.length === 0 ? ( -
    (empty list)
    - ) : ( -
      - {todos.map((todo, index) => { - const glyph = - todo.status === 'completed' - ? '☑' - : todo.status === 'in_progress' - ? '◐' - : '☐' - const glyphCls = - todo.status === 'pending' ? 'text-muted' : 'text-accent' - const textCls = - todo.status === 'completed' - ? 'text-muted line-through' - : todo.status === 'in_progress' - ? 'text-ink' - : 'text-ink-dim' - const label = - todo.status === 'in_progress' && todo.activeForm - ? todo.activeForm - : todo.content - return ( -
    • - - - {label} - -
    • - ) - })} -
    - )} -
    - ) -}) diff --git a/src/renderer/src/features/feed/ui/semantic/index.ts b/src/renderer/src/features/feed/ui/semantic/index.ts index bc38bb9d..4dd49e5e 100644 --- a/src/renderer/src/features/feed/ui/semantic/index.ts +++ b/src/renderer/src/features/feed/ui/semantic/index.ts @@ -9,6 +9,5 @@ export { SemanticLiveBlockRow } from '@renderer/features/feed/ui/semantic/BlockRow' export { SemanticCollapsedActivityRow } from '@renderer/features/feed/ui/semantic/CollapsedActivityRow' -export { SemanticTodoList } from '@renderer/features/feed/ui/semantic/TodoList' export { buildSemanticRenderUnits } from '@renderer/features/feed/ui/semantic/renderUnits' export type { SemanticRenderUnit } from '@renderer/features/feed/ui/semantic/types' diff --git a/src/shared/types/providerConfig.ts b/src/shared/types/providerConfig.ts index 80817cc0..ad8373db 100644 --- a/src/shared/types/providerConfig.ts +++ b/src/shared/types/providerConfig.ts @@ -221,7 +221,6 @@ export type RendererProviderConfig = { * it impossible for Block.tsx to distinguish "fall back" from "intentionally * consumed". */ - renderToolUse?: (block: ToolUseBlock) => ReactNode | undefined renderToolResult?: ( block: ToolResultBlock, context: { sourceTool?: ToolUseBlock | null },