238 lines
12 KiB
Swift
238 lines
12 KiB
Swift
import Foundation
|
|
import NucleicProtocol
|
|
|
|
/// What a session is doing *right now*, in one line: the tool it's in the middle of and the
|
|
/// argument that identifies it ("Running `swift build`", "Reading Sources/AppStore.swift"), or
|
|
/// the non-tool phase it's in (thinking, writing its answer).
|
|
///
|
|
/// This exists for the sessions you *aren't* looking at — the Orchestra workers in the Subagents
|
|
/// panel. The open chat narrates itself through the transcript; a worker is a row in a list, and
|
|
/// "Running" alone doesn't tell you whether it's compiling, waiting on a long test, or stuck on a
|
|
/// tool that never returns. The panel shows this line so the user can follow a fan-out without
|
|
/// opening each worker's transcript in turn.
|
|
///
|
|
/// Derived by folding the event batch that already flows through `AppStore.ingestUI` (see
|
|
/// ``advanced(from:by:relativeTo:)``) — no extra subscription, no transcript re-read. Kept small
|
|
/// and `Equatable` so an unchanged reading is written back as a no-op and doesn't wake every
|
|
/// observer of the store on each streamed chunk.
|
|
public struct SessionActivity: Equatable, Sendable {
|
|
/// Which kind of work the line describes — the panel picks its glyph from this.
|
|
public enum Kind: Sendable, Equatable {
|
|
/// In a tool call that hasn't returned yet.
|
|
case tool
|
|
/// Reasoning (an extended-thinking block is streaming).
|
|
case thinking
|
|
/// Writing its answer.
|
|
case responding
|
|
/// Between the two — a tool just returned, or a turn just started.
|
|
case working
|
|
}
|
|
|
|
public let kind: Kind
|
|
/// The present-progressive verb, *without* a trailing ellipsis: "Running", "Reading",
|
|
/// "Searching on a macOS VM". Pair it with ``detail``, or use ``gerund`` on its own.
|
|
public let verb: String
|
|
/// The one argument that identifies the call — the shell command, the file path, the search
|
|
/// pattern — collapsed to a single line and capped for a list row. Nil for a tool that takes
|
|
/// nothing worth showing, and for the non-tool phases.
|
|
public let detail: String?
|
|
/// The wire tool name, so the UI can reuse its existing icon table rather than this file
|
|
/// growing a second one. Nil for the non-tool phases.
|
|
public let toolName: String?
|
|
/// The call this line describes, so its result event retires exactly this activity and not a
|
|
/// later one that overtook it.
|
|
public let toolCallID: String?
|
|
/// When this reading *started* — preserved across the repeated events that describe the same
|
|
/// thing (a streaming `thinking`, a `toolCallStarted` followed by its `toolCallCompleted`),
|
|
/// so a row can say how long the worker has been on it.
|
|
public let since: Date
|
|
|
|
public init(
|
|
kind: Kind, verb: String, detail: String? = nil, toolName: String? = nil,
|
|
toolCallID: String? = nil, since: Date
|
|
) {
|
|
self.kind = kind
|
|
self.verb = verb
|
|
self.detail = detail
|
|
self.toolName = toolName
|
|
self.toolCallID = toolCallID
|
|
self.since = since
|
|
}
|
|
|
|
/// The verb alone, as a working-row line: "Running…".
|
|
public var gerund: String { verb + "…" }
|
|
|
|
/// The whole line: verb plus the argument that identifies the call, or just the gerund when
|
|
/// there's nothing to name.
|
|
public var line: String {
|
|
guard let detail, !detail.isEmpty else { return gerund }
|
|
return "\(verb) \(detail)"
|
|
}
|
|
|
|
/// Whether two readings describe the same work — everything but when it started. Used to
|
|
/// carry ``since`` forward (and to keep the store's value untouched) while a tool call streams
|
|
/// its arguments or a thinking block streams its text.
|
|
func describesSameWork(as other: SessionActivity) -> Bool {
|
|
kind == other.kind && verb == other.verb && detail == other.detail
|
|
&& toolName == other.toolName && toolCallID == other.toolCallID
|
|
}
|
|
}
|
|
|
|
// MARK: - Deriving one from the event stream
|
|
|
|
extension SessionActivity {
|
|
|
|
/// Fold a whole batch: later events win, and an event that says nothing about what the session
|
|
/// is doing (usage, file changes, notes) leaves the reading alone.
|
|
public static func advanced(
|
|
from current: SessionActivity?, by batch: [AgentEvent], relativeTo root: String?
|
|
) -> SessionActivity? {
|
|
batch.reduce(current) { advanced(from: $0, by: $1, relativeTo: root) }
|
|
}
|
|
|
|
/// Apply one event. `nil` out means "not doing anything" — the turn ended.
|
|
public static func advanced(
|
|
from current: SessionActivity?, by event: AgentEvent, relativeTo root: String?
|
|
) -> SessionActivity? {
|
|
switch event.kind {
|
|
// A call's arguments arrive in two waves: the name on `started`, the authoritative input on
|
|
// `completed`. Both map to the same reading, so the second refines the first in place
|
|
// rather than restarting the clock. (`toolCallInputDelta` is deliberately ignored — it's a
|
|
// JSON fragment, and re-parsing a partial object per fragment would churn the store for a
|
|
// line that can't change until the input is whole.)
|
|
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
|
return settle(
|
|
SessionActivity(
|
|
kind: .tool,
|
|
verb: verb(forTool: call.name),
|
|
detail: compactLine(detail(forTool: call.name, input: call.input, relativeTo: root)),
|
|
toolName: call.name,
|
|
toolCallID: call.toolCallID,
|
|
since: event.at),
|
|
against: current)
|
|
|
|
// The call returned: the session is thinking about the result, not still in the tool. Only
|
|
// *its own* result retires it — a nested subagent's result can land while the parent's call
|
|
// is still open.
|
|
case .toolResult(let result):
|
|
guard let current, current.toolCallID == result.toolCallID else { return current }
|
|
return settle(
|
|
SessionActivity(kind: .working, verb: "Working", since: event.at), against: current)
|
|
|
|
case .thinking:
|
|
return settle(
|
|
SessionActivity(kind: .thinking, verb: "Thinking", since: event.at), against: current)
|
|
|
|
case .assistantText:
|
|
return settle(
|
|
SessionActivity(kind: .responding, verb: "Responding", since: event.at),
|
|
against: current)
|
|
|
|
// A prompt landed (the worker's assignment, or a supervisor's reply) — the turn is starting.
|
|
case .userText:
|
|
return settle(
|
|
SessionActivity(kind: .working, verb: "Working", since: event.at), against: current)
|
|
|
|
// The turn is over: no line at all, rather than a stale "Running swift build" frozen on a
|
|
// finished worker's row.
|
|
case .runFinished:
|
|
return nil
|
|
|
|
default:
|
|
return current
|
|
}
|
|
}
|
|
|
|
/// Carry the clock across events that describe one piece of work. An identical reading is
|
|
/// returned *as* the current one, so the store sees no change at all; a refinement of the same
|
|
/// call — `toolCallCompleted` filling in the arguments `toolCallStarted` didn't carry — keeps
|
|
/// the start time the call already had, so the row's "on this since" counts from when the
|
|
/// worker entered the tool rather than from the last event to mention it. A reading for
|
|
/// *different* work (including the result that retires the call) starts its own clock.
|
|
private static func settle(_ next: SessionActivity, against current: SessionActivity?)
|
|
-> SessionActivity
|
|
{
|
|
guard let current else { return next }
|
|
if current.describesSameWork(as: next) { return current }
|
|
guard let id = current.toolCallID, id == next.toolCallID else { return next }
|
|
return SessionActivity(
|
|
kind: next.kind, verb: next.verb, detail: next.detail, toolName: next.toolName,
|
|
toolCallID: next.toolCallID, since: current.since)
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool vocabulary
|
|
|
|
extension SessionActivity {
|
|
|
|
/// The present-progressive verb for a tool call, ellipsis-free — "Read" → "Reading". Covers
|
|
/// the core tool set here and defers to ``SandboxToolDisplay`` for Nucleic's own VM/container/
|
|
/// orchestra tools, so a worker on the macOS VM reads "Running on a macOS VM" rather than the
|
|
/// wire name. Unknown tools fall back to "Running <name>" rather than guessing a gerund.
|
|
///
|
|
/// The open chat's working row (`SessionDetailView.gerund`) reads the same table, so the line
|
|
/// under a worker in the Subagents panel and the line under the chat you opened it in can't
|
|
/// drift apart.
|
|
public static func verb(forTool name: String) -> String {
|
|
switch name {
|
|
case "Read", "NotebookRead": return "Reading"
|
|
case "Write": return "Writing"
|
|
case "Edit", "MultiEdit", "NotebookEdit": return "Editing"
|
|
case "Bash", "BashOutput": return "Running"
|
|
case "Grep", "Glob": return "Searching"
|
|
case "WebFetch": return "Fetching"
|
|
case "WebSearch": return "Searching the web"
|
|
case "Task", "Agent": return "Delegating"
|
|
case "TodoWrite", "TaskCreate", "TaskUpdate": return "Planning"
|
|
case MCPApprovalServer.qualifiedHostExecToolName, MCPApprovalServer.hostExecToolName:
|
|
return "Running a command on host"
|
|
default:
|
|
// The sandbox gerunds are already full phrases ("Running on a macOS VM…"); drop the
|
|
// ellipsis so they compose with a detail the same way the core verbs do.
|
|
if let gerund = SandboxToolDisplay.gerund(for: name) {
|
|
return gerund.hasSuffix("…") ? String(gerund.dropLast()) : gerund
|
|
}
|
|
return "Running \(SandboxToolDisplay.bareName(name))"
|
|
}
|
|
}
|
|
|
|
/// The single most informative argument of a tool call — the shell command, the file path
|
|
/// (trimmed to where it sits in the worktree), the search pattern. Shared with the transcript's
|
|
/// compact tool rows (`TranscriptRow.toolDetail`) so a worker's live line and its transcript
|
|
/// name the same thing. Nil when the tool carries nothing worth showing.
|
|
public static func detail(forTool name: String, input: JSONValue, relativeTo root: String?)
|
|
-> String?
|
|
{
|
|
func arg(_ key: String) -> String? { input[key]?.stringValue }
|
|
let detail: String? = switch name {
|
|
case "Bash": arg("command")
|
|
case "Read", "Edit", "MultiEdit", "Write":
|
|
arg("file_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
|
|
case "NotebookEdit":
|
|
arg("notebook_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
|
|
case "Grep", "Glob": arg("pattern")
|
|
case "WebFetch": arg("url")
|
|
case "WebSearch": arg("query")
|
|
case "Task", "Agent": arg("description")
|
|
// The host command itself, rather than the raw `{"command": …}` JSON a generic fallback
|
|
// would print. (The VM/container/orchestra tools parse their own arguments below.)
|
|
case MCPApprovalServer.qualifiedHostExecToolName: arg("command")
|
|
default: SandboxToolDisplay.detail(for: name, input: input)
|
|
}
|
|
// Trim any worktree path mentioned *anywhere* in the detail (commands, patterns), not just
|
|
// in the dedicated file-path argument.
|
|
guard let detail else { return nil }
|
|
return HeuristicSummary.relativizePaths(detail, relativeTo: root)
|
|
}
|
|
|
|
/// Collapse a detail to one bounded line: newlines and runs of whitespace become single
|
|
/// spaces, and anything past `limit` is elided. A heredoc or a multi-megabyte `Write` body
|
|
/// would otherwise ride in the observable store and get truncated by the row anyway.
|
|
static func compactLine(_ text: String?, limit: Int = 160) -> String? {
|
|
guard let text else { return nil }
|
|
let collapsed = text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
|
if collapsed.isEmpty { return nil }
|
|
return collapsed.count <= limit ? collapsed : String(collapsed.prefix(limit)) + "…"
|
|
}
|
|
}
|