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 134 |
# 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 @unregistered = false @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.
234 235 236 |
# File 'lib/clacky/ui2/progress_handle.rb', line 234 def __detach_entry! @monitor.synchronize { @entry_id = nil } end |
#__force_render! ⇒ Object
Test hook: force a synchronous render regardless of tick cadence.
255 256 257 |
# File 'lib/clacky/ui2/progress_handle.rb', line 255 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.
240 241 242 243 |
# File 'lib/clacky/ui2/progress_handle.rb', line 240 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.
250 251 252 |
# File 'lib/clacky/ui2/progress_handle.rb', line 250 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.
221 222 223 224 225 |
# File 'lib/clacky/ui2/progress_handle.rb', line 221 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 and crash-safe — if a previous finish was
interrupted (e.g. Thread#raise(AgentInterrupted) hit between
stop_ticker and unregister_progress), a follow-up finish
will still complete the unregister so the handle does not stay
orphaned on the owner's progress stack.
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/clacky/ui2/progress_handle.rb', line 184 def finish(final_message: nil) snapshot = @monitor.synchronize do return if @unregistered first_close = @state == :running @state = :closed if first_close { first_close: first_close, message: || @message, elapsed: elapsed_seconds, } end stop_ticker 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) @monitor.synchronize { @unregistered = true } end |
#running? ⇒ Boolean
True between start and finish.
215 216 217 |
# File 'lib/clacky/ui2/progress_handle.rb', line 215 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.
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/clacky/ui2/progress_handle.rb', line 140 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.
209 210 211 212 |
# File 'lib/clacky/ui2/progress_handle.rb', line 209 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).
164 165 166 167 168 169 170 171 172 173 |
# File 'lib/clacky/ui2/progress_handle.rb', line 164 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 |