-
- {label()}
-
+
+ {label()}
+
+ }
+ >
+
+
{(d) => }
diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/docket.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/docket.ts
new file mode 100644
index 000000000..efcfc0939
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/ui/src/amicode/docket.ts
@@ -0,0 +1,102 @@
+import { editRowDiff, editRowFilePath } from "./edit-row"
+
+/* The docket — collapsed tool-group rows carry their EVIDENCE inline (Aaron
+ * 2026-09-04): file tokens with ±, pattern tokens with repeat counts, shell
+ * tallies. Pure and testable; the group components only render. A docket is a
+ * bounded list: the first `max` unique tokens plus a `more` count, so a
+ * hundred-file run still fits on one line. Parts are structural (tool name +
+ * state) — the same shape edit-row.ts accepts — so tests need no SDK types.
+ *
+ * Lives beside edit-row.ts (same import seam) and takes its basename inline
+ * (core's getFilename is a path.split("/").pop()) so the module stays free of
+ * cross-package imports — the extension vitest lane runs it headless. */
+
+/** Basename of a path (core's getFilename semantics: everything past the
+ * final slash — a trailing slash yields the empty string, as upstream). */
+function getFilename(path: string): string {
+ return path.split("/").pop() ?? path
+}
+
+export type DocketPart = {
+ tool: string
+ state: { status?: string; input?: Record; metadata?: Record }
+}
+
+export type DocketToken =
+ | { kind: "file"; name: string; dir?: string; additions?: number; deletions?: number }
+ | { kind: "pattern"; text: string; count: number }
+ | { kind: "dir"; text: string }
+
+export type Docket = { tokens: DocketToken[]; more: number }
+
+/** Cap a token list: keep the first `max`, report the rest as `more`. */
+function cap(tokens: DocketToken[], max: number): Docket {
+ if (tokens.length <= max) return { tokens, more: 0 }
+ return { tokens: tokens.slice(0, max), more: tokens.length - max }
+}
+
+/** Edited-files docket: one token per unique target path, ± summed across
+ * repeat edits of the same file. Parts without a path (pending) skip. */
+export function editDocket(parts: DocketPart[], max = 3): Docket {
+ const byPath = new Map()
+ for (const part of parts) {
+ const path = editRowFilePath(part)
+ if (!path) continue
+ const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined
+ const diff = editRowDiff(part)
+ const entry = byPath.get(path) ?? { dir: dir ?? "", additions: 0, deletions: 0 }
+ entry.additions += diff?.additions ?? 0
+ entry.deletions += diff?.deletions ?? 0
+ byPath.set(path, entry)
+ }
+ const tokens: DocketToken[] = [...byPath.entries()].map(([path, d]) => ({
+ kind: "file",
+ name: getFilename(path),
+ dir: d.dir || undefined,
+ additions: d.additions || undefined,
+ deletions: d.deletions || undefined,
+ }))
+ return cap(tokens, max)
+}
+
+/** Explored docket: reads surface as file tokens, searches as their pattern
+ * (repeat searches of one pattern merge with a count), lists as directory
+ * tokens. Order follows the run. */
+export function contextDocket(parts: DocketPart[], max = 3): Docket {
+ const tokens: DocketToken[] = []
+ const seen = new Map()
+ const push = (key: string, token: DocketToken) => {
+ if (seen.has(key)) {
+ const existing = seen.get(key)!
+ if (existing.kind === "pattern") existing.count++
+ return
+ }
+ seen.set(key, token)
+ tokens.push(token)
+ }
+ for (const part of parts) {
+ const input = part.state.input ?? {}
+ const filePath = typeof input.filePath === "string" ? input.filePath : undefined
+ const path = typeof input.path === "string" ? input.path : "/"
+ const pattern = typeof input.pattern === "string" ? input.pattern : undefined
+ switch (part.tool) {
+ case "read":
+ if (filePath) push(filePath, { kind: "file", name: getFilename(filePath) })
+ break
+ case "glob":
+ case "grep":
+ if (pattern) push(pattern, { kind: "pattern", text: pattern, count: 1 })
+ break
+ case "list":
+ push(path, { kind: "dir", text: path })
+ break
+ }
+ }
+ return cap(tokens, max)
+}
+
+/** Worked-in-shell docket: a tally, plus the failure count when it isn't zero. */
+export function shellDocket(parts: DocketPart[]): { commands: number; failed: number } {
+ const failed = parts.filter((part) => part.state.status === "error").length
+ return { commands: parts.length, failed }
+}
diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts
index bfb383c32..e9df968e0 100644
--- a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { clampShellLabel, shellRowLabel, SHELL_ROW_MAX } from "./shell-row"
+import { clampShellLabel, shellRowDetail, shellRowLabel, SHELL_ROW_MAX } from "./shell-row"
// Regression coverage for "a constant error not going away" (2026-07-29).
//
@@ -58,3 +58,27 @@ describe("shell row label", () => {
expect(clampShellLabel("'Install the dependencies'")).toBe("Install the dependencies")
})
})
+
+describe("shellRowDetail", () => {
+ test("exit surfaces only when it isn't 0", () => {
+ expect(shellRowDetail({ state: { metadata: { exit: 0 } } }).exit).toBeUndefined()
+ expect(shellRowDetail({ state: { metadata: { exit: 64 } } }).exit).toBe(64)
+ })
+
+ test("duration comes from state.time when both ends exist", () => {
+ expect(
+ shellRowDetail({ state: { time: { start: 1000, end: 4250 }, metadata: {} } }).durationMs,
+ ).toBe(3250)
+ expect(shellRowDetail({ state: { time: { start: 1000 }, metadata: {} } }).durationMs).toBeUndefined()
+ })
+
+ test("output preview is the clamped first line", () => {
+ const detail = shellRowDetail({ state: { metadata: { output: "line one\nline two" } } })
+ expect(detail.preview).toBe("line one")
+ expect(shellRowDetail({ state: { metadata: { output: "x".repeat(SHELL_ROW_MAX + 10) } } }).preview?.endsWith("…")).toBe(true)
+ })
+
+ test("a pending part yields an empty detail", () => {
+ expect(shellRowDetail({})).toEqual({})
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts
index 400bfed9b..8b4cde4f0 100644
--- a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts
@@ -22,9 +22,37 @@ export interface ShellRowPartLike {
state?: {
input?: Record
title?: unknown
+ metadata?: Record
+ time?: { start?: number; end?: number }
}
}
+export interface ShellRowDetail {
+ exit?: number
+ durationMs?: number
+ preview?: string
+}
+
+/** The row's evidence beyond the command itself (Aaron 2026-09-05): exit code
+ * when it isn't 0, wall duration from state.time, and the tool's own output
+ * preview (first line only — the row is a glance, not a terminal). Everything
+ * optional; a pending part yields an empty detail. */
+export function shellRowDetail(part: ShellRowPartLike): ShellRowDetail {
+ const detail: ShellRowDetail = {}
+ const metadata = part.state?.metadata ?? {}
+ const exit = metadata.exit
+ if (typeof exit === "number" && exit !== 0) detail.exit = exit
+ const { start, end } = part.state?.time ?? {}
+ if (typeof start === "number" && typeof end === "number" && end >= start) {
+ detail.durationMs = end - start
+ }
+ const output = metadata.output
+ if (typeof output === "string" && output.trim() !== "") {
+ detail.preview = clampShellLabel(output, SHELL_ROW_MAX)
+ }
+ return detail
+}
+
/** Trim, take the first line, drop wrapping quotes, and elide past SHELL_ROW_MAX. */
export function clampShellLabel(raw: string, max: number = SHELL_ROW_MAX): string {
let s = raw.split("\n")[0]!.trim()
diff --git a/packages/app-bundle/overlay/packages/ui/src/components/amicode-docket.tsx b/packages/app-bundle/overlay/packages/ui/src/components/amicode-docket.tsx
new file mode 100644
index 000000000..45b6c663b
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/ui/src/components/amicode-docket.tsx
@@ -0,0 +1,3 @@
+// AMICODE: re-export shim (same wildcard-export pattern as amicode-card.tsx).
+// Logic lives in ../amicode/docket.ts.
+export { contextDocket, editDocket, shellDocket, type DocketPart, type DocketToken, type Docket } from "../amicode/docket"
diff --git a/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx b/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx
index 2750a3dd2..fc06a303e 100644
--- a/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx
+++ b/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx
@@ -1,3 +1,3 @@
// AMICODE: re-export shim (same wildcard-export pattern as amicode-card.tsx).
// Logic lives in ../amicode/shell-row.ts.
-export { shellRowLabel } from "../amicode/shell-row"
+export { shellRowDetail, shellRowLabel } from "../amicode/shell-row"
diff --git a/packages/extension/test/yellow_chunks_853.test.ts b/packages/extension/test/yellow_chunks_853.test.ts
new file mode 100644
index 000000000..ff3c4910f
--- /dev/null
+++ b/packages/extension/test/yellow_chunks_853.test.ts
@@ -0,0 +1,157 @@
+// Issue #853 — the yellow-chunks port (fork amico/yellow-chunks-on-21 → the
+// app overlay). Headless under vitest (the #848/#859/#862 pattern): the pure
+// helpers the port added — the docket token builders (6dac9ce04) and the
+// shell row detail (85d3eaf04) — plus CSS-grammar regression guards for the
+// visual grammar the port establishes (the #349 deletion bug class: the slab,
+// the docket slots, the yellow answer chip, the per-scheme prompt-bubble
+// seating, the shell command anatomy).
+import { describe, expect, test } from "vitest"
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
+import { contextDocket, editDocket, shellDocket, type DocketPart } from "../../app-bundle/overlay/packages/ui/src/amicode/docket"
+import { shellRowDetail, SHELL_ROW_MAX } from "../../app-bundle/overlay/packages/ui/src/amicode/shell-row"
+
+const overlay = (...p: string[]) => join(__dirname, "..", "..", "app-bundle", "overlay", ...p)
+
+function part(tool: string, input: Record = {}, status = "done", metadata: Record = {}): DocketPart {
+ return { tool, state: { status, input, metadata } }
+}
+
+function edit(filePath: string, additions = 0, deletions = 0): DocketPart {
+ return part("edit", { filePath }, "done", { filediff: { additions, deletions } })
+}
+
+describe("editDocket (fork 6dac9ce04, ported)", () => {
+ test("one token per unique file, ± summed across repeat edits", () => {
+ const docket = editDocket([edit("/a/b/solve.jl", 5, 1), edit("/a/b/solve.jl", 7, 2), edit("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/x/spec.toml", 4, 0)])
+ expect(docket.more).toBe(0)
+ expect(docket.tokens).toEqual([
+ { kind: "file", name: "solve.jl", dir: "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/a/b", additions: 12, deletions: 3 },
+ { kind: "file", name: "spec.toml", dir: "/x", additions: 4, deletions: undefined },
+ ])
+ })
+
+ test("caps at max and reports the rest", () => {
+ const docket = editDocket(["a.ts", "b.ts", "c.ts", "d.ts"].map((f) => edit(`/p/${f}`)), 2)
+ expect(docket.tokens).toHaveLength(2)
+ expect(docket.more).toBe(2)
+ })
+
+ test("pending parts without a path contribute nothing", () => {
+ expect(editDocket([part("edit")])).toEqual({ tokens: [], more: 0 })
+ })
+
+ test("a file edited with zero recorded diff still appears, without ±", () => {
+ const docket = editDocket([part("edit", { filePath: "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/q/bare.jl" })])
+ expect(docket.tokens).toEqual([{ kind: "file", name: "bare.jl", dir: "/q", additions: undefined, deletions: undefined }])
+ })
+})
+
+describe("contextDocket (fork 6dac9ce04, ported)", () => {
+ test("reads become file tokens, searches merge patterns, lists become dirs", () => {
+ const parts = [
+ part("read", { filePath: "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/a/one.jl" }),
+ part("grep", { pattern: "fidelity", path: "/a" }),
+ part("grep", { pattern: "fidelity", path: "/a" }),
+ part("list", { path: "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/a/src" }),
+ ]
+ expect(contextDocket(parts)).toEqual({
+ tokens: [
+ { kind: "file", name: "one.jl" },
+ { kind: "pattern", text: "fidelity", count: 2 },
+ { kind: "dir", text: "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/a/src" },
+ ],
+ more: 0,
+ })
+ })
+
+ test("caps and counts the tail", () => {
+ const docket = contextDocket(["x", "y", "z", "w"].map((p) => part("read", { filePath: `/${p}.jl` })), 2)
+ expect(docket.tokens).toHaveLength(2)
+ expect(docket.more).toBe(2)
+ })
+})
+
+describe("shellDocket (fork 6dac9ce04, ported)", () => {
+ test("counts commands and failures", () => {
+ const parts = [part("bash"), part("bash"), part("bash", {}, "error")]
+ expect(shellDocket(parts)).toEqual({ commands: 3, failed: 1 })
+ })
+})
+
+describe("shellRowDetail (fork 85d3eaf04, ported)", () => {
+ test("exit surfaces only when it isn't 0", () => {
+ expect(shellRowDetail({ state: { metadata: { exit: 0 } } }).exit).toBeUndefined()
+ expect(shellRowDetail({ state: { metadata: { exit: 64 } } }).exit).toBe(64)
+ })
+
+ test("duration comes from state.time when both ends exist", () => {
+ expect(
+ shellRowDetail({ state: { time: { start: 1000, end: 4250 }, metadata: {} } }).durationMs,
+ ).toBe(3250)
+ expect(shellRowDetail({ state: { time: { start: 1000 }, metadata: {} } }).durationMs).toBeUndefined()
+ })
+
+ test("output preview is the clamped first line", () => {
+ const detail = shellRowDetail({ state: { metadata: { output: "line one\nline two" } } })
+ expect(detail.preview).toBe("line one")
+ expect(shellRowDetail({ state: { metadata: { output: "x".repeat(SHELL_ROW_MAX + 10) } } }).preview?.endsWith("…")).toBe(true)
+ })
+
+ test("a pending part yields an empty detail", () => {
+ expect(shellRowDetail({})).toEqual({})
+ })
+})
+
+describe("yellow-chunks CSS grammar (the #349 deletion bug class)", () => {
+ const messageCss = () => readFileSync(overlay("packages", "session-ui", "src", "components", "message-part.css"), "utf8")
+ const markdownCss = () => readFileSync(overlay("packages", "session-ui", "src", "components", "markdown.css"), "utf8")
+ const polishCss = () => readFileSync(overlay("packages", "app", "src", "design-polish.css"), "utf8")
+
+ test("the answer renders as the prompt bubble's chip (fork 8c91b45fa)", () => {
+ const css = messageCss()
+ expect(css).toMatch(/answer-text[^}]*--prompt-bubble-bg/s)
+ expect(css).toMatch(/answer-text[^}]*margin-left:\s*auto/s)
+ })
+
+ test("the docket slots carry the collapsed rows' evidence (fork 6dac9ce04)", () => {
+ const css = messageCss()
+ expect(css).toContain('[data-slot="context-tool-group-docket"]')
+ expect(css).toContain('[data-slot="docket-token"] .docket-file-icon')
+ expect(css).toContain('[data-slot="docket-token"][data-kind="more"]')
+ expect(css).toMatch(/docket-diff[^}]*data-sign="add"/s)
+ expect(css).toContain('[data-slot="docket-failed"]')
+ })
+
+ test("the slab: fenced code leaves the prose-card grammar (fork 1bd3ae7d7)", () => {
+ const css = markdownCss()
+ expect(css).toContain("--markdown-slab-bg")
+ expect(css).toMatch(/\[data-prose-fragment\] \[data-component="markdown-code"\][^}]*margin:\s*2px -14px/s)
+ expect(css).toMatch(/\[data-prose-fragment\] \[data-component="markdown-code"\]::before[^}]*attr\(data-language\)/s)
+ expect(css).toMatch(/\[data-prose-fragment\] \[data-component="markdown-code"\]\[data-code-kind="shell"\] \.shiki[^}]*background:\s*transparent/s)
+ })
+
+ test("the prompt bubble seats per scheme — yellow chip on light, dark box + full yellow border on dark (fork dc116cd6c + 99cb3f93d)", () => {
+ const css = polishCss()
+ expect(css).toContain("--prompt-bubble-edge")
+ expect(css).toMatch(/\[data-color-scheme="dark"\] \{\s*--prompt-bubble-bg: var\(--v2-background-bg-layer-01\);\s*--prompt-bubble-ink: var\(--v2-text-text-base\);\s*--prompt-bubble-edge: var\(--accent\);/s)
+ })
+
+ test("the assistant fragments stay on the theme hairline in both schemes (fork 1e2e44b66 net)", () => {
+ const css = polishCss()
+ expect(css).toMatch(/\[data-prose-fragment\] \{[^}]*border: var\(--border-width\) solid var\(--v2-border-border-base\);/s)
+ expect(css).not.toContain("--prose-fragment-edge")
+ })
+
+ test("the shell command anatomy slots exist (fork 85d3eaf04)", () => {
+ const css = messageCss()
+ for (const slot of ["cmd-dot", "cmd-prompt", "cmd-duration", "cmd-exit", "cmd-preview", "docket-row-file"]) {
+ expect(css).toContain(`[data-slot="${slot}"]`)
+ }
+ expect(css).toMatch(/cmd-dot-pulse/)
+ })
+
+ test("the WAAPI merge note records the bubble-lock decision (fork 8c91b45fa)", () => {
+ expect(polishCss()).toContain("WAAPI merge")
+ })
+})