Class: Clacky::UI2::ProgressHandle
- Inherits:
-
Object
- Object
- Clacky::UI2::ProgressHandle
- Defined in:
- lib/clacky/ui2/progress_handle.rb
Overview
An owned progress indicator.
Why this exists
The previous design had a single, globally-shared spinner slot on UiController (‘@progress_id` / `@progress_thread` / `@progress_message` / `@progress_start_time`). Every caller — Agent#run, Agent#think, LlmCaller retry, idle compression, MemoryUpdater — wrote into the same slot and hoped to remember to close it. When control flow was interrupted (user types a new message during idle compression, AgentInterrupted is raised) a ticker thread would be left running and a new spinner would reuse the same entry, producing two concurrent tickers repainting the same line in different colors.
In the new design each caller owns a ProgressHandle. The handle encapsulates:
-
its own OutputBuffer entry id (may become nil while another handle is on top — see “Stack semantics” below);
-
its own ticker thread (exactly one per handle, stopped and joined on
finish); -
its own message, style, start time;
Owners (UiController) keep a stack of live handles and follow the protocol below.
Owner protocol
An “owner” must respond to three methods:
register_progress(handle) -> Integer (entry_id) | nil
Called exactly once when the handle starts. The owner pushes
the handle onto its stack, creates an OutputBuffer entry, and
returns that entry id. Before pushing, the owner may detach
the previous top-of-stack (Plan B: its entry is removed from
the buffer until the new top finishes).
unregister_progress(handle, final_frame:) -> void
Called exactly once when the handle finishes. The owner pops
the handle from its stack, renders +final_frame+ into the
entry (or removes the entry if +final_frame+ is nil), and may
reattach the new top-of-stack if one exists.
render_frame(handle, frame) -> void
Called by the ticker (and by +update+) on every paint. The
owner is responsible for ignoring the call if +handle+ is not
currently top-of-stack — the handle itself does NOT know about
the stack.
Stack semantics (Plan B)
When a new handle is pushed on top of an existing one, the lower handle’s OutputBuffer entry is removed (owner calls __detach_entry! on it). When the new top finishes, the owner re-creates an entry for the lower handle and calls __reattach_entry! with the new id. This keeps the visible output clean: exactly one progress line on screen at a time, and no visual “stacking” of frozen progress lines.
Thread safety
The handle uses a Monitor (reentrant) to serialize state changes between the caller thread and the ticker thread. Public methods (start, update, finish) are safe to call from any thread.
Constant Summary collapse
- DEFAULT_TICK_INTERVAL =
Default tick interval (seconds). Matches the old global spinner cadence. Tests may pass a smaller interval for speed.
0.5- VALID_STYLES =
Style hint for the renderer. The owner decides what colors to use; the handle only forwards the hint as part of the frame metadata so the renderer can pick between e.g. yellow “working” and gray “quiet” palettes.
:primary — foreground task, should also update sessionbar :quiet — background task (idle compression, retries); does NOT bump sessionbar to 'working' %i[primary quiet].freeze
- FAST_FINISH_THRESHOLD_SECONDS =
Threshold (seconds) below which a
quiet_on_fast_finishhandle collapses its final frame — i.e. the progress line is REMOVED from the output buffer instead of being kept as a permanent “Executing foo… (0s)” log line. Operations that finish this fast didn’t need a spinner in the first place; keeping the final frame would be visual noise. 2
Instance Attribute Summary collapse
-
#entry_id ⇒ Object
readonly
Returns the value of attribute entry_id.
-
#message ⇒ Object
readonly
Returns the value of attribute message.
-
#start_time ⇒ Object
readonly
Returns the value of attribute start_time.
-
#style ⇒ Object
readonly
Returns the value of attribute style.
Instance Method Summary collapse
-
#__detach_entry! ⇒ Object
Owner calls this when this handle is being pushed below a new top.
-
#__force_render! ⇒ Object
Test hook: force a synchronous render regardless of tick cadence.
-
#__reattach_entry!(new_entry_id) ⇒ Object
Owner calls this when this handle becomes top-of-stack again (the handle above finished).
-
#current_frame ⇒ Object
Compose the current visual frame.
-
#finish(final_message: nil) ⇒ Object
(also: #cancel)
Stop the ticker, render one final frame, and unregister from the owner.
-
#initialize(owner:, message:, style: :primary, tick_interval: DEFAULT_TICK_INTERVAL, quiet_on_fast_finish: false, clock: -> { Time.now }) ⇒ ProgressHandle
constructor
A new instance of ProgressHandle.
-
#running? ⇒ Boolean
True between
startandfinish. -
#start ⇒ self
Start rendering.
-
#ticker_alive? ⇒ Boolean
True while the ticker thread is alive.
-
#update(message: nil, metadata: nil) ⇒ Object
Change the message or metadata mid-flight.
Constructor Details
#initialize(owner:, message:, style: :primary, tick_interval: DEFAULT_TICK_INTERVAL, quiet_on_fast_finish: false, clock: -> { Time.now }) ⇒ ProgressHandle
Returns a new instance of ProgressHandle.
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/clacky/ui2/progress_handle.rb', line 108 def initialize(owner:, message:, style: :primary, tick_interval: DEFAULT_TICK_INTERVAL, quiet_on_fast_finish: false, clock: -> { Time.now }) unless VALID_STYLES.include?(style) raise ArgumentError, "unknown progress style: #{style.inspect} (valid: #{VALID_STYLES.inspect})" end @owner = owner @message = .to_s @style = style @tick_interval = tick_interval @quiet_on_fast_finish = quiet_on_fast_finish @clock = clock @entry_id = nil @start_time = nil @ticker = nil @state = :fresh # :fresh → :running → :closed @metadata = {} @monitor = Monitor.new end |
Instance Attribute Details
#entry_id ⇒ Object (readonly)
Returns the value of attribute entry_id.
86 87 88 |
# File 'lib/clacky/ui2/progress_handle.rb', line 86 def entry_id @entry_id end |
#message ⇒ Object (readonly)
Returns the value of attribute message.
86 87 88 |
# File 'lib/clacky/ui2/progress_handle.rb', line 86 def @message end |
#start_time ⇒ Object (readonly)
Returns the value of attribute start_time.
86 87 88 |
# File 'lib/clacky/ui2/progress_handle.rb', line 86 def start_time @start_time end |
#style ⇒ Object (readonly)
Returns the value of attribute style.
86 87 88 |
# File 'lib/clacky/ui2/progress_handle.rb', line 86 def style @style end |
Instance Method Details
#__detach_entry! ⇒ Object
Owner calls this when this handle is being pushed below a new top. The handle loses its OutputBuffer entry until restored.
217 218 219 |
# File 'lib/clacky/ui2/progress_handle.rb', line 217 def __detach_entry! @monitor.synchronize { @entry_id = nil } end |
#__force_render! ⇒ Object
Test hook: force a synchronous render regardless of tick cadence.
229 230 231 |
# File 'lib/clacky/ui2/progress_handle.rb', line 229 def __force_render! render_now end |
#__reattach_entry!(new_entry_id) ⇒ Object
Owner calls this when this handle becomes top-of-stack again (the handle above finished). A fresh entry id is supplied.
223 224 225 226 |
# File 'lib/clacky/ui2/progress_handle.rb', line 223 def __reattach_entry!(new_entry_id) @monitor.synchronize { @entry_id = new_entry_id } render_now end |
#current_frame ⇒ Object
Compose the current visual frame. The owner gets this string via render_frame and is responsible for writing it into the entry.
204 205 206 207 208 |
# File 'lib/clacky/ui2/progress_handle.rb', line 204 def current_frame @monitor.synchronize do compose_frame(@message, elapsed_seconds, @metadata) end end |
#finish(final_message: nil) ⇒ Object Also known as: cancel
Stop the ticker, render one final frame, and unregister from the owner. Idempotent — calling twice is a no-op.
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/clacky/ui2/progress_handle.rb', line 169 def finish(final_message: nil) snapshot = @monitor.synchronize do return if @state != :running @state = :closed { message: || @message, elapsed: elapsed_seconds } end stop_ticker # Collapse fast-finishers to a removed entry so tools that complete # in under FAST_FINISH_THRESHOLD_SECONDS don't leave a permanent # "Executing foo… (0s)" line. The owner interprets final_frame: nil # as "remove the entry entirely". final_frame = if @quiet_on_fast_finish && snapshot[:elapsed] < FAST_FINISH_THRESHOLD_SECONDS nil else compose_final_frame(snapshot[:message], snapshot[:elapsed]) end @owner.unregister_progress(self, final_frame: final_frame) end |
#running? ⇒ Boolean
True between start and finish.
198 199 200 |
# File 'lib/clacky/ui2/progress_handle.rb', line 198 def running? @monitor.synchronize { @state == :running } end |
#start ⇒ self
Start rendering. Registers with the owner (allocating an entry id and pushing onto its stack) and launches the ticker thread.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/clacky/ui2/progress_handle.rb', line 132 def start @monitor.synchronize do return self unless @state == :fresh @state = :running @start_time = @clock.call @entry_id = @owner.register_progress(self) end # Fire one initial frame synchronously so the user sees the # spinner immediately — no "blank line for half a second" bug. render_now start_ticker self end |
#ticker_alive? ⇒ Boolean
True while the ticker thread is alive.
192 193 194 195 |
# File 'lib/clacky/ui2/progress_handle.rb', line 192 def ticker_alive? t = @ticker !!(t && t.alive?) end |
#update(message: nil, metadata: nil) ⇒ Object
Change the message or metadata mid-flight. Safe to call from any thread. Triggers an immediate re-render (if top-of-stack; the owner will ignore the call otherwise).
155 156 157 158 159 160 161 162 |
# File 'lib/clacky/ui2/progress_handle.rb', line 155 def update(message: nil, metadata: nil) @monitor.synchronize do return if @state != :running @message = .to_s if @metadata = if end render_now end |