Skip to content

Commit 81b9ede

Browse files
authored
Show the answering model in the inspector and mark model switches on the strip (#8)
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.
1 parent 1e6cdba commit 81b9ede

10 files changed

Lines changed: 152 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## 0.2.5
4+
5+
- **The model that answered is no longer invisible.** `TranscriptMessage` now carries the
6+
assistant's `providerID`/`modelID` (it was already on OpenCode's own message, just never
7+
copied over), so the inspector shows a `Model` line for assistant turns and steps, not only
8+
for branch headers. The Model lane's turn rule also thickens (`┃` instead of `│`) at the turn
9+
where the answering model actually switches — colour there was already spoken for by
10+
kind (text/reasoning), so a switch gets a shape change on the rule rather than a competing
11+
colour.
12+
13+
- **Fixed:** `y` silently did nothing on a `⎇` branch row or a separator — there's no message
14+
there to copy, but it gave no feedback either, which read as "copy is broken" rather than
15+
"nothing here to copy." It now notifies either way.
16+
17+
- **Fixed: `y` on a large payload could report success while the terminal quietly dropped it.**
18+
OSC 52 (the escape-sequence clipboard `y` used) has no ack from the terminal — a `true` from
19+
`@opentui` only means the sequence was written, not that the terminal actually applied it, and
20+
some terminals silently truncate or drop payloads past their own size cap. `copyText` now
21+
always writes `.opencode/context-tree/last-copy.txt` too, clipboard hit or not, so a paste
22+
that silently didn't land still has one reliable place to read it from — the toast says so
23+
when the target was the clipboard.
24+
325
## 0.2.4 — 2026-09-04
426

527
- Consumers (`s`) counts the **system prompt**. It walked the transcript only, so its total

‎DESIGN.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,15 @@ records as user messages, and can use the headless `/ctree` commands.
504504
> cells would merge and the pills would stop being countable events. When the layout overflows,
505505
> a one-line overview track under the lanes shows the window's position and red ticks at failed
506506
> tool calls, so global orientation survives without giving up pill fidelity.
507+
>
508+
> **Model switches (0.2.5).** Colour on the Model lane is categorical by *kind* (text vs.
509+
> reasoning), not by which model answered — recoloring per model would compete with that and
510+
> with the error/warning colours other lanes already use. So a mid-session model change (an
511+
> explicit switch, not a branch's fixed `--model`) is marked structurally instead: the turn
512+
> rule on the Model lane thickens to `┃` at the turn where the answering model differs from the
513+
> last turn that had one. The inspector's `Model` line (now populated for assistant turns and
514+
> steps, from the message's own `providerID`/`modelID`) is the way to confirm which model that
515+
> actually was.
507516
508517
**On the DSH comparison.** The three-lane split is ours. DSH's own `ui-trajectory` README
509518
describes a *single* combined Overview ("A fixed Overview above the ledger projects real record

‎src/core/lanes.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export type LaneEvent = {
7777
durationMs?: number
7878
error?: boolean
7979
tokens: number
80+
/** `providerID/modelID` of the assistant message this event belongs to (model-lane events only). */
81+
model?: string
8082
}
8183

8284
/** One terminal cell of one lane. */
@@ -121,7 +123,8 @@ function partEvent(message: TranscriptMessage, part: StepPart, turn: number): La
121123
}
122124
if (part.type === "tool")
123125
return { ...base, lane: "tools", kind: "tool", error: part.state?.status === "error", tokens: estimateTokens(part.state?.output ?? "") + estimateTokens(JSON.stringify(part.state?.input ?? "")) }
124-
return { ...base, lane: "model", kind: part.type === "reasoning" ? "reasoning" : "text", tokens: estimateTokens(part.text ?? "") }
126+
const model = message.model ? `${message.model.providerID}/${message.model.modelID}` : undefined
127+
return { ...base, lane: "model", kind: part.type === "reasoning" ? "reasoning" : "text", tokens: estimateTokens(part.text ?? ""), model }
125128
}
126129

