Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 9 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 }
}

/**
Expand Down Expand Up @@ -205,6 +208,10 @@ export type EventLayout = {
empty: Record<LaneEvent["lane"], boolean>
/** 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[]
}

/**
Expand All @@ -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<number, string>()
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 })
Expand All @@ -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 }
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/core/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}

Expand Down
11 changes: 7 additions & 4 deletions src/tui/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
32 changes: 25 additions & 7 deletions src/tui/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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") ?? "")
Expand Down
3 changes: 2 additions & 1 deletion src/tui/transcripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }

export function toStepPart(p: AnyPart): StepPart {
Expand All @@ -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),
}
}
Expand Down
7 changes: 5 additions & 2 deletions test/fixtures/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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. */
Expand Down
36 changes: 36 additions & 0 deletions test/lanes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })
Expand Down
Loading
Loading