The presentation models for one conversation's transcript, all defined in
Spettro/Model/TranscriptModels.swift. A transcript is a flat, ordered
list of items — user/assistant messages, streamed reasoning, local
notices, and tool calls — so ChatView can render everything in the order
it happened with a single ForEach(session.items).
These are pure value types (struct/enum, Equatable, Identifiable).
They contain no SwiftUI and no ACP wire code; everything view-facing is
exposed as computed properties so the views stay free of parsing logic.
ACPToolCallEvent / session updates
│
▼ (ChatSession.applyToolEvent, appendAssistant, appendReasoning, …)
[TranscriptItem] = .message(ChatMessage) | .tool(ToolCallItem)
│
▼ (ChatView → TranscriptItemView → ToolCallView)
bubbles, reasoning blocks, tool rows, sub-agent cards
enum TranscriptItem: Identifiable, Equatable {
case message(ChatMessage)
case tool(ToolCallItem)
var id: String {
switch self {
case let .message(m): return "msg-\(m.id.uuidString)"
case let .tool(t): return "tool-\(t.id)"
}
}
}One ordered entry in a transcript. Only two cases:
.message(ChatMessage)— any text bubble (user, assistant, reasoning, notice)..tool(ToolCallItem)— one tool invocation reported by the agent.
The id is a String (not UUID) because the two cases draw from
different id spaces: message ids are app-generated UUIDs, tool ids are
the agent's ACP toolCallId strings. Prefixing (msg- / tool-) keeps
them collision-free inside one ForEach. ChatView also uses the id for
scroll anchoring (.id(item.id) on each row).
ChatSession owns the array as @Published var items: [TranscriptItem];
persistence maps each item to/from a Codable StoredItem via
TranscriptItem.stored / TranscriptItem.init(stored:) in
Model/SessionStore.swift.
struct ChatMessage: Identifiable, Equatable {
enum Role: Equatable {
case user
case assistant
case reasoning
case notice(isError: Bool)
}
let id: UUID
var role: Role
var text: String
var attachments: [ImageAttachment]
var isStreaming: Bool
let timestamp: Date
}A chat bubble: the user's prompt, the assistant's answer, streamed reasoning, or a local system notice.
| Property | Type | Meaning |
|---|---|---|
id |
UUID |
Generated at creation; stable identity for diffing. |
role |
Role |
Which bubble style TranscriptItemView picks. |
text |
String |
The full text; grows while isStreaming is true. |
attachments |
[ImageAttachment] |
Downsampled images the user pasted/dropped in. |
isStreaming |
Bool |
True while deltas are still being appended. |
timestamp |
Date |
Creation time (let, defaults to Date()). |
Everything except id and timestamp is var so ChatSession can
mutate a bubble in place as deltas arrive (items[count - 1] = .message(last)).
Role is an enum with an associated value on .notice, so the error flag
travels with the role rather than as a separate property:
.user— a prompt the user sent. Created byChatSession.appendUserMessage(_:attachments:), which also back-fills the chat title from the first prompt..assistant— the agent's answer text.ChatSession.appendAssistant(_:)appends deltas to the last bubble while it streams, and contains dedup/replay handling for chunks the CLI re-sends onsession/resume..reasoning— the agent's streamed chain-of-thought (ACP "thought" chunks). See Reasoning blocks below..notice(isError:)— a local notice that never went over the wire ("Turn cancelled.", "Couldn't start a session: …"). Created byChatSession.appendNotice(_:isError:);isErrorselects the error styling in the view. Persistence stores the flag alongside the role string (SessionStore.swift).
TranscriptItemView switches on the role to choose UserBubble,
AssistantBubble, ReasoningView, or the notice row.
Reasoning is modelled as ordinary messages with role == .reasoning, not
as a separate TranscriptItem case. The streaming lifecycle:
ChatSession.appendReasoning(_:)appends the delta to the last item if it is a.reasoningmessage withisStreaming == true; otherwise it starts a new reasoning bubble.ChatSession.endReasoningStream()flipsisStreamingtofalseon every reasoning bubble (it loops over allitems.indices, not just the last) — called both when the first assistant text delta arrives and fromendStreaming().
So at any moment there is at most one streaming reasoning block (the tail), and any number of finished ones earlier in the transcript.
struct ToolCallItem: Identifiable, Equatable {
let id: String // ACP toolCallId
var title: String
var kind: String?
var status: ACPToolStatus
var output: String
var diffs: [ToolDiff]
var locations: [String]
var argsJSON: String?
let timestamp: Date
}A tool invocation the agent reported, with its live status and output.
| Property | Type | Meaning |
|---|---|---|
id |
String |
The ACP toolCallId — this is how session/update events find their row. |
title |
String |
Raw CLI title, formatted name {json args}, optionally prefixed [agent#n] . |
kind |
String? |
ACP tool kind (read, edit, execute, think, …). Drives the icon and verb. |
status |
ACPToolStatus |
pending / inProgress / completed / failed / unknown (ACP/ACPProtocol.swift). |
output |
String |
Concatenated plain-text content blocks from the event. |
diffs |
[ToolDiff] |
File edits reported as diff content blocks. |
locations |
[String] |
Paths the agent attached to the call; used as a fallback detail. |
argsJSON |
String? |
The full ACP rawInput re-encoded as JSON — the reliable argument source. |
timestamp |
Date |
When the row was first created. |
ChatSession.applyToolEvent(_:isStart:) either updates an existing item
matched by toolCallId (each field replaced only when the event carries a
new value) or appends a new one. argsJSON is populated from
event.rawInput?.encodedJSONString.
struct ToolDiff: Equatable, Hashable {
var path: String
var oldText: String?
var newText: String
}One file edit: the path plus the full before/after text (oldText is nil
for new files). Built in applyToolEvent from .diff(path, oldText, newText) content blocks. Not a line diff — see changedLines below for
how the view derives one.
Everything ToolCallView shows is a computed property here, so the view
never touches the raw title string. The two helpers that power most of
them:
parsedTitle(private) — splits the CLI'sname {json args}title (after stripping an optional[agent#n]prefix) into(agent: String?, name: String, args: [String: JSONValue]?). It prefers decodingargsJSON(the untruncated ACPrawInput) and only falls back to the title's inline braces, because the CLI truncates inline args at 120 characters, often leaving invalid JSON. If there are no braces at all, the whole title is the name and args come fromargsJSONalone.argString(_:_:)(private) — first non-empty string value among a list of candidate argument keys.
An SF Symbol chosen from kind:
kind |
Symbol |
|---|---|
read |
doc.text |
edit |
pencil |
delete |
trash |
move |
arrow.right.doc.on.clipboard |
search |
magnifyingglass |
execute |
terminal |
think |
brain |
fetch |
globe |
switch_mode |
arrow.triangle.2.circlepath |
| anything else | wrench.and.screwdriver |
A short verb for the row label, chosen from kind (with two name-based
refinements):
kind |
Label |
|---|---|
execute |
Terminal |
read |
Read |
edit |
Edit |
delete |
Delete |
move |
Move |
search |
List when the tool name is ls, else Search |
fetch |
Fetch |
think |
Agent when the name starts with agent, else Plan |
switch_mode |
Mode |
| anything else | the capitalized tool name, or Tool if empty |
The one-line human detail — the shell command, the file's short path, the search pattern — never raw JSON. Resolution order, by tool name:
bash/shell/exec→ thecommand(orcmd) argument.agent→agent: task(just the agent name when there's no task).- anything else → the first of the
path/file/file_path/filenameargs orlocations.first, shortened withshortPath; if apattern/query/regexarg exists it becomes"<pattern> in <path>". With no path, it falls back to a string arg (content,task,description,pattern,query,regex,url,job_id,id,name). For unknown tools with args it renders compact sortedkey: valuepairs (string/int/bool values only) instead of JSON.
Before returning, newlines are collapsed to " ⏎ " so the row stays one
line, and a parsed [agent#n] prefix is re-applied as [agent#n] detail.
static func shortPath(_ path: String) -> StringCollapses an absolute path to its last few meaningful components: paths
with more than three /-separated components are reduced to the last
three joined by /; shorter paths are returned unchanged.
var diffStat: (added: Int, removed: Int)?Added/removed line counts across all of the call's diffs — the +N/-N
label in the tool row (DiffStatLabel). Nil when there are no diffs. The
counts come from changedLines, so unchanged context lines are not
counted: a pure insertion reports only the inserted lines, not the whole
file.
extension ToolCallItem.ToolDiff {
var changedLines: (old: [String], new: [String])
}The lines that actually changed, derived by splitting both sides on \n
and stripping the common leading and trailing lines. Explicitly not a
real diff algorithm, but tight enough that small edits show only their
changed region. ToolCallView's diff view renders these as the red/green
line block (capped at 80 lines), and diffStat sums them.
When the agent delegates work to another agent (the agent tool), the
same ToolCallItem is rendered as a dedicated sub-agent card
(SubAgentCallView in Views/ToolCallView.swift) instead of a generic
tool row. Two derivations drive that card:
struct SubAgentCall: Equatable {
var agent: String
var task: String?
}
var subAgentCall: SubAgentCall? { … }Non-nil when this tool call spins up another agent. Detection:
- the parsed tool name is exactly
agent, or starts withagent(the title formagent explore: task text).
Extraction, in order:
- From parsed args —
agent(required) andtask(optional) keys. - From the bare title form
agent <who>: <task>when args didn't parse — the name afteragentup to the first:is the agent, the rest is the task. - Otherwise a bare
SubAgentCall(agent: "agent", task: nil).
ToolCallView.body checks this first: if let call = tool.subAgentCall
renders SubAgentCallView, otherwise the normal row.
struct SubAgentResult: Equatable {
var status: String
var summary: String
}
var subAgentResult: SubAgentResult? { … }The sub-agent's reported outcome, parsed from the call's output, which
the CLI emits as {"agent":…,"status":…,"summary":…}. Nil unless
subAgentCall is non-nil and output is non-empty. Parsing:
- Decode the whole output as
JSONValue; takesummary(falling back tooutput) andstatus(defaulting to"ok"). Returned when a summary or a status field is present. - The CLI truncates long outputs, leaving invalid JSON — in that case the
private
extractJSONString(field:from:)helper salvages the"summary"field by hand (scanning to the closing quote or end of text, unescaping\n,\t, and dropping\r), withstatussalvaged the same way or defaulted to"ok".
The view treats result.status == "error" as a failure alongside
tool.status == .failed, and decides "still running" from
status == .pending || status == .inProgress.
The file ends with two conveniences used by the transcript pipeline:
ACPToolStatus.isTerminal—truefor.completedand.failed, the states after which no further updates are expected for a tool call.JSONValue.encodedJSONString— re-encodes a.objectvalue as a compact JSON string (a.stringvalue is returned as-is; anything else yields nil).ChatSession.applyToolEventuses it to turn the ACP event'srawInputinto theargsJSONstring thatparsedTitlelater decodes.
| Consumer | What it uses |
|---|---|
Model/ChatSession.swift |
Builds and mutates items (appendUserMessage, appendAssistant, appendReasoning, appendNotice, applyToolEvent, endStreaming). |
Model/SessionStore.swift |
TranscriptItem.stored / init(stored:) Codable mapping, including the noticeIsError flag. |
Views/ChatView.swift |
ForEach(session.items) + .id(item.id); watches the last message's text.count to keep scrolled to the bottom while streaming. |
Views/TranscriptItemView.swift |
Switches on the case and the Role to pick a bubble view. |
Views/ToolCallView.swift |
symbolName, displayName, displayDetail, diffStat, subAgentCall, subAgentResult, changedLines. |
PreviewSupport.swift |
TranscriptItem.previewTranscript and friends — the #Preview fixture data. |