127130
/**
@@ -205,6 +208,10 @@ export type EventLayout = {
205208
empty: Record<LaneEvent["lane"], boolean>
206209
/** cells carrying a turn rule, drawn across every lane */
207210
rules: number[]
211+
/** subset of `rules` where the Model lane's answering model differs from the previous turn
212+
* that had one — the strip's only cue that a switch happened (DESIGN.md §7.1 keeps colour
213+
* categorical by lane, not per-model, so this rides the existing turn-rule machinery). */
214+
modelChanges: number[]
208215
}
209216

210217
/**
@@ -215,12 +222,24 @@ export type EventLayout = {
215222
export function layoutEventStrip(transcript: Transcript, mode: LaneMode, filter: Filter = "all"): EventLayout {
216223
const events = eventsOf(transcript, filter)
217224
const widths = mode === "duration" ? durationWidths(events, events.length * DURATION_CELLS) : events.map(() => 1)
225+
// one representative model per turn (its first Model-lane event that has one), so a turn with
226+
// no text/reasoning (tool-only, or a filter that hid it) carries no opinion either way
227+
const turnModel = new Map<number, string>()
228+
for (const e of events) if (e.lane === "model" && e.model && !turnModel.has(e.turn)) turnModel.set(e.turn, e.model)
229+
let lastModel = turnModel.get(events[0]?.turn ?? -1)
218230
const spans: { start: number; end: number }[] = []
219231
const rules: number[] = []
232+
const modelChanges: number[] = []
220233
let cursor = 0
221234
events.forEach((_, i) => {
222235
const boundary = isTurnBoundary(events, i)
223-
if (boundary) rules.push(cursor + 1) // centred in the wider gap it opens
236+
if (boundary) {
237+
const cell = cursor + 1
238+
rules.push(cell) // centred in the wider gap it opens
239+
const model = turnModel.get(events[i]!.turn)
240+
if (model && lastModel && model !== lastModel) modelChanges.push(cell)
241+
if (model) lastModel = model
242+
}
224243
const start = cursor + (i === 0 ? 0 : boundary ? TURN_RULE_GAP : 1)
225244
cursor = start + (widths[i] ?? 1)
226245
spans.push({ start, end: cursor })
@@ -232,7 +251,7 @@ export function layoutEventStrip(transcript: Transcript, mode: LaneMode, filter:
232251
for (let c = spans[i]!.start; c < spans[i]!.end; c++) lanes[e.lane][c] = cell
233252
})
234253
const has = (lane: LaneEvent["lane"]) => !events.some((e) => e.lane === lane)
235-
return { events, spans, totalWidth: cursor, lanes, empty: { input: has("input"), model: has("model"), tools: has("tools") }, rules }
254+
return { events, spans, totalWidth: cursor, lanes, empty: { input: has("input"), model: has("model"), tools: has("tools") }, rules, modelChanges }
236255
}
237256

238257
/**

‎src/core/transcript.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export type TranscriptMessage = {
3131
/** OpenCode-native compaction summary marker (not the ctree "jump summary", which is a
3232
* regular user message tagged via `metadata.ctree.kind === "summary"` instead). */
3333
summary?: boolean
34+
/** The model that answered (assistant messages only) — for the inspector and the strip's
35+
* model-change marker. */
36+
model?: { providerID: string; modelID: string }
3437
parts: StepPart[]
3538
}
3639

