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

Constructor Details

#initialize(session_id, broadcaster) ⇒ WebUIController

Returns a new instance of WebUIController.



20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/clacky/server/web_ui_controller.rb', line 20

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.



18
19
20
# File 'lib/clacky/server/web_ui_controller.rb', line 18

def session_id
  @session_id
end

Instance Method Details

#append_output(content) ⇒ Object



184
185
186
187
# File 'lib/clacky/server/web_ui_controller.rb', line 184

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



50
51
52
# File 'lib/clacky/server/web_ui_controller.rb', line 50

def channel_subscribed?
  @subscribers_mutex.synchronize { !@channel_subscribers.empty? }
end

#clear_inputObject

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



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

def clear_input; end

#clear_progressObject



260
261
262
263
264
265
# File 'lib/clacky/server/web_ui_controller.rb', line 260

def clear_progress
  @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.
  show_progress(progress_type: "thinking", phase: "done")
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.



56
57
58
59
60
61
62
63
64
# File 'lib/clacky/server/web_ui_controller.rb', line 56

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



386
387
388
389
# File 'lib/clacky/server/web_ui_controller.rb', line 386

def emit(type, **data)
  event = { type: type, session_id: @session_id }.merge(data)
  @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.



395
396
397
398
399
400
401
402
403
404
# File 'lib/clacky/server/web_ui_controller.rb', line 395

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
    warn "[WebUIController] channel subscriber error: #{e.message}"
  end
end

#log(message, level: :info) ⇒ Object



211
212
213
214
# File 'lib/clacky/server/web_ui_controller.rb', line 211

def log(message, level: :info)
  emit("log", level: level.to_s, message: message)
  # Log forwarding intentionally skipped — too noisy for IM
end

#replay_live_stateObject

Replay in-progress command state to a newly (re-)subscribing browser tab. Called by http_server.rb after the subscribe handshake when the session is still running a shell command. Without this, switching sessions and switching back results in a blank progress area — the progress event and 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).



282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/clacky/server/web_ui_controller.rb', line 282

def replay_live_state
  return unless @live_progress_state

  # Replay complete progress state (not just message)
  state = @live_progress_state
  emit("progress",
    message: state[:message],
    progress_type: state[:progress_type],
    phase: "active",
    status: "start",
    metadata: state[:metadata] || {}
  )

  buf = @live_stdout_buffer
  emit("tool_stdout", lines: buf) if buf && !buf.empty?
end

#request_confirmation(message, default: true) ⇒ Object

seconds



330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/clacky/server/web_ui_controller.rb', line 330

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



320
321
322
323
# File 'lib/clacky/server/web_ui_controller.rb', line 320

def set_idle_status
  emit("session_update", status: "idle")
  forward_to_subscribers { |sub| sub.set_idle_status }
end

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



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

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

#set_working_statusObject



315
316
317
318
# File 'lib/clacky/server/web_ui_controller.rb', line 315

def set_working_status
  emit("session_update", status: "working")
  forward_to_subscribers { |sub| sub.set_working_status }
end

#show_assistant_message(content, files:) ⇒ Object



87
88
89
90
91
92
# File 'lib/clacky/server/web_ui_controller.rb', line 87

def show_assistant_message(content, files:)
  return if (content.nil? || content.to_s.strip.empty?) && files.empty?

  emit("assistant_message", content: content, files: files)
  forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files) }
end

#show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false) ⇒ Object



172
173
174
175
176
177
178
179
180
181
182
# File 'lib/clacky/server/web_ui_controller.rb', line 172

def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
  data = { iterations: iterations, cost: cost }
  data[:duration]               = duration            if duration
  data[:cache_stats]            = cache_stats         if cache_stats
  data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
  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)
  end
end

#show_diff(old_content, new_content, max_lines: 50) ⇒ Object



162
163
164
165
# File 'lib/clacky/server/web_ui_controller.rb', line 162

def show_diff(old_content, new_content, max_lines: 50)
  emit("diff", old_size: old_content.bytesize, new_size: new_content.bytesize)
  # Diffs are too verbose for IM — intentionally not forwarded
end

#show_error(message) ⇒ Object



201
202
203
204
# File 'lib/clacky/server/web_ui_controller.rb', line 201

def show_error(message)
  emit("error", message: message)
  forward_to_subscribers { |sub| sub.show_error(message) }
end

#show_file_edit_preview(path) ⇒ Object



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

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



152
153
154
155
# File 'lib/clacky/server/web_ui_controller.rb', line 152

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



142
143
144
145
# File 'lib/clacky/server/web_ui_controller.rb', line 142

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 ===



