Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift
T

291 lines
13 KiB
Swift
Raw Normal View History

import SwiftUI
import NucleicProtocol
/// 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.
struct TranscriptRow: View {
let item: TranscriptItem
var body: some View {
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-07-05 19:44:00 -07:00
// A completed AskUserQuestion reads as a pretty "You answered" Q&A card; a git-commit
// pipeline as a structured commit card; a host_exec call as a host card (purpose +
// command + breakdown); a lone subagent spawn as its own gold subagent card; every
// other tool — and an as-yet-unanswered question — is a plain collapsible card. Mirrors
// the Mac's `toolRow` routing so each family reads the same on both platforms.
2026-06-26 20:25:24 -07:00
if group.isOrchestration {
SubagentCard(group: group)
2026-07-05 19:44:00 -07:00
} else if let card = answeredQuestionCard(group) {
card
} else if let card = gitCommitRowCard(group) {
card
} 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)
}
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)
2026-07-05 19:37:58 -07:00
case .note(let text, let icon, _, _):
Label(text, systemImage: icon ?? "arrow.triangle.branch")
.font(.caption).foregroundStyle(.secondary)
case .raw(let type, let body):
Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2)
}
}
private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model }
2026-07-05 19:44:13 -07:00
/// The pretty card for a *completed* AskUserQuestion whose result records the user's choices,
/// or nil when this isn't that (wrong tool, still pending, errored, or an unexpected result
/// shape) — in which case the row falls back to a normal tool card. Mirrors the Mac's
/// `answeredQuestionCard`.
private func answeredQuestionCard(_ group: ToolGroup) -> AskUserQuestionResultCard? {
guard group.isAskUserQuestion, !group.isError, let result = group.result,
let questions = AskUserQuestion.questions(from: group.input) else { return nil }
let answers = AskUserQuestion.answers(in: result.resultText, for: questions)
guard !answers.isEmpty else { return nil }
return AskUserQuestionResultCard(questions: questions, answers: answers)
}
/// A git-commit `Bash` pipeline rendered as the structured commit card the Mac shows at the row
/// level (subject + Markdown body + the new hash + "Show command"), or nil for any non-commit
/// call — which keeps the normal collapsible tool card. Mirrors the Mac's `gitCommitCard`.
private func gitCommitRowCard(_ group: ToolGroup) -> GitCommitCard? {
guard let command = group.bashCommand, let commit = GitCommitSummary.parse(command)
else { return nil }
let output = group.result.map(\.resultText).flatMap { $0.isEmpty ? nil : $0 }
return GitCommitCard(commit: commit, rawCommand: command, output: output)
}
}
/// 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.
/// 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.
struct MessageBubble: View {
let role: TranscriptItem.Role
let text: String
var body: some View {
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 {
MarkdownText(markdown: text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Inline-only Markdown for one-line contexts (host-exec purpose lines etc.), where the
/// block renderer above would be overkill.
static func markdown(_ string: String) -> AttributedString {
(try? AttributedString(markdown: string, options: .init(
interpretedSyntax: .inlineOnlyPreservingWhitespace,
failurePolicy: .returnPartiallyParsedIfPossible))) ?? AttributedString(string)
}
}
2026-07-05 19:45:21 -07:00
/// The agent's reasoning, set apart from its prose by a quiet left rule and a `sparkles` glyph,
/// italic and muted — the phone echo of the Mac's `thinkingRow`. Renders nothing for an
/// empty/redacted thinking block (matching the desktop), so a placeholder row never shows.
struct ThinkingRow: View {
let text: String
var body: some View {
2026-07-05 19:45:21 -07:00
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
HStack(alignment: .top, spacing: 8) {
Rectangle().fill(.quaternary).frame(width: 2).cornerRadius(1)
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "sparkles").font(.caption2).foregroundStyle(.tertiary)
Text(trimmed)
.font(.callout.italic()).foregroundStyle(.secondary)
.lineSpacing(4)
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
2026-07-05 19:45:21 -07:00
}
}
}
/// 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 — 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)) }
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 {
HStack(spacing: 8) {
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
}
}
}
extension JSONValue {
2026-07-05 19:42:59 -07:00
/// Human-readable tool-result text: a plain string, the joined text of an MCP content array,
/// or the canonical JSON as a last resort. Mirrors the Mac's `TranscriptRow.resultText`, so an
/// answered-question card and a git-commit card read the result the same way on both platforms.
var resultText: String {
if let string = stringValue { return string }
if let array = arrayValue {
let texts = array.compactMap { $0["text"]?.stringValue }
if !texts.isEmpty { return texts.joined(separator: "\n") }
}
let canonical = canonicalString()
return canonical == "null" ? "" : canonical
}
/// 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()
}
}
}