‎src/tui/actions.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -713,14 +713,17 @@ export const BRANCH_DIALOG = { title: "Branch here → new OpenCode session", pl
713713
export const COPY_HINT = ".opencode/context-tree/last-copy.txt"
714714

715715
/** `y` copy: the terminal's own clipboard through @opentui's OSC 52 (works over ssh/tmux when
716-
* the terminal allows it), falling back to `COPY_HINT`. Throws if that file cannot be written. */
716+
* the terminal allows it) — but OSC 52 has no ack from the terminal, so a `true` here only
717+
* means the escape sequence was written, not that the terminal actually applied it (some
718+
* silently drop or truncate large payloads with no visible sign). `COPY_HINT` is therefore
719+
* always written too, whether or not OSC 52 reports success, so there's one place that's
720+
* reliably the last thing you copied. Throws if that file cannot be written. */
717721
export function copyText(api: TuiPluginApi, text: string, directory: string): { target: "clipboard" | "file"; hint: string } {
718-
const renderer = api.renderer as unknown as { copyToClipboardOSC52?: (text: string) => boolean } | undefined
719-
// an empty selection must never wipe the user's clipboard; it still lands in the file
720-
if (text && renderer?.copyToClipboardOSC52?.(text)) return { target: "clipboard", hint: "clipboard" }
721722
const file = path.join(directory, COPY_HINT)
722723
fs.mkdirSync(path.dirname(file), { recursive: true })
723724
fs.writeFileSync(file, text)
725+
const renderer = api.renderer as unknown as { copyToClipboardOSC52?: (text: string) => boolean } | undefined
726+
if (text && renderer?.copyToClipboardOSC52?.(text)) return { target: "clipboard", hint: "clipboard" }
724727
return { target: "file", hint: COPY_HINT }
725728
}
726729

