Merge nucleic/humble-slate-civet-chwe into dev

This commit is contained in:
2026-08-04 20:20:50 -07:00
parent 85137cb1cb
commit 6a637f3822
14 changed files with 1827 additions and 135 deletions
+237
View File
@@ -0,0 +1,237 @@
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)) + ""
}
}