191
192
193
194
# File 'lib/clacky/server/web_ui_controller.rb', line 191

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 ===



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/clacky/server/web_ui_controller.rb', line 218

def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
  if phase == "active"
    @progress_start_time = Time.now
    # Store complete progress state for replay when user switches back to this session
    @live_progress_state = {
      message: message,
      progress_type: progress_type,
      metadata: 
    }
    # Reset stdout buffer for each new command so re-subscribe only replays current run
    @live_stdout_buffer = []
  elsif phase == "done"
    # Clear progress state when done
    @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?
  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



157
158
159
160
# File 'lib/clacky/server/web_ui_controller.rb', line 157

def show_shell_preview(command)
  emit("shell_preview", command: command)
  forward_to_subscribers { |sub| sub.show_shell_preview(command) }
end

#show_success(message) ⇒ Object



206
207
208
209
# File 'lib/clacky/server/web_ui_controller.rb', line 206

def show_success(message)
  emit("success", message: message)
  forward_to_subscribers { |sub| sub.show_success(message) }
end

#show_token_usage(token_data) ⇒ Object



167
168
169
170
# File 'lib/clacky/server/web_ui_controller.rb', line 167

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



137
138
139
140
# File 'lib/clacky/server/web_ui_controller.rb', line 137

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



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/clacky/server/web_ui_controller.rb', line 94

def show_tool_call(name, args)
  args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args

  # Special handling for request_user_feedback — emit a dedicated UI event
  if name.to_s == "request_user_feedback"
    question = args_data.is_a?(Hash) ? (args_data[:question] || args_data["question"]).to_s : ""
    context  = args_data.is_a?(Hash) ? (args_data[:context]  || args_data["context"]).to_s  : ""
    options  = args_data.is_a?(Hash) ? (args_data[:options]  || args_data["options"])        : nil

    # Normalize options to array (guard against malformed data)
    options = Array(options) if options && !options.is_a?(Array)

    emit("request_feedback",
         question: question,
         context: context,
         options: options || [])
    # Don't forward to IM subscribers — they get the formatted text version already
    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



131
132
133
134
135
# File 'lib/clacky/server/web_ui_controller.rb', line 131

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



125
126
127
128
129
# File 'lib/clacky/server/web_ui_controller.rb', line 125

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.



252
253
254
255
256
257
258
# File 'lib/clacky/server/web_ui_controller.rb', line 252

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) ⇒ Object

Output display ===



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/clacky/server/web_ui_controller.rb', line 68

def show_user_message(content, created_at: nil, files: [], source: :web)
  data = { content: content }
  data[:created_at] = created_at if created_at
  # Build ev.images for the frontend renderer (history_user_message):
  #   - Images with data_url → pass the data_url directly (<img> thumbnail)
  #   - Disk files (PDF, doc, etc., no data_url) → "pdf:name" sentinel (renders a badge)
  rendered = Array(files).filter_map do |f|
    url  = f[:data_url] || f["data_url"]
    name = f[:name]     || f["name"]
    url || (name ? "pdf:#{name}" : nil)
  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



196
197
198
199
# File 'lib/clacky/server/web_ui_controller.rb', line 196

def show_warning(message)
  emit("warning", message: message)
  forward_to_subscribers { |sub| sub.show_warning(message) }
end

#stopObject

Lifecycle ===



368
369
370
# File 'lib/clacky/server/web_ui_controller.rb', line 368

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:



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

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.



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

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)


45
46
47
# File 'lib/clacky/server/web_ui_controller.rb', line 45

def unsubscribe_channel(subscriber)
  @subscribers_mutex.synchronize { @channel_subscribers.delete(subscriber) }
end

#update_sessionbar(tasks: nil, cost: nil, status: nil) ⇒ Object

State updates ===



301
302
303
304
305
306
307
308
# File 'lib/clacky/server/web_ui_controller.rb', line 301

def update_sessionbar(tasks: nil, cost: nil, status: nil)
  data = {}
  data[:tasks]  = tasks  if tasks
  data[:cost]   = cost   if cost
  data[:status] = status if status
  emit("session_update", **data) unless data.empty?
  forward_to_subscribers { |sub| sub.update_sessionbar(tasks: tasks, cost: cost, status: status) }
end

#update_todos(todos) ⇒ Object



310
311
312
313
# File 'lib/clacky/server/web_ui_controller.rb', line 310

def update_todos(todos)
  emit("todo_update", todos: todos)
  forward_to_subscribers { |sub| sub.update_todos(todos) }
end