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.25- 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- IDLE_HINT_THRESHOLD_SECONDS =
Show “Thinking for Ns” once the gap since the last LLM stream chunk reaches this many seconds. Bedrock often pauses 5–18s while generating large content blocks (long tool_use JSON in particular); without this hint users assume the agent is stuck.
2- SPINNER_FRAMES =
%w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
- SPINNER_INTERVAL_MS =
250
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).
-
#__rebind_entry!(new_entry_id) ⇒ Object
Like __reattach_entry! but skips the render_now hop.
-
#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.
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/clacky/ui2/progress_handle.rb', line 114 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 = {} @last_chunk_at = nil @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.
227 228 229 |
# File 'lib/clacky/ui2/progress_handle.rb', line 227 def __detach_entry! @monitor.synchronize { @entry_id = nil } end |
#__force_render! ⇒ Object
Test hook: force a synchronous render regardless of tick cadence.
248 249 250 |
# File 'lib/clacky/ui2/progress_handle.rb', line 248 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.
233 234 235 236 |
# File 'lib/clacky/ui2/progress_handle.rb', line 233 def __reattach_entry!(new_entry_id) @monitor.synchronize { @entry_id = new_entry_id } render_now end |
#__rebind_entry!(new_entry_id) ⇒ Object
Like __reattach_entry! but skips the render_now hop. Used by the owner when it has just painted a frame into the new entry itself (e.g. while rotating the handle to remain at the buffer tail) and is still inside its own synchronization — calling render_now there would re-enter the owner’s mutex.
243 244 245 |
# File 'lib/clacky/ui2/progress_handle.rb', line 243 def __rebind_entry!(new_entry_id) @monitor.synchronize { @entry_id = new_entry_id } 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.
214 215 216 217 218 |
# File 'lib/clacky/ui2/progress_handle.rb', line 214 def current_frame @monitor.synchronize do compose_frame(@message, elapsed_seconds, @metadata, idle_seconds) 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.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/clacky/ui2/progress_handle.rb', line 179 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.
208 209 210 |
# File 'lib/clacky/ui2/progress_handle.rb', line 208 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.
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/clacky/ui2/progress_handle.rb', line 139 def start @monitor.synchronize do return self unless @state == :fresh @state = :running @start_time = @clock.call @last_chunk_at = @start_time @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.
202 203 204 205 |
# File 'lib/clacky/ui2/progress_handle.rb', line 202 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).
163 164 165 166 167 168 169 170 171 172 |
# File 'lib/clacky/ui2/progress_handle.rb', line 163 def update(message: nil, metadata: nil) @monitor.synchronize do return if @state != :running @message = .to_s if if @metadata = @last_chunk_at = @clock.call end end end |