‎src/tui/route.tsx‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { buildSpineMap, buildTreeView, currentChainOf, formatPromptAt, promptAtR
1414
import { ContextGauge } from "./gauge.js"
1515
import type { Transcript } from "../core/transcript.js"
1616
import type { JournalStore } from "../shared/store.js"
17-
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"
17+
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"
1818
import { decisionSummary, exportDecisions, renderDecision } from "../core/decision.js"
1919
import { laneLabel, laneSuffix, layoutEventStrip, overviewTrack, stripIndexFor, windowFor, LANE_CHROME, type LaneMode, type StripCell } from "../core/lanes.js"
2020
import { bar, consumers, type Consumer, type ConsumerEntry } from "../core/consumers.js"
@@ -634,16 +634,20 @@ export function TreeRoute(props: TreeRouteProps) {
634634
const start = laneOffset()
635635
const w = laneWidth()
636636
const rules = new Set(layout().rules)
637+
const modelChanges = new Set(layout().modelChanges)
637638
const runs: { text: string; fg: unknown; bg: unknown }[] = []
638639
for (let c = 0; c < w; c++) {
639640
const cell = layout().lanes[lane][start + c] ?? null
640641
const sel = cell !== null && cur.has(cell.eventIndex)
641-
const color = cell === null ? t.textMuted : cellColor(cell)
642+
const changedHere = lane === "model" && modelChanges.has(start + c)
643+
const color = cell === null ? (changedHere ? t.primary : t.textMuted) : cellColor(cell)
642644
const fg = sel ? t.background : color
643645
const bg = sel ? color : undefined
644646
// a turn boundary is a rule across all three lanes, the way DSH marks turns on its
645-
// Overview — it never lands on a pill, the gap that holds it is opened for it
646-
const glyph = cell?.glyph ?? (rules.has(start + c) ? "│" : " ")
647+
// Overview — it never lands on a pill, the gap that holds it is opened for it. On the
648+
// Model lane that rule thickens where the answering model actually switched, since colour
649+
// there is already spoken for by kind (text/reasoning), not identity.
650+
const glyph = cell?.glyph ?? (changedHere ? "┃" : rules.has(start + c) ? "│" : " ")
647651
const last = runs[runs.length - 1]
648652
if (last && last.fg === fg && last.bg === bg) last.text += glyph
649653
else runs.push({ text: glyph, fg, bg })
@@ -741,6 +745,7 @@ export function TreeRoute(props: TreeRouteProps) {
741745
if (row.label) kv("Label", row.label)
742746
kv("Tokens", `~${formatK(row.tokens)}`)
743747
kv("At", msg ? new Date(msg.time.created).toISOString().slice(11, 19) : "?")
748+
if (msg?.model) kv("Model", `${msg.model.providerID}/${msg.model.modelID}`)
744749
if (!row.inContext) muted("not in this branch's context")
745750
block("Text", text)
746751
return out
@@ -749,6 +754,7 @@ export function TreeRoute(props: TreeRouteProps) {
749754
const stepNo = msg ? msg.parts.filter((p) => p.type === "tool" || p.type === "text").findIndex((p) => p.id === row.partID) + 1 : 0
750755
head(`${row.glyph} ${part?.type === "tool" ? part.tool : row.glyph === "◇" ? "compaction" : "assistant"} · T${turn?.kind === "turn" ? turn.turn : "?"} · step ${stepNo}`)
751756
kv("Hierarchy", `T${turn?.kind === "turn" ? turn.turn : "?"} › assistant › step ${stepNo}`)
757+
if (msg?.model) kv("Model", `${msg.model.providerID}/${msg.model.modelID}`)
752758
if (!row.inContext) muted("not in this branch's context")
753759
if (row.tokenFields) {
754760
const tf = row.tokenFields
@@ -859,6 +865,11 @@ export function TreeRoute(props: TreeRouteProps) {
859865
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`)
860866
}
861867

868+
/** OSC 52 clipboard "success" has no ack from the terminal, so `copyText` always writes
869+
* `COPY_HINT` too — surface it even on a reported clipboard hit, since that's the only
870+
* place a large paste that silently didn't land is still reliably sitting. */
871+
const copyNotice = (length: number, hint: string) => `copied ${length} chars → ${hint}${hint === "clipboard" ? ` (paste empty? it's also at ${COPY_HINT})` : ""}`
872+
862873
function copySelected() {
863874
// in the consumers panel `y` copies the selected entry — the only way to read a system
864875
// 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) {
867878
const idx = line?.bucket.kind === "system" ? line.entry?.systemIndex : undefined
868879
const part = idx === undefined ? undefined : systemParts()?.[idx]
869880
const text = part?.text ?? line?.entry?.preview ?? ""
870-
if (!text) return
881+
if (!text) {
882+
notify("nothing to copy here")
883+
return
884+
}
871885
try {
872886
const { hint } = copyText(api, text, directory)
873-
notify(`copied ${text.length} chars → ${hint}`)
887+
notify(copyNotice(text.length, hint))
874888
} catch (e) {
875889
notify(`copy failed: ${e instanceof Error ? e.message : String(e)}`)
876890
}
877891
return
878892
}
879893
const row = current()
880-
if (!row || row.kind === "branch" || row.kind === "separator") return
894+
if (!row) return
895+
if (row.kind === "branch" || row.kind === "separator") {
896+
notify(row.kind === "branch" ? "nothing to copy on a branch row — pick a turn or step" : "nothing to copy on a separator")
897+
return
898+
}
881899
const tr = row.sessionID === sessionID ? live() : others()[row.sessionID]
882900
const msg = tr?.messages.find((m) => m.id === row.messageID)
883901
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") ?? "")

‎src/tui/transcripts.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
66
import type { StepPart, Transcript, TranscriptMessage } from "../core/transcript.js"
77

8-
type AnyMessage = { id: string; role: string; time: { created: number; completed?: number }; tokens?: TranscriptMessage["tokens"]; summary?: unknown }
8+
type AnyMessage = { id: string; role: string; time: { created: number; completed?: number }; tokens?: TranscriptMessage["tokens"]; summary?: unknown; providerID?: string; modelID?: string }
99
type AnyPart = { id: string; type: string; text?: string; tool?: string; callID?: string; state?: StepPart["state"]; time?: StepPart["time"]; metadata?: Record<string, unknown> }
1010

1111
export function toStepPart(p: AnyPart): StepPart {
@@ -19,6 +19,7 @@ export function toTranscriptMessage(m: AnyMessage, parts: readonly AnyPart[]): T
1919
time: m.time,
2020
tokens: m.tokens,
2121
summary: m.role === "assistant" && m.summary === true ? true : undefined,
22+
model: m.role === "assistant" && m.providerID && m.modelID ? { providerID: m.providerID, modelID: m.modelID } : undefined,
2223
parts: parts.map(toStepPart),
2324
}
2425
}

‎test/fixtures/tree.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ const tick = () => (t += 1000)
1313
export function user(id: string, text: string): TranscriptMessage {
1414
return { id, role: "user", time: { created: tick() }, parts: [{ id: `${id}-p0`, type: "text", text }] }
1515
}
16-
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 {
16+
export function assistant(
17+
id: string,
18+
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 } },
19+
): TranscriptMessage {
1720
const parts: TranscriptMessage["parts"] = [{ id: `${id}-ss`, type: "step-start" }]
1821
if (opts.think) {
1922
const start = tick()
@@ -25,7 +28,7 @@ export function assistant(id: string, opts: { text?: string; think?: { text?: st
2528
}
2629
if (opts.text) parts.push({ id: `${id}-text`, type: "text", text: opts.text })
2730
parts.push({ id: `${id}-sf`, type: "step-finish" })
28-
return { id, role: "assistant", time: { created: tick() }, tokens: { input: opts.input ?? 1000, output: opts.output ?? 50, reasoning: 0, cache: { read: 0, write: 0 } }, parts }
31+
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 }
2932
}
3033

3134
/** Copy a prefix the way `session.fork` does: same content, fresh IDs. */

‎test/lanes.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,42 @@ describe("event strip", () => {
108108
}
109109
})
110110

111+
test("modelChanges marks the boundary where the answering model actually switches", () => {
112+
const sonnet = { providerID: "anthropic", modelID: "claude-sonnet-5" }
113+
const opus = { providerID: "anthropic", modelID: "claude-opus-5" }
114+
const three = T([
115+
user("u1", "a"),
116+
assistant("a1", { text: "one", model: sonnet }),
117+
user("u2", "b"),
118+
assistant("a2", { text: "two", model: sonnet }), // same model: no mark
119+
user("u3", "c"),
120+
assistant("a3", { text: "three", model: opus }), // switched: mark
121+
])
122+
const l = layoutEventStrip(three, "turns")
123+
expect(l.rules.length).toBe(2)
124+
expect(l.modelChanges).toEqual([l.rules[1]])
125+
})
126+
127+
test("no model data at all: modelChanges stays empty, never guesses", () => {
128+
const two = T([user("u1", "a"), assistant("a1", { text: "one" }), user("u2", "b"), assistant("a2", { text: "two" })])
129+
expect(layoutEventStrip(two, "turns").modelChanges).toEqual([])
130+
})
131+
132+
test("a tool-only turn between two models carries no opinion: the switch still shows on the next text turn", () => {
133+
const sonnet = { providerID: "anthropic", modelID: "claude-sonnet-5" }
134+
const opus = { providerID: "anthropic", modelID: "claude-opus-5" }
135+
const three = T([
136+
user("u1", "a"),
137+
assistant("a1", { text: "one", model: sonnet }),
138+
user("u2", "b"),
139+
assistant("a2", { tool: { name: "bash", input: { command: "ls" }, output: "out" } }), // no text/model
140+
user("u3", "c"),
141+
assistant("a3", { text: "three", model: opus }),
142+
])
143+
const l = layoutEventStrip(three, "turns")
144+
expect(l.modelChanges).toEqual([l.rules[1]])
145+
})
146+
111147
test("duration mode: widths follow durations, untimed events keep one cell", () => {
112148
const slow = assistant("a1", { tool: { name: "bash", input: { command: "slow" }, output: "x", ms: 8000 } })
113149
const fast = assistant("a2", { tool: { name: "bash", input: { command: "fast" }, output: "x", ms: 1000 } })

0 commit comments

Comments
 (0)