Class: Clacky::Server::WebUIController
- Inherits:
-
Object
- Object
- Clacky::Server::WebUIController
show all
- Includes:
- UIInterface
- Defined in:
- lib/clacky/server/web_ui_controller.rb
Overview
WebUIController implements UIInterface for the web server mode.
Instead of writing to stdout, it broadcasts JSON events over WebSocket connections.
Multiple browser tabs can subscribe to the same session_id.
request_confirmation blocks the calling thread until the browser sends a response,
mirroring the behaviour of JsonUIController (which reads from stdin).
Constant Summary
collapse
- CONFIRMATION_TIMEOUT =
Blocking interaction ===
Emits a request_confirmation event and blocks until the browser responds.
Timeout after 5 minutes to avoid hanging threads forever.
300
Instance Attribute Summary collapse
Instance Method Summary
collapse
-
#append_output(content) ⇒ Object
-
#channel_subscribed? ⇒ Boolean
True if any channel subscribers are registered.
-
#clear_input ⇒ Object
-
#deliver_confirmation(conf_id, result) ⇒ Object
Deliver a confirmation answer received from the browser.
-
#emit(type, **data) ⇒ Object
-
#forward_to_subscribers(&block) ⇒ Object
Forward a UIInterface call to all registered channel subscribers.
-
#initialize(session_id, broadcaster) ⇒ WebUIController
constructor
A new instance of WebUIController.
-
#log(message, level: :info) ⇒ Object
-
#phase_end(phase_id, summary: nil) ⇒ Object
-
#phase_start(kind:, label: nil, concurrent: false) ⇒ Object
Phase grouping ===.
-
#replay_live_state ⇒ Object
Replay in-progress command state to a newly (re-)subscribing browser tab.
-
#request_confirmation(message, default: true) ⇒ Object
-
#set_idle_status ⇒ Object
-
#set_input_tips(message, type: :info) ⇒ Object
-
#set_working_status ⇒ Object
-
#show_assistant_message(content, files:, interim: false, created_at: nil) ⇒ Object
-
#show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil) ⇒ Object
-
#show_diff(old_content, new_content, max_lines: 50) ⇒ Object
-
#show_error(message, code: nil, top_up_url: nil, raw_message: nil) ⇒ Object
-
#show_feedback_request(question, context, options, questions: nil) ⇒ Object
-
#show_file_edit_preview(path) ⇒ Object
-
#show_file_error(error_message) ⇒ Object
-
#show_file_write_preview(path, is_new_file:) ⇒ Object
-
#show_info(message, prefix_newline: true) ⇒ Object
Status messages ===.
-
#show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}) ⇒ Object
Progress ===.
-
#show_shell_preview(command) ⇒ Object
-
#show_success(message) ⇒ Object
-
#show_token_usage(token_data) ⇒ Object
-
#show_tool_args(formatted_args) ⇒ Object
-
#show_tool_call(name, args) ⇒ Object
-
#show_tool_error(error) ⇒ Object
-
#show_tool_result(result) ⇒ Object
-
#show_tool_stdout(lines) ⇒ Object
Stream shell stdout/stderr lines to the browser while a command is running.
-
#show_user_message(content, created_at: nil, files: [], source: :web, skill_command: nil, skill_command_display: nil) ⇒ Object
Output display ===.
-
#show_warning(message) ⇒ Object
-
#stop ⇒ Object
Lifecycle ===.
-
#subscribe_channel(subscriber) ⇒ void
Register a channel subscriber (e.g. ChannelUIController).
-
#tool_call_summary(name, args) ⇒ Object
Generate a short human-readable summary for a tool call display.
-
#unsubscribe_channel(subscriber) ⇒ void
Remove a previously registered channel subscriber.
-
#update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil) ⇒ Object
State updates ===.
-
#update_todos(todos) ⇒ Object
#request_feedback_with_countdown, #show_goal_status, #show_subagent_end, #show_subagent_start, #start_progress, #stream_thinking_progress, #update_permission_mode, #with_phase, #with_progress
Constructor Details
#initialize(session_id, broadcaster) ⇒ WebUIController
Returns a new instance of WebUIController.
21
22
23
24
25
26
27
28
29
30
31
32
33
|
# File 'lib/clacky/server/web_ui_controller.rb', line 21
def initialize(session_id, broadcaster)
@session_id = session_id
@broadcaster = broadcaster @mutex = Mutex.new
@pending_confirmations = {}
@channel_subscribers = []
@subscribers_mutex = Mutex.new
end
|
Instance Attribute Details
#session_id ⇒ Object
Returns the value of attribute session_id.
19
20
21
|
# File 'lib/clacky/server/web_ui_controller.rb', line 19
def session_id
@session_id
end
|
Instance Method Details
#append_output(content) ⇒ Object
208
209
210
211
|
# File 'lib/clacky/server/web_ui_controller.rb', line 208
def append_output(content)
emit("output", content: content)
forward_to_subscribers { |sub| sub.append_output(content) }
end
|
#channel_subscribed? ⇒ Boolean
Returns true if any channel subscribers are registered.
51
52
53
|
# File 'lib/clacky/server/web_ui_controller.rb', line 51
def channel_subscribed?
@subscribers_mutex.synchronize { !@channel_subscribers.empty? }
end
|
426
|
# File 'lib/clacky/server/web_ui_controller.rb', line 426
def clear_input; end
|
#deliver_confirmation(conf_id, result) ⇒ Object
Deliver a confirmation answer received from the browser.
Called by the HTTP server when a confirmation message arrives over WebSocket.
57
58
59
60
61
62
63
64
65
|
# File 'lib/clacky/server/web_ui_controller.rb', line 57
def deliver_confirmation(conf_id, result)
@mutex.synchronize do
pending = @pending_confirmations[conf_id]
return unless pending
pending[:result] = result
pending[:cond].signal
end
end
|
#emit(type, **data) ⇒ Object
449
450
451
452
453
454
455
|
# File 'lib/clacky/server/web_ui_controller.rb', line 449
def emit(type, **data)
event = { type: type, session_id: @session_id }.merge(data)
if (pid = Thread.current[:clacky_phase_id]) && !data.key?(:phase_id)
event[:phase_id] = pid
end
@broadcaster.call(@session_id, event)
end
|
#forward_to_subscribers(&block) ⇒ Object
Forward a UIInterface call to all registered channel subscribers.
Each subscriber is called in the same thread as the caller (Agent thread).
Errors in individual subscribers are rescued and logged so they never
interrupt the main agent execution.
461
462
463
464
465
466
467
468
469
470
|
# File 'lib/clacky/server/web_ui_controller.rb', line 461
def forward_to_subscribers(&block)
subscribers = @subscribers_mutex.synchronize { @channel_subscribers.dup }
return if subscribers.empty?
subscribers.each do |sub|
block.call(sub)
rescue StandardError => e
Clacky::Logger.error("[WebUIController] channel subscriber error", error: e)
end
end
|
#log(message, level: :info) ⇒ Object
260
261
262
263
|
# File 'lib/clacky/server/web_ui_controller.rb', line 260
def log(message, level: :info)
emit("log", level: level.to_s, message: message)
end
|
#phase_end(phase_id, summary: nil) ⇒ Object
237
238
239
240
241
242
243
244
|
# File 'lib/clacky/server/web_ui_controller.rb', line 237
def phase_end(phase_id, summary: nil)
Thread.current[:clacky_phase_id] = nil if Thread.current[:clacky_phase_id] == phase_id
event = { type: "phase_end", session_id: @session_id, phase_id: phase_id }
event[:summary] = summary if summary
@broadcaster.call(@session_id, event)
end
|
#phase_start(kind:, label: nil, concurrent: false) ⇒ Object
227
228
229
230
231
232
233
234
235
|
# File 'lib/clacky/server/web_ui_controller.rb', line 227
def phase_start(kind:, label: nil, concurrent: false)
pid = SecureRandom.uuid
Thread.current[:clacky_phase_id] = pid
event = { type: "phase_start", session_id: @session_id, phase_id: pid, kind: kind.to_s }
event[:label] = label if label
@broadcaster.call(@session_id, event)
pid
end
|
#replay_live_state ⇒ Object
Replay in-progress command state to a newly (re-)subscribing browser tab.
all tool_stdout lines that fired while the user was away are lost.
Replay live state when a client re-subscribes (e.g. after switching sessions).
Plan C: we do NOT re-emit tool_call here.
The tool-item is already rendered in the DOM via the normal flow.
We only replay:
1. progress(start) — restores the spinner / progress bar
2. tool_stdout — fills in all stdout received so far
The frontend's appendToolStdout will attach to the last visible .tool-item
even when _liveLastToolItem is null (after the tab re-loaded).
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
|
# File 'lib/clacky/server/web_ui_controller.rb', line 332
def replay_live_state
return unless @live_progress_state
state = @live_progress_state
started_at_ms = @progress_start_time ? (@progress_start_time.to_f * 1000).round : nil
emit("progress",
message: state[:message],
progress_type: state[:progress_type],
phase: "active",
status: "start",
metadata: state[:metadata] || {},
started_at: started_at_ms
)
buf = @live_stdout_buffer
emit("tool_stdout", lines: buf) if buf && !buf.empty?
end
|
#request_confirmation(message, default: true) ⇒ Object
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
|
# File 'lib/clacky/server/web_ui_controller.rb', line 393
def request_confirmation(message, default: true)
conf_id = "conf_#{SecureRandom.hex(4)}"
cond = ConditionVariable.new
pending = { cond: cond, result: nil }
@mutex.synchronize { @pending_confirmations[conf_id] = pending }
emit("request_confirmation", id: conf_id, message: message, default: default)
forward_to_subscribers { |sub| sub.show_warning("⏳ Confirmation requested: #{message}") }
@mutex.synchronize do
cond.wait(@mutex, CONFIRMATION_TIMEOUT)
@pending_confirmations.delete(conf_id)
result = pending[:result]
return default if result.nil?
case result.to_s.downcase
when "yes", "y" then true
when "no", "n" then false
else result.to_s
end
end
end
|
#set_idle_status ⇒ Object
377
378
379
380
381
382
383
384
385
386
|
# File 'lib/clacky/server/web_ui_controller.rb', line 377
def set_idle_status
if @live_progress_state
emit("progress", phase: "done", status: "stop")
@live_progress_state = nil
@progress_start_time = nil
end
emit("session_update", status: "idle")
forward_to_subscribers { |sub| sub.set_idle_status }
end
|
427
|
# File 'lib/clacky/server/web_ui_controller.rb', line 427
def set_input_tips(message, type: :info); end
|
#set_working_status ⇒ Object
372
373
374
375
|
# File 'lib/clacky/server/web_ui_controller.rb', line 372
def set_working_status
emit("session_update", status: "working")
forward_to_subscribers { |sub| sub.set_working_status }
end
|
#show_assistant_message(content, files:, interim: false, created_at: nil) ⇒ Object
99
100
101
102
103
104
105
106
107
108
109
110
|
# File 'lib/clacky/server/web_ui_controller.rb', line 99
def show_assistant_message(content, files:, interim: false, created_at: nil)
return if (content.nil? || content.to_s.strip.empty?) && files.empty?
web_content = Clacky::Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
emit("assistant_message", content: web_content, files: files, created_at: created_at)
forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files, interim: interim) }
end
|
#show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil) ⇒ Object
193
194
195
196
197
198
199
200
201
202
203
204
205
206
|
# File 'lib/clacky/server/web_ui_controller.rb', line 193
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
data = { iterations: iterations, cost: cost }
data[:task_id] = task_id if task_id
data[:duration] = duration if duration
data[:cache_stats] = cache_stats if cache_stats
data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
data[:cost_source] = cost_source.to_s if cost_source
emit("complete", **data)
forward_to_subscribers do |sub|
sub.show_complete(iterations: iterations, cost: cost, duration: duration,
cache_stats: cache_stats, awaiting_user_feedback: awaiting_user_feedback,
cost_source: cost_source, task_id: task_id)
end
end
|
#show_diff(old_content, new_content, max_lines: 50) ⇒ Object
184
185
186
|
# File 'lib/clacky/server/web_ui_controller.rb', line 184
def show_diff(old_content, new_content, max_lines: 50)
emit("diff", old_size: old_content.bytesize, new_size: new_content.bytesize)
end
|
#show_error(message, code: nil, top_up_url: nil, raw_message: nil) ⇒ Object
246
247
248
249
250
251
252
253
|
# File 'lib/clacky/server/web_ui_controller.rb', line 246
def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
payload = { message: message }
payload[:code] = code if code
payload[:top_up_url] = top_up_url if top_up_url
payload[:raw_message] = raw_message if raw_message
emit("error", **payload)
forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url, raw_message: raw_message) }
end
|
#show_feedback_request(question, context, options, questions: nil) ⇒ Object
112
113
114
115
|
# File 'lib/clacky/server/web_ui_controller.rb', line 112
def show_feedback_request(question, context, options, questions: nil)
emit("request_feedback", question: question, context: context,
options: options, questions: questions || [])
end
|
#show_file_edit_preview(path) ⇒ Object
169
170
171
172
|
# File 'lib/clacky/server/web_ui_controller.rb', line 169
def show_file_edit_preview(path)
emit("file_preview", path: path, operation: "edit")
forward_to_subscribers { |sub| sub.show_file_edit_preview(path) }
end
|
#show_file_error(error_message) ⇒ Object
174
175
176
177
|
# File 'lib/clacky/server/web_ui_controller.rb', line 174
def show_file_error(error_message)
emit("file_error", error: error_message)
forward_to_subscribers { |sub| sub.show_file_error(error_message) }
end
|
#show_file_write_preview(path, is_new_file:) ⇒ Object
164
165
166
167
|
# File 'lib/clacky/server/web_ui_controller.rb', line 164
def show_file_write_preview(path, is_new_file:)
emit("file_preview", path: path, operation: "write", is_new_file: is_new_file)
forward_to_subscribers { |sub| sub.show_file_write_preview(path, is_new_file: is_new_file) }
end
|
#show_info(message, prefix_newline: true) ⇒ Object
215
216
217
218
|
# File 'lib/clacky/server/web_ui_controller.rb', line 215
def show_info(message, prefix_newline: true)
emit("info", message: message)
forward_to_subscribers { |sub| sub.show_info(message) }
end
|
#show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}) ⇒ Object
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
|
# File 'lib/clacky/server/web_ui_controller.rb', line 267
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
if phase == "active"
if @live_progress_state.nil? || @live_progress_state[:progress_type] != progress_type
@progress_start_time = Time.now
@live_stdout_buffer = []
end
@live_progress_state = {
message: message,
progress_type: progress_type,
metadata: metadata
}
elsif phase == "done"
@live_tool_call = nil @live_progress_state = nil
@progress_start_time = nil
end
data = {
message: message,
progress_type: progress_type,
phase: phase,
status: phase == "active" ? "start" : "stop" }
data[:metadata] = metadata unless metadata.empty?
if phase == "active" && @progress_start_time
data[:started_at] = (@progress_start_time.to_f * 1000).round
end
data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
emit("progress", **data)
forward_to_subscribers { |sub| sub.show_progress(message) }
end
|
#show_shell_preview(command) ⇒ Object
179
180
181
182
|
# File 'lib/clacky/server/web_ui_controller.rb', line 179
def show_shell_preview(command)
emit("shell_preview", command: command)
forward_to_subscribers { |sub| sub.show_shell_preview(command) }
end
|
#show_success(message) ⇒ Object
255
256
257
258
|
# File 'lib/clacky/server/web_ui_controller.rb', line 255
def show_success(message)
emit("success", message: message)
forward_to_subscribers { |sub| sub.show_success(message) }
end
|
#show_token_usage(token_data) ⇒ Object
188
189
190
191
|
# File 'lib/clacky/server/web_ui_controller.rb', line 188
def show_token_usage(token_data)
emit("token_usage", **token_data)
end
|
159
160
161
162
|
# File 'lib/clacky/server/web_ui_controller.rb', line 159
def show_tool_args(formatted_args)
emit("tool_args", args: formatted_args)
forward_to_subscribers { |sub| sub.show_tool_args(formatted_args) }
end
|
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
|
# File 'lib/clacky/server/web_ui_controller.rb', line 117
def show_tool_call(name, args)
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
if Clacky::Tools::AskUser.feedback_tool?(name)
questions = Clacky::Tools::AskUser.normalize_questions(args_data)
context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
first = questions.first || {}
emit("request_feedback",
question: first[:question].to_s,
context: context,
options: first[:options] || [],
questions: questions)
forward_to_subscribers { |sub| sub.show_tool_call(name, args_data) }
return
end
summary = tool_call_summary(name, args_data)
@live_tool_call = { name: name, args: args_data, summary: summary }
emit("tool_call", name: name, args: args_data, summary: summary)
forward_to_subscribers { |sub| sub.show_tool_call(name, args_data) }
end
|
153
154
155
156
157
|
# File 'lib/clacky/server/web_ui_controller.rb', line 153
def show_tool_error(error)
error_msg = error.is_a?(Exception) ? error.message : error.to_s
emit("tool_error", error: error_msg)
forward_to_subscribers { |sub| sub.show_tool_error(error) }
end
|
147
148
149
150
151
|
# File 'lib/clacky/server/web_ui_controller.rb', line 147
def show_tool_result(result)
@live_tool_call = nil emit("tool_result", result: result)
forward_to_subscribers { |sub| sub.show_tool_result(result) }
end
|
Stream shell stdout/stderr lines to the browser while a command is running.
Called immediately via on_output callback from shell.rb — no polling delay.
Lines are also buffered in @live_stdout_buffer so late-joining subscribers
(e.g. user switches away and back) can receive a replay of what they missed.
312
313
314
315
316
317
318
|
# File 'lib/clacky/server/web_ui_controller.rb', line 312
def show_tool_stdout(lines)
return if lines.nil? || lines.empty?
@live_stdout_buffer ||= []
@live_stdout_buffer.concat(lines)
emit("tool_stdout", lines: lines)
end
|
#show_user_message(content, created_at: nil, files: [], source: :web, skill_command: nil, skill_command_display: nil) ⇒ Object
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
# File 'lib/clacky/server/web_ui_controller.rb', line 69
def show_user_message(content, created_at: nil, files: [], source: :web, skill_command: nil, skill_command_display: nil)
data = { content: content }
data[:created_at] = created_at if created_at
data[:skill_command] = skill_command if skill_command
data[:skill_command_display] = skill_command_display if skill_command_display
rendered = Array(files).filter_map do |f|
url = f[:data_url] || f["data_url"]
name = f[:name] || f["name"]
type = f[:type] || f["type"]
path = f[:path] || f["path"]
if url
url
elsif type.to_s == "image" && path && File.exist?(path.to_s)
"/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
elsif name
"pdf:#{name}"
end
end
data[:images] = rendered unless rendered.empty?
emit("history_user_message", **data)
return unless source == :web
forward_to_subscribers { |sub| sub.show_user_message(content) if sub.respond_to?(:show_user_message) }
end
|
#show_warning(message) ⇒ Object
220
221
222
223
|
# File 'lib/clacky/server/web_ui_controller.rb', line 220
def show_warning(message)
emit("warning", message: message)
forward_to_subscribers { |sub| sub.show_warning(message) }
end
|
#stop ⇒ Object
431
432
433
|
# File 'lib/clacky/server/web_ui_controller.rb', line 431
def stop
emit("server_stop")
end
|
#subscribe_channel(subscriber) ⇒ void
This method returns an undefined value.
Register a channel subscriber (e.g. ChannelUIController).
The subscriber will receive every UIInterface call that this controller handles.
39
40
41
|
# File 'lib/clacky/server/web_ui_controller.rb', line 39
def subscribe_channel(subscriber)
@subscribers_mutex.synchronize { @channel_subscribers << subscriber }
end
|
Generate a short human-readable summary for a tool call display.
Delegates to each tool's own format_call method when available.
438
439
440
441
442
443
444
445
446
447
|
# File 'lib/clacky/server/web_ui_controller.rb', line 438
def tool_call_summary(name, args)
class_name = name.to_s.split("_").map(&:capitalize).join
return nil unless Clacky::Tools.const_defined?(class_name)
tool = Clacky::Tools.const_get(class_name).new
args_sym = args.is_a?(Hash) ? args.transform_keys(&:to_sym) : {}
tool.format_call(args_sym)
rescue StandardError
nil
end
|
#unsubscribe_channel(subscriber) ⇒ void
This method returns an undefined value.
Remove a previously registered channel subscriber.
46
47
48
|
# File 'lib/clacky/server/web_ui_controller.rb', line 46
def unsubscribe_channel(subscriber)
@subscribers_mutex.synchronize { @channel_subscribers.delete(subscriber) }
end
|
#update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil) ⇒ Object
356
357
358
359
360
361
362
363
364
365
|
# File 'lib/clacky/server/web_ui_controller.rb', line 356
def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil)
data = {}
data[:tasks] = tasks if tasks
data[:cost] = cost if cost
data[:cost_source] = cost_source if cost_source
data[:status] = status if status
data[:latency] = latency if latency
emit("session_update", **data) unless data.empty?
forward_to_subscribers { |sub| sub.update_sessionbar(tasks: tasks, cost: cost, cost_source: cost_source, status: status, latency: latency) }
end
|
#update_todos(todos) ⇒ Object
367
368
369
370
|
# File 'lib/clacky/server/web_ui_controller.rb', line 367
def update_todos(todos)
emit("todo_update", todos: todos)
forward_to_subscribers { |sub| sub.update_todos(todos) }
end
|