From aa13be86c99ed0f7e7e22942ed19505ffe177a12 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Fri, 4 Sep 2026 23:10:34 +0800 Subject: [PATCH] Show the answering model in the inspector and mark model switches on the strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TranscriptMessage now carries providerID/modelID (it was already on OpenCode's own message, just never copied over), so the inspector shows a Model line for assistant turns and steps, not only branch headers. The Model lane's turn rule also thickens to a heavier glyph at the turn where the model actually switched, since colour there is already spoken for by kind (text/reasoning). Also fixes two `y`-copy papercuts found while testing this: a branch/separator row silently did nothing instead of saying there's nothing to copy, and OSC 52 "success" (which has no ack from the terminal) is no longer trusted alone — copyText always writes the local fallback file too, so a paste that silently didn't land still has one reliable place to read it from. --- CHANGELOG.md | 22 ++++++++++++++++++++++ DESIGN.md | 9 +++++++++ src/core/lanes.ts | 25 ++++++++++++++++++++++--- src/core/transcript.ts | 3 +++ src/tui/actions.ts | 11 +++++++---- src/tui/route.tsx | 32 +++++++++++++++++++++++++------- src/tui/transcripts.ts | 3 ++- test/fixtures/tree.ts | 7 +++++-- test/lanes.test.ts | 36 ++++++++++++++++++++++++++++++++++++ test/transcripts.test.ts | 22 +++++++++++++++++++++- 10 files changed, 152 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c11bc13..1df3e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.2.5 + +- **The model that answered is no longer invisible.** `TranscriptMessage` now carries the + assistant's `providerID`/`modelID` (it was already on OpenCode's own message, just never + copied over), so the inspector shows a `Model` line for assistant turns and steps, not only + for branch headers. The Model lane's turn rule also thickens (`┃` instead of `│`) at the turn + where the answering model actually switches — colour there was already spoken for by + kind (text/reasoning), so a switch gets a shape change on the rule rather than a competing + colour. + +- **Fixed:** `y` silently did nothing on a `⎇` branch row or a separator — there's no message + there to copy, but it gave no feedback either, which read as "copy is broken" rather than + "nothing here to copy." It now notifies either way. + +- **Fixed: `y` on a large payload could report success while the terminal quietly dropped it.** + OSC 52 (the escape-sequence clipboard `y` used) has no ack from the terminal — a `true` from + `@opentui` only means the sequence was written, not that the terminal actually applied it, and + some terminals silently truncate or drop payloads past their own size cap. `copyText` now + always writes `.opencode/context-tree/last-copy.txt` too, clipboard hit or not, so a paste + that silently didn't land still has one reliable place to read it from — the toast says so + when the target was the clipboard. + ## 0.2.4 — 2026-09-04 - Consumers (`s`) counts the **system prompt**. It walked the transcript only, so its total diff --git a/DESIGN.md b/DESIGN.md index 31f6995..47d4cde 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -504,6 +504,15 @@ records as user messages, and can use the headless `/ctree` commands. > cells would merge and the pills would stop being countable events. When the layout overflows, > a one-line overview track under the lanes shows the window's position and red ticks at failed > tool calls, so global orientation survives without giving up pill fidelity. +> +> **Model switches (0.2.5).** Colour on the Model lane is categorical by *kind* (text vs. +> reasoning), not by which model answered — recoloring per model would compete with that and +> with the error/warning colours other lanes already use. So a mid-session model change (an +> explicit switch, not a branch's fixed `--model`) is marked structurally instead: the turn +> rule on the Model lane thickens to `┃` at the turn where the answering model differs from the +> last turn that had one. The inspector's `Model` line (now populated for assistant turns and +> steps, from the message's own `providerID`/`modelID`) is the way to confirm which model that +> actually was. **On the DSH comparison.** The three-lane split is ours. DSH's own `ui-trajectory` README describes a *single* combined Overview ("A fixed Overview above the ledger projects real record diff --git a/src/core/lanes.ts b/src/core/lanes.ts index cdc89a8..d7a2535 100644 --- a/src/core/lanes.ts +++ b/src/core/lanes.ts @@ -77,6 +77,8 @@ export type LaneEvent = { durationMs?: number error?: boolean tokens: number + /** `providerID/modelID` of the assistant message this event belongs to (model-lane events only). */ + model?: string } /** One terminal cell of one lane. */ @@ -121,7 +123,8 @@ function partEvent(message: TranscriptMessage, part: StepPart, turn: number): La } if (part.type === "tool") return { ...base, lane: "tools", kind: "tool", error: part.state?.status === "error", tokens: estimateTokens(part.state?.output ?? "") + estimateTokens(JSON.stringify(part.state?.input ?? "")) } - return { ...base, lane: "model", kind: part.type === "reasoning" ? "reasoning" : "text", tokens: estimateTokens(part.text ?? "") } + const model = message.model ? `${message.model.providerID}/${message.model.modelID}` : undefined + return { ...base, lane: "model", kind: part.type === "reasoning" ? "reasoning" : "text", tokens: estimateTokens(part.text ?? ""), model } } /** @@ -205,6 +208,10 @@ export type EventLayout = { empty: Record /** cells carrying a turn rule, drawn across every lane */ rules: number[] + /** subset of `rules` where the Model lane's answering model differs from the previous turn + * that had one — the strip's only cue that a switch happened (DESIGN.md §7.1 keeps colour + * categorical by lane, not per-model, so this rides the existing turn-rule machinery). */ + modelChanges: number[] } /** @@ -215,12 +222,24 @@ export type EventLayout = { export function layoutEventStrip(transcript: Transcript, mode: LaneMode, filter: Filter = "all"): EventLayout { const events = eventsOf(transcript, filter) const widths = mode === "duration" ? durationWidths(events, events.length * DURATION_CELLS) : events.map(() => 1) + // one representative model per turn (its first Model-lane event that has one), so a turn with + // no text/reasoning (tool-only, or a filter that hid it) carries no opinion either way + const turnModel = new Map() + for (const e of events) if (e.lane === "model" && e.model && !turnModel.has(e.turn)) turnModel.set(e.turn, e.model) + let lastModel = turnModel.get(events[0]?.turn ?? -1) const spans: { start: number; end: number }[] = [] const rules: number[] = [] + const modelChanges: number[] = [] let cursor = 0 events.forEach((_, i) => { const boundary = isTurnBoundary(events, i) - if (boundary) rules.push(cursor + 1) // centred in the wider gap it opens + if (boundary) { + const cell = cursor + 1 + rules.push(cell) // centred in the wider gap it opens + const model = turnModel.get(events[i]!.turn) + if (model && lastModel && model !== lastModel) modelChanges.push(cell) + if (model) lastModel = model + } const start = cursor + (i === 0 ? 0 : boundary ? TURN_RULE_GAP : 1) cursor = start + (widths[i] ?? 1) spans.push({ start, end: cursor }) @@ -232,7 +251,7 @@ export function layoutEventStrip(transcript: Transcript, mode: LaneMode, filter: for (let c = spans[i]!.start; c < spans[i]!.end; c++) lanes[e.lane][c] = cell }) const has = (lane: LaneEvent["lane"]) => !events.some((e) => e.lane === lane) - return { events, spans, totalWidth: cursor, lanes, empty: { input: has("input"), model: has("model"), tools: has("tools") }, rules } + return { events, spans, totalWidth: cursor, lanes, empty: { input: has("input"), model: has("model"), tools: has("tools") }, rules, modelChanges } } /** diff --git a/src/core/transcript.ts b/src/core/transcript.ts index bbe33a8..715616f 100644 --- a/src/core/transcript.ts +++ b/src/core/transcript.ts @@ -31,6 +31,9 @@ export type TranscriptMessage = { /** OpenCode-native compaction summary marker (not the ctree "jump summary", which is a * regular user message tagged via `metadata.ctree.kind === "summary"` instead). */ summary?: boolean + /** The model that answered (assistant messages only) — for the inspector and the strip's + * model-change marker. */ + model?: { providerID: string; modelID: string } parts: StepPart[] } diff --git a/src/tui/actions.ts b/src/tui/actions.ts index 83bde25..81649cc 100644 --- a/src/tui/actions.ts +++ b/src/tui/actions.ts @@ -713,14 +713,17 @@ export const BRANCH_DIALOG = { title: "Branch here → new OpenCode session", pl export const COPY_HINT = ".opencode/context-tree/last-copy.txt" /** `y` copy: the terminal's own clipboard through @opentui's OSC 52 (works over ssh/tmux when - * the terminal allows it), falling back to `COPY_HINT`. Throws if that file cannot be written. */ + * the terminal allows it) — but OSC 52 has no ack from the terminal, so a `true` here only + * means the escape sequence was written, not that the terminal actually applied it (some + * silently drop or truncate large payloads with no visible sign). `COPY_HINT` is therefore + * always written too, whether or not OSC 52 reports success, so there's one place that's + * reliably the last thing you copied. Throws if that file cannot be written. */ export function copyText(api: TuiPluginApi, text: string, directory: string): { target: "clipboard" | "file"; hint: string } { - const renderer = api.renderer as unknown as { copyToClipboardOSC52?: (text: string) => boolean } | undefined - // an empty selection must never wipe the user's clipboard; it still lands in the file - if (text && renderer?.copyToClipboardOSC52?.(text)) return { target: "clipboard", hint: "clipboard" } const file = path.join(directory, COPY_HINT) fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, text) + const renderer = api.renderer as unknown as { copyToClipboardOSC52?: (text: string) => boolean } | undefined + if (text && renderer?.copyToClipboardOSC52?.(text)) return { target: "clipboard", hint: "clipboard" } return { target: "file", hint: COPY_HINT } } diff --git a/src/tui/route.tsx b/src/tui/route.tsx index 3bd79a9..698f4e7 100644 --- a/src/tui/route.tsx +++ b/src/tui/route.tsx @@ -14,7 +14,7 @@ import { buildSpineMap, buildTreeView, currentChainOf, formatPromptAt, promptAtR import { ContextGauge } from "./gauge.js" import type { Transcript } from "../core/transcript.js" import type { JournalStore } from "../shared/store.js" -import { applyCrop, branchLabel, BRANCH_DIALOG, clip as clipTo, copyText, createNamedBranch, describeTail, executeJump, executeUndo, jumpDialogOptions, jumpDialogTitle, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergePickerFigures, MERGE_TRUST, setLabel, UNDO_KEY, type ActionContext, type MergeMode, type SummaryChoice } from "./actions.js" +import { applyCrop, branchLabel, BRANCH_DIALOG, clip as clipTo, COPY_HINT, copyText, createNamedBranch, describeTail, executeJump, executeUndo, jumpDialogOptions, jumpDialogTitle, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergePickerFigures, MERGE_TRUST, setLabel, UNDO_KEY, type ActionContext, type MergeMode, type SummaryChoice } from "./actions.js" import { decisionSummary, exportDecisions, renderDecision } from "../core/decision.js" import { laneLabel, laneSuffix, layoutEventStrip, overviewTrack, stripIndexFor, windowFor, LANE_CHROME, type LaneMode, type StripCell } from "../core/lanes.js" import { bar, consumers, type Consumer, type ConsumerEntry } from "../core/consumers.js" @@ -634,16 +634,20 @@ export function TreeRoute(props: TreeRouteProps) { const start = laneOffset() const w = laneWidth() const rules = new Set(layout().rules) + const modelChanges = new Set(layout().modelChanges) const runs: { text: string; fg: unknown; bg: unknown }[] = [] for (let c = 0; c < w; c++) { const cell = layout().lanes[lane][start + c] ?? null const sel = cell !== null && cur.has(cell.eventIndex) - const color = cell === null ? t.textMuted : cellColor(cell) + const changedHere = lane === "model" && modelChanges.has(start + c) + const color = cell === null ? (changedHere ? t.primary : t.textMuted) : cellColor(cell) const fg = sel ? t.background : color const bg = sel ? color : undefined // a turn boundary is a rule across all three lanes, the way DSH marks turns on its - // Overview — it never lands on a pill, the gap that holds it is opened for it - const glyph = cell?.glyph ?? (rules.has(start + c) ? "│" : " ") + // Overview — it never lands on a pill, the gap that holds it is opened for it. On the + // Model lane that rule thickens where the answering model actually switched, since colour + // there is already spoken for by kind (text/reasoning), not identity. + const glyph = cell?.glyph ?? (changedHere ? "┃" : rules.has(start + c) ? "│" : " ") const last = runs[runs.length - 1] if (last && last.fg === fg && last.bg === bg) last.text += glyph else runs.push({ text: glyph, fg, bg }) @@ -741,6 +745,7 @@ export function TreeRoute(props: TreeRouteProps) { if (row.label) kv("Label", row.label) kv("Tokens", `~${formatK(row.tokens)}`) kv("At", msg ? new Date(msg.time.created).toISOString().slice(11, 19) : "?") + if (msg?.model) kv("Model", `${msg.model.providerID}/${msg.model.modelID}`) if (!row.inContext) muted("not in this branch's context") block("Text", text) return out @@ -749,6 +754,7 @@ export function TreeRoute(props: TreeRouteProps) { const stepNo = msg ? msg.parts.filter((p) => p.type === "tool" || p.type === "text").findIndex((p) => p.id === row.partID) + 1 : 0 head(`${row.glyph} ${part?.type === "tool" ? part.tool : row.glyph === "◇" ? "compaction" : "assistant"} · T${turn?.kind === "turn" ? turn.turn : "?"} · step ${stepNo}`) kv("Hierarchy", `T${turn?.kind === "turn" ? turn.turn : "?"} › assistant › step ${stepNo}`) + if (msg?.model) kv("Model", `${msg.model.providerID}/${msg.model.modelID}`) if (!row.inContext) muted("not in this branch's context") if (row.tokenFields) { const tf = row.tokenFields @@ -859,6 +865,11 @@ export function TreeRoute(props: TreeRouteProps) { notify(picks.length ? `marked ${picks.length} unprotected ${c.source} result${picks.length === 1 ? "" : "s"} — ⏎ to apply` : `every ${c.source} result is protected; mark with space (twice) to override`) } + /** OSC 52 clipboard "success" has no ack from the terminal, so `copyText` always writes + * `COPY_HINT` too — surface it even on a reported clipboard hit, since that's the only + * place a large paste that silently didn't land is still reliably sitting. */ + const copyNotice = (length: number, hint: string) => `copied ${length} chars → ${hint}${hint === "clipboard" ? ` (paste empty? it's also at ${COPY_HINT})` : ""}` + function copySelected() { // in the consumers panel `y` copies the selected entry — the only way to read a system // part in full, since it is not a message and has no row of its own @@ -867,17 +878,24 @@ export function TreeRoute(props: TreeRouteProps) { const idx = line?.bucket.kind === "system" ? line.entry?.systemIndex : undefined const part = idx === undefined ? undefined : systemParts()?.[idx] const text = part?.text ?? line?.entry?.preview ?? "" - if (!text) return + if (!text) { + notify("nothing to copy here") + return + } try { const { hint } = copyText(api, text, directory) - notify(`copied ${text.length} chars → ${hint}`) + notify(copyNotice(text.length, hint)) } catch (e) { notify(`copy failed: ${e instanceof Error ? e.message : String(e)}`) } return } const row = current() - if (!row || row.kind === "branch" || row.kind === "separator") return + if (!row) return + if (row.kind === "branch" || row.kind === "separator") { + notify(row.kind === "branch" ? "nothing to copy on a branch row — pick a turn or step" : "nothing to copy on a separator") + return + } const tr = row.sessionID === sessionID ? live() : others()[row.sessionID] const msg = tr?.messages.find((m) => m.id === row.messageID) const text = row.kind === "step" ? String(msg?.parts.find((p) => p.id === row.partID)?.state?.output ?? msg?.parts.find((p) => p.id === row.partID)?.text ?? "") : (msg?.parts.map((p) => p.text ?? "").join("\n") ?? "") diff --git a/src/tui/transcripts.ts b/src/tui/transcripts.ts index c1012ba..6165233 100644 --- a/src/tui/transcripts.ts +++ b/src/tui/transcripts.ts @@ -5,7 +5,7 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { StepPart, Transcript, TranscriptMessage } from "../core/transcript.js" -type AnyMessage = { id: string; role: string; time: { created: number; completed?: number }; tokens?: TranscriptMessage["tokens"]; summary?: unknown } +type AnyMessage = { id: string; role: string; time: { created: number; completed?: number }; tokens?: TranscriptMessage["tokens"]; summary?: unknown; providerID?: string; modelID?: string } type AnyPart = { id: string; type: string; text?: string; tool?: string; callID?: string; state?: StepPart["state"]; time?: StepPart["time"]; metadata?: Record } export function toStepPart(p: AnyPart): StepPart { @@ -19,6 +19,7 @@ export function toTranscriptMessage(m: AnyMessage, parts: readonly AnyPart[]): T time: m.time, tokens: m.tokens, summary: m.role === "assistant" && m.summary === true ? true : undefined, + model: m.role === "assistant" && m.providerID && m.modelID ? { providerID: m.providerID, modelID: m.modelID } : undefined, parts: parts.map(toStepPart), } } diff --git a/test/fixtures/tree.ts b/test/fixtures/tree.ts index aee1d91..888c6da 100644 --- a/test/fixtures/tree.ts +++ b/test/fixtures/tree.ts @@ -13,7 +13,10 @@ const tick = () => (t += 1000) export function user(id: string, text: string): TranscriptMessage { return { id, role: "user", time: { created: tick() }, parts: [{ id: `${id}-p0`, type: "text", text }] } } -export function assistant(id: string, opts: { text?: string; think?: { text?: string; ms?: number }; tool?: { name: string; input: unknown; output: string; ms?: number }; input?: number; output?: number }): TranscriptMessage { +export function assistant( + id: string, + opts: { text?: string; think?: { text?: string; ms?: number }; tool?: { name: string; input: unknown; output: string; ms?: number }; input?: number; output?: number; model?: { providerID: string; modelID: string } }, +): TranscriptMessage { const parts: TranscriptMessage["parts"] = [{ id: `${id}-ss`, type: "step-start" }] if (opts.think) { const start = tick() @@ -25,7 +28,7 @@ export function assistant(id: string, opts: { text?: string; think?: { text?: st } if (opts.text) parts.push({ id: `${id}-text`, type: "text", text: opts.text }) parts.push({ id: `${id}-sf`, type: "step-finish" }) - return { id, role: "assistant", time: { created: tick() }, tokens: { input: opts.input ?? 1000, output: opts.output ?? 50, reasoning: 0, cache: { read: 0, write: 0 } }, parts } + return { id, role: "assistant", time: { created: tick() }, tokens: { input: opts.input ?? 1000, output: opts.output ?? 50, reasoning: 0, cache: { read: 0, write: 0 } }, model: opts.model, parts } } /** Copy a prefix the way `session.fork` does: same content, fresh IDs. */ diff --git a/test/lanes.test.ts b/test/lanes.test.ts index fdf67c9..95ee3d7 100644 --- a/test/lanes.test.ts +++ b/test/lanes.test.ts @@ -108,6 +108,42 @@ describe("event strip", () => { } }) + test("modelChanges marks the boundary where the answering model actually switches", () => { + const sonnet = { providerID: "anthropic", modelID: "claude-sonnet-5" } + const opus = { providerID: "anthropic", modelID: "claude-opus-5" } + const three = T([ + user("u1", "a"), + assistant("a1", { text: "one", model: sonnet }), + user("u2", "b"), + assistant("a2", { text: "two", model: sonnet }), // same model: no mark + user("u3", "c"), + assistant("a3", { text: "three", model: opus }), // switched: mark + ]) + const l = layoutEventStrip(three, "turns") + expect(l.rules.length).toBe(2) + expect(l.modelChanges).toEqual([l.rules[1]]) + }) + + test("no model data at all: modelChanges stays empty, never guesses", () => { + const two = T([user("u1", "a"), assistant("a1", { text: "one" }), user("u2", "b"), assistant("a2", { text: "two" })]) + expect(layoutEventStrip(two, "turns").modelChanges).toEqual([]) + }) + + test("a tool-only turn between two models carries no opinion: the switch still shows on the next text turn", () => { + const sonnet = { providerID: "anthropic", modelID: "claude-sonnet-5" } + const opus = { providerID: "anthropic", modelID: "claude-opus-5" } + const three = T([ + user("u1", "a"), + assistant("a1", { text: "one", model: sonnet }), + user("u2", "b"), + assistant("a2", { tool: { name: "bash", input: { command: "ls" }, output: "out" } }), // no text/model + user("u3", "c"), + assistant("a3", { text: "three", model: opus }), + ]) + const l = layoutEventStrip(three, "turns") + expect(l.modelChanges).toEqual([l.rules[1]]) + }) + test("duration mode: widths follow durations, untimed events keep one cell", () => { const slow = assistant("a1", { tool: { name: "bash", input: { command: "slow" }, output: "x", ms: 8000 } }) const fast = assistant("a2", { tool: { name: "bash", input: { command: "fast" }, output: "x", ms: 1000 } }) diff --git a/test/transcripts.test.ts b/test/transcripts.test.ts index c3e79e9..c3db6e5 100644 --- a/test/transcripts.test.ts +++ b/test/transcripts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { mergeTranscripts } from "../src/tui/transcripts.js" +import { mergeTranscripts, toTranscriptMessage } from "../src/tui/transcripts.js" import type { Transcript, TranscriptMessage } from "../src/core/transcript.js" const msg = (id: string, text: string): TranscriptMessage => ({ @@ -11,6 +11,26 @@ const msg = (id: string, text: string): TranscriptMessage => ({ const tr = (messages: TranscriptMessage[]): Transcript => ({ sessionID: "s", title: "t", status: "available", messages }) +describe("toTranscriptMessage", () => { + const base = { id: "a1", role: "assistant", time: { created: 1 } } + + test("carries providerID/modelID as model on an assistant message", () => { + const m = toTranscriptMessage({ ...base, providerID: "anthropic", modelID: "claude-sonnet-5" }, []) + expect(m.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-5" }) + }) + + test("no model on a user message, even if the fields are somehow present", () => { + const m = toTranscriptMessage({ id: "u1", role: "user", time: { created: 1 }, providerID: "anthropic", modelID: "claude-sonnet-5" }, []) + expect(m.model).toBeUndefined() + }) + + test("missing either field: no model, not a half-filled one", () => { + expect(toTranscriptMessage({ ...base, providerID: "anthropic" }, []).model).toBeUndefined() + expect(toTranscriptMessage({ ...base, modelID: "claude-sonnet-5" }, []).model).toBeUndefined() + expect(toTranscriptMessage(base, []).model).toBeUndefined() + }) +}) + describe("mergeTranscripts", () => { const full = tr([msg("u1", "one"), msg("a2", "two"), msg("u3", "three")])