Merge nucleic/humble-slate-civet-chwe into dev
This commit is contained in:
@@ -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)) + "…"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user