2026-06-13 01:12:55 -07:00
|
|
|
import SwiftUI
|
|
|
|
|
import NucleicProtocol
|
|
|
|
|
|
2026-06-21 04:03:53 -07:00
|
|
|
/// Read-friendly rendering of one projected transcript row (UX_IOS §4) — the mobile echo of the
|
|
|
|
|
/// Mac transcript. Assistant/user prose as Markdown bubbles, tool calls as collapsible cards,
|
|
|
|
|
/// Orchestra spawns in gold, plus the informational rows (usage/cost, rate limits, turn
|
|
|
|
|
/// boundaries, run outcome) the older remote dropped.
|
2026-06-13 01:12:55 -07:00
|
|
|
struct TranscriptRow: View {
|
2026-06-21 04:03:53 -07:00
|
|
|
let item: TranscriptItem
|
2026-06-13 01:12:55 -07:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-06-21 04:03:53 -07:00
|
|
|
switch item.kind {
|
|
|
|
|
case .message(let role, let text):
|
|
|
|
|
MessageBubble(role: role, text: text)
|
|
|
|
|
case .thinking(let text):
|
|
|
|
|
ThinkingRow(text: text)
|
|
|
|
|
case .tool(let group):
|
2026-06-26 20:25:24 -07:00
|
|
|
// A lone subagent spawn reads as its own gold subagent card (type, task, status, and
|
2026-06-27 19:45:56 -07:00
|
|
|
// its nested activity on expand); a host_exec call reads as a structured host card
|
|
|
|
|
// (purpose + command + breakdown); every other tool is a plain collapsible card.
|
2026-06-26 20:25:24 -07:00
|
|
|
if group.isOrchestration {
|
|
|
|
|
SubagentCard(group: group)
|
2026-06-27 19:45:56 -07:00
|
|
|
} else if group.isHostExec {
|
|
|
|
|
HostExecToolCard(group: group)
|
2026-06-26 20:25:24 -07:00
|
|
|
} else {
|
|
|
|
|
ToolCallCard(group: group)
|
|
|
|
|
}
|
|
|
|
|
case .toolBlock(let groups):
|
|
|
|
|
// A run made entirely of subagent spawns is a multi-agent fan-out — the gold
|
|
|
|
|
// orchestration card; any other run is one contiguous tool block.
|
|
|
|
|
if groups.allSatisfy(\.isOrchestration) {
|
|
|
|
|
OrchestrationCard(groups: groups)
|
|
|
|
|
} else {
|
|
|
|
|
ToolBlockCard(groups: groups)
|
|
|
|
|
}
|
2026-06-21 04:03:53 -07:00
|
|
|
case .sessionStarted(let model, let cwd):
|
|
|
|
|
InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd)
|
|
|
|
|
case .usage(let usage):
|
|
|
|
|
UsageRow(usage: usage)
|
|
|
|
|
case .rateLimit(let limit):
|
|
|
|
|
RateLimitRow(limit: limit)
|
|
|
|
|
case .turnBoundary:
|
|
|
|
|
TurnBoundaryRow()
|
|
|
|
|
case .approval(let toolName):
|
|
|
|
|
Label("Approval requested: \(toolName)", systemImage: "exclamationmark.triangle.fill")
|
|
|
|
|
.font(.caption).foregroundStyle(Palette.attention)
|
|
|
|
|
case .runFinished(let outcome):
|
|
|
|
|
RunFinishedRow(outcome: outcome)
|
|
|
|
|
case .error(let message):
|
|
|
|
|
Label(message, systemImage: "xmark.octagon.fill")
|
|
|
|
|
.font(.caption).foregroundStyle(Palette.danger)
|
|
|
|
|
case .note(let text, let icon, _):
|
|
|
|
|
Label(text, systemImage: icon ?? "arrow.triangle.branch")
|
2026-06-13 22:51:04 -07:00
|
|
|
.font(.caption).foregroundStyle(.secondary)
|
2026-06-21 04:03:53 -07:00
|
|
|
case .raw(let type, let body):
|
|
|
|
|
Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
2026-06-13 01:12:55 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 04:03:53 -07:00
|
|
|
private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 03:43:13 -07:00
|
|
|
/// Render an assistant/user message with the full Markdown renderer the Mac transcript uses
|
|
|
|
|
/// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving
|
|
|
|
|
/// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
|
2026-07-05 01:00:03 -07:00
|
|
|
/// The user's messages sit right-aligned in a soft accent bubble; the assistant's prose runs
|
|
|
|
|
/// open on the background — no box — so the transcript reads as a page, not a stack of cards.
|
2026-06-21 04:03:53 -07:00
|
|
|
struct MessageBubble: View {
|
|
|
|
|
let role: TranscriptItem.Role
|
|
|
|
|
let text: String
|
2026-06-13 01:12:55 -07:00
|
|
|
|
2026-06-21 04:03:53 -07:00
|
|
|
var body: some View {
|
2026-07-05 01:00:03 -07:00
|
|
|
if role == .user {
|
|
|
|
|
HStack {
|
|
|
|
|
Spacer(minLength: 48)
|
|
|
|
|
MarkdownText(markdown: text)
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 9)
|
|
|
|
|
.background(Palette.accent.opacity(0.15),
|
|
|
|
|
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-07-02 03:43:13 -07:00
|
|
|
MarkdownText(markdown: text)
|
2026-06-21 04:03:53 -07:00
|
|
|
.textSelection(.enabled)
|
2026-07-05 01:00:03 -07:00
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
2026-06-13 01:12:55 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 03:43:13 -07:00
|
|
|
/// Inline-only Markdown for one-line contexts (host-exec purpose lines etc.), where the
|
|
|
|
|
/// block renderer above would be overkill.
|
2026-06-21 04:03:53 -07:00
|
|
|
static func markdown(_ string: String) -> AttributedString {
|
|
|
|
|
(try? AttributedString(markdown: string, options: .init(
|
|
|
|
|
interpretedSyntax: .inlineOnlyPreservingWhitespace,
|
|
|
|
|
failurePolicy: .returnPartiallyParsedIfPossible))) ?? AttributedString(string)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct ThinkingRow: View {
|
|
|
|
|
let text: String
|
|
|
|
|
var body: some View {
|
|
|
|
|
Text(text)
|
|
|
|
|
.font(.callout.italic())
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A muted single-line note with an optional trailing detail (e.g. session-started cwd).
|
|
|
|
|
struct InfoLine: View {
|
|
|
|
|
let icon: String
|
|
|
|
|
let text: String
|
|
|
|
|
var detail: String? = nil
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
Image(systemName: icon)
|
|
|
|
|
Text(text)
|
|
|
|
|
if let detail { Text(detail).lineLimit(1).truncationMode(.middle).foregroundStyle(.tertiary) }
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}
|
|
|
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Per-turn token accounting + cost — the signal the desktop shows and the old remote dropped.
|
|
|
|
|
struct UsageRow: View {
|
|
|
|
|
let usage: Usage
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing: 10) {
|
|
|
|
|
if let input = usage.inputTokens { metric("arrow.down", tokens(input)) }
|
|
|
|
|
if let output = usage.outputTokens { metric("arrow.up", tokens(output)) }
|
|
|
|
|
if let cost = usage.costUSD, cost > 0 {
|
|
|
|
|
Text(String(format: "$%.4f", cost)).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}
|
|
|
|
|
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
private func metric(_ icon: String, _ value: String) -> some View {
|
|
|
|
|
HStack(spacing: 2) { Image(systemName: icon); Text(value) }
|
|
|
|
|
}
|
|
|
|
|
private func tokens(_ n: Int) -> String {
|
|
|
|
|
n >= 1000 ? String(format: "%.1fk", Double(n) / 1000) : "\(n)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A rate-limit warning so the user sees a window tightening before a turn fails — first-class,
|
|
|
|
|
/// not buried as a raw event.
|
|
|
|
|
struct RateLimitRow: View {
|
|
|
|
|
let limit: RateLimit
|
|
|
|
|
private var warn: Bool { (limit.status ?? "").contains("warning") || limit.status == "rejected" }
|
|
|
|
|
var body: some View {
|
|
|
|
|
Label(text, systemImage: warn ? "exclamationmark.circle.fill" : "clock.arrow.circlepath")
|
|
|
|
|
.font(.caption).foregroundStyle(warn ? Palette.attention : .secondary)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
private var text: String {
|
|
|
|
|
var parts = ["Rate limit"]
|
|
|
|
|
if let type = limit.rateLimitType { parts.append(type.replacingOccurrences(of: "_", with: " ")) }
|
|
|
|
|
if let status = limit.status { parts.append(status.replacingOccurrences(of: "_", with: " ")) }
|
|
|
|
|
if let resets = limit.resetsAt {
|
|
|
|
|
parts.append("· resets \(resets.formatted(date: .omitted, time: .shortened))")
|
|
|
|
|
}
|
|
|
|
|
return parts.joined(separator: " ")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct TurnBoundaryRow: View {
|
|
|
|
|
var body: some View {
|
2026-06-13 01:12:55 -07:00
|
|
|
HStack(spacing: 8) {
|
2026-06-21 04:03:53 -07:00
|
|
|
Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1)
|
|
|
|
|
Text("turn").font(.caption2).foregroundStyle(.tertiary)
|
|
|
|
|
Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct RunFinishedRow: View {
|
|
|
|
|
let outcome: RunFinished.Outcome
|
|
|
|
|
var body: some View {
|
|
|
|
|
Label(label, systemImage: icon).font(.caption).foregroundStyle(color)
|
|
|
|
|
}
|
|
|
|
|
private var label: String { "Run \(outcome.rawValue)" }
|
|
|
|
|
private var icon: String {
|
|
|
|
|
switch outcome {
|
|
|
|
|
case .completed: "flag.checkered"
|
|
|
|
|
case .interrupted: "stop.circle"
|
|
|
|
|
case .errored: "xmark.octagon"
|
|
|
|
|
case .maxTurns: "flag.checkered"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
private var color: Color {
|
|
|
|
|
switch outcome {
|
|
|
|
|
case .completed, .maxTurns: Palette.success
|
|
|
|
|
case .interrupted: Palette.paused
|
|
|
|
|
case .errored: Palette.danger
|
2026-06-13 01:12:55 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension JSONValue {
|
|
|
|
|
/// A one-line gist of a tool input/result for the collapsed row.
|
|
|
|
|
var compactSummary: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .string(let s): return s
|
|
|
|
|
case .object(let o):
|
|
|
|
|
if let cmd = o["command"]?.stringValue { return cmd }
|
|
|
|
|
if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path }
|
|
|
|
|
return o.keys.sorted().joined(separator: ", ")
|
|
|
|
|
case .array(let a): return "[\(a.count) items]"
|
|
|
|
|
case .number(let n): return String(n)
|
|
|
|
|
case .bool(let b): return String(b)
|
|
|
|
|
case .null: return "null"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-16 00:11:11 -07:00
|
|
|
|
|
|
|
|
/// The full, **untruncated** content of a tool input for the approval card —
|
|
|
|
|
/// the user must see exactly what they are granting before allowing. Unlike
|
|
|
|
|
/// `compactSummary` (a one-line gist that drops values for complex objects) this
|
|
|
|
|
/// returns the primary field verbatim, falling back to the pretty-printed input
|
|
|
|
|
/// so nothing is hidden. The caller scrolls; it never clips.
|
|
|
|
|
var approvalDetail: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .string(let s): return s
|
|
|
|
|
case .object(let o):
|
|
|
|
|
if let primary = o["command"]?.stringValue
|
|
|
|
|
?? o["file_path"]?.stringValue
|
|
|
|
|
?? o["path"]?.stringValue
|
|
|
|
|
?? o["notebook_path"]?.stringValue
|
|
|
|
|
?? o["url"]?.stringValue
|
|
|
|
|
?? o["query"]?.stringValue {
|
|
|
|
|
return primary
|
|
|
|
|
}
|
|
|
|
|
return o.isEmpty ? "" : prettyString()
|
|
|
|
|
case .null: return ""
|
|
|
|
|
default: return prettyString()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
}
|