Skip to content

Latest commit

 

History

History
366 lines (291 loc) · 15.1 KB

12-transcript-models.md

File metadata and controls

366 lines (291 loc) · 15.1 KB

Transcript models

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

TranscriptItem

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.

ChatMessage

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)).

Roles

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 by ChatSession.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 on session/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 by ChatSession.appendNotice(_:isError:); isError selects 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 blocks

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 .reasoning message with isStreaming == true; otherwise it starts a new reasoning bubble.
  • ChatSession.endReasoningStream() flips isStreaming to false on every reasoning bubble (it loops over all items.indices, not just the last) — called both when the first assistant text delta arrives and from endStreaming().

So at any moment there is at most one streaming reasoning block (the tail), and any number of finished ones earlier in the transcript.

ToolCallItem

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.

ToolDiff

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.

Presentation derivations on ToolCallItem

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's name {json args} title (after stripping an optional [agent#n] prefix) into (agent: String?, name: String, args: [String: JSONValue]?). It prefers decoding argsJSON (the untruncated ACP rawInput) 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 from argsJSON alone.
  • argString(_:_:) (private) — first non-empty string value among a list of candidate argument keys.

symbolName

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

displayName

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

displayDetail

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 → the command (or cmd) argument.
  • agent → agent: task (just the agent name when there's no task).
  • anything else → the first of the path / file / file_path / filename args or locations.first, shortened with shortPath; if a pattern / query / regex arg 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 sorted key: value pairs (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.

shortPath(_:)

static func shortPath(_ path: String) -> String

Collapses 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.

diffStat

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.

ToolDiff.changedLines

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.

Sub-agent items

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:

subAgentCall

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 with agent (the title form agent explore: task text).

Extraction, in order:

  1. From parsed args — agent (required) and task (optional) keys.
  2. From the bare title form agent <who>: <task> when args didn't parse — the name after agent up to the first : is the agent, the rest is the task.
  3. Otherwise a bare SubAgentCall(agent: "agent", task: nil).

ToolCallView.body checks this first: if let call = tool.subAgentCall renders SubAgentCallView, otherwise the normal row.

subAgentResult

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:

  1. Decode the whole output as JSONValue; take summary (falling back to output) and status (defaulting to "ok"). Returned when a summary or a status field is present.
  2. 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), with status salvaged 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.

Small shared extensions

The file ends with two conveniences used by the transcript pipeline:

  • ACPToolStatus.isTerminal — true for .completed and .failed, the states after which no further updates are expected for a tool call.
  • JSONValue.encodedJSONString — re-encodes a .object value as a compact JSON string (a .string value is returned as-is; anything else yields nil). ChatSession.applyToolEvent uses it to turn the ACP event's rawInput into the argsJSON string that parsedTitle later decodes.

Where these are consumed

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.