Class: Clacky::Server::WebUIController

Inherits:
Object
  • Object
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

Methods included from UIInterface

#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   # callable: broadcaster.call(session_id, event_hash)
  @mutex       = Mutex.new

  # Pending confirmation state: { id => ConditionVariable, result => value }
  @pending_confirmations = {}

  # Channel subscribers: array of objects implementing UIInterface.
  # All emitted events are forwarded to each subscriber after WebSocket broadcast.
  @channel_subscribers = []
  @subscribers_mutex   = Mutex.new
end

Instance Attribute Details

#session_idObject (readonly)

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.

Returns:

  • (Boolean)

    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

#clear_inputObject

Input control (no-ops in web mode) ===



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)
  # Log forwarding intentionally skipped — too noisy for IM
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)
  # Clear thread-local before emitting end so the end event itself
  # doesn't get tagged with the phase_id it's closing.
  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

Phase grouping ===



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
  # Emit without auto-injection (the start event itself defines the phase)
  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_stateObject

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

  # Replay complete progress state (not just message).
  # Include started_at (ms since epoch) so the frontend can resume the
  # elapsed-time counter from the correct origin instead of resetting to 0.
  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

seconds



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)

  # Notify channel subscribers that confirmation is pending — non-blocking.
  # They display a notice; the actual decision comes from the Web UI user.
  forward_to_subscribers { |sub| sub.show_warning("⏳ Confirmation requested: #{message}") }

  # Block until browser replies or timeout
  @mutex.synchronize do
    cond.wait(@mutex, CONFIRMATION_TIMEOUT)
    @pending_confirmations.delete(conf_id)
    result = pending[:result]

    # Timed out — use default
    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_statusObject



377
378
379
380
381
382
383
384
385
386
# File 'lib/clacky/server/web_ui_controller.rb', line 377

def set_idle_status
  # Clear any in-progress state when transitioning to idle
  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

#set_input_tips(message, type: :info) ⇒ Object



427
# File 'lib/clacky/server/web_ui_controller.rb', line 427

def set_input_tips(message, type: :info); end

#set_working_statusObject



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?

  # Rewrite local image paths (file:// and bare absolute) to /api/local-image
  # proxy URLs only for the browser, which runs on http://localhost and is
  # blocked by browser security policy from loading file:// directly.
  # Channel subscribers receive the original content so they can deliver
  # local images as native attachments via send_file().
  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

Status messages ===



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

Progress ===



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"
    # Only set start time when transitioning into a fresh progress phase.
    # Streaming LLM calls fire show_progress every chunk for token updates;
    # resetting the timer each time would make the elapsed counter jitter
    # back to 0 in the UI and force the frontend to rebuild its interval.
    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: 
    }
  elsif phase == "done"
    @live_tool_call = nil   # command finished — nothing left to replay
    # Keep @live_stdout_buffer intact — it will be reset on the next show_progress call.
    # This allows a brief replay window even after the command finishes.
    @live_progress_state = nil
    @progress_start_time = nil
  end
  
  data = {
    message: message,
    progress_type: progress_type,
    phase: phase,
    status: phase == "active" ? "start" : "stop"  # backward compat
  }
  data[:metadata] =  unless .empty?
  # Always include started_at for "active" phase so the frontend can set the
  # correct timer origin even on the very first event (not just replay).
  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)
  # Token usage is internal detail — intentionally not forwarded
end

#show_tool_args(formatted_args) ⇒ Object



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

#show_tool_call(name, args) ⇒ Object



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

  # Special handling for ask_user — emit a dedicated UI event
  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)
    # IM subscribers render the same questions as plain text — they have no card.
    forward_to_subscribers { |sub| sub.show_tool_call(name, args_data) }
    return
  end

  # Generate a human-readable summary using the tool's format_call method
  summary = tool_call_summary(name, args_data)

  # Remember the current in-flight tool call so replay_live_state can re-emit it
  # when a browser tab re-subscribes after switching sessions.
  @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

#show_tool_error(error) ⇒ Object



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

#show_tool_result(result) ⇒ Object



147
148
149
150
151
# File 'lib/clacky/server/web_ui_controller.rb', line 147

def show_tool_result(result)
  @live_tool_call = nil   # tool finished — no longer in-flight
  emit("tool_result", result: result)
  forward_to_subscribers { |sub| sub.show_tool_result(result) }
end

#show_tool_stdout(lines) ⇒ Object

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)
  # Not forwarded to IM subscribers — too noisy
end

#show_user_message(content, created_at: nil, files: [], source: :web, skill_command: nil, skill_command_display: nil) ⇒ Object

Output display ===



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
  # Build ev.images for the frontend renderer (history_user_message):
  #   - Images with data_url → pass the data_url directly (<img> thumbnail)
  #   - Disk image files (type=="image", has path) → /api/local-image proxy URL
  #   - Disk files (PDF, doc, etc., no data_url, no image path) → "pdf:name" sentinel
  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)
  # Only forward to channel subscribers when the message originated from the WebUI,
  # to avoid echoing channel messages back to the same channel.
  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

#stopObject

Lifecycle ===



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.

Parameters:



39
40
41
# File 'lib/clacky/server/web_ui_controller.rb', line 39

def subscribe_channel(subscriber)
  @subscribers_mutex.synchronize { @channel_subscribers << subscriber }
end

#tool_call_summary(name, args) ⇒ Object

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.

Parameters:

  • subscriber (Object)


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

State updates ===



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