From 2ef49d1cde042349915fc11d16e9999d6d7559f4 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Thu, 13 Aug 2026 18:32:46 +0100 Subject: [PATCH] feat(companion): read a session's conversation as a chat lens on the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal stays the interface; this adds the second lens onto the same live session. The host turns an agent's on-disk transcript into an AgentEvent stream clients subscribe to with a cursor, so a phone that reconnects mid-turn asks for the gap rather than the whole conversation, and a dormant session still reads. The phone renders those events natively — prose, tool cards, diffs, plans — and its composer types into the same PTY the Mac terminal owns, so there is one session and no local/remote mode switch. A prompt now waits for the agent's screen to settle before it is typed. Bytes written while a CLI is still painting are read by whatever holds the PTY at the time and are simply gone: the message disappears, no turn starts, and nothing streams back, so the lens looks broken in both directions from one cause. --- Shared/Sources/TermioShared/AgentEvent.swift | 236 +++ .../Sources/TermioShared/WireProtocol.swift | 30 + Sources/termio/Agents/AgentEventStore.swift | 695 +++++++++ Sources/termio/App/App.swift | 3 + .../termio/Companion/CompanionServer.swift | 81 +- .../AgentEventNormalizerTests.swift | 340 +++++ .../AgentEventStoreStreamTests.swift | 61 + ios/Sources/AppDelegate.swift | 21 +- ios/Sources/ChatLensViewController.swift | 1267 +++++++++++++++++ ios/Sources/CompanionTransport.swift | 234 ++- ios/Sources/Localizable.xcstrings | 190 +++ ios/Sources/MarkdownAttributedText.swift | 265 ++++ ios/Sources/MobileSettings.swift | 14 + ios/Sources/RootContainerViewController.swift | 27 +- ios/Sources/SessionViewController.swift | 174 +++ ios/Sources/SettingsViewController.swift | 26 +- ios/Sources/TerminalViewController.swift | 54 +- 17 files changed, 3691 insertions(+), 27 deletions(-) create mode 100644 Shared/Sources/TermioShared/AgentEvent.swift create mode 100644 Sources/termio/Agents/AgentEventStore.swift create mode 100644 Tests/termioTests/AgentEventNormalizerTests.swift create mode 100644 Tests/termioTests/AgentEventStoreStreamTests.swift create mode 100644 ios/Sources/ChatLensViewController.swift create mode 100644 ios/Sources/MarkdownAttributedText.swift create mode 100644 ios/Sources/SessionViewController.swift diff --git a/Shared/Sources/TermioShared/AgentEvent.swift b/Shared/Sources/TermioShared/AgentEvent.swift new file mode 100644 index 00000000..6fe987be --- /dev/null +++ b/Shared/Sources/TermioShared/AgentEvent.swift @@ -0,0 +1,236 @@ +import Foundation + +/// One event on a session's **content plane** — the structured projection of an +/// agent's conversation, derived from the transcript the agent writes to disk. +/// +/// The byte plane (raw PTY frames) and this plane describe the same session and +/// ride the same connection on different channels. They differ in durability: +/// bytes die with the process, events live as long as the transcript file, which +/// is why a dormant session still has a readable conversation and a blank +/// terminal. +/// +/// Two properties the wire depends on, both consequences of the source being a +/// **file** rather than a live stream: +/// +/// - **`seq` is dense and monotonic per session.** A client that reconnects +/// asks for everything after the highest `seq` it holds. Same mechanism as +/// the PTY ring buffer's catch-up, so there is one reconnect story, not one +/// per plane. +/// - **Tool events are upserts keyed by `call`.** Re-reading a transcript, +/// resuming, or forking makes the same tool record appear again; replaying it +/// must converge rather than duplicate. A start/end event *pair* would not — +/// which is why this is a single mutable event and not two. +public struct AgentEvent: Codable, Sendable, Equatable { + public enum Role: String, Codable, Sendable, Equatable { + case user, agent, system + } + + /// ACP's tool vocabulary. Deliberately a small closed set: the client picks + /// an icon and an affordance from it, and an unrecognized tool degrades to + /// `.other` rather than going unrendered. + public enum ToolKind: String, Codable, Sendable, Equatable { + case read, edit, execute, search, think, fetch, other + } + + public enum ToolStatus: String, Codable, Sendable, Equatable { + case pending, running, done, error + } + + public enum TurnStatus: String, Codable, Sendable, Equatable { + case completed, failed, cancelled + } + + /// Whether the session has a live process behind it. `dormant` is not an + /// error state: it is the case the content plane exists to serve. + public enum LiveState: String, Codable, Sendable, Equatable { + case live, dormant + } + + public struct PlanItem: Codable, Sendable, Equatable { + public enum Status: String, Codable, Sendable, Equatable { + case pending, inProgress, completed + } + public let text: String + public let status: Status + + public init(text: String, status: Status) { + self.text = text + self.status = status + } + } + + public enum Payload: Codable, Sendable, Equatable { + case turnStart + case turnEnd(status: TurnStatus) + /// Assistant or user prose. `thinking` marks reasoning the client + /// renders collapsed — it is content, not a separate channel. + case text(text: String, thinking: Bool) + case tool( + call: String, name: String, kind: ToolKind, title: String, + subtitle: String?, status: ToolStatus, locations: [String]) + /// An edit tool's content, already unified so the client never has to + /// know how a given agent spells "old text" and "new text". + case diff(call: String, path: String, unified: String) + case plan(items: [PlanItem]) + case usage(tokens: Int, cost: Double?, contextLeft: Int?) + case sessionInfo(title: String, model: String?, state: LiveState) + } + + public let seq: Int + public let role: Role + /// When the agent wrote this, from the transcript's own clock — not when + /// the phone received it. A conversation read days later still groups by + /// the day it happened, and a replay after a reconnect cannot restamp + /// itself to "now". + public let at: Date? + /// The turn this event belongs to, when the transcript says so. + public let turn: String? + /// The parent tool call for a subagent's events. Present from day one + /// because retrofitting it means re-keying every stored event: a sidechain + /// can arrive before the parent tool call that spawned it, and the host + /// buffers those orphans so the client stays dumb. + public let parent: String? + public let payload: Payload + + public init( + seq: Int, role: Role, at: Date? = nil, turn: String? = nil, parent: String? = nil, + payload: Payload + ) { + self.seq = seq + self.role = role + self.at = at + self.turn = turn + self.parent = parent + self.payload = payload + } + + /// The upsert key: the identity a replayed event collapses onto. Prose is + /// append-only and has none; tools, diffs and the plan do. + /// + /// The plan's key is constant because an agent has one task list per + /// session: every revision of it lands on the same row, which is the + /// difference between a checklist and a dozen stale copies of a TUI box + /// scrolling past. + public var upsertKey: String? { + switch payload { + case .tool(let call, _, _, _, _, _, _): return "tool:\(call)" + case .diff(let call, let path, _): return "diff:\(call):\(path)" + case .plan: return "plan" + default: return nil + } + } +} + +extension AgentEvent { + /// Hand-rolled JSON in the same style as `CompanionControl`: both ends read + /// plain dictionaries, so an older peer skips a field it doesn't know + /// instead of failing the whole batch. + public var jsonObject: [String: Any] { + var object: [String: Any] = ["seq": seq, "role": role.rawValue] + // Milliseconds since the epoch: an integer both ends already agree on, + // where a formatted date would drag a parser and a locale into the wire. + if let at { object["at"] = Int(at.timeIntervalSince1970 * 1000) } + if let turn { object["turn"] = turn } + if let parent { object["parent"] = parent } + + switch payload { + case .turnStart: + object["ev"] = ["t": "turn-start"] + case .turnEnd(let status): + object["ev"] = ["t": "turn-end", "status": status.rawValue] + case .text(let text, let thinking): + var event: [String: Any] = ["t": "text", "text": text] + if thinking { event["thinking"] = true } + object["ev"] = event + case .tool(let call, let name, let kind, let title, let subtitle, let status, let locations): + var event: [String: Any] = [ + "t": "tool", "call": call, "name": name, "kind": kind.rawValue, + "title": title, "status": status.rawValue, + ] + if let subtitle { event["subtitle"] = subtitle } + if !locations.isEmpty { event["locations"] = locations } + object["ev"] = event + case .diff(let call, let path, let unified): + object["ev"] = ["t": "diff", "call": call, "path": path, "unified": unified] + case .plan(let items): + object["ev"] = [ + "t": "plan", + "items": items.map { ["text": $0.text, "status": $0.status.rawValue] }, + ] + case .usage(let tokens, let cost, let contextLeft): + var event: [String: Any] = ["t": "usage", "tokens": tokens] + if let cost { event["cost"] = cost } + if let contextLeft { event["contextLeft"] = contextLeft } + object["ev"] = event + case .sessionInfo(let title, let model, let state): + var event: [String: Any] = ["t": "session-info", "title": title, "state": state.rawValue] + if let model { event["model"] = model } + object["ev"] = event + } + return object + } + + /// Returns nil for an event this build has no case for, so a newer Mac can + /// add an event type without a older phone dropping the batch around it. + public init?(json object: [String: Any]) { + guard let seq = object["seq"] as? Int, + let roleName = object["role"] as? String, + let role = Role(rawValue: roleName), + let event = object["ev"] as? [String: Any], + let type = event["t"] as? String + else { return nil } + + let payload: Payload + switch type { + case "turn-start": + payload = .turnStart + case "turn-end": + let status = (event["status"] as? String).flatMap(TurnStatus.init(rawValue:)) + payload = .turnEnd(status: status ?? .completed) + case "text": + guard let text = event["text"] as? String else { return nil } + payload = .text(text: text, thinking: event["thinking"] as? Bool ?? false) + case "tool": + guard let call = event["call"] as? String, let name = event["name"] as? String + else { return nil } + payload = .tool( + call: call, name: name, + kind: (event["kind"] as? String).flatMap(ToolKind.init(rawValue:)) ?? .other, + title: event["title"] as? String ?? name, + subtitle: event["subtitle"] as? String, + status: (event["status"] as? String).flatMap(ToolStatus.init(rawValue:)) ?? .done, + locations: event["locations"] as? [String] ?? []) + case "diff": + guard let call = event["call"] as? String, let path = event["path"] as? String, + let unified = event["unified"] as? String + else { return nil } + payload = .diff(call: call, path: path, unified: unified) + case "plan": + let raw = event["items"] as? [[String: Any]] ?? [] + payload = .plan( + items: raw.compactMap { item in + guard let text = item["text"] as? String else { return nil } + let status = (item["status"] as? String).flatMap(PlanItem.Status.init(rawValue:)) + return PlanItem(text: text, status: status ?? .pending) + }) + case "usage": + payload = .usage( + tokens: event["tokens"] as? Int ?? 0, cost: event["cost"] as? Double, + contextLeft: event["contextLeft"] as? Int) + case "session-info": + payload = .sessionInfo( + title: event["title"] as? String ?? "", + model: event["model"] as? String, + state: (event["state"] as? String).flatMap(LiveState.init(rawValue:)) ?? .dormant) + default: + return nil + } + + let at = (object["at"] as? Int).map { + Date(timeIntervalSince1970: Double($0) / 1000) + } + self.init( + seq: seq, role: role, at: at, turn: object["turn"] as? String, + parent: object["parent"] as? String, payload: payload) + } +} diff --git a/Shared/Sources/TermioShared/WireProtocol.swift b/Shared/Sources/TermioShared/WireProtocol.swift index 8920028d..51ee6066 100644 --- a/Shared/Sources/TermioShared/WireProtocol.swift +++ b/Shared/Sources/TermioShared/WireProtocol.swift @@ -133,6 +133,21 @@ public enum CompanionControl: Codable, Sendable, Equatable { /// it into a `WKWebView` overlay. Large, so it rides the 8 MB-capped socket. case traceHTML(sessionID: String, html: String) + /// Phone → Mac: subscribe to a session's content plane. `since` is the + /// highest `seq` the client already holds, so 0 asks for the whole + /// conversation and a reconnect asks only for the gap — the same + /// durable-object-plus-cursor shape the PTY ring buffer uses, so the two + /// planes share one reconnect story instead of growing two. + /// + /// Valid for a dormant session: the transcript outlives the process, which + /// is the single strongest reason this plane exists. + case subscribeEvents(sessionID: String, since: Int) + + /// Mac → phone: a batch of content-plane events, ascending by `seq`. + /// Batched rather than one frame per event because a cold subscribe + /// replays thousands at once. + case agentEvents(sessionID: String, events: [AgentEvent]) + /// Phone → Mac: list the hosts in the Mac's `~/.ssh/config`. The phone is /// sandboxed and has no `~/.ssh`, so the Mac reads it and the phone imports /// the results into its own SSH manager. @@ -240,6 +255,12 @@ public enum CompanionControl: Codable, Sendable, Equatable { return Self.json(["t": "trace", "session": sessionID, "dark": dark]) case .traceHTML(let sessionID, let html): return Self.json(["t": "traceHTML", "session": sessionID, "html": html]) + case .subscribeEvents(let sessionID, let since): + return Self.json(["t": "subscribeEvents", "session": sessionID, "since": since]) + case .agentEvents(let sessionID, let events): + return Self.json([ + "t": "agentEvents", "session": sessionID, "events": events.map(\.jsonObject), + ]) case .sshConfigHosts: return #"{"t":"sshConfigHosts"}"# case .sshConfigList(let hosts): @@ -401,6 +422,15 @@ public enum CompanionControl: Codable, Sendable, Equatable { guard let sessionID = obj["session"] as? String, let html = obj["html"] as? String else { return nil } return .traceHTML(sessionID: sessionID, html: html) + case "subscribeEvents": + guard let sessionID = obj["session"] as? String else { return nil } + return .subscribeEvents(sessionID: sessionID, since: obj["since"] as? Int ?? 0) + case "agentEvents": + guard let sessionID = obj["session"] as? String else { return nil } + // compactMap, not a failing map: one event type this build predates + // drops itself out of the batch rather than voiding the batch. + let raw = obj["events"] as? [[String: Any]] ?? [] + return .agentEvents(sessionID: sessionID, events: raw.compactMap(AgentEvent.init(json:))) case "sshConfigHosts": return .sshConfigHosts case "sshConfigList": diff --git a/Sources/termio/Agents/AgentEventStore.swift b/Sources/termio/Agents/AgentEventStore.swift new file mode 100644 index 00000000..ed1b9198 --- /dev/null +++ b/Sources/termio/Agents/AgentEventStore.swift @@ -0,0 +1,695 @@ +import Foundation +import TermioShared + +/// The host side of the content plane: turns an agent's on-disk transcript into +/// the `AgentEvent` stream clients subscribe to. +/// +/// Why a store and not a renderer: `SessionTraceRenderer` answers "what did this +/// whole conversation look like" in one pass, which is the right shape for a +/// static HTML page and the wrong one for a phone that reconnects mid-turn. This +/// keeps a per-session log with a cursor, so a client can ask for the gap since +/// the last event it holds. +/// +/// **An actor, not a main-actor class.** Reading and parsing a transcript tail is +/// file I/O plus JSON decoding; on a 20 MB conversation that is tens of +/// milliseconds. Doing it on the main actor would put a stutter in the Mac's UI +/// every time a phone was watching a busy agent — the content plane must never +/// be able to hitch the app that hosts it. +/// +/// **Watched, not polled.** A timer ticking per subscribed session is an idle +/// wakeup storm on battery, and this codebase has already paid for one of those. +/// The transcript is watched with a vnode source and coalesced; a slow backstop +/// refresh covers the cases kqueue misses (a writer that replaces the file, a +/// network mount). +/// +/// Delivery order vs display order — the one subtlety worth stating. `seq` is a +/// **delivery** counter. When a tool's result lands, its event is re-emitted +/// with a fresh `seq` so a client sitting at an older cursor learns about the +/// change. Display order is the client's business: it inserts an event at first +/// sight of its `upsertKey` and updates in place afterwards, so a tool card +/// never jumps to the bottom of the transcript when it finishes. +actor AgentEventStore { + static let shared = AgentEventStore() + + /// Writes arrive in bursts (an agent flushes several records at once), so a + /// vnode event is coalesced before the tail is read. + private static let coalesceNanoseconds: UInt64 = 200_000_000 + /// Covers what kqueue does not: a writer that replaces the file rather than + /// appending, and mounts where vnode events are unreliable. + private static let backstopSeconds: UInt64 = 5 + /// A backstop against a pathological transcript, not a normal path. Reached + /// only by a conversation far longer than any real session. + private static let maximumLoggedEvents = 50_000 + + private final class SessionState { + var path: String + /// The delivery log, ascending by `seq`. A superseded version of an + /// upsert-keyed event is removed rather than left behind, so a cold + /// subscribe replays each tool exactly once, already in its final state. + var log: [AgentEvent] = [] + var nextSeq = 1 + /// Bytes of the transcript already parsed. Claude's JSONL is + /// append-only, so a refresh only has to read the tail. + var consumedBytes: UInt64 = 0 + /// Carry-over for a trailing partial line: the agent can flush + /// mid-record, and parsing half a JSON object would drop the event. + var pendingLine = "" + var normalizer = ClaudeTranscriptNormalizer() + var watcher: DispatchSourceFileSystemObject? + + init(path: String) { self.path = path } + + func reset(path: String) { + self.path = path + log.removeAll() + nextSeq = 1 + consumedBytes = 0 + pendingLine = "" + normalizer = ClaudeTranscriptNormalizer() + } + } + + /// One client's view of one session. The cursor lives here, not on the + /// session: two phones watching the same conversation advance + /// independently, and neither may consume the other's updates. + private struct Subscription { + let sessionID: String + var cursor: Int + let continuation: AsyncStream<[AgentEvent]>.Continuation + } + + /// Keyed by client *and* session so one connection can hold more than one + /// subscription without them overwriting each other. + private struct Key: Hashable { + let client: ObjectIdentifier + let sessionID: String + } + + private var sessions: [String: SessionState] = [:] + private var subscriptions: [Key: Subscription] = [:] + private var backstop: Task? + + // MARK: - Subscribing + + /// Replays everything after `since` and then streams updates. A `since` of 0 + /// is a cold subscribe: the whole conversation, including one belonging to a + /// session with no live process behind it. + func subscribe( + client: ObjectIdentifier, sessionID: String, transcriptPath: String, since: Int + ) -> AsyncStream<[AgentEvent]> { + let state = session(sessionID, path: transcriptPath) + refresh(state) + + let (stream, continuation) = AsyncStream<[AgentEvent]>.makeStream() + let key = Key(client: client, sessionID: sessionID) + subscriptions[key] = Subscription( + sessionID: sessionID, cursor: since, continuation: continuation) + + deliver(to: key) + startWatching(sessionID) + startBackstopIfNeeded() + + continuation.onTermination = { [weak self] _ in + Task { await self?.unsubscribe(client: client, sessionID: sessionID) } + } + return stream + } + + func unsubscribe(client: ObjectIdentifier, sessionID: String) { + subscriptions.removeValue(forKey: Key(client: client, sessionID: sessionID))? + .continuation.finish() + pruneIdleResources() + } + + /// Drops every subscription a client holds — the connection went away. + func unsubscribeAll(client: ObjectIdentifier) { + for key in subscriptions.keys where key.client == client { + subscriptions.removeValue(forKey: key)?.continuation.finish() + } + pruneIdleResources() + } + + private func session(_ sessionID: String, path: String) -> SessionState { + if let existing = sessions[sessionID] { + // A resume can point the same session at a new transcript file; + // keeping the old offset would read from the wrong place forever. + if existing.path != path { + existing.watcher?.cancel() + existing.watcher = nil + existing.reset(path: path) + } + return existing + } + let fresh = SessionState(path: path) + sessions[sessionID] = fresh + return fresh + } + + /// Sends each subscriber only what it has not seen, then advances its own + /// cursor. Reading the log once and fanning out is what makes two viewers of + /// one session correct. + private func deliver(to key: Key? = nil) { + let targets = key.map { [$0] } ?? Array(subscriptions.keys) + for target in targets { + guard var subscription = subscriptions[target], + let state = sessions[subscription.sessionID] + else { continue } + // Kept in log order, which is conversation order — not sorted by + // `seq`, which is delivery order. The client upserts, so a revised + // event arriving out of numeric order still lands in its place. + let pending = state.log.filter { $0.seq > subscription.cursor } + guard let highest = pending.map(\.seq).max() else { continue } + subscription.cursor = highest + subscriptions[target] = subscription + subscription.continuation.yield(pending) + } + } + + // MARK: - Watching + + private func startWatching(_ sessionID: String) { + guard let state = sessions[sessionID], state.watcher == nil else { return } + let descriptor = open(state.path, O_EVTONLY) + guard descriptor >= 0 else { return } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: [.write, .extend, .delete, .rename], + queue: DispatchQueue.global(qos: .utility)) + source.setEventHandler { [weak self] in + Task { await self?.transcriptChanged(sessionID) } + } + source.setCancelHandler { close(descriptor) } + source.resume() + state.watcher = source + } + + private func transcriptChanged(_ sessionID: String) async { + // Coalesce the burst: an agent flushing a turn produces several writes, + // and reading the tail once at the end of them is both cheaper and less + // likely to split a record. + try? await Task.sleep(nanoseconds: Self.coalesceNanoseconds) + guard let state = sessions[sessionID] else { return } + + // A replaced file leaves the old descriptor watching an unlinked inode, + // so re-arm onto the new one before reading. + if let watcher = state.watcher, watcher.data.contains(.delete) || watcher.data.contains(.rename) { + watcher.cancel() + state.watcher = nil + startWatching(sessionID) + } + refresh(state) + deliver() + } + + private func startBackstopIfNeeded() { + guard backstop == nil else { return } + backstop = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.backstopSeconds * 1_000_000_000) + guard let self else { return } + await self.refreshAll() + } + } + } + + private func refreshAll() { + let watched = Set(subscriptions.values.map(\.sessionID)) + for sessionID in watched { + guard let state = sessions[sessionID] else { continue } + refresh(state) + } + deliver() + } + + private func pruneIdleResources() { + guard subscriptions.isEmpty else { return } + backstop?.cancel() + backstop = nil + for state in sessions.values { + state.watcher?.cancel() + state.watcher = nil + } + } + + // MARK: - Reading + + /// Reads the transcript's unread tail and folds the new rows into the log. + private func refresh(_ state: SessionState) { + guard let handle = FileHandle(forReadingAtPath: state.path) else { return } + defer { try? handle.close() } + + // A transcript that shrank was replaced (a `--resume` that rewrote it, or + // a cleared session). Start over rather than reading from a stale offset + // into the middle of a record. + let size = (try? handle.seekToEnd()) ?? 0 + if size < state.consumedBytes { state.reset(path: state.path) } + guard size > state.consumedBytes else { return } + + try? handle.seek(toOffset: state.consumedBytes) + guard let data = try? handle.readToEnd(), !data.isEmpty else { return } + state.consumedBytes = size + + let text = state.pendingLine + (String(data: data, encoding: .utf8) ?? "") + var lines = text.components(separatedBy: "\n") + // The last fragment is only a complete record if the read ended on a + // newline; otherwise hold it back until the rest arrives. + state.pendingLine = text.hasSuffix("\n") ? "" : (lines.popLast() ?? "") + + for line in lines { + guard !line.isEmpty, let lineData = line.data(using: .utf8), + let row = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] + else { continue } + for produced in state.normalizer.events(from: row) { + append(produced, to: state) + } + } + } + + private func append(_ produced: ClaudeTranscriptNormalizer.Produced, to state: SessionState) { + let event = AgentEvent( + seq: state.nextSeq, role: produced.role, at: produced.at, turn: produced.turn, + parent: produced.parent, payload: produced.payload) + state.nextSeq += 1 + + // A revised event keeps its **place** and takes a new `seq`. The place + // is what a cold subscriber reads as conversation order: a tool card + // that moved to the end of the log every time it finished would replay + // below the diff it produced, and a plan would sink under the work it + // describes. The new `seq` is what a client already holding the old + // version needs in order to be told about the change at all. + if let key = event.upsertKey, + let existing = state.log.firstIndex(where: { $0.upsertKey == key }) { + state.log[existing] = event + } else { + state.log.append(event) + } + + if state.log.count > Self.maximumLoggedEvents { + let dropped = state.log.count - Self.maximumLoggedEvents + state.log.removeFirst(dropped) + Log.companion.notice( + "content plane dropped \(dropped, privacy: .public) oldest events past the cap") + } + } +} + +/// Claude's transcript dialect. One of a closed set — the manifest says *where* +/// an agent's transcript lives, this says *how that shape reads*. Deliberately +/// not a configurable mapping DSL: every product-grade implementation of this +/// (waku, linkcode) writes real code per dialect, and pretending it is data buys +/// a mini-interpreter that is harder to write and harder to test. +/// +/// Lenient by construction. The format is a vendor's internal detail, and a real +/// transcript carries a dozen row types that are not conversation at all +/// (`mode`, `attachment`, `file-history-snapshot`, …). Anything unrecognized is +/// skipped, never fatal. +struct ClaudeTranscriptNormalizer { + struct Produced { + let role: AgentEvent.Role + let at: Date? + let turn: String? + let parent: String? + let payload: AgentEvent.Payload + + var upsertKey: String? { + switch payload { + case .tool(let call, _, _, _, _, _, _): return "tool:\(call)" + case .diff(let call, let path, _): return "diff:\(call):\(path)" + case .plan: return "plan" + default: return nil + } + } + } + + /// Tool calls seen so far, so a `tool_result` arriving in a later row can + /// re-emit the original card with its outcome instead of appearing as an + /// orphaned blob of output. + private var openTools: [String: Produced] = [:] + + /// The session's task list in creation order. Claude Code writes a plan two + /// ways depending on its version: `TodoWrite` sends the whole list in one + /// call, `TaskCreate`/`TaskUpdate` mutate one item at a time. Both fold into + /// the same plan event, so the phone never learns which CLI wrote the + /// transcript — and the incremental shape is why the list has to be held + /// here rather than read off a single row. + private var tasks: [(id: String, text: String, status: AgentEvent.PlanItem.Status)] = [] + + mutating func events(from row: [String: Any]) -> [Produced] { + let role: AgentEvent.Role + switch row["type"] as? String { + case "assistant": role = .agent + case "user": role = .user + default: return [] + } + + // A meta row is text the CLI injected into the conversation on the + // user's behalf — a skill's body, an appended reminder. Rendering it in + // a user bubble claims the human said it, which is worse than not + // showing it at all. + if row["isMeta"] as? Bool == true { return [] } + + guard let message = row["message"] as? [String: Any] else { return [] } + let at = timestamp(row["timestamp"]) + let turn = row["uuid"] as? String + // A sidechain is a subagent's transcript interleaved into the parent's. + // The field is carried even though this build renders it inline, because + // adding it later means re-keying every stored event. + let parent = (row["isSidechain"] as? Bool == true) ? row["parentUuid"] as? String : nil + + if let plain = message["content"] as? String { + guard let text = Self.spoken(plain, by: role) else { return [] } + return [ + Produced( + role: role, at: at, turn: turn, parent: parent, + payload: .text(text: text, thinking: false)) + ] + } + guard let blocks = message["content"] as? [[String: Any]] else { return [] } + + var produced: [Produced] = [] + for block in blocks { + switch block["type"] as? String { + case "text": + if let text = Self.spoken(block["text"] as? String ?? "", by: role) { + produced.append( + Produced(role: role, at: at, turn: turn, parent: parent, payload: .text(text: text, thinking: false))) + } + case "thinking": + let text = block["thinking"] as? String ?? "" + if !text.isEmpty { + produced.append( + Produced(role: role, at: at, turn: turn, parent: parent, payload: .text(text: text, thinking: true))) + } + case "tool_use": + produced.append(contentsOf: toolUse(block, role: role, at: at, turn: turn, parent: parent)) + case "tool_result": + if let updated = toolResult(block) { produced.append(updated) } + default: + break + } + } + return produced + } + + /// Claude stamps every row `"2026-08-13T12:04:11.312Z"`. Both formatters + /// are kept because the fractional part is not guaranteed, and a missing + /// timestamp is not worth dropping an event over — it only costs that + /// message its clock. Instance-held rather than static: a formatter is not + /// `Sendable`, and one per normalizer is still one per session. + private let isoFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private let isoPlain = ISO8601DateFormatter() + + private func timestamp(_ raw: Any?) -> Date? { + guard let text = raw as? String, !text.isEmpty else { return nil } + return isoFractional.date(from: text) ?? isoPlain.date(from: text) + } + + /// What a turn actually said, or nil when it said nothing a reader should + /// see. Only user rows are filtered: the CLI writes its own plumbing into + /// the user side of the transcript — a slash command arrives as a + /// `` envelope and its output as `` — + /// and a phone showing those as things the human typed reads as nonsense. + /// A slash command is a real turn, so it survives as the command itself. + private static func spoken(_ text: String, by role: AgentEvent.Role) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard role == .user else { return text } + + if let name = tagged("command-name", in: trimmed) { + let arguments = tagged("command-args", in: trimmed) ?? "" + return arguments.isEmpty ? name : "\(name) \(arguments)" + } + if trimmed.hasPrefix("") { return nil } + return text + } + + private static func tagged(_ tag: String, in text: String) -> String? { + guard let open = text.range(of: "<\(tag)>"), + let close = text.range(of: "", range: open.upperBound.. [Produced] { + guard let call = block["id"] as? String, let name = block["name"] as? String else { + return [] + } + let input = block["input"] as? [String: Any] ?? [:] + + // TodoWrite is a plan, not a tool card. Promoting it is one of the four + // things the chat lens can do that the terminal cannot. + if name == "TodoWrite", let todos = input["todos"] as? [[String: Any]] { + let items = todos.compactMap { todo -> AgentEvent.PlanItem? in + guard let text = (todo["content"] ?? todo["activeForm"]) as? String else { return nil } + return AgentEvent.PlanItem( + text: text, status: Self.planStatus(todo["status"] as? String ?? "")) + } + return [Produced(role: role, at: at, turn: turn, parent: parent, payload: .plan(items: items))] + } + + // The newer shape of the same thing: one task per call, so the list is + // accumulated here and the whole plan re-emitted. An update naming a + // task this reader never saw (a transcript joined mid-session) falls + // through to a plain tool card rather than inventing a checklist item. + if name == "TaskCreate" || name == "TaskUpdate", let items = applyTaskCall(name, input: input) { + return [Produced(role: role, at: at, turn: turn, parent: parent, payload: .plan(items: items))] + } + + let card = Produced( + role: role, at: at, turn: turn, parent: parent, + payload: .tool( + call: call, name: name, kind: Self.kind(of: name), + title: Self.title(of: name, input: input), + subtitle: Self.subtitle(of: name, input: input), status: .running, + locations: Self.locations(of: name, input: input))) + openTools[call] = card + + var produced = [card] + // An edit tool carries the before and after inline, so the diff needs no + // git round-trip and works for a file that was never committed. + if let path = input["file_path"] as? String { + if let old = input["old_string"] as? String, let new = input["new_string"] as? String { + produced.append( + Produced( + role: role, at: at, turn: turn, parent: parent, + payload: .diff( + call: call, path: path, + unified: UnifiedDiff.between(old: old, new: new, path: path)))) + } else if name == "Write", let content = input["content"] as? String { + produced.append( + Produced( + role: role, at: at, turn: turn, parent: parent, + payload: .diff( + call: call, path: path, + unified: UnifiedDiff.between(old: "", new: content, path: path)))) + } + } + return produced + } + + /// Folds one `TaskCreate` / `TaskUpdate` into the running task list and + /// returns the whole plan, or nil when the call says nothing about a task + /// this reader is tracking. + /// + /// Ids are the creation ordinal, which is what the tool's own reply + /// ("Task #2 created successfully") numbers them by — the create call + /// itself never carries the id, and the reply is a sentence rather than a + /// field, so the ordinal is the more reliable of the two. + private mutating func applyTaskCall(_ name: String, input: [String: Any]) + -> [AgentEvent.PlanItem]? + { + if name == "TaskCreate" { + guard let subject = (input["subject"] ?? input["activeForm"]) as? String, + !subject.isEmpty + else { return nil } + tasks.append((id: String(tasks.count + 1), text: subject, status: .pending)) + } else { + guard let taskID = taskIdentifier(in: input), + let index = tasks.firstIndex(where: { $0.id == taskID }) + else { return nil } + if let subject = input["subject"] as? String, !subject.isEmpty { + tasks[index].text = subject + } + if let status = input["status"] as? String { + tasks[index].status = Self.planStatus(status) + } + } + return tasks.map { AgentEvent.PlanItem(text: $0.text, status: $0.status) } + } + + /// The id arrives as a string in every transcript seen so far, but a JSON + /// number is the obvious way for it to change, and a plan silently + /// stopping is worse than the two lines it costs to accept both. + private func taskIdentifier(in input: [String: Any]) -> String? { + if let text = input["taskId"] as? String { return text } + if let number = input["taskId"] as? Int { return String(number) } + return nil + } + + private static func planStatus(_ raw: String) -> AgentEvent.PlanItem.Status { + switch raw { + case "completed": return .completed + case "in_progress": return .inProgress + default: return .pending + } + } + + private mutating func toolResult(_ block: [String: Any]) -> Produced? { + guard let call = block["tool_use_id"] as? String, let open = openTools[call], + case .tool(_, let name, let kind, let title, let subtitle, _, let locations) = open.payload + else { return nil } + let failed = block["is_error"] as? Bool ?? false + let updated = Produced( + role: open.role, at: open.at, turn: open.turn, parent: open.parent, + payload: .tool( + call: call, name: name, kind: kind, title: title, subtitle: subtitle, + status: failed ? .error : .done, locations: locations)) + openTools[call] = updated + return updated + } + + /// Maps a tool name onto ACP's closed vocabulary. An unknown tool lands on + /// `.other` and still renders — the client never has to know this list. + static func kind(of name: String) -> AgentEvent.ToolKind { + switch name { + case "Read", "NotebookRead": return .read + case "Edit", "Write", "NotebookEdit": return .edit + case "Bash", "BashOutput", "KillShell": return .execute + case "Grep", "Glob", "ToolSearch": return .search + case "Task", "TodoWrite": return .think + case "WebFetch", "WebSearch": return .fetch + default: return .other + } + } + + private static func title(of name: String, input: [String: Any]) -> String { + if let path = input["file_path"] as? String { + return (path as NSString).lastPathComponent + } + if let command = input["command"] as? String { return shortened(command) } + if let pattern = input["pattern"] as? String { return pattern } + if let query = input["query"] as? String { return query } + if let url = input["url"] as? String { return url } + if let prompt = input["prompt"] as? String { return prompt } + return name + } + + private static func subtitle(of name: String, input: [String: Any]) -> String? { + if let description = input["description"] as? String { return description } + if let path = input["file_path"] as? String { + let directory = (path as NSString).deletingLastPathComponent + return directory.isEmpty ? nil : abbreviatingHome(directory) + } + return nil + } + + private static func locations(of name: String, input: [String: Any]) -> [String] { + if let path = input["file_path"] as? String { return [path] } + if let path = input["path"] as? String { return [path] } + return [] + } + + /// Makes a command legible in two lines on a phone. Agents habitually prefix + /// a `cd` into an absolute worktree path, which on a 390pt screen spends the + /// whole card on the prefix and truncates the part that says what ran. + private static func shortened(_ command: String) -> String { + var text = command + // Both separators appear in practice, and the path may be quoted. + for separator in [" && ", "; "] where text.hasPrefix("cd ") { + if let range = text.range(of: separator) { + text = String(text[range.upperBound...]) + break + } + } + return abbreviatingHome(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static func abbreviatingHome(_ text: String) -> String { + let home = NSHomeDirectory() + guard !home.isEmpty else { return text } + return text.replacingOccurrences(of: home, with: "~") + } +} + +/// Builds unified-diff text from an edit tool's before and after strings, so the +/// phone can render it with the same `DiffParser` the git pane already uses +/// rather than learning a second diff shape. +enum UnifiedDiff { + static func between(old: String, new: String, path: String) -> String { + let oldLines = old.isEmpty ? [] : old.components(separatedBy: "\n") + let newLines = new.isEmpty ? [] : new.components(separatedBy: "\n") + let common = longestCommonSubsequence(oldLines, newLines) + + var body: [String] = [] + var oldIndex = 0 + var newIndex = 0 + for anchor in common { + while oldIndex < oldLines.count, oldLines[oldIndex] != anchor { + body.append("-" + oldLines[oldIndex]) + oldIndex += 1 + } + while newIndex < newLines.count, newLines[newIndex] != anchor { + body.append("+" + newLines[newIndex]) + newIndex += 1 + } + body.append(" " + anchor) + oldIndex += 1 + newIndex += 1 + } + while oldIndex < oldLines.count { + body.append("-" + oldLines[oldIndex]) + oldIndex += 1 + } + while newIndex < newLines.count { + body.append("+" + newLines[newIndex]) + newIndex += 1 + } + + let name = (path as NSString).lastPathComponent + let header = [ + "--- a/\(name)", "+++ b/\(name)", + "@@ -1,\(oldLines.count) +1,\(newLines.count) @@", + ] + return (header + body).joined(separator: "\n") + } + + /// Classic dynamic-programming LCS. Edit payloads are the changed region of + /// a file rather than the whole file, so the quadratic table stays small; + /// the guard below keeps a pathological payload from stalling the host. + private static func longestCommonSubsequence(_ old: [String], _ new: [String]) -> [String] { + guard !old.isEmpty, !new.isEmpty else { return [] } + guard old.count * new.count <= 1_000_000 else { return [] } + + var table = [[Int]](repeating: [Int](repeating: 0, count: new.count + 1), count: old.count + 1) + for i in stride(from: old.count - 1, through: 0, by: -1) { + for j in stride(from: new.count - 1, through: 0, by: -1) { + table[i][j] = + old[i] == new[j] + ? table[i + 1][j + 1] + 1 : max(table[i + 1][j], table[i][j + 1]) + } + } + + var result: [String] = [] + var i = 0 + var j = 0 + while i < old.count, j < new.count { + if old[i] == new[j] { + result.append(old[i]) + i += 1 + j += 1 + } else if table[i + 1][j] >= table[i][j + 1] { + i += 1 + } else { + j += 1 + } + } + return result + } +} diff --git a/Sources/termio/App/App.swift b/Sources/termio/App/App.swift index de996ae5..e9036650 100644 --- a/Sources/termio/App/App.swift +++ b/Sources/termio/App/App.swift @@ -332,6 +332,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { ptyForSession: { [weak store] id in store?.companionPTY(for: id) }, + sessionIsLive: { [weak store] id in + store?.companionSessionIsLive(id) ?? false + }, startSession: { [weak store] projectID, agent in store?.companionStartSession(projectID: projectID, agent: agent) }, diff --git a/Sources/termio/Companion/CompanionServer.swift b/Sources/termio/Companion/CompanionServer.swift index 9288cc0c..3ffa405d 100644 --- a/Sources/termio/Companion/CompanionServer.swift +++ b/Sources/termio/Companion/CompanionServer.swift @@ -105,6 +105,11 @@ final class CompanionServer { private let port: UInt16 private let rosterProvider: () -> CompanionRoster private let ptyForSession: (String) -> PTYProcess? + /// Whether a session already has a shell behind it. Separate from + /// `ptyForSession` because that one *starts* one: asking it "is this live?" + /// on the content plane would make reading a finished conversation spawn a + /// process, which is the opposite of what this plane is for. + private let sessionIsLive: (String) -> Bool /// Creates a session for a `start` request. A nil agent is the phone's /// bare New Chat — the store resolves it through the same default-agent /// policy as ⌘N. Returns the new session's wire id plus the agent wire id @@ -130,6 +135,9 @@ final class CompanionServer { /// and an attach is keystroke access to a shell. private var authenticatedWireByConnection: [ObjectIdentifier: Int] = [:] private var bridges: [ObjectIdentifier: PTYBridge] = [:] + /// One content-plane pump per connection, cancelled when it drops so a + /// vanished phone stops a session's transcript watch. + private var eventPumps: [ObjectIdentifier: Task] = [:] private var lastRoster: CompanionRoster? private var pollTimer: Timer? private var ticks = 0 @@ -143,6 +151,7 @@ final class CompanionServer { port: UInt16 = CompanionServer.defaultPort, rosterProvider: @escaping () -> CompanionRoster, ptyForSession: @escaping (String) -> PTYProcess?, + sessionIsLive: @escaping (String) -> Bool, startSession: @escaping (String, String?) -> (sessionID: String, agentID: String)?, stopSession: @escaping (String) -> Bool, startScratchTerminal: @escaping () -> (sessionID: String, agentID: String)?, @@ -152,6 +161,7 @@ final class CompanionServer { self.port = port self.rosterProvider = rosterProvider self.ptyForSession = ptyForSession + self.sessionIsLive = sessionIsLive self.startSession = startSession self.stopSession = stopSession self.startScratchTerminal = startScratchTerminal @@ -268,6 +278,9 @@ final class CompanionServer { bridge.pty.claimHostOwnership() } } + eventPumps[id]?.cancel() + eventPumps[id] = nil + Task { await AgentEventStore.shared.unsubscribeAll(client: id) } connectionByID[id]?.cancel() connectionByID[id] = nil connections.remove(id) @@ -411,6 +424,8 @@ final class CompanionServer { handleReadDiff(projectID: projectID, path: path, status: status, on: connection) case .trace(let sessionID, let dark): handleTrace(sessionID: sessionID, dark: dark, on: connection) + case .subscribeEvents(let sessionID, let since): + handleSubscribeEvents(sessionID: sessionID, since: since, on: connection) case .sshConfigHosts: sendControl(.sshConfigList(hosts: Self.parseSSHConfigHosts()), to: connection) case .unsupported(let type): @@ -423,7 +438,7 @@ final class CompanionServer { "ignoring unsupported control \(Self.loggableTag(type), privacy: .public)" ) case .auth, .exit, .error, .started, .fileList, .file, .written, .uploaded, - .searchResults, .traceHTML, .sshConfigList, .changes, .diff: + .searchResults, .traceHTML, .sshConfigList, .changes, .diff, .agentEvents: break } } @@ -474,6 +489,62 @@ final class CompanionServer { sendControl(.traceHTML(sessionID: sessionID, html: html), to: connection) } + // MARK: - Content plane + + /// Subscribe a phone to a session's conversation events, replaying the gap + /// since the cursor it holds and then streaming updates. + /// + /// This deliberately reuses `traceProvider`, which already resolves a + /// transcript from disk for a session the Mac never opened — so a dormant + /// session, whose terminal has no bytes left to send, still answers with its + /// full history. + /// + /// A session with no transcript is answered with an empty batch rather than + /// an `.error`: this connection is also the PTY bridge, and the phone treats + /// an error frame there as a fatal drop of the live terminal. + /// A cold subscribe on a long conversation replays thousands of events, and + /// the socket has a frame cap — so the backlog goes out in chunks rather + /// than one frame that would be dropped for being too large. + private static let eventBatchSize = 400 + + private func handleSubscribeEvents(sessionID: String, since: Int, on connection: NWConnection) { + guard let (path, title) = traceProvider(sessionID) else { + Log.companion.notice("subscribeEvents: no transcript for this session") + sendControl(.agentEvents(sessionID: sessionID, events: []), to: connection) + return + } + + // The header rides ahead of the backlog so the phone can title the view + // and show live-vs-dormant before the conversation finishes decoding. + // Liveness is the byte plane's question, so ask the byte plane: a + // session with no PTY is one whose terminal has nothing left to show, + // which is exactly when the phone should default to this lens. Asked + // without starting one — reading a conversation must never be what + // brings a session back to life. + let live = sessionIsLive(sessionID) + let header = AgentEvent( + seq: 0, role: .system, + payload: .sessionInfo(title: title, model: nil, state: live ? .live : .dormant)) + sendControl(.agentEvents(sessionID: sessionID, events: [header]), to: connection) + + let client = ObjectIdentifier(connection) + eventPumps[client]?.cancel() + eventPumps[client] = Task { [weak self] in + let stream = await AgentEventStore.shared.subscribe( + client: client, sessionID: sessionID, transcriptPath: path, since: since) + for await batch in stream { + guard !Task.isCancelled else { return } + for chunk in stride(from: 0, to: batch.count, by: Self.eventBatchSize) { + let slice = Array(batch[chunk.. Bool { + guard let (_, session) = findCompanionSession(wireID) else { return false } + return ptyProcesses[session.id] != nil + } + /// Resolve a session's transcript path and display title for a phone /// `trace` request. Learns the path from disk when no hook has delivered it /// yet — the same fallback the desktop Info pane uses — so the phone can diff --git a/Tests/termioTests/AgentEventNormalizerTests.swift b/Tests/termioTests/AgentEventNormalizerTests.swift new file mode 100644 index 00000000..093d5c34 --- /dev/null +++ b/Tests/termioTests/AgentEventNormalizerTests.swift @@ -0,0 +1,340 @@ +import TermioShared +import XCTest + +@testable import termio + +/// Pins the Claude transcript dialect and the wire round-trip behind it: the +/// block types that become events, the row types that must be skipped without +/// throwing, the upsert that makes a re-read converge instead of duplicating, +/// the promotion of TodoWrite into a plan, the unified diff built from an edit +/// tool's before/after, and a batch surviving an encode/decode hop. +final class AgentEventNormalizerTests: XCTestCase { + private func normalize(_ rows: [[String: Any]]) -> [ClaudeTranscriptNormalizer.Produced] { + var normalizer = ClaudeTranscriptNormalizer() + return rows.flatMap { normalizer.events(from: $0) } + } + + private func assistant(_ blocks: [[String: Any]], uuid: String = "u1") -> [String: Any] { + ["type": "assistant", "uuid": uuid, "message": ["content": blocks]] + } + + func testTextAndThinkingBecomeSeparateEvents() { + let events = normalize([ + assistant([ + ["type": "thinking", "thinking": "weighing options"], + ["type": "text", "text": "Here is the plan."], + ]) + ]) + XCTAssertEqual(events.count, 2) + guard case .text(let reasoning, let isThinking) = events[0].payload, + case .text(let prose, let isProse) = events[1].payload + else { return XCTFail("expected two text events") } + XCTAssertEqual(reasoning, "weighing options") + XCTAssertTrue(isThinking) + XCTAssertEqual(prose, "Here is the plan.") + XCTAssertFalse(isProse) + } + + /// A real transcript is mostly rows that are not conversation. None of them + /// may produce an event, and none may throw. + func testNonConversationRowsAreSkipped() { + let noise: [[String: Any]] = [ + ["type": "mode", "mode": "plan"], + ["type": "attachment", "id": "a1"], + ["type": "file-history-snapshot", "snapshot": [:]], + ["type": "ai-title", "title": "something"], + ["type": "summary"], + ["type": "assistant"], + ["type": "user", "message": ["content": []]], + ] + XCTAssertTrue(normalize(noise).isEmpty) + } + + func testToolResultUpsertsOntoItsCallRatherThanAddingACard() { + var normalizer = ClaudeTranscriptNormalizer() + let started = normalizer.events( + from: assistant([ + ["type": "tool_use", "id": "t1", "name": "Bash", "input": ["command": "swift build"]] + ])) + XCTAssertEqual(started.count, 1) + guard case .tool(_, _, let kind, let title, _, let running, _) = started[0].payload else { + return XCTFail("expected a tool event") + } + XCTAssertEqual(kind, .execute) + XCTAssertEqual(title, "swift build") + XCTAssertEqual(running, .running) + + let finished = normalizer.events( + from: [ + "type": "user", "uuid": "u2", + "message": ["content": [["type": "tool_result", "tool_use_id": "t1", "is_error": true]]], + ]) + XCTAssertEqual(finished.count, 1) + guard case .tool(let call, _, _, _, _, let status, _) = finished[0].payload else { + return XCTFail("expected the tool event again") + } + XCTAssertEqual(status, .error) + // Same upsert key both times: the client replaces in place, so a + // replayed transcript converges instead of growing a second card. + XCTAssertEqual(started[0].upsertKey, finished[0].upsertKey) + XCTAssertEqual(call, "t1") + } + + func testTodoWriteBecomesAPlanNotAToolCard() { + let events = normalize([ + assistant([ + [ + "type": "tool_use", "id": "t2", "name": "TodoWrite", + "input": [ + "todos": [ + ["content": "Write the normalizer", "status": "completed"], + ["content": "Render it", "status": "in_progress"], + ["content": "Ship", "status": "pending"], + ] + ], + ] + ]) + ]) + XCTAssertEqual(events.count, 1) + guard case .plan(let items) = events[0].payload else { return XCTFail("expected a plan") } + XCTAssertEqual(items.map(\.status), [.completed, .inProgress, .pending]) + XCTAssertEqual(items.first?.text, "Write the normalizer") + } + + /// The user side of a transcript carries the CLI's own plumbing. None of it + /// may reach the phone as something the human said. + func testInjectedUserContentIsNotShownAsTheHumanSpeaking() { + func user(_ text: String, meta: Bool = false) -> [String: Any] { + var row: [String: Any] = [ + "type": "user", "uuid": "u9", "message": ["content": text], + ] + if meta { row["isMeta"] = true } + return row + } + + XCTAssertTrue(normalize([user("Base directory for this skill: …", meta: true)]).isEmpty) + XCTAssertTrue(normalize([user("Login successful")]).isEmpty) + + let command = normalize([ + user( + "/clear\nclear\n" + ) + ]) + guard case .text(let spoken, _) = command.first?.payload else { + return XCTFail("a slash command is still a turn the human took") + } + XCTAssertEqual(spoken, "/clear") + + guard case .text(let typed, _) = normalize([user("so should you close it?")]).first?.payload + else { return XCTFail("expected the human's own words") } + XCTAssertEqual(typed, "so should you close it?") + } + + /// The shape current Claude Code writes: one task per call, status changed + /// later by id. Every call re-emits the whole plan onto the same row, so the + /// phone shows one checklist rather than a card per mutation. + func testTaskCallsAccumulateIntoOnePlan() { + var normalizer = ClaudeTranscriptNormalizer() + func call(_ name: String, _ input: [String: Any], id: String) -> [ClaudeTranscriptNormalizer + .Produced] + { + normalizer.events( + from: assistant([["type": "tool_use", "id": id, "name": name, "input": input]])) + } + + XCTAssertEqual(call("TaskCreate", ["subject": "Read the transcript"], id: "c1").count, 1) + let created = call("TaskCreate", ["subject": "Render it"], id: "c2") + guard case .plan(let both) = created[0].payload else { return XCTFail("expected a plan") } + XCTAssertEqual(both.map(\.text), ["Read the transcript", "Render it"]) + XCTAssertEqual(both.map(\.status), [.pending, .pending]) + + let updated = call("TaskUpdate", ["taskId": "1", "status": "completed"], id: "c3") + guard case .plan(let after) = updated[0].payload else { return XCTFail("expected a plan") } + XCTAssertEqual(after.map(\.status), [.completed, .pending]) + // One row for the whole plan, whichever call revised it. + XCTAssertEqual(created[0].upsertKey, updated[0].upsertKey) + XCTAssertEqual(created[0].upsertKey, "plan") + + // A status change for a task this reader never saw is a tool card, not + // an invented checklist entry. + let stray = call("TaskUpdate", ["taskId": "99", "status": "completed"], id: "c4") + guard case .tool = stray[0].payload else { return XCTFail("expected a tool card") } + } + + func testEditToolAlsoProducesAUnifiedDiffTheDiffParserCanRead() { + let events = normalize([ + assistant([ + [ + "type": "tool_use", "id": "t3", "name": "Edit", + "input": [ + "file_path": "/tmp/Sample.swift", + "old_string": "let a = 1\nlet b = 2\nlet c = 3", + "new_string": "let a = 1\nlet b = 20\nlet c = 3", + ], + ] + ]) + ]) + XCTAssertEqual(events.count, 2) + guard case .diff(_, let path, let unified) = events[1].payload else { + return XCTFail("expected a diff alongside the tool card") + } + XCTAssertEqual(path, "/tmp/Sample.swift") + // Only the changed line moves; the LCS keeps the neighbours as context. + XCTAssertTrue(unified.contains("-let b = 2")) + XCTAssertTrue(unified.contains("+let b = 20")) + XCTAssertTrue(unified.contains(" let a = 1")) + XCTAssertFalse(unified.contains("-let a = 1")) + + let parsed = DiffParser.lines(from: unified) + XCTAssertEqual(parsed.filter { $0.kind == .addition }.count, 1) + XCTAssertEqual(parsed.filter { $0.kind == .deletion }.count, 1) + } + + func testWriteToolDiffsAgainstAnEmptyFile() { + let events = normalize([ + assistant([ + [ + "type": "tool_use", "id": "t4", "name": "Write", + "input": ["file_path": "/tmp/New.swift", "content": "one\ntwo"], + ] + ]) + ]) + guard case .diff(_, _, let unified) = events.last?.payload else { + return XCTFail("expected a diff for a new file") + } + XCTAssertTrue(unified.contains("+one")) + XCTAssertTrue(unified.contains("+two")) + // A new file is all additions — checked through the parser, since the + // `--- a/` header legitimately starts with hyphens. + XCTAssertTrue(DiffParser.lines(from: unified).allSatisfy { $0.kind != .deletion }) + } + + func testUnknownToolStillRendersAsAnOtherCard() { + let events = normalize([ + assistant([["type": "tool_use", "id": "t5", "name": "SomeFutureTool", "input": [:]]]) + ]) + guard case .tool(_, let name, let kind, _, _, _, _) = events.first?.payload else { + return XCTFail("expected a tool event") + } + XCTAssertEqual(name, "SomeFutureTool") + XCTAssertEqual(kind, .other) + } + + /// The batch has to survive the hop the phone actually makes it take. + func testEventBatchSurvivesTheWireRoundTrip() { + let events = [ + AgentEvent(seq: 1, role: .agent, payload: .text(text: "hello", thinking: false)), + AgentEvent( + seq: 2, role: .agent, + payload: .tool( + call: "t1", name: "Read", kind: .read, title: "App.swift", + subtitle: "/tmp", status: .done, locations: ["/tmp/App.swift"])), + AgentEvent( + seq: 3, role: .system, + payload: .sessionInfo(title: "termio", model: nil, state: .dormant)), + ] + let encoded = CompanionControl.agentEvents(sessionID: "s1", events: events).encoded() + guard case .agentEvents(let sessionID, let decoded)? = CompanionControl.decode(encoded) else { + return XCTFail("batch did not decode") + } + XCTAssertEqual(sessionID, "s1") + XCTAssertEqual(decoded, events) + } + + /// Two phones on one session must each get the whole conversation. The + /// first cut shared a single cursor on the session, so whichever client + /// polled first consumed the new bytes and the second silently received an + /// empty batch — the exact failure the multi-client rule forbids. + /// A cold subscribe replays the log, so the log has to be in conversation + /// order. The trap: a tool's card is revised when its result lands, and a + /// revision that re-appended would put the finished card *below* the diff + /// it produced — every edit in the transcript would read backwards. + func testAFinishedToolKeepsItsPlaceAheadOfItsOwnDiff() async throws { + let path = NSTemporaryDirectory() + "chat-lens-\(UUID().uuidString).jsonl" + let rows = """ + {"type":"assistant","uuid":"u1","message":{"content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/tmp/A.swift","old_string":"a","new_string":"b"}}]}} + {"type":"user","uuid":"u2","message":{"content":[{"type":"tool_result","tool_use_id":"t1"}]}} + {"type":"assistant","uuid":"u3","message":{"content":[{"type":"text","text":"done"}]}} + """ + try (rows + "\n").write(toFile: path, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = AgentEventStore() + let client = NSObject() + var stream = await store.subscribe( + client: ObjectIdentifier(client), sessionID: "s2", transcriptPath: path, since: 0 + ).makeAsyncIterator() + guard let batch = await stream.next() else { return XCTFail("no replay") } + + XCTAssertEqual(batch.map(\.upsertKey), ["tool:t1", "diff:t1:/tmp/A.swift", nil]) + guard case .tool(_, _, _, _, _, let status, _) = batch[0].payload else { + return XCTFail("expected the tool card first") + } + // In its original place, but already carrying the result. + XCTAssertEqual(status, .done) + } + + func testTwoClientsOnOneSessionEachReceiveEverything() async throws { + let path = NSTemporaryDirectory() + "chat-lens-\(UUID().uuidString).jsonl" + let row = """ + {"type":"assistant","uuid":"u1","message":{"content":[{"type":"text","text":"first"}]}} + """ + try (row + "\n").write(toFile: path, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = AgentEventStore() + let firstClient = NSObject() + let secondClient = NSObject() + + var first = await store.subscribe( + client: ObjectIdentifier(firstClient), sessionID: "s1", transcriptPath: path, since: 0 + ).makeAsyncIterator() + var second = await store.subscribe( + client: ObjectIdentifier(secondClient), sessionID: "s1", transcriptPath: path, since: 0 + ).makeAsyncIterator() + + let firstBatch = await first.next() + let secondBatch = await second.next() + XCTAssertEqual(firstBatch?.count, 1) + XCTAssertEqual(secondBatch?.count, 1, "second client was starved by the first") + XCTAssertEqual(firstBatch, secondBatch) + + // The live path is where the bug actually was: one client's read used to + // consume the tail for everyone. + let appended = """ + {"type":"assistant","uuid":"u2","message":{"content":[{"type":"text","text":"second"}]}} + """ + guard let handle = FileHandle(forWritingAtPath: path) else { + return XCTFail("cannot append to the transcript") + } + try handle.seekToEnd() + try handle.write(contentsOf: Data((appended + "\n").utf8)) + try handle.close() + + let firstUpdate = await first.next() + let secondUpdate = await second.next() + XCTAssertEqual(firstUpdate?.count, 1) + XCTAssertEqual(secondUpdate?.count, 1, "second client missed the live update") + XCTAssertEqual(firstUpdate, secondUpdate) + if case .text(let text, _)? = firstUpdate?.first?.payload { + XCTAssertEqual(text, "second") + } else { + XCTFail("expected the appended text event") + } + } + + /// An event type this build predates drops itself out of the batch instead + /// of voiding the batch around it. + func testUnknownEventTypeDropsWithoutTakingTheBatchDown() { + let payload = """ + {"t":"agentEvents","session":"s1","events":[\ + {"seq":1,"role":"agent","ev":{"t":"text","text":"kept"}},\ + {"seq":2,"role":"agent","ev":{"t":"telepathy","mood":"blue"}}]} + """ + guard case .agentEvents(_, let decoded)? = CompanionControl.decode(payload) else { + return XCTFail("batch did not decode") + } + XCTAssertEqual(decoded.count, 1) + XCTAssertEqual(decoded.first?.seq, 1) + } +} diff --git a/Tests/termioTests/AgentEventStoreStreamTests.swift b/Tests/termioTests/AgentEventStoreStreamTests.swift new file mode 100644 index 00000000..e8575fcd --- /dev/null +++ b/Tests/termioTests/AgentEventStoreStreamTests.swift @@ -0,0 +1,61 @@ +import TermioShared +import XCTest + +@testable import termio + +/// The content plane's live half: a subscriber must be told about a turn the +/// agent appends *after* it subscribed. The cold replay is the easy case and +/// was never the one that broke — a lens that only ever paints its backlog +/// looks like a working chat right up until you send something. +final class AgentEventStoreStreamTests: XCTestCase { + private func assistantLine(_ text: String, uuid: String) -> String { + let row: [String: Any] = [ + "type": "assistant", "uuid": uuid, + "message": ["content": [["type": "text", "text": text]]], + ] + let data = try! JSONSerialization.data(withJSONObject: row) + return String(decoding: data, as: UTF8.self) + "\n" + } + + func testAppendedTurnReachesALiveSubscriber() async throws { + let path = NSTemporaryDirectory() + "termio-stream-\(UUID().uuidString).jsonl" + try assistantLine("first", uuid: "a1").write( + toFile: path, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: path) } + + let client = ObjectIdentifier(NSObject()) + let sessionID = UUID().uuidString + let stream = await AgentEventStore.shared.subscribe( + client: client, sessionID: sessionID, transcriptPath: path, since: 0) + + var iterator = stream.makeAsyncIterator() + let backlog = await iterator.next() + XCTAssertEqual(backlog?.count, 1, "cold subscribe should replay the one record") + + // The agent writes another turn while the subscriber is listening. + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path)) + try handle.seekToEnd() + try handle.write(contentsOf: Data(assistantLine("second", uuid: "a2").utf8)) + try handle.close() + + let live = await withTaskGroup(of: [AgentEvent]?.self) { group in + group.addTask { await iterator.next() ?? nil } + group.addTask { + // Generous next to the store's own 5s backstop: this test is + // asking whether the update arrives at all, not how fast. + try? await Task.sleep(nanoseconds: 15_000_000_000) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + + await AgentEventStore.shared.unsubscribe(client: client, sessionID: sessionID) + + guard let live, case .text(let text, _) = live.first?.payload else { + return XCTFail("no live batch — the appended turn never reached the subscriber") + } + XCTAssertEqual(text, "second") + } +} diff --git a/ios/Sources/AppDelegate.swift b/ios/Sources/AppDelegate.swift index 670ccd10..2d6af864 100644 --- a/ios/Sources/AppDelegate.swift +++ b/ios/Sources/AppDelegate.swift @@ -53,13 +53,28 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { ) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - let terminal = TerminalViewController(companionURL: url, session: session) - root.open(terminal, sessionKey: session?.key, animated: false) + // An agent session opens as its conversation; a plain shell — + // and `-open-inspector`, which asks for the terminal's own file + // drawer — opens the terminal directly. + let opensChat = session.map { + $0.rosterID != nil && $0.agent.id != RosterAgent.terminal.id + } ?? false + let terminal: TerminalViewController? = + opensChat && !args.contains("-open-inspector") + ? nil : TerminalViewController(companionURL: url, session: session) + let screen: UIViewController = + terminal + ?? SessionViewController( + companionURL: url, + session: session ?? MockSession( + title: url.host ?? "companion", project: "", agent: .terminal, + status: .idle, subtitle: "", time: "")) + root.open(screen, sessionKey: session?.key, animated: false) // `-open-inspector` slides the file drawer out once attached, // so simctl runs can screenshot the live tree. if args.contains("-open-inspector") { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - terminal.setDrawer(open: true, animated: false) + terminal?.setDrawer(open: true, animated: false) } } } diff --git a/ios/Sources/ChatLensViewController.swift b/ios/Sources/ChatLensViewController.swift new file mode 100644 index 00000000..55cc0a70 --- /dev/null +++ b/ios/Sources/ChatLensViewController.swift @@ -0,0 +1,1267 @@ +import TermioShared +import UIKit + +/// The chat lens: a session's conversation rendered natively from the content +/// plane, as an alternative to looking at the same session's terminal grid. +/// +/// It is not a second kind of session and not a chat client bolted onto a +/// terminal — it is the same live session seen through its structured events. +/// The four things it can do that an 80-column grid on a phone cannot are the +/// whole reason it exists, and the bar it has to clear: +/// +/// 1. A dormant session still reads — the transcript outlives the process, so +/// this view has content where the terminal has a blank screen. +/// 2. Diffs render as diffs, at phone width, instead of wrapped grid rows. +/// 3. A plan is a checklist rather than a redrawn box. +/// 4. (Next) a permission question is a button rather than a menu you arrow to. +/// +/// Two rules keep it smooth on a long conversation, both borrowed from how chat +/// clients that scroll well are built: +/// +/// - **Attributed text is produced once per row and cached**, never rebuilt in +/// `cellForRowAt`. Parsing Markdown during scrolling is the classic way to +/// turn a transcript into a stuttering one. +/// - **The table is driven by a diffable data source.** An arriving batch +/// appends or reconfigures the rows it touches; it never reloads the table, +/// which would rebuild every visible cell and unseat the scroll position. +final class ChatLensViewController: UIViewController { + /// One row. Tool and diff rows are addressed by their event's upsert key so + /// a later version of the same event replaces it **in place** — the client + /// keeps first-seen order, which is what stops a tool card from jumping to + /// the bottom of the transcript the moment it finishes. + /// + /// A reference type so the rendered text can be memoized on first display + /// without copying the row back into the table's snapshot. + private final class Row { + enum Kind { + case text(role: AgentEvent.Role, markdown: String, thinking: Bool) + case tool(name: String, kind: AgentEvent.ToolKind, title: String, + subtitle: String?, status: AgentEvent.ToolStatus) + case diff(path: String, unified: String) + case plan(items: [AgentEvent.PlanItem]) + /// "Today", "Yesterday", "12 August" — the pill every messenger + /// puts between two days of conversation. + case day(String) + } + + let id: String + var kind: Kind + /// The transcript's clock for this row, shown on prose and used to + /// decide where a day boundary falls. + var at: Date? + /// True when the row above is the same speaker, which tightens the gap + /// above this one. Grouping is what turns a list of bubbles into a + /// conversation with a rhythm. + var grouped = false + /// A message this phone wrote that the transcript has not echoed back + /// yet. Drawn dimmed, and replaced by the real event when it lands. + var pending = false + /// Memoized on first display. Cleared whenever `kind` changes, so an + /// upserted tool card re-renders exactly once. + var rendered: NSAttributedString? + + init(id: String, kind: Kind) { + self.id = id + self.kind = kind + } + } + + private var rowsByID: [String: Row] = [:] + /// The highest `seq` applied. Handed back on reconnect so the Mac replays + /// only the gap. + private(set) var cursor = 0 + + private let tableView = UITableView(frame: .zero, style: .plain) + private let emptyLabel = UILabel() + /// Shown only for a dormant session. Worth its own row rather than a + /// navigation prompt: "there is no process behind this, and you are reading + /// it anyway" is the one thing this view does that the terminal cannot, so + /// it should not be a subtitle the eye skips. + private let dormantBanner = UILabel() + private var dataSource: UITableViewDiffableDataSource? + + /// Called when the view wants events from `since`. Set by the presenter so + /// this controller never owns a transport. + var onNeedEvents: ((Int) -> Void)? + /// Back to the session list. + var onRequestBack: (() -> Void)? + /// Switch this session to its terminal. + var onRequestTerminal: (() -> Void)? + /// Submit what the user typed to the agent. + var onSend: ((String) -> Void)? + + /// Where you are, above the title — the same two-line header the terminal + /// draws, because the two views are one session and swapping between them + /// should not move the chrome. + var context: String? { + didSet { + contextLabel.text = context + contextLabel.isHidden = context?.isEmpty ?? true + } + } + override var title: String? { + didSet { titleLabel.text = title } + } + + /// What the session is doing right now, in the line under the title — + /// Telegram's "typing…" slot, filled from the same agent status the + /// session list shows. It replaces the project · branch line while the + /// agent is busy or blocked, and yields back when there is nothing to say. + var activity: SessionStatus = .idle { + didSet { + guard activity != oldValue else { return } + switch activity { + case .working: contextLabel.text = localized("working…") + case .needsAttention: contextLabel.text = localized("waiting for you") + case .idle, .done: contextLabel.text = context + } + contextLabel.textColor = activity == .needsAttention ? .systemOrange : .secondaryLabel + contextLabel.isHidden = contextLabel.text?.isEmpty ?? true + // Liveness has two sources — the header event, sent once when the + // subscription opened, and this status, which is current. An agent + // that is working is by definition running, so the banner cannot + // keep claiming otherwise. + if activity == .working || activity == .needsAttention { showDormantBanner(false) } + } + } + + private let headerBar = UIStackView() + private let titleLabel = UILabel() + private let contextLabel = UILabel() + private lazy var composer = ChatComposerView() + /// Numbers the local echoes, so two identical messages stay two rows. + private var pendingCount = 0 + /// The day and speaker of the last row appended, for separators and + /// grouping. Both follow append order, which is conversation order. + private var lastDayKey: String? + private var lastRole: AgentEvent.Role? + /// Jumps back to the newest message. Hidden while already at the bottom — + /// a control that is always visible over a conversation reads as chrome. + private let jumpButton = UIButton(type: .system) + private let sendFeedback = UIImpactFeedbackGenerator(style: .light) + /// Measured row heights, fed back as estimates. Self-sizing cells with one + /// fixed estimate make a long conversation lurch while scrolling: every + /// row that turns out taller than the guess shifts everything below it. + private var heights: [String: CGFloat] = [:] + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + tableView.separatorStyle = .none + tableView.allowsSelection = false + tableView.estimatedRowHeight = 64 + tableView.rowHeight = UITableView.automaticDimension + tableView.keyboardDismissMode = .interactive + tableView.register(ChatTextCell.self, forCellReuseIdentifier: ChatTextCell.reuseID) + tableView.register(ChatToolCell.self, forCellReuseIdentifier: ChatToolCell.reuseID) + tableView.register(ChatDiffCell.self, forCellReuseIdentifier: ChatDiffCell.reuseID) + tableView.register(ChatPlanCell.self, forCellReuseIdentifier: ChatPlanCell.reuseID) + tableView.register(ChatDayCell.self, forCellReuseIdentifier: ChatDayCell.reuseID) + tableView.delegate = self + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + + dataSource = UITableViewDiffableDataSource(tableView: tableView) { + [weak self] table, indexPath, identifier in + guard let self, let row = self.rowsByID[identifier] else { return UITableViewCell() } + return self.cell(for: row, in: table, at: indexPath) + } + // Arrivals rise from the bottom edge, the way a message does in every + // messenger; a fade reads as content changing rather than arriving. + dataSource?.defaultRowAnimation = .bottom + + emptyLabel.text = localized("No conversation yet.") + emptyLabel.textColor = .secondaryLabel + emptyLabel.font = .preferredFont(forTextStyle: .subheadline) + emptyLabel.adjustsFontForContentSizeCategory = true + emptyLabel.textAlignment = .center + emptyLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyLabel) + + dormantBanner.font = UIFontMetrics(forTextStyle: .caption1) + .scaledFont(for: .systemFont(ofSize: 12, weight: .medium)) + dormantBanner.adjustsFontForContentSizeCategory = true + dormantBanner.textColor = .secondaryLabel + dormantBanner.textAlignment = .center + dormantBanner.backgroundColor = .secondarySystemBackground + dormantBanner.isHidden = true + dormantBanner.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(dormantBanner) + + configureHeader() + + composer.translatesAutoresizingMaskIntoConstraints = false + composer.onSend = { [weak self] text in self?.submit(text) } + view.addSubview(composer) + + jumpButton.applyGlassSymbol("chevron.down", pointSize: 15) + jumpButton.accessibilityIdentifier = "chat.jump" + jumpButton.accessibilityLabel = localized("Scroll to Latest") + jumpButton.tintColor = .label + jumpButton.alpha = 0 + jumpButton.addAction( + UIAction { [weak self] _ in self?.scrollToBottom(animated: true) }, for: .touchUpInside) + jumpButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(jumpButton) + + bannerHeight = dormantBanner.heightAnchor.constraint(equalToConstant: 0) + NSLayoutConstraint.activate([ + dormantBanner.topAnchor.constraint(equalTo: headerBar.bottomAnchor, constant: 4), + dormantBanner.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dormantBanner.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bannerHeight, + tableView.topAnchor.constraint(equalTo: dormantBanner.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + // Runs to the bottom edge and clears the floating bar with a content + // inset instead of stopping at it, so the transcript passes behind + // the pill rather than being cut off by an invisible wall. + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + composer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composer.trailingAnchor.constraint(equalTo: view.trailingAnchor), + // The bar's fill runs to the screen edge so nothing shows through + // beneath it; the pill inside is what rides the keyboard. The guide + // collapses to the safe area when the keyboard is down, so one + // constraint covers both states and tracks an interactive dismissal + // frame by frame. + composer.bottomAnchor.constraint(equalTo: view.bottomAnchor), + composer.pillBottomAnchor.constraint( + equalTo: view.keyboardLayoutGuide.topAnchor, constant: -8), + + jumpButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), + jumpButton.bottomAnchor.constraint(equalTo: composer.topAnchor, constant: -12), + jumpButton.widthAnchor.constraint(equalToConstant: 38), + jumpButton.heightAnchor.constraint(equalToConstant: 38), + ]) + + onNeedEvents?(cursor) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // The composer rides the keyboard, so how much of the table it covers + // changes frame by frame during an interactive dismissal — the inset is + // read from its actual frame rather than assumed. + let covered = max(0, view.bounds.maxY - composer.frame.minY) + guard abs(tableView.contentInset.bottom - covered) > 0.5 else { return } + tableView.contentInset.bottom = covered + tableView.verticalScrollIndicatorInsets.bottom = covered + } + + /// The session's chrome: back chevron to the list, the two-line title + /// centered, and the one control that matters here — the switch to the + /// terminal. Deliberately the terminal's own header, because a session that + /// changed its bar when you switched views would read as two screens + /// instead of two views of one conversation. + private func configureHeader() { + contextLabel.font = .preferredFont(forTextStyle: .caption2) + contextLabel.textColor = .secondaryLabel + contextLabel.textAlignment = .center + // Whatever the presenter set before this view loaded. + contextLabel.text = context + contextLabel.isHidden = context?.isEmpty ?? true + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + titleLabel.textColor = .label + titleLabel.textAlignment = .center + titleLabel.lineBreakMode = .byTruncatingTail + + let titles = UIStackView(arrangedSubviews: [contextLabel, titleLabel]) + titles.axis = .vertical + titles.alignment = .fill + + let back = UIButton(type: .system) + back.applyGlassSymbol("chevron.left", pointSize: 18) + back.accessibilityIdentifier = "chat.back" + back.tintColor = .label + back.addAction(UIAction { [weak self] _ in self?.onRequestBack?() }, for: .touchUpInside) + + let terminal = UIButton(type: .system) + terminal.applyGlassSymbol("apple.terminal", pointSize: 16) + terminal.accessibilityIdentifier = "chat.terminal" + terminal.accessibilityLabel = localized("Terminal") + terminal.tintColor = .label + terminal.addAction(UIAction { [weak self] _ in self?.onRequestTerminal?() }, for: .touchUpInside) + + headerBar.axis = .horizontal + headerBar.alignment = .center + headerBar.spacing = 4 + headerBar.addArrangedSubview(back) + headerBar.addArrangedSubview(titles) + headerBar.addArrangedSubview(terminal) + headerBar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(headerBar) + NSLayoutConstraint.activate([ + headerBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + headerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), + headerBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + back.widthAnchor.constraint(equalToConstant: 44), + back.heightAnchor.constraint(equalToConstant: 44), + terminal.widthAnchor.constraint(equalToConstant: 44), + terminal.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + private var bannerHeight = NSLayoutConstraint() + + private func showDormantBanner(_ shown: Bool) { + dormantBanner.isHidden = !shown + bannerHeight.constant = shown ? 26 : 0 + } + + /// Send what was typed, and show it immediately. + /// + /// The message is not confirmed until the agent's transcript records the + /// turn, which is a second or so away — a chat that showed nothing until + /// then would read as having swallowed it. So the row goes up dimmed and is + /// replaced by the real event when it arrives (`reconcilePendingRow`). + private func submit(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let dataSource else { return } + onSend?(trimmed) + + pendingCount += 1 + let identifier = "pending:\(pendingCount)" + var snapshot = dataSource.snapshot() + if snapshot.numberOfSections == 0 { snapshot.appendSections([0]) } + // The separator first: it clears the grouping run, so asking it after + // would group this message under yesterday's last one. + let sentAt = Date() + if let separator = daySeparator(before: sentAt) { + rowsByID[separator.id] = separator + snapshot.appendItems([separator.id], toSection: 0) + } + + let row = Row(id: identifier, kind: .text(role: .user, markdown: trimmed, thinking: false)) + row.pending = true + row.at = sentAt + row.grouped = lastRole == .user + lastRole = .user + rowsByID[identifier] = row + snapshot.appendItems([identifier], toSection: 0) + sendFeedback.impactOccurred() + emptyLabel.isHidden = true + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.scrollToBottom() + } + } + + /// Drops the local echo an arriving user event stands in for. Matching on + /// text rather than an id is what the transcript allows: the agent's record + /// of the turn carries no reference to the keystrokes that produced it. + private func reconcilePendingRow(matching text: String) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return rowsByID.first { _, row in + guard row.pending, case .text(_, let markdown, _) = row.kind else { return false } + return markdown == trimmed + }?.key + } + + /// Folds a batch into the view. Safe to call with events already applied: + /// an upsert-keyed event replaces its earlier version and a stale `seq` is + /// ignored, so a duplicate batch after a reconnect is a no-op. + func apply(_ events: [AgentEvent]) { + guard !events.isEmpty, let dataSource else { return } + var snapshot = dataSource.snapshot() + if snapshot.numberOfSections == 0 { snapshot.appendSections([0]) } + + var appended = false + var reconfigured: [String] = [] + + for event in events { + switch event.payload { + case .sessionInfo(let sessionTitle, _, let state): + if title?.isEmpty ?? true { title = sessionTitle } + dormantBanner.text = localized("Not running — showing saved history") + showDormantBanner(state == .dormant && activity != .working) + continue + case .turnStart, .turnEnd, .usage: + // Nothing to draw yet. Turn boundaries earn their keep when the + // composer lands and needs to know a turn is in flight. + continue + default: + break + } + + guard event.seq > cursor || event.upsertKey != nil else { continue } + cursor = max(cursor, event.seq) + + let kind: Row.Kind + switch event.payload { + case .text(let text, let thinking): + kind = .text(role: event.role, markdown: text, thinking: thinking) + case .tool(_, let name, let toolKind, let title, let subtitle, let status, _): + kind = .tool( + name: name, kind: toolKind, title: title, subtitle: subtitle, status: status) + case .diff(_, let path, let unified): + kind = .diff(path: path, unified: unified) + case .plan(let planItems): + kind = .plan(items: planItems) + default: + continue + } + + // The transcript caught up with something this phone sent: the real + // event takes the local echo's place rather than sitting under it. + // The echo's clock is inherited when the event has none, so a + // confirmed message never loses the time it was sent at. + var inheritedClock: Date? + if case .text(let text, false) = event.payload, event.role == .user, + let echo = reconcilePendingRow(matching: text) { + inheritedClock = rowsByID.removeValue(forKey: echo)?.at + snapshot.deleteItems([echo]) + } + + let identifier = event.upsertKey ?? "seq:\(event.seq)" + if let existing = rowsByID[identifier] { + existing.kind = kind + existing.rendered = nil + heights[identifier] = nil + reconfigured.append(identifier) + } else { + let clock = event.at ?? inheritedClock + if let separator = daySeparator(before: clock) { + rowsByID[separator.id] = separator + snapshot.appendItems([separator.id], toSection: 0) + } + let row = Row(id: identifier, kind: kind) + row.at = clock + if case .text = kind { + row.grouped = lastRole == event.role + lastRole = event.role + } else { + lastRole = nil + } + rowsByID[identifier] = row + snapshot.appendItems([identifier], toSection: 0) + appended = true + } + } + + emptyLabel.isHidden = !rowsByID.isEmpty + if !reconfigured.isEmpty { + // reconfigure, not reload: the cell keeps its identity and its + // place, so an in-flight tool card updating cannot scroll the view. + snapshot.reconfigureItems(reconfigured.filter { snapshot.itemIdentifiers.contains($0) }) + } + guard appended || !reconfigured.isEmpty else { return } + + let shouldStickToBottom = appended && isNearBottom + // A live message slides in; a cold replay of hundreds does not — an + // animated diff over a whole conversation is a visible stall, and + // nobody is watching the moment it lands anyway. + let arrived = snapshot.numberOfItems - dataSource.snapshot().numberOfItems + let animates = appended && arrived <= 3 && view.window != nil + dataSource.apply(snapshot, animatingDifferences: animates) { [weak self] in + guard shouldStickToBottom else { return } + self?.scrollToBottom(animated: animates) + } + } + + /// The text a long press copies: the message, the command, or the diff — + /// whatever that row is actually made of. A day pill has nothing to copy. + private func copyableText(of identifier: String) -> String? { + guard let row = rowsByID[identifier] else { return nil } + switch row.kind { + case .text(_, let markdown, _): return markdown + case .tool(_, _, let title, _, _): return title + case .diff(let path, let unified): return "\(path)\n\(unified)" + case .plan(let items): return items.map { "- \($0.text)" }.joined(separator: "\n") + case .day: return nil + } + } + + /// A pill row when the day changes, and nothing otherwise. Rows without a + /// timestamp inherit the current day rather than forcing a break: a + /// separator that appeared because one event lacked a clock would be worse + /// than no separator at all. + private func daySeparator(before date: Date?) -> Row? { + guard let date else { return nil } + let key = Self.dayKey.string(from: date) + guard key != lastDayKey else { return nil } + lastDayKey = key + // Grouping never spans a day break. + lastRole = nil + return Row(id: "day:\(key)", kind: .day(Self.dayLabel(for: date))) + } + + private static let dayKey: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let dayName: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("dMMMM") + return formatter + }() + + static let timeOfDay: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter + }() + + private static func dayLabel(for date: Date) -> String { + let calendar = Calendar.current + if calendar.isDateInToday(date) { return localized("Today") } + if calendar.isDateInYesterday(date) { return localized("Yesterday") } + return dayName.string(from: date) + } + + /// Only follow new output when the reader is already at the end — yanking + /// someone back down while they are reading earlier output is the single + /// most irritating thing a live transcript can do. + private var isNearBottom: Bool { + let offset = tableView.contentOffset.y + tableView.bounds.height + return offset >= tableView.contentSize.height - 120 + } + + private func scrollToBottom(animated: Bool = false) { + guard let count = dataSource?.snapshot().numberOfItems, count > 0 else { return } + tableView.scrollToRow( + at: IndexPath(row: count - 1, section: 0), at: .bottom, animated: animated) + updateJumpButton() + } + + /// The jump control fades rather than pops: it appears while the reader is + /// scrolling, and a control that materializes mid-gesture is the kind of + /// motion that reads as a glitch. + private func updateJumpButton() { + let wanted: CGFloat = isNearBottom ? 0 : 1 + guard jumpButton.alpha != wanted else { return } + UIView.animate(withDuration: 0.2) { self.jumpButton.alpha = wanted } + } + + private func cell(for row: Row, in table: UITableView, at indexPath: IndexPath) -> UITableViewCell { + switch row.kind { + case .text(let role, let markdown, let thinking): + let cell = + table.dequeueReusableCell(withIdentifier: ChatTextCell.reuseID, for: indexPath) + as? ChatTextCell ?? ChatTextCell() + if row.rendered == nil { + var style = MarkdownAttributedText.Style() + if thinking { style.textColor = .secondaryLabel } + row.rendered = MarkdownAttributedText.render(markdown, style: style) + } + cell.configure( + role: role, text: row.rendered, thinking: thinking, pending: row.pending, + time: row.at.map(Self.timeOfDay.string(from:)), grouped: row.grouped) + return cell + case .tool(let name, let kind, let title, let subtitle, let status): + let cell = + table.dequeueReusableCell(withIdentifier: ChatToolCell.reuseID, for: indexPath) + as? ChatToolCell ?? ChatToolCell() + cell.configure(name: name, kind: kind, title: title, subtitle: subtitle, status: status) + return cell + case .diff(let path, let unified): + let cell = + table.dequeueReusableCell(withIdentifier: ChatDiffCell.reuseID, for: indexPath) + as? ChatDiffCell ?? ChatDiffCell() + if row.rendered == nil { row.rendered = ChatDiffCell.render(unified) } + cell.configure(path: path, body: row.rendered) + return cell + case .plan(let planItems): + let cell = + table.dequeueReusableCell(withIdentifier: ChatPlanCell.reuseID, for: indexPath) + as? ChatPlanCell ?? ChatPlanCell() + cell.configure(items: planItems) + return cell + case .day(let label): + let cell = + table.dequeueReusableCell(withIdentifier: ChatDayCell.reuseID, for: indexPath) + as? ChatDayCell ?? ChatDayCell() + cell.configure(label) + return cell + } + } +} + +// MARK: - Scrolling and message actions + +extension ChatLensViewController: UITableViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateJumpButton() + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath) { + guard let identifier = dataSource?.itemIdentifier(for: indexPath) else { return } + heights[identifier] = cell.bounds.height + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let identifier = dataSource?.itemIdentifier(for: indexPath) else { return 64 } + return heights[identifier] ?? 64 + } + + /// Long press to copy — the one message action worth having before replies + /// and reactions exist, and the one people reach for on a phone when the + /// text is a path or a command they want on the Mac. + func tableView( + _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + guard let identifier = dataSource?.itemIdentifier(for: indexPath), + let text = copyableText(of: identifier) + else { return nil } + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + UIMenu(children: [ + UIAction(title: localized("Copy"), image: UIImage(systemName: "doc.on.doc")) { _ in + UIPasteboard.general.string = text + } + ]) + } + } +} + + +// MARK: - Cells + +/// Prose. The asymmetry is deliberate and matches what every phone client of a +/// long-form assistant converges on: what the human typed is short and gets a +/// bubble; what the agent wrote is long, contains code, and gets the full +/// column. Bubbling agent output would cost ~20% of the width that its code +/// blocks need most. +private final class ChatTextCell: UITableViewCell { + static let reuseID = "text" + + private let bubble = UIView() + private let body = UITextView() + private let caption = UILabel() + private let time = UILabel() + /// Adjusted per configure: a message under the same speaker sits closer to + /// it than to a change of speaker. + private var topSpacing = NSLayoutConstraint() + /// The cell's bottom is owned by the bubble or by the time label, never + /// both — two required constraints to the same edge is how a stamp ends up + /// silently collapsed to nothing. + private var bubbleClosesCell = NSLayoutConstraint() + private var timeClosesCell = NSLayoutConstraint() + /// Both layouts are built once and toggled. Rebuilding constraints on every + /// configure is a per-scroll cost for a decision that only has two answers. + private var userLayout: [NSLayoutConstraint] = [] + private var agentLayout: [NSLayoutConstraint] = [] + private var bubbleInsets: [NSLayoutConstraint] = [] + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + + body.isEditable = false + body.isScrollEnabled = false + body.backgroundColor = .clear + body.textContainerInset = .zero + body.textContainer.lineFragmentPadding = 0 + body.dataDetectorTypes = [.link] + body.adjustsFontForContentSizeCategory = true + body.translatesAutoresizingMaskIntoConstraints = false + + caption.font = UIFontMetrics(forTextStyle: .caption2) + .scaledFont(for: .systemFont(ofSize: 11, weight: .medium)) + caption.adjustsFontForContentSizeCategory = true + caption.textColor = .secondaryLabel + caption.translatesAutoresizingMaskIntoConstraints = false + + bubble.layer.cornerRadius = 18 + bubble.layer.cornerCurve = .continuous + bubble.translatesAutoresizingMaskIntoConstraints = false + + time.font = UIFontMetrics(forTextStyle: .caption2) + .scaledFont(for: .systemFont(ofSize: 10)) + time.adjustsFontForContentSizeCategory = true + time.textColor = .tertiaryLabel + time.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(bubble) + contentView.addSubview(caption) + contentView.addSubview(time) + bubble.addSubview(body) + + let top = body.topAnchor.constraint(equalTo: bubble.topAnchor) + let bottom = body.bottomAnchor.constraint(equalTo: bubble.bottomAnchor) + let leading = body.leadingAnchor.constraint(equalTo: bubble.leadingAnchor) + let trailing = body.trailingAnchor.constraint(equalTo: bubble.trailingAnchor) + bubbleInsets = [top, bottom, leading, trailing] + + topSpacing = caption.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10) + bubbleClosesCell = bubble.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor, constant: -4) + timeClosesCell = time.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor, constant: -4) + NSLayoutConstraint.activate([ + topSpacing, + caption.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + bubble.topAnchor.constraint(equalTo: caption.bottomAnchor, constant: 2), + bubbleClosesCell, + bubble.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + top, bottom, leading, trailing, + + time.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -2), + time.topAnchor.constraint(equalTo: bubble.bottomAnchor, constant: 2), + ]) + + userLayout = [ + bubble.leadingAnchor.constraint( + greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60) + ] + agentLayout = [ + bubble.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16) + ] + } + + required init?(coder: NSCoder) { nil } + + func configure( + role: AgentEvent.Role, text: NSAttributedString?, thinking: Bool, pending: Bool = false, + time stamp: String? = nil, grouped: Bool = false + ) { + let isUser = role == .user && !thinking + caption.text = thinking ? localized("Thinking") : nil + caption.isHidden = !thinking + body.attributedText = text + // A clock on every line is noise; the user's own messages are the ones + // people scan for ("when did I ask for that?"), so only those carry it. + let showsTime = isUser && stamp != nil + time.text = stamp + time.isHidden = !showsTime + bubbleClosesCell.isActive = !showsTime + timeClosesCell.isActive = showsTime + topSpacing.constant = grouped ? 2 : 10 + // Dimmed until the transcript confirms it — the same "sent, not yet + // acknowledged" grammar every messenger uses. + contentView.alpha = pending ? 0.45 : 1 + + // The one thing on the page that is *yours* sits highest in the fill + // ladder; everything the agent produced sits below it. Same family, so + // the page still reads as one material in both light and dark. + bubble.backgroundColor = isUser ? .secondarySystemFill : .clear + let inset: CGFloat = isUser ? 12 : 0 + bubbleInsets[0].constant = inset + bubbleInsets[1].constant = -inset + bubbleInsets[2].constant = inset + bubbleInsets[3].constant = -inset + + NSLayoutConstraint.deactivate(isUser ? agentLayout : userLayout) + NSLayoutConstraint.activate(isUser ? userLayout : agentLayout) + + accessibilityLabel = text?.string + } +} + +/// A tool call. One row, because on a phone the useful information is *which* +/// tool touched *what* and whether it worked — the output itself is usually +/// hundreds of lines and belongs behind a tap, not in the scroll. +private final class ChatToolCell: UITableViewCell { + static let reuseID = "tool" + + private let card = UIView() + private let icon = UIImageView() + private let title = UILabel() + private let subtitle = UILabel() + private let statusIcon = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + + card.backgroundColor = .tertiarySystemFill + card.layer.cornerRadius = 12 + card.layer.cornerCurve = .continuous + card.translatesAutoresizingMaskIntoConstraints = false + + icon.tintColor = .secondaryLabel + icon.contentMode = .scaleAspectFit + icon.translatesAutoresizingMaskIntoConstraints = false + + title.font = UIFontMetrics(forTextStyle: .footnote) + .scaledFont(for: .monospacedSystemFont(ofSize: 13, weight: .regular)) + title.adjustsFontForContentSizeCategory = true + title.textColor = .label + title.numberOfLines = 2 + title.lineBreakMode = .byTruncatingTail + title.translatesAutoresizingMaskIntoConstraints = false + + subtitle.font = UIFontMetrics(forTextStyle: .caption2) + .scaledFont(for: .systemFont(ofSize: 11)) + subtitle.adjustsFontForContentSizeCategory = true + subtitle.textColor = .secondaryLabel + subtitle.numberOfLines = 1 + subtitle.lineBreakMode = .byTruncatingHead + subtitle.translatesAutoresizingMaskIntoConstraints = false + + statusIcon.contentMode = .scaleAspectFit + statusIcon.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(card) + [icon, title, subtitle, statusIcon].forEach(card.addSubview) + + NSLayoutConstraint.activate([ + card.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + card.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4), + card.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + card.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12), + icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 12), + icon.widthAnchor.constraint(equalToConstant: 16), + icon.heightAnchor.constraint(equalToConstant: 16), + + title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10), + title.topAnchor.constraint(equalTo: card.topAnchor, constant: 10), + title.trailingAnchor.constraint(equalTo: statusIcon.leadingAnchor, constant: -10), + + subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor), + subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor), + subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 2), + subtitle.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10), + + statusIcon.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12), + statusIcon.centerYAnchor.constraint(equalTo: card.centerYAnchor), + statusIcon.widthAnchor.constraint(equalToConstant: 14), + statusIcon.heightAnchor.constraint(equalToConstant: 14), + ]) + } + + required init?(coder: NSCoder) { nil } + + func configure( + name: String, kind: AgentEvent.ToolKind, title toolTitle: String, + subtitle toolSubtitle: String?, status: AgentEvent.ToolStatus + ) { + icon.image = UIImage(systemName: Self.symbol(for: kind)) + title.text = toolTitle + subtitle.text = toolSubtitle ?? name + + let statusDescription: String + switch status { + case .pending, .running: + statusIcon.image = UIImage(systemName: "circle.dotted") + statusIcon.tintColor = .tertiaryLabel + statusDescription = localized("running") + case .done: + statusIcon.image = UIImage(systemName: "checkmark") + statusIcon.tintColor = .systemGreen + statusDescription = localized("finished") + case .error: + statusIcon.image = UIImage(systemName: "xmark") + statusIcon.tintColor = .systemRed + statusDescription = localized("failed") + } + + isAccessibilityElement = true + accessibilityLabel = "\(name), \(toolTitle), \(statusDescription)" + } + + private static func symbol(for kind: AgentEvent.ToolKind) -> String { + switch kind { + case .read: return "doc.text" + case .edit: return "pencil" + case .execute: return "terminal" + case .search: return "magnifyingglass" + case .think: return "sparkles" + case .fetch: return "globe" + case .other: return "wrench.and.screwdriver" + } + } +} + +/// A diff, parsed with the same `DiffParser` the Mac's git pane uses so the +/// phone never learns a second diff shape. Long hunks are capped: a 900-line +/// edit is not something anyone reads inside a chat scroll. +private final class ChatDiffCell: UITableViewCell { + static let reuseID = "diff" + /// Counted in *logical* diff lines, but the budget is set by visual ones: at + /// phone width a line of prose wraps to three or four rows, so 40 logical + /// lines is a screen-and-a-half of solid colour. A diff here is a preview + /// that says which file changed and roughly how — the full hunk belongs + /// behind a tap. + private static let maximumLines = 14 + + private let card = UIView() + private let pathLabel = UILabel() + private let bodyLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + + card.backgroundColor = .tertiarySystemFill + card.layer.cornerRadius = 12 + card.layer.cornerCurve = .continuous + card.clipsToBounds = true + card.translatesAutoresizingMaskIntoConstraints = false + + pathLabel.font = UIFontMetrics(forTextStyle: .caption2) + .scaledFont(for: .systemFont(ofSize: 11, weight: .medium)) + pathLabel.adjustsFontForContentSizeCategory = true + pathLabel.textColor = .secondaryLabel + pathLabel.lineBreakMode = .byTruncatingHead + pathLabel.translatesAutoresizingMaskIntoConstraints = false + + bodyLabel.numberOfLines = 0 + bodyLabel.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(card) + card.addSubview(pathLabel) + card.addSubview(bodyLabel) + + NSLayoutConstraint.activate([ + card.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + card.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4), + card.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + card.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + pathLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 10), + pathLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12), + pathLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12), + + bodyLabel.topAnchor.constraint(equalTo: pathLabel.bottomAnchor, constant: 6), + bodyLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12), + bodyLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12), + bodyLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10), + ]) + } + + required init?(coder: NSCoder) { nil } + + func configure(path: String, body: NSAttributedString?) { + pathLabel.text = (path as NSString).lastPathComponent + bodyLabel.attributedText = body + } + + /// Built once per row and cached by the caller — the parse plus per-line + /// attribute runs are far too much work to repeat on every dequeue. + static func render(_ unified: String) -> NSAttributedString { + let font = UIFontMetrics(forTextStyle: .caption2) + .scaledFont(for: .monospacedSystemFont(ofSize: 11, weight: .regular)) + let rendered = NSMutableAttributedString() + var shown = 0 + var hidden = 0 + + for line in DiffParser.lines(from: unified) where line.kind != .hunk { + guard shown < maximumLines else { + hidden += 1 + continue + } + shown += 1 + // `DiffLine.text` has its marker stripped so renderers can style it + // separately; here the marker goes back inline, which is what reads + // at phone width without a gutter column. + let marker: String + let color: UIColor + let background: UIColor + switch line.kind { + case .addition: + marker = "+" + color = .systemGreen + background = UIColor.systemGreen.withAlphaComponent(0.12) + case .deletion: + marker = "-" + color = .systemRed + background = UIColor.systemRed.withAlphaComponent(0.12) + default: + marker = " " + color = .secondaryLabel + background = .clear + } + rendered.append( + NSAttributedString( + string: marker + line.text + "\n", + attributes: [.font: font, .foregroundColor: color, .backgroundColor: background])) + } + if hidden > 0 { + rendered.append( + NSAttributedString( + string: localized("+\(hidden) more lines"), + attributes: [ + .font: UIFont.preferredFont(forTextStyle: .caption2), + .foregroundColor: UIColor.tertiaryLabel, + ])) + } + return rendered + } +} + +/// The agent's plan as a real checklist. In the terminal this is a box the TUI +/// redraws in place, which on a phone means it scrolls past as a dozen stale +/// copies; here the same event upserts onto one row. +private final class ChatPlanCell: UITableViewCell { + static let reuseID = "plan" + + private let card = UIView() + private let stack = UIStackView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + + card.backgroundColor = .tertiarySystemFill + card.layer.cornerRadius = 12 + card.layer.cornerCurve = .continuous + card.translatesAutoresizingMaskIntoConstraints = false + + stack.axis = .vertical + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(card) + card.addSubview(stack) + + NSLayoutConstraint.activate([ + card.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + card.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4), + card.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + card.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 12), + stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12), + stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12), + ]) + } + + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + stack.arrangedSubviews.forEach { $0.removeFromSuperview() } + } + + func configure(items: [AgentEvent.PlanItem]) { + stack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for item in items { + let row = UIStackView() + row.axis = .horizontal + row.spacing = 8 + row.alignment = .firstBaseline + + let mark = UIImageView() + mark.contentMode = .scaleAspectFit + switch item.status { + case .completed: + mark.image = UIImage(systemName: "checkmark.circle.fill") + mark.tintColor = .systemGreen + case .inProgress: + mark.image = UIImage(systemName: "circle.dashed.inset.filled") + mark.tintColor = .systemBlue + case .pending: + mark.image = UIImage(systemName: "circle") + mark.tintColor = .tertiaryLabel + } + mark.setContentHuggingPriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + mark.widthAnchor.constraint(equalToConstant: 14), + mark.heightAnchor.constraint(equalToConstant: 14), + ]) + + let label = UILabel() + label.text = item.text + label.font = .preferredFont(forTextStyle: .subheadline) + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + label.textColor = item.status == .completed ? .secondaryLabel : .label + + row.addArrangedSubview(mark) + row.addArrangedSubview(label) + stack.addArrangedSubview(row) + } + } +} + +// MARK: - Composer + +/// The input bar. What you type here is bracketed-pasted into the session's +/// PTY and submitted — the same bytes the Mac sends when you type at the +/// terminal, because there is no second way in: the agent is a program on a +/// terminal, not an API. +/// +/// Return inserts a newline and the button sends, which is the iOS messenger +/// convention and the right one here: agent prompts are routinely several +/// lines, and a Return that submitted would cut most of them in half. +final class ChatComposerView: UIView, UITextViewDelegate { + var onSend: ((String) -> Void)? + + private let pill = UIView() + private let field = UITextView() + private let placeholder = UILabel() + private let sendButton = UIButton(type: .system) + private let maximumHeight: CGFloat = 132 + private let restingHeight: CGFloat = 44 + private var fieldHeight = NSLayoutConstraint() + /// Text inset inside the pill. The placeholder is a sibling of the field + /// rather than its text, so it has to be positioned to the same origin. + private static let textInset = UIEdgeInsets(top: 12, left: 18, bottom: 12, right: 6) + + override init(frame: CGRect) { + super.init(frame: frame) + // The page's own color and no rule across the top: the bar reads as the + // bottom of the page with a control resting on it, rather than a slab + // announced by a hairline. It has to be opaque — the transcript scrolls + // behind it, and a transparent bar lets half a line of code show through + // the gaps around the pill. + backgroundColor = .systemBackground + + pill.backgroundColor = .tertiarySystemFill + pill.layer.cornerCurve = .continuous + pill.translatesAutoresizingMaskIntoConstraints = false + addSubview(pill) + + field.font = .preferredFont(forTextStyle: .body) + field.adjustsFontForContentSizeCategory = true + field.isScrollEnabled = false + field.backgroundColor = .clear + field.textContainerInset = Self.textInset + field.textContainer.lineFragmentPadding = 0 + field.delegate = self + field.accessibilityIdentifier = "chat.composer" + field.translatesAutoresizingMaskIntoConstraints = false + pill.addSubview(field) + + placeholder.text = localized("Message") + placeholder.font = .preferredFont(forTextStyle: .body) + placeholder.adjustsFontForContentSizeCategory = true + placeholder.textColor = .tertiaryLabel + placeholder.isUserInteractionEnabled = false + placeholder.translatesAutoresizingMaskIntoConstraints = false + pill.addSubview(placeholder) + + // A plain filled circle, not a glass button: this one rides inside the + // pill, and glass is for controls floating free over content. + var config = UIButton.Configuration.plain() + config.image = UIImage(systemName: "arrow.up") + config.preferredSymbolConfigurationForImage = .init(pointSize: 15, weight: .semibold) + config.background.cornerRadius = 16 + sendButton.configuration = config + sendButton.configurationUpdateHandler = { button in + // Neutral, never accent-filled: enablement shows in the glyph and + // the chip, the way the rest of the app's controls read. + button.configuration?.background.backgroundColor = + button.isEnabled ? .tertiarySystemFill : .clear + button.configuration?.baseForegroundColor = + button.isEnabled ? .label : .quaternaryLabel + } + sendButton.accessibilityIdentifier = "chat.send" + sendButton.accessibilityLabel = localized("Send") + sendButton.isEnabled = false + sendButton.addAction(UIAction { [weak self] _ in self?.send() }, for: .touchUpInside) + sendButton.translatesAutoresizingMaskIntoConstraints = false + pill.addSubview(sendButton) + + fieldHeight = field.heightAnchor.constraint(equalToConstant: restingHeight) + NSLayoutConstraint.activate([ + pill.topAnchor.constraint(equalTo: topAnchor, constant: 6), + pill.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + pill.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + + field.topAnchor.constraint(equalTo: pill.topAnchor), + field.bottomAnchor.constraint(equalTo: pill.bottomAnchor), + field.leadingAnchor.constraint(equalTo: pill.leadingAnchor), + field.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor), + fieldHeight, + + placeholder.leadingAnchor.constraint( + equalTo: field.leadingAnchor, constant: Self.textInset.left), + placeholder.topAnchor.constraint( + equalTo: field.topAnchor, constant: Self.textInset.top), + + // Pinned to the bottom, so a prompt that grows to several lines + // keeps its send key under the thumb instead of drifting upward. + sendButton.trailingAnchor.constraint(equalTo: pill.trailingAnchor, constant: -6), + sendButton.bottomAnchor.constraint(equalTo: pill.bottomAnchor, constant: -6), + sendButton.widthAnchor.constraint(equalToConstant: 32), + sendButton.heightAnchor.constraint(equalToConstant: 32), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + /// The pill's bottom edge. The owner rides this on the keyboard while the + /// bar's fill runs all the way to the screen edge — without that split, the + /// transcript shows through the home-indicator strip under the pill. + var pillBottomAnchor: NSLayoutYAxisAnchor { pill.bottomAnchor } + + override func layoutSubviews() { + super.layoutSubviews() + // A capsule while it holds one line; once the prompt grows past two, a + // half-height radius would bow the sides into a lozenge, so it settles + // into a rounded rectangle instead. + pill.layer.cornerRadius = min(pill.bounds.height / 2, 24) + } + + private func send() { + let text = field.text ?? "" + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + field.text = "" + textViewDidChange(field) + onSend?(text) + } + + func textViewDidChange(_ textView: UITextView) { + let empty = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + placeholder.isHidden = !textView.text.isEmpty + sendButton.isEnabled = !empty + // Grow with the text up to a few lines, then scroll inside the field. + let fitted = textView.sizeThatFits( + CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude) + ).height + let clamped = min(max(fitted, restingHeight), maximumHeight) + guard abs(clamped - fieldHeight.constant) > 0.5 else { return } + fieldHeight.constant = clamped + textView.isScrollEnabled = fitted > maximumHeight + } +} + + +/// The day pill between two days of conversation. Centered, capsule, dimmed — +/// the shape every messenger converged on, because it has to be legible while +/// being ignorable. +private final class ChatDayCell: UITableViewCell { + static let reuseID = "day" + + private let pill = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + + pill.font = UIFontMetrics(forTextStyle: .caption2) + .scaledFont(for: .systemFont(ofSize: 11, weight: .semibold)) + pill.adjustsFontForContentSizeCategory = true + pill.textColor = .secondaryLabel + pill.textAlignment = .center + pill.backgroundColor = .tertiarySystemFill + pill.layer.cornerRadius = 11 + pill.layer.cornerCurve = .continuous + pill.clipsToBounds = true + pill.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pill) + + NSLayoutConstraint.activate([ + pill.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + pill.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + pill.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6), + pill.heightAnchor.constraint(equalToConstant: 22), + pill.widthAnchor.constraint(greaterThanOrEqualToConstant: 74), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + func configure(_ label: String) { + pill.text = " \(label) " + accessibilityLabel = label + } +} diff --git a/ios/Sources/CompanionTransport.swift b/ios/Sources/CompanionTransport.swift index 2ba954c1..76cb0ebc 100644 --- a/ios/Sources/CompanionTransport.swift +++ b/ios/Sources/CompanionTransport.swift @@ -63,18 +63,72 @@ final class CompanionTransport: NSObject { /// roster. Control messages remain optimistic because their version-0 /// meanings are stable. Guarded by `gridLock`. private var authAccepted = false + /// Whether this connection has claimed the session's PTY. Guarded by + /// `gridLock`; re-read on every connect, so the claim survives a reconnect + /// exactly like the event subscription does. + private var attachRequested: Bool + /// Prompts written before the socket was usable, sent in order once it is. + /// Guarded by `gridLock`. + private var pendingPrompts: [Data] = [] + /// Whether a prompt is being held for the agent's TUI to finish painting. + /// Guarded by `gridLock`, like the rest of the send-order state. + private enum PromptGate { + case open + case waiting(deadline: Date) + + var isWaiting: Bool { if case .waiting = self { true } else { false } } + + /// Settled once the screen has been quiet for `quietFor` — or once the + /// deadline passes, because a prompt held forever is the same bug as a + /// prompt thrown away. + func isSettled(quietFor: TimeInterval, at now: Date, lastOutput: Date) -> Bool { + guard case .waiting(let deadline) = self else { return false } + if now >= deadline { return true } + return now.timeIntervalSince(lastOutput) >= quietFor + } + } + private var promptGate = PromptGate.open + /// How long the screen must be still before a prompt is typed into it. + private static let quietWindow: TimeInterval = 0.7 + /// When the last PTY frame arrived. Written on the URLSession queue, read + /// under `gridLock` by the gate check. + private var lastOutputAt = Date.distantPast + /// The content-plane cursor, or nil when nothing is watching this session's + /// conversation. Advanced from arriving batches so a reconnect asks for the + /// gap rather than the whole transcript. Guarded by `gridLock`. + private var eventsCursor: Int? /// Remote PTY bytes for the terminal. Fired on a URLSession queue. var onOutput: ((Data) -> Void)? + /// The link's current state. A view that adopts a socket someone else + /// already opened has missed every transition it made, so the state is kept + /// and replayed to a newly assigned observer — otherwise a terminal joining + /// a live connection shows "Connecting…" until the next drop. + private(set) var state: State = .connecting /// State transitions, delivered on the main queue. - var onState: ((State) -> Void)? + var onState: ((State) -> Void)? { + didSet { + guard let onState else { return } + let current = state + DispatchQueue.main.async { onState(current) } + } + } /// A rendered trace document arrived (reply to `requestTrace`). Delivered /// on the main queue. var onTrace: ((String) -> Void)? + /// A batch of content-plane events arrived — either the backlog replayed + /// after `subscribeEvents`, or a live update. Delivered on the main queue. + var onAgentEvents: (([AgentEvent]) -> Void)? - init(url: URL, attachSessionID: String? = nil) { + /// `attachesOnConnect: false` opens the session's socket without claiming + /// its PTY. Reading a conversation must not start a shell: the Mac creates + /// a session's process on first attach, so a chat opened on a finished + /// session would otherwise bring it back to life just by being read. The + /// terminal calls `attachPTY()` when it actually needs bytes. + init(url: URL, attachSessionID: String? = nil, attachesOnConnect: Bool = true) { self.url = url self.attachSessionID = attachSessionID + attachRequested = attachesOnConnect } deinit { @@ -119,6 +173,92 @@ final class CompanionTransport: NSObject { task?.send(.data(data)) { _ in } } + /// Submit a prompt to the agent: the text as one bracketed paste, then a + /// carriage return. + /// + /// The framing is what keeps a multi-line message from submitting itself + /// line by line — every agent TUI enables mode 2004 — and the bytes ride the + /// binary channel, which reaches the PTY verbatim. (The Mac's own snippet + /// path carries a warning about this: routed through a terminal surface's + /// text encoder instead, the ESC becomes a key press and the TUI shows a + /// literal `[200~`.) + /// + /// Typing at a session is the one act that legitimately starts it, so this + /// claims the PTY if the connection had not already. + func sendPrompt(_ text: String) { + let started = attachPTY() + var payload = Data(("\u{1B}[200~" + text + "\u{1B}[201~").utf8) + payload.append(contentsOf: Data("\r".utf8)) + + gridLock.lock() + let ready = authAccepted + // A session the phone just woke has no agent behind it yet: the Mac + // spawns the process, the CLI boots, and only when its TUI has painted + // is there an input line to type into. Bytes written before that are + // read by whatever holds the PTY at the time and are simply gone — the + // message disappears, no turn starts, and nothing ever streams back, so + // the lens looks broken in both directions at once. A prompt that + // started the session therefore waits for the screen to settle. + // Not just a cold attach: the container opens the terminal first, so by + // the time you switch to the lens and type, the session was attached + // seconds ago and the agent is still booting. A screen that is actively + // painting is one with nothing ready to read input, whoever attached it. + let painting = Date().timeIntervalSince(lastOutputAt) < Self.quietWindow + if started || painting { promptGate = .waiting(deadline: Date().addingTimeInterval(12)) } + // A message typed before the socket finished authenticating, or while + // it is down, waits rather than disappearing — the one thing a chat may + // never do with something you wrote. Keystrokes are not queued: a key + // replayed late lands in whatever the TUI is showing by then. + let promptGateArmed = promptGate.isWaiting + let holding = !ready || promptGateArmed + if holding { pendingPrompts.append(payload) } + gridLock.unlock() + guard !holding else { + if promptGateArmed { scheduleGateCheck() } + return + } + task?.send(.data(payload)) { _ in } + } + + /// Polls for the agent's TUI to finish painting, then releases the prompt + /// held in `sendPrompt`. Quiet output is the readiness signal available to a + /// client: the Mac knows a process exists, but only the bytes say the CLI is + /// done drawing and is sitting at its prompt. + private func scheduleGateCheck() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self, !stopped else { return } + gridLock.lock() + let settled = promptGate.isSettled( + quietFor: Self.quietWindow, at: Date(), lastOutput: lastOutputAt) + if settled { promptGate = .open } + let stillWaiting = promptGate.isWaiting + gridLock.unlock() + if settled { + flushPendingPrompts() + } else if stillWaiting { + scheduleGateCheck() + } + } + } + + /// Flush prompts written while the link was down, oldest first. + private func flushPendingPrompts() { + gridLock.lock() + // Auth landing is not the same as the agent being ready: a prompt that + // woke a session stays held until the gate opens, whichever finishes + // first. + guard !promptGate.isWaiting else { + gridLock.unlock() + return + } + let queued = pendingPrompts + pendingPrompts.removeAll() + gridLock.unlock() + for payload in queued { + task?.send(.data(payload)) { _ in } + } + } + func resize(cols: Int, rows: Int) { gridLock.lock() gridCols = cols @@ -133,6 +273,27 @@ final class CompanionTransport: NSObject { sendGrid() } + /// Claim the session's PTY on this socket. Idempotent, and safe to call + /// before the socket is open — the claim is remembered and sent with the + /// auth preamble, the same way the event subscription is. + /// + /// Returns whether *this* call made the claim, which is the only moment a + /// dormant session can start a process — and so the only moment a prompt + /// has to wait for one (see `sendPrompt`). + @discardableResult + func attachPTY() -> Bool { + gridLock.lock() + let alreadyAttached = attachRequested + attachRequested = true + let ready = authSent + gridLock.unlock() + guard !alreadyAttached else { return false } + guard ready, let attachSessionID else { return true } + task?.send(.string(CompanionControl.attach(sessionID: attachSessionID).encoded())) { _ in } + sendGrid() + return true + } + /// Ask the Mac to render this session's agent transcript as an HTML trace; /// the reply arrives on `onTrace`. `dark` is the phone's own light/dark /// trait so the page matches. No-op until the socket is authed. @@ -145,6 +306,48 @@ final class CompanionTransport: NSObject { task?.send(.string(control)) { _ in } } + /// Subscribe to this session's conversation events. `since` is the highest + /// `seq` already held, so a reconnect asks only for the gap — the same + /// cursor the PTY ring buffer uses, which is why re-attaching after a tunnel + /// blip does not re-download the whole conversation. + /// + /// The subscription is *remembered*, not fired once. A lens opened before + /// the socket finished authenticating, and a socket that dropped and came + /// back, both have to end up subscribed — the first was a race that left the + /// view permanently empty, and the second is the normal life of a phone. + func subscribeEvents(since: Int) { + gridLock.lock() + eventsCursor = max(eventsCursor ?? 0, since) + let cursor = eventsCursor ?? 0 + let ready = authSent + gridLock.unlock() + guard ready, let attachSessionID else { return } + let control = CompanionControl.subscribeEvents(sessionID: attachSessionID, since: cursor) + task?.send(.string(control.encoded())) { _ in } + } + + /// Stop wanting events — the lens was dismissed. The Mac's pump retires with + /// the connection, so this only stops *this* end from re-subscribing on + /// every reconnect while nobody is looking. + func stopEvents() { + gridLock.lock() + eventsCursor = nil + gridLock.unlock() + onAgentEvents = nil + } + + /// Re-issues a remembered subscription on a fresh socket. Called from + /// `didOpen` after the attach so the two planes come back in the same order + /// they were established. + private func resubscribeEvents() { + gridLock.lock() + let cursor = eventsCursor + gridLock.unlock() + guard let cursor, let attachSessionID else { return } + let control = CompanionControl.subscribeEvents(sessionID: attachSessionID, since: cursor) + task?.send(.string(control.encoded())) { _ in } + } + private func sendGrid() { gridLock.lock() let cols = gridCols @@ -223,6 +426,11 @@ final class CompanionTransport: NSObject { case .success(let message): switch message { case .data(let data): + // The screen painting is the only readiness signal a client + // has for the agent behind the PTY; `sendPrompt` waits on it. + gridLock.lock() + lastOutputAt = Date() + gridLock.unlock() onOutput?(data) case .string(let text): if let roster = CompanionRoster.decode(text) { @@ -233,6 +441,7 @@ final class CompanionTransport: NSObject { gridLock.lock() authAccepted = true gridLock.unlock() + flushPendingPrompts() } } else { switch CompanionControl.decode(text) { @@ -242,6 +451,16 @@ final class CompanionTransport: NSObject { finish(.failed(message)) case .traceHTML(_, let html): DispatchQueue.main.async { [onTrace] in onTrace?(html) } + case .agentEvents(_, let events): + // The cursor lives here rather than in the view so a + // reconnect can ask for the gap even if the lens is + // mid-teardown when the socket dies. + if let highest = events.map(\.seq).max() { + gridLock.lock() + if let cursor = eventsCursor { eventsCursor = max(cursor, highest) } + gridLock.unlock() + } + DispatchQueue.main.async { [onAgentEvents] in onAgentEvents?(events) } default: break } @@ -257,7 +476,10 @@ final class CompanionTransport: NSObject { } private func notify(_ state: State) { - DispatchQueue.main.async { [onState] in onState?(state) } + DispatchQueue.main.async { [weak self] in + self?.state = state + self?.onState?(state) + } } } @@ -290,14 +512,18 @@ extension CompanionTransport: URLSessionWebSocketDelegate { // fired during the connect stayed suppressed until this moment. gridLock.lock() authSent = true + let claimsPTY = attachRequested gridLock.unlock() - if let attachSessionID { + if claimsPTY, let attachSessionID { let attach = CompanionControl.attach(sessionID: attachSessionID).encoded() task.send(.string(attach)) { _ in } } // The grid must follow the attach on every connect — the server's // repaint of the freshly wiped screen is driven by this claim. sendGrid() + // Both planes re-establish on the same socket, in the same order, from + // one `didOpen`: that is the whole point of carrying them together. + resubscribeEvents() DispatchQueue.main.async { [weak self] in guard let self, task === self.task, !stopped else { return } isConnected = true diff --git a/ios/Sources/Localizable.xcstrings b/ios/Sources/Localizable.xcstrings index 4e8c80c8..cc90ee24 100644 --- a/ios/Sources/Localizable.xcstrings +++ b/ios/Sources/Localizable.xcstrings @@ -201,6 +201,16 @@ } } }, + "Chat": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天" + } + } + } + }, "Chats": { "localizations": { "zh-Hans": { @@ -321,6 +331,16 @@ } } }, + "Copy": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝" + } + } + } + }, "Devices": { "localizations": { "zh-Hans": { @@ -601,6 +621,16 @@ } } }, + "Message": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "消息" + } + } + } + }, "My Devices": { "localizations": { "zh-Hans": { @@ -721,6 +751,16 @@ } } }, + "No conversation yet.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还没有对话" + } + } + } + }, "No projects open": { "localizations": { "zh-Hans": { @@ -801,6 +841,16 @@ } } }, + "Not running — showing saved history": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未在运行 —— 显示已保存的记录" + } + } + } + }, "OK": { "localizations": { "zh-Hans": { @@ -841,6 +891,16 @@ } } }, + "Open Sessions in Chat": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以聊天打开会话" + } + } + } + }, "Open Settings": { "localizations": { "zh-Hans": { @@ -1101,6 +1161,16 @@ } } }, + "Scroll to Latest": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "回到最新" + } + } + } + }, "Scroll to bottom": { "localizations": { "zh-Hans": { @@ -1141,6 +1211,16 @@ } } }, + "Send": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送" + } + } + } + }, "Send to Agent": { "localizations": { "zh-Hans": { @@ -1151,6 +1231,16 @@ } } }, + "Sessions": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话" + } + } + } + }, "Settings": { "localizations": { "zh-Hans": { @@ -1221,6 +1311,16 @@ } } }, + "Switch between the terminal and the conversation from the session header.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在会话顶栏切换终端与对话。" + } + } + } + }, "System": { "localizations": { "zh-Hans": { @@ -1311,6 +1411,16 @@ } } }, + "Thinking": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "思考" + } + } + } + }, "This diff was fetched without full context because the file is large, so the skipped lines aren't on the phone.": { "localizations": { "zh-Hans": { @@ -1331,6 +1441,16 @@ } } }, + "Today": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + } + } + }, "Toggle the task checklist": { "localizations": { "zh-Hans": { @@ -1501,6 +1621,16 @@ } } }, + "Yesterday": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + } + } + }, "Your ElevenLabs API key": { "localizations": { "zh-Hans": { @@ -1521,6 +1651,26 @@ } } }, + "failed": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败" + } + } + } + }, + "finished": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已完成" + } + } + } + }, "plain text": { "localizations": { "zh-Hans": { @@ -1531,6 +1681,16 @@ } } }, + "running": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "进行中" + } + } + } + }, "save failed": { "localizations": { "zh-Hans": { @@ -1631,6 +1791,26 @@ } } }, + "waiting for you": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等你回复" + } + } + } + }, + "working…": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "进行中…" + } + } + } + }, "•••• Set": { "localizations": { "zh-Hans": { @@ -1710,6 +1890,16 @@ } } } + }, + "+%lld more lines": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还有 %lld 行" + } + } + } } }, "version": "1.0" diff --git a/ios/Sources/MarkdownAttributedText.swift b/ios/Sources/MarkdownAttributedText.swift new file mode 100644 index 00000000..302dcfb8 --- /dev/null +++ b/ios/Sources/MarkdownAttributedText.swift @@ -0,0 +1,265 @@ +import UIKit + +/// Renders an agent's Markdown into an attributed string for the chat lens. +/// +/// Foundation's `AttributedString(markdown:)` handles inline syntax well and +/// block syntax not at all — it flattens fenced code, headings and lists into +/// one run of prose, which is most of what an agent writes. So block structure +/// is parsed here and each block's *inline* markup is handed to Foundation. +/// +/// The renderer is incremental at the block level, not the token level: the +/// content plane's source is a transcript the agent flushes a message at a time, +/// so there is no half-written sentence to stream. New text arrives as new +/// blocks appended below, which is also why nothing here needs to re-lay-out +/// what is already on screen. +enum MarkdownAttributedText { + struct Style { + var body: UIFont = .preferredFont(forTextStyle: .body) + var code: UIFont = UIFontMetrics(forTextStyle: .body) + .scaledFont(for: .monospacedSystemFont(ofSize: 13, weight: .regular)) + var textColor: UIColor = .label + var secondaryColor: UIColor = .secondaryLabel + /// A fenced block reads as one slab. Inline code sits *inside* a + /// sentence, so it takes a far lighter tint: at the block's weight, an + /// attributed background paints a full line-height box behind every + /// span and shreds the paragraph into stripes. + var codeBackground: UIColor = .tertiarySystemFill + var inlineCodeBackground: UIColor = .quaternarySystemFill + + /// Leading and block spacing are derived from the body size, so Dynamic + /// Type scales the page's rhythm and not just its glyphs. The system + /// default sets lines tight enough that a few paragraphs of agent prose + /// read as one wall of text; ~0.22em of extra leading and a full blank + /// line's worth between blocks is what separates the thoughts. + var lineSpacing: CGFloat { (body.pointSize * 0.22).rounded() } + var blockSpacing: CGFloat { (body.pointSize * 0.62).rounded() } + /// Where a list item's text column starts. Wrapped lines land here too, + /// so the marker keeps its own gutter instead of the second line sliding + /// back underneath the number. + var listIndent: CGFloat { (body.pointSize * 1.35).rounded() } + } + + static func render(_ markdown: String, style: Style = Style()) -> NSAttributedString { + let output = NSMutableAttributedString() + for block in blocks(in: markdown) { + if output.length > 0 { output.append(NSAttributedString(string: "\n")) } + output.append(render(block, style: style)) + } + return output + } + + // MARK: - Block parsing + + private enum Block { + case paragraph(String) + case heading(level: Int, text: String) + case code(language: String?, body: String) + case listItem(marker: String, text: String) + case quote(String) + case rule + } + + private static func blocks(in markdown: String) -> [Block] { + var blocks: [Block] = [] + var paragraph: [String] = [] + + func flushParagraph() { + let joined = paragraph.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !joined.isEmpty { blocks.append(.paragraph(joined)) } + paragraph.removeAll() + } + + var lines = markdown.components(separatedBy: "\n")[...] + while let line = lines.first { + lines = lines.dropFirst() + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") { + flushParagraph() + let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + var body: [String] = [] + // An unterminated fence runs to the end rather than swallowing + // the rest as prose: a transcript can be read mid-write. + while let next = lines.first, !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + body.append(next) + lines = lines.dropFirst() + } + if lines.first != nil { lines = lines.dropFirst() } + blocks.append(.code(language: language.isEmpty ? nil : language, body: body.joined(separator: "\n"))) + continue + } + + if trimmed.isEmpty { + flushParagraph() + continue + } + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + flushParagraph() + blocks.append(.rule) + continue + } + if let hashes = trimmed.range(of: "^#{1,6} ", options: .regularExpression) { + flushParagraph() + let level = trimmed.distance(from: trimmed.startIndex, to: hashes.upperBound) - 1 + blocks.append(.heading(level: level, text: String(trimmed[hashes.upperBound...]))) + continue + } + if trimmed.hasPrefix("> ") { + flushParagraph() + blocks.append(.quote(String(trimmed.dropFirst(2)))) + continue + } + if let bullet = trimmed.range(of: "^([-*+]|\\d+\\.) ", options: .regularExpression) { + flushParagraph() + let marker = trimmed[bullet].trimmingCharacters(in: .whitespaces) + blocks.append( + .listItem( + marker: marker == "-" || marker == "*" || marker == "+" ? "•" : marker, + text: String(trimmed[bullet.upperBound...]))) + continue + } + paragraph.append(line) + } + flushParagraph() + return blocks + } + + // MARK: - Block rendering + + private static func render(_ block: Block, style: Style) -> NSAttributedString { + switch block { + case .paragraph(let text): + let rendered = NSMutableAttributedString( + attributedString: inline(text, font: style.body, color: style.textColor, style: style)) + return rendered.laidOut(with: baseParagraph(style)) + + case .heading(let level, let text): + let size = max(style.body.pointSize + CGFloat(4 - level) * 2, style.body.pointSize) + let font = UIFont.systemFont(ofSize: size, weight: level <= 2 ? .bold : .semibold) + let paragraphStyle = baseParagraph(style) + // A heading belongs to what follows it, so it takes its air from + // above and keeps a short gap below. + paragraphStyle.paragraphSpacingBefore = style.blockSpacing + paragraphStyle.paragraphSpacing = (style.blockSpacing * 0.35).rounded() + let rendered = NSMutableAttributedString( + attributedString: inline(text, font: font, color: style.textColor, style: style)) + return rendered.laidOut(with: paragraphStyle) + + case .code(let language, let body): + let paragraphStyle = baseParagraph(style) + // Code is already vertical; prose leading between its lines only + // loosens the one block that should read as a unit. + paragraphStyle.lineSpacing = (style.lineSpacing * 0.4).rounded() + paragraphStyle.firstLineHeadIndent = 10 + paragraphStyle.headIndent = 10 + paragraphStyle.paragraphSpacingBefore = (style.blockSpacing * 0.5).rounded() + paragraphStyle.paragraphSpacing = (style.blockSpacing * 0.5).rounded() + let rendered = NSMutableAttributedString( + string: body, + attributes: [ + .font: style.code, .foregroundColor: style.textColor, + .backgroundColor: style.codeBackground, .paragraphStyle: paragraphStyle, + ]) + if let language { + let caption = NSAttributedString( + string: language + "\n", + attributes: [ + .font: UIFont.systemFont(ofSize: 11, weight: .medium), + .foregroundColor: style.secondaryColor, + .paragraphStyle: paragraphStyle, + ]) + rendered.insert(caption, at: 0) + } + return rendered + + case .listItem(let marker, let text): + let paragraphStyle = baseParagraph(style) + // Items in one list are one thought — they sit closer to each other + // than the list sits to the prose around it. + paragraphStyle.paragraphSpacing = (style.blockSpacing * 0.4).rounded() + // The marker occupies a gutter and the text starts at a tab stop, so + // "10." and "•" open the same column and wrapped lines stay in it. + paragraphStyle.firstLineHeadIndent = 0 + paragraphStyle.headIndent = style.listIndent + paragraphStyle.defaultTabInterval = style.listIndent + paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: style.listIndent)] + let rendered = NSMutableAttributedString( + string: marker + "\t", + attributes: [.font: style.body, .foregroundColor: style.secondaryColor]) + rendered.append(inline(text, font: style.body, color: style.textColor, style: style)) + return rendered.laidOut(with: paragraphStyle) + + case .quote(let text): + let paragraphStyle = baseParagraph(style) + paragraphStyle.headIndent = style.listIndent + paragraphStyle.firstLineHeadIndent = style.listIndent + let rendered = NSMutableAttributedString( + attributedString: inline(text, font: style.body, color: style.secondaryColor, style: style)) + return rendered.laidOut(with: paragraphStyle) + + case .rule: + return NSAttributedString( + string: "───", + attributes: [ + .font: style.body, .foregroundColor: style.secondaryColor, + .paragraphStyle: baseParagraph(style), + ]) + } + } + + /// The leading and trailing air every block starts from. + private static func baseParagraph(_ style: Style) -> NSMutableParagraphStyle { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = style.lineSpacing + paragraphStyle.paragraphSpacing = style.blockSpacing + return paragraphStyle + } + + /// Inline markup only — bold, italic, inline code, links. Falls back to the + /// raw text when Foundation rejects the markup, so a stray backtick shows + /// the character rather than blanking the message. + private static func inline( + _ text: String, font: UIFont, color: UIColor, style: Style + ) -> NSAttributedString { + let base: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + guard + let parsed = try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) + else { return NSAttributedString(string: text, attributes: base) } + + let rendered = NSMutableAttributedString(parsed) + rendered.addAttributes(base, range: NSRange(location: 0, length: rendered.length)) + + // Re-apply the inline intents Foundation recorded, since the base font + // above just flattened them. + rendered.enumerateAttribute( + .inlinePresentationIntent, in: NSRange(location: 0, length: rendered.length) + ) { value, range, _ in + guard let raw = value as? UInt else { return } + let intent = InlinePresentationIntent(rawValue: raw) + var traits: UIFontDescriptor.SymbolicTraits = [] + if intent.contains(.stronglyEmphasized) { traits.insert(.traitBold) } + if intent.contains(.emphasized) { traits.insert(.traitItalic) } + if intent.contains(.code) { + rendered.addAttributes( + [.font: style.code, .backgroundColor: style.inlineCodeBackground], range: range) + return + } + if !traits.isEmpty, let descriptor = font.fontDescriptor.withSymbolicTraits(traits) { + rendered.addAttribute(.font, value: UIFont(descriptor: descriptor, size: font.pointSize), range: range) + } + } + return rendered + } +} + +private extension NSMutableAttributedString { + /// Stamps one paragraph style over the whole block. Applied last, because + /// Foundation's inline parse leaves its own default style behind on any run + /// it touched and the block's rhythm has to win. + func laidOut(with paragraphStyle: NSParagraphStyle) -> NSAttributedString { + addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: length)) + return self + } +} diff --git a/ios/Sources/MobileSettings.swift b/ios/Sources/MobileSettings.swift index 4f1788cf..e000e47b 100644 --- a/ios/Sources/MobileSettings.swift +++ b/ios/Sources/MobileSettings.swift @@ -47,6 +47,7 @@ final class MobileSettings { static let fontSize = "appearance.fontSize" static let terminalKeys = "terminalKeyboard.keys" static let pushToTalk = "voice.pushToTalk" + static let opensInChat = "session.opensInChat" static let transcriptionProvider = "voice.provider" } @@ -107,6 +108,17 @@ final class MobileSettings { } } + /// Which view a session opens in. Off — the terminal — is the default and + /// the product's position: the terminal is the interface, and the chat is + /// the second way to look at the same session, not a replacement for it. + /// Both views stay one tap apart whichever way this is set. + var opensInChat: Bool { + didSet { + defaults.set(opensInChat, forKey: Key.opensInChat) + notify() + } + } + /// Which transcription service dictation uses — the user's Settings ▸ Voice /// choice. Each provider keeps its own key in the Keychain, so switching /// never loses the other's key. @@ -125,6 +137,7 @@ final class MobileSettings { Key.fontSize: Self.defaultFontSize, Key.terminalKeys: TerminalKeyCatalog.defaultIDs, Key.pushToTalk: false, + Key.opensInChat: false, Key.transcriptionProvider: TranscriptionProvider.openAI.rawValue, ]) appearanceMode = AppearanceMode( @@ -136,6 +149,7 @@ final class MobileSettings { terminalKeyIDs = defaults.stringArray(forKey: Key.terminalKeys) ?? TerminalKeyCatalog.defaultIDs pushToTalkEnabled = defaults.bool(forKey: Key.pushToTalk) + opensInChat = defaults.bool(forKey: Key.opensInChat) transcriptionProvider = TranscriptionProvider( rawValue: defaults.string(forKey: Key.transcriptionProvider) ?? "" ) ?? .openAI diff --git a/ios/Sources/RootContainerViewController.swift b/ios/Sources/RootContainerViewController.swift index 86ed3f5a..ced653a4 100644 --- a/ios/Sources/RootContainerViewController.swift +++ b/ios/Sources/RootContainerViewController.swift @@ -124,13 +124,16 @@ final class RootContainerViewController: UIViewController { guard let self else { return } // Coming back to a parked session reuses its screen: same surface, // scrollback and connection intact — no surface teardown/rebuild. - // Every session is the terminal itself (the iSH pattern): the - // agent's TUI is already the conversation UI, keys go straight in. + // An agent session opens as its conversation with the terminal one + // tap away; a plain shell has no conversation to read, so it is the + // terminal itself. let screen: UIViewController if let parked = recentTerminals[session.key] { screen = parked } else if let companionURL, session.rosterID != nil { - screen = TerminalViewController(companionURL: companionURL, session: session) + screen = session.agent.id == RosterAgent.terminal.id + ? TerminalViewController(companionURL: companionURL, session: session) + : SessionViewController(companionURL: companionURL, session: session) } else { screen = TerminalViewController(session: session) } @@ -204,14 +207,16 @@ final class RootContainerViewController: UIViewController { } } } - if let terminal = screen as? TerminalViewController { - terminal.onRequestBack = { [weak self] in self?.goHome() } - terminal.onBackBegan = { [weak self] in self?.beginInteractiveBack() } - terminal.onBackChanged = { [weak self] tx in self?.updateInteractiveBack(translationX: tx) } - terminal.onBackEnded = { [weak self] vx, commit in + if let sessionScreen = screen as? SessionScreen { + sessionScreen.onRequestBack = { [weak self] in self?.goHome() } + sessionScreen.onBackBegan = { [weak self] in self?.beginInteractiveBack() } + sessionScreen.onBackChanged = { [weak self] tx in + self?.updateInteractiveBack(translationX: tx) + } + sessionScreen.onBackEnded = { [weak self] vx, commit in self?.finishInteractiveBack(velocityX: vx, commit: commit) } - terminal.onClose = { [weak self, weak screen] in + sessionScreen.onClose = { [weak self, weak screen] in guard let screen else { return } self?.close(screen) } @@ -223,7 +228,7 @@ final class RootContainerViewController: UIViewController { let isReopen = screen.parent === self installIfNeeded(screen) if isReopen { - (screen as? TerminalViewController)?.prepareForReappearance() + (screen as? SessionScreen)?.prepareForReappearance() } store.currentSessionKey = sessionKey refreshHomeLists() @@ -402,7 +407,7 @@ final class RootContainerViewController: UIViewController { // CAMetalLayer (with a dangling delegate) in the tree; drop it before // the next CoreAnimation commit can fault on it. See TerminalVC. screen.view.removeFromSuperview() - (screen as? TerminalViewController)?.releaseOrphanedSurfaceLayers() + (screen as? SessionScreen)?.releaseOrphanedSurfaceLayers() screen.removeFromParent() } } diff --git a/ios/Sources/SessionViewController.swift b/ios/Sources/SessionViewController.swift new file mode 100644 index 00000000..18ccd5b7 --- /dev/null +++ b/ios/Sources/SessionViewController.swift @@ -0,0 +1,174 @@ +import TermioShared +import UIKit + +/// What the container slides in over the session list. Both the conversation +/// and the terminal are views *of a session*, so the container speaks this +/// instead of naming one of them. +@MainActor +protocol SessionScreen: UIViewController { + var onClose: (() -> Void)? { get set } + var onRequestBack: (() -> Void)? { get set } + var onBackBegan: (() -> Void)? { get set } + var onBackChanged: ((CGFloat) -> Void)? { get set } + var onBackEnded: ((_ velocityX: CGFloat, _ commit: Bool) -> Void)? { get set } + /// A parked screen comes back without `viewDidAppear`, so the per-return + /// work runs from here. + func prepareForReappearance() + func releaseOrphanedSurfaceLayers() +} + +extension TerminalViewController: SessionScreen {} + +/// One agent session and its two views: the terminal, and the conversation. +/// +/// **The terminal opens first.** That is the product's position rather than a +/// default nobody thought about — the terminal *is* the interface, and the +/// chat is a second way to look at the same session for the things an +/// 80-column grid on a phone cannot do (read a finished conversation, show a +/// diff as a diff, put a plan in a checklist). Settings ▸ Appearance flips +/// which one a session lands on; either way the other is one tap from the +/// header. +/// +/// Two consequences that are the point rather than side effects: +/// +/// - **Opening a session does not start anything.** The Mac creates a session's +/// shell on first attach, so a chat that attached on open would resurrect +/// every finished session the moment it was read. The socket connects, the +/// content plane subscribes, and the PTY is claimed only when the terminal is +/// shown. +/// - **One socket, two views.** The container owns the connection; the terminal +/// borrows it. Switching views costs no second connection, and a flaky link +/// cannot leave one plane up and the other down. +final class SessionViewController: UIViewController, SessionScreen { + var onClose: (() -> Void)? + var onRequestBack: (() -> Void)? + var onBackBegan: (() -> Void)? + var onBackChanged: ((CGFloat) -> Void)? + var onBackEnded: ((_ velocityX: CGFloat, _ commit: Bool) -> Void)? + + private let session: MockSession + private let companionURL: URL + private let transport: CompanionTransport + private let chat = ChatLensViewController() + /// Built the first time the terminal is asked for, then kept: its surface + /// is a live libghostty instance with scrollback, and rebuilding it on + /// every switch would throw away the screen the agent painted. + private var terminal: TerminalViewController? + + init(companionURL: URL, session: MockSession) { + self.session = session + self.companionURL = companionURL + transport = CompanionTransport( + url: companionURL, attachSessionID: session.rosterID, attachesOnConnect: false) + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + deinit { + transport.stop() + if let statusObserver { NotificationCenter.default.removeObserver(statusObserver) } + } + + /// The agent's live status, from the roster socket — the app's only status + /// feed, and the same one the session list reads. The chat shows it where a + /// messenger shows "typing…", which is the honest translation: it means the + /// same thing to the person waiting. + private var statusObserver: NSObjectProtocol? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + chat.title = session.title + chat.context = [session.project, session.worktreeBranch ?? session.branch] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " · ") + chat.onRequestBack = { [weak self] in self?.onRequestBack?() } + chat.onRequestTerminal = { [weak self] in self?.showTerminal() } + chat.onNeedEvents = { [weak self] since in self?.transport.subscribeEvents(since: since) } + chat.onSend = { [weak self] text in self?.transport.sendPrompt(text) } + transport.onAgentEvents = { [weak chat] events in chat?.apply(events) } + + // The chat is built either way — it subscribes to the content plane on + // load, so switching to it later is instant instead of a cold replay — + // but the terminal is what is on top unless the setting says otherwise. + chat.activity = session.status + statusObserver = NotificationCenter.default.addObserver( + forName: .sessionStatusesDidChange, object: nil, queue: .main + ) { [weak self] note in + guard let self, let rosterID = session.rosterID, + let statuses = note.userInfo?["statuses"] as? [String: SessionStatus], + let status = statuses[rosterID] + else { return } + MainActor.assumeIsolated { self.chat.activity = status } + } + + install(chat) + if !MobileSettings.shared.opensInChat { showTerminal() } + transport.start() + } + + // MARK: - Views of the session + + private func showTerminal() { + let screen = terminal ?? makeTerminal() + terminal = screen + install(screen) + // Installing it is what makes it appear, which is what claims the PTY — + // see `TerminalViewController.viewDidAppear`. + view.bringSubviewToFront(screen.view) + setNeedsStatusBarAppearanceUpdate() + } + + private func showChat() { + install(chat) + view.bringSubviewToFront(chat.view) + // The terminal stays a child with its surface alive, hidden behind the + // chat, so switching back is instant and the scrollback survives. + terminal?.view.isHidden = true + terminal?.dropKeyboard() + } + + private func makeTerminal() -> TerminalViewController { + let screen = TerminalViewController( + companionURL: companionURL, session: session, transport: transport) + screen.onRequestChat = { [weak self] in self?.showChat() } + // Back leaves the session entirely. The chat is not "behind" the + // terminal — they are peers, reached by the header's switch — so a + // back gesture that landed on the chat instead of the list would make + // leaving a session a two-step affair. + screen.onRequestBack = { [weak self] in self?.onRequestBack?() } + screen.onBackBegan = { [weak self] in self?.onBackBegan?() } + screen.onBackChanged = { [weak self] offset in self?.onBackChanged?(offset) } + screen.onBackEnded = { [weak self] velocity, commit in + self?.onBackEnded?(velocity, commit) + } + screen.onClose = { [weak self] in self?.onClose?() } + return screen + } + + private func install(_ child: UIViewController) { + if child.parent !== self { + addChild(child) + child.view.frame = view.bounds + child.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(child.view) + child.didMove(toParent: self) + } + child.view.isHidden = false + } + + // MARK: - SessionScreen + + func prepareForReappearance() { + if terminal?.view.isHidden == false { terminal?.prepareForReappearance() } + } + + func releaseOrphanedSurfaceLayers() { + terminal?.releaseOrphanedSurfaceLayers() + } +} diff --git a/ios/Sources/SettingsViewController.swift b/ios/Sources/SettingsViewController.swift index 462c7ec4..dbd48736 100644 --- a/ios/Sources/SettingsViewController.swift +++ b/ios/Sources/SettingsViewController.swift @@ -251,19 +251,36 @@ final class AppearanceSettingsViewController: UITableViewController { // MARK: - Table - override func numberOfSections(in tableView: UITableView) -> Int { 1 } + override func numberOfSections(in tableView: UITableView) -> Int { 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - Row.allCases.count + section == 0 ? Row.allCases.count : 1 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - localized("Terminal") + section == 0 ? localized("Terminal") : localized("Sessions") + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + guard section == 1 else { return nil } + return localized("Switch between the terminal and the conversation from the session header.") } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - // A static four-row form; building cells directly beats reuse plumbing. + // A static form; building cells directly beats reuse plumbing. let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + guard indexPath.section == 0 else { + cell.textLabel?.text = localized("Open Sessions in Chat") + cell.selectionStyle = .none + let toggle = UISwitch() + toggle.isOn = settings.opensInChat + toggle.addAction(UIAction { [weak self, weak toggle] _ in + guard let self, let toggle else { return } + settings.opensInChat = toggle.isOn + }, for: .valueChanged) + cell.accessoryView = toggle + return cell + } switch Row(rawValue: indexPath.row) { case .appearance: cell.textLabel?.text = localized("Appearance") @@ -306,6 +323,7 @@ final class AppearanceSettingsViewController: UITableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) + guard indexPath.section == 0 else { return } switch Row(rawValue: indexPath.row) { case .lightTheme: navigationController?.pushViewController(ThemePickerViewController(slot: .light), animated: true) diff --git a/ios/Sources/TerminalViewController.swift b/ios/Sources/TerminalViewController.swift index 7ea48b2a..8922fe97 100644 --- a/ios/Sources/TerminalViewController.swift +++ b/ios/Sources/TerminalViewController.swift @@ -30,6 +30,11 @@ final class TerminalViewController: UIViewController { /// freed the surface and raced libghostty's render threads. var onRequestBack: (() -> Void)? + /// Set by the session container: switch to this session's conversation. + /// Absent for a plain shell, which has no conversation — the button then + /// never appears rather than appearing dead. + var onRequestChat: (() -> Void)? + /// Interactive back-swipe hooks (set by RootContainerViewController). The /// rightward drag is finger-tracked instead of a discrete pop: `Began` /// starts the interactive transition, `Changed` reports the horizontal @@ -51,6 +56,10 @@ final class TerminalViewController: UIViewController { private var fitDebounce: DispatchWorkItem? private lazy var shellSession = ShellSession(shell: defaultSandboxShell) private var companion: CompanionTransport? + /// Non-nil when the session container owns the socket. Its lifetime is the + /// container's: this view attaches when it needs bytes and leaves the + /// connection running when it goes away. + private let borrowedTransport: CompanionTransport? private var companionSession: InMemoryTerminalSession? private let headerBar = UIStackView() private let contextLabel = UILabel() @@ -116,25 +125,31 @@ final class TerminalViewController: UIViewController { init(session: MockSession) { self.session = session backend = .demoShell + borrowedTransport = nil super.init(nibName: nil, bundle: nil) hidesBottomBarWhenPushed = true } /// A companion terminal: bridges a real Mac session's PTY when `session` /// carries a roster id, else streams whatever the server serves (PoC mode). - init(companionURL: URL, session: MockSession? = nil) { + /// + /// `transport` is the session container's socket. Both views of a session + /// ride one connection, so when the container already has one open this + /// view borrows it — and never starts or stops what it does not own. + init(companionURL: URL, session: MockSession? = nil, transport: CompanionTransport? = nil) { self.session = session ?? MockSession( title: companionURL.host ?? "companion", project: "companion", agent: RosterAgent.terminal, status: .idle, subtitle: "", time: "" ) backend = .companion(companionURL) + borrowedTransport = transport super.init(nibName: nil, bundle: nil) hidesBottomBarWhenPushed = true } deinit { - companion?.stop() + if borrowedTransport == nil { companion?.stop() } uploadClient?.stop() restylePump?.invalidate() if let settingsObserver { @@ -188,7 +203,9 @@ final class TerminalViewController: UIViewController { case .demoShell: shellSession.start() case .companion: - companion?.start() + // A borrowed socket is already connected (the chat has been + // reading over it); this view only adds its claim on the PTY. + if borrowedTransport == nil { companion?.start() } else { companion?.attachPTY() } } } else if case .companion = backend { // Re-entering a parked session claims the PTY's winsize back — @@ -301,9 +318,31 @@ final class TerminalViewController: UIViewController { overflow.alpha = 0 overflow.isUserInteractionEnabled = false } + // The switch to the conversation. A visible control rather than a menu + // item: it is the one thing on this header a user reaches for often, + // and it must be as cheap as the chat's switch back to here. + let chat = UIButton(type: .system) + chat.applyGlassSymbol("bubble.left.and.bubble.right", pointSize: 15) + chat.accessibilityIdentifier = "terminal.chat" + chat.accessibilityLabel = localized("Chat") + chat.tintColor = .label + chat.isHidden = onRequestChat == nil + chat.addAction(UIAction { [weak self] _ in self?.onRequestChat?() }, for: .touchUpInside) + // Balances the two right-hand buttons so the title stays centered. + let leftSpacer = UIView() + leftSpacer.isUserInteractionEnabled = false + headerBar.addArrangedSubview(back) + headerBar.addArrangedSubview(leftSpacer) headerBar.addArrangedSubview(titles) + headerBar.addArrangedSubview(chat) headerBar.addArrangedSubview(overflow) + NSLayoutConstraint.activate([ + chat.widthAnchor.constraint(equalToConstant: 44), + chat.heightAnchor.constraint(equalToConstant: 44), + leftSpacer.widthAnchor.constraint(equalTo: chat.widthAnchor), + ]) + leftSpacer.isHidden = chat.isHidden headerBar.translatesAutoresizingMaskIntoConstraints = false view.addSubview(headerBar) NSLayoutConstraint.activate([ @@ -353,6 +392,12 @@ final class TerminalViewController: UIViewController { /// back into view. The view was only hidden (never removed from the /// window), so `viewDidAppear` does not fire — re-run the parts that must /// happen on every return. + /// Hand back the keyboard when the session switches to its other view — + /// this screen is only hidden, not dismissed, so nothing else resigns it. + func dropKeyboard() { + terminalView.resignFirstResponder() + } + func prepareForReappearance() { if !drawerOpen { focusInput() } // Re-entering a parked companion session claims the PTY's winsize back — @@ -692,7 +737,8 @@ final class TerminalViewController: UIViewController { case .demoShell: return shellSession.terminalSession case .companion(let url): - let transport = CompanionTransport(url: url, attachSessionID: session.rosterID) + let transport = borrowedTransport + ?? CompanionTransport(url: url, attachSessionID: session.rosterID) // The phone mirrors the Mac's PTY through a live libghostty surface, // which answers terminal queries (XTVERSION, DA, DSR) on its own. The // Mac's authoritative surface already answered; the phone's duplicate