From d77179df2cae14bcc3426f7739783fc15fab04ff Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Thu, 24 Sep 2026 13:45:04 +0000 Subject: [PATCH 1/2] Show the spinner during setup's model calls Stack discovery and branch naming run before any stage opens, so the status row stayed hidden. A new ActivityStarted/ActivityEnded event pair brackets them and the terminal shows its label. --- .../scala/orca/runner/FlowLifecycle.scala | 17 +++++++++++++-- .../scala/orca/runner/LoggingListener.scala | 5 ++++- .../terminal/TerminalEventRenderer.scala | 4 ++++ .../terminal/TerminalEventRendererTest.scala | 21 +++++++++++++++++++ .../main/scala/orca/events/OrcaEvent.scala | 10 +++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/runner/src/main/scala/orca/runner/FlowLifecycle.scala b/runner/src/main/scala/orca/runner/FlowLifecycle.scala index 8b2e8fe2..1b712bcf 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) + activity("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,17 @@ object FlowLifecycle: ) StackOutcome.Discovered(settings) + /** Run `op` bracketed by [[OrcaEvent.ActivityStarted]] / + * [[OrcaEvent.ActivityEnded]], so the terminal shows `label` on its status + * row while setup work runs outside any stage. + */ + private def activity[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 +693,8 @@ object FlowLifecycle: emit: OrcaEvent => Unit )(using InStage, WorkspaceWrite): FeatureBranch = val strategy = branchNaming.getOrElse(BranchNamingStrategy.shortenPrompt) - val resolvedName = strategy.resolve(userPrompt, agent) + val resolvedName = + activity("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/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala b/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala index 8ba4fb14..5c8ab779 100644 --- a/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala +++ b/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala @@ -487,6 +487,27 @@ 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 started = renderWith( + animated = true, + List(OrcaEvent.ActivityStarted("Naming the branch")) + ) + assert( + started.contains("Naming the branch"), + s"activity label missing from the status row; got: '$started'" + ) + val ended = renderWith( + animated = true, + List( + OrcaEvent.ActivityStarted("Naming the branch"), + OrcaEvent.ActivityEnded() + ) + ) + assert( + ended.endsWith("\r\u001b[2K"), + s"status row should be cleared once the activity ends; got: '$ended'" + ) + 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..69eecad6 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 that runs before any stage opens — today, setup's model + * calls (stack discovery, branch naming). `label` says what is running. + * 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. From 70bfb871ecdd3234f5781e351d391e9f27e35394 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Thu, 24 Sep 2026 14:06:33 +0000 Subject: [PATCH 2/2] Address review: tidy activity docs and test, fix stale dirty-tree comment --- .../scala/orca/runner/DirtyTreePolicy.scala | 12 ++++++------ .../main/scala/orca/runner/FlowLifecycle.scala | 12 ++++-------- .../orca/runner/terminal/TerminalOutput.scala | 2 +- .../terminal/TerminalEventRendererTest.scala | 18 +++++++----------- .../src/main/scala/orca/events/OrcaEvent.scala | 8 ++++---- 5 files changed, 22 insertions(+), 30 deletions(-) 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 1b712bcf..e1af7e91 100644 --- a/runner/src/main/scala/orca/runner/FlowLifecycle.scala +++ b/runner/src/main/scala/orca/runner/FlowLifecycle.scala @@ -550,7 +550,7 @@ object FlowLifecycle: StackOutcome.Configured(settings) case SettingsResolution.NeedsDiscovery(existingContent) => val (settings, entries) = - activity("Discovering format, lint & test commands", emit): + withActivity("Discovering format, lint & test commands", emit): StackDiscovery.discover(agent, workDir, emit, existingContent) val fileText = existingContent match case None => SettingsFile.render(entries) @@ -564,11 +564,7 @@ object FlowLifecycle: ) StackOutcome.Discovered(settings) - /** Run `op` bracketed by [[OrcaEvent.ActivityStarted]] / - * [[OrcaEvent.ActivityEnded]], so the terminal shows `label` on its status - * row while setup work runs outside any stage. - */ - private def activity[T](label: String, emit: OrcaEvent => Unit)( + private def withActivity[T](label: String, emit: OrcaEvent => Unit)( op: => T ): T = emit(OrcaEvent.ActivityStarted(label)) @@ -693,8 +689,8 @@ object FlowLifecycle: emit: OrcaEvent => Unit )(using InStage, WorkspaceWrite): FeatureBranch = val strategy = branchNaming.getOrElse(BranchNamingStrategy.shortenPrompt) - val resolvedName = - activity("Naming the branch", emit)(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/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 5c8ab779..6b63e60a 100644 --- a/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala +++ b/runner/src/test/scala/orca/runner/terminal/TerminalEventRendererTest.scala @@ -488,15 +488,7 @@ class TerminalEventRendererTest extends munit.FunSuite: ) test("an activity outside any stage pins its label until it ends"): - val started = renderWith( - animated = true, - List(OrcaEvent.ActivityStarted("Naming the branch")) - ) - assert( - started.contains("Naming the branch"), - s"activity label missing from the status row; got: '$started'" - ) - val ended = renderWith( + val rendered = renderWith( animated = true, List( OrcaEvent.ActivityStarted("Naming the branch"), @@ -504,8 +496,12 @@ class TerminalEventRendererTest extends munit.FunSuite: ) ) assert( - ended.endsWith("\r\u001b[2K"), - s"status row should be cleared once the activity ends; got: '$ended'" + 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"): diff --git a/tools/src/main/scala/orca/events/OrcaEvent.scala b/tools/src/main/scala/orca/events/OrcaEvent.scala index 69eecad6..ecd335d3 100644 --- a/tools/src/main/scala/orca/events/OrcaEvent.scala +++ b/tools/src/main/scala/orca/events/OrcaEvent.scala @@ -28,10 +28,10 @@ enum OrcaEvent: /** The stage at `path` ended; see [[StageStarted]]. */ case StageEnded private[orca] (path: StagePath.Stage, outcome: StageOutcome) - /** Orca began work that runs before any stage opens — today, setup's model - * calls (stack discovery, branch naming). `label` says what is running. - * Always followed by [[ActivityEnded]], before the next `ActivityStarted` or - * [[StageStarted]]; emitted on the flow's owner thread. + /** 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)