diff --git a/runner/src/main/scala/orca/runner/DirtyTreePolicy.scala b/runner/src/main/scala/orca/runner/DirtyTreePolicy.scala index 3933cfce..268a186f 100644 --- a/runner/src/main/scala/orca/runner/DirtyTreePolicy.scala +++ b/runner/src/main/scala/orca/runner/DirtyTreePolicy.scala @@ -69,12 +69,12 @@ private[runner] object DirtyTreePolicy: * * This writes the terminal outside `TerminalOutput`'s prompt transaction, * which is otherwise the single owner of the cursor. What makes that safe is - * WHEN it runs: setup precedes the first stage, and the status row is only - * ever raised by a `StageStarted`, so nothing is pinned at the bottom and - * the animator's `tick` is a no-op. Routing it through the transaction - * instead would mean handing setup the run's `Interaction` — including every - * embedder's non-terminal one. Residual: the read blocks uninterruptibly, so - * a fork failing elsewhere in the run's scope waits for the answer. + * WHEN it runs: before any stage or setup activity raises the status row, so + * nothing is pinned at the bottom and the animator's `tick` is a no-op. + * Routing it through the transaction instead would mean handing setup the + * run's `Interaction` — including every embedder's non-terminal one. + * Residual: the read blocks uninterruptibly, so a fork failing elsewhere in + * the run's scope waits for the answer. */ def promptOnStderr(dirtyCount: Int): DirtyTreeChoice = Console.err.println( diff --git a/runner/src/main/scala/orca/runner/FlowLifecycle.scala b/runner/src/main/scala/orca/runner/FlowLifecycle.scala index 8b2e8fe2..e1af7e91 100644 --- a/runner/src/main/scala/orca/runner/FlowLifecycle.scala +++ b/runner/src/main/scala/orca/runner/FlowLifecycle.scala @@ -550,7 +550,8 @@ object FlowLifecycle: StackOutcome.Configured(settings) case SettingsResolution.NeedsDiscovery(existingContent) => val (settings, entries) = - StackDiscovery.discover(agent, workDir, emit, existingContent) + withActivity("Discovering format, lint & test commands", emit): + StackDiscovery.discover(agent, workDir, emit, existingContent) val fileText = existingContent match case None => SettingsFile.render(entries) case Some(content) => @@ -563,6 +564,13 @@ object FlowLifecycle: ) StackOutcome.Discovered(settings) + private def withActivity[T](label: String, emit: OrcaEvent => Unit)( + op: => T + ): T = + emit(OrcaEvent.ActivityStarted(label)) + try op + finally emit(OrcaEvent.ActivityEnded()) + /** The run's stack settings, and whether [[resolveStackSettings]] just wrote * them to the settings file, which then still needs its own commit. */ @@ -681,7 +689,8 @@ object FlowLifecycle: emit: OrcaEvent => Unit )(using InStage, WorkspaceWrite): FeatureBranch = val strategy = branchNaming.getOrElse(BranchNamingStrategy.shortenPrompt) - val resolvedName = strategy.resolve(userPrompt, agent) + val resolvedName = withActivity("Naming the branch", emit): + strategy.resolve(userPrompt, agent) // Resolved once, shared by both fallback triggers below (a // protected-name refusal and a git-level `BranchAlreadyExists` // collision use the exact same deterministic name). diff --git a/runner/src/main/scala/orca/runner/LoggingListener.scala b/runner/src/main/scala/orca/runner/LoggingListener.scala index 304fc6c1..05ab1b35 100644 --- a/runner/src/main/scala/orca/runner/LoggingListener.scala +++ b/runner/src/main/scala/orca/runner/LoggingListener.scala @@ -22,7 +22,10 @@ private[orca] class LoggingListener extends OrcaListener: log.info("stage start: {}", path.display) case OrcaEvent.StageEnded(path, outcome) => log.info("stage end: {} ({})", path.display, outcome) - case OrcaEvent.Step(message) => log.info("step: {}", message) + case OrcaEvent.ActivityStarted(label) => + log.info("activity start: {}", label) + case _: OrcaEvent.ActivityEnded => log.info("activity end") + case OrcaEvent.Step(message) => log.info("step: {}", message) case OrcaEvent.Bookkeeping(message) => log.info("bookkeeping: {}", message) case OrcaEvent.Caveat(message) => log.info("caveat: {}", message) diff --git a/runner/src/main/scala/orca/runner/terminal/TerminalEventRenderer.scala b/runner/src/main/scala/orca/runner/terminal/TerminalEventRenderer.scala index 98941a03..2ce1c2b3 100644 --- a/runner/src/main/scala/orca/runner/terminal/TerminalEventRenderer.scala +++ b/runner/src/main/scala/orca/runner/terminal/TerminalEventRenderer.scala @@ -49,6 +49,10 @@ private[terminal] class TerminalEventRenderer( stack = stack.drop(1) stageEmitters = StageEmitters.Silent output.setStatus(stack.headOption) + case OrcaEvent.ActivityStarted(label) => + output.setStatus(Some(label)) + case _: OrcaEvent.ActivityEnded => + output.setStatus(stack.headOption) case OrcaEvent.ToolUse(tool, args, agent) => // Recorded before the branch: an agent whose reads go out unnamed is // still an emitter, so its prose and writes get named once the stage has diff --git a/runner/src/main/scala/orca/runner/terminal/TerminalOutput.scala b/runner/src/main/scala/orca/runner/terminal/TerminalOutput.scala index 6b15d51d..f7836d02 100644 --- a/runner/src/main/scala/orca/runner/terminal/TerminalOutput.scala +++ b/runner/src/main/scala/orca/runner/terminal/TerminalOutput.scala @@ -232,7 +232,7 @@ private[terminal] object TerminalOutputState: /** Carriage return + ANSI Erase-In-Line-2 (clear entire line). `\u001b` is * the ESC byte, a Unicode escape so the source stays grep-friendly. */ - private val ClearLine: String = "\r\u001b[2K" + private[terminal] val ClearLine: String = "\r\u001b[2K" private val DefaultLabel: String = "Thinking..." diff --git a/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala b/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala index 8ba4fb14..6b63e60a 100644 --- a/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala +++ b/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala @@ -487,6 +487,23 @@ class TerminalEventRendererTest extends munit.FunSuite: s"outer stage title leaked into the status bar; tail was: '$tail'" ) + test("an activity outside any stage pins its label until it ends"): + val rendered = renderWith( + animated = true, + List( + OrcaEvent.ActivityStarted("Naming the branch"), + OrcaEvent.ActivityEnded() + ) + ) + assert( + rendered.contains("Naming the branch"), + s"activity label missing from the status row; got: '$rendered'" + ) + assert( + rendered.endsWith(TerminalOutputState.ClearLine), + s"status row should be cleared once the activity ends; got: '$rendered'" + ) + test("nested stages indent inner content; no ✔ ever appears in the log"): val output = renderEvents( List( diff --git a/tools/src/main/scala/orca/events/OrcaEvent.scala b/tools/src/main/scala/orca/events/OrcaEvent.scala index 557352b7..ecd335d3 100644 --- a/tools/src/main/scala/orca/events/OrcaEvent.scala +++ b/tools/src/main/scala/orca/events/OrcaEvent.scala @@ -28,6 +28,16 @@ enum OrcaEvent: /** The stage at `path` ended; see [[StageStarted]]. */ case StageEnded private[orca] (path: StagePath.Stage, outcome: StageOutcome) + /** Orca began work outside any stage, such as a setup model call; `label` + * names it for display. Always followed by [[ActivityEnded]], before the + * next `ActivityStarted` or [[StageStarted]]; emitted on the flow's owner + * thread. + */ + case ActivityStarted private[orca] (label: String) + + /** The work announced by the preceding [[ActivityStarted]] finished. */ + case ActivityEnded private[orca] () + /** One tool invocation by the agent named in `agent`. Backends emit `None` — * a drain doesn't know which agent it runs for; * [[OrcaListener.attributedTo]] wraps it and stamps the name on the way out.