openclacky 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "shellwords"
4
- require "socket"
4
+ require "yaml"
5
5
  require "tmpdir"
6
6
  require_relative "base"
7
7
  require_relative "shell"
@@ -13,8 +13,6 @@ module Clacky
13
13
  self.tool_description = <<~DESC
14
14
  Browser automation for login-related operations (sign-in, OAuth, form submission requiring session). For simple page fetch or search, prefer web_fetch or web_search instead.
15
15
 
16
- isolated: true = built-in browser (default, no setup, login persists). false = user's Chrome (keeps cookies/login, needs one-time debug setup; opens URLs in new tab).
17
-
18
16
  SNAPSHOT — always run before interacting with a page. Refs (@e1, @e2...) expire after page changes, always re-snapshot before acting on a changed page:
19
17
  - 'snapshot -i -C' — interactive + cursor-clickable elements (recommended default)
20
18
  - 'snapshot -i' — interactive elements only (faster, for simple forms)
@@ -39,101 +37,78 @@ module Clacky
39
37
  command: {
40
38
  type: "string",
41
39
  description: "agent-browser command, e.g. 'open https://...', 'snapshot -i', 'click @e1', 'fill @e2 \"text\"'"
42
- },
43
- session: {
44
- type: "string",
45
- description: "Named session for parallel browser instances (optional)"
46
- },
47
- isolated: {
48
- type: "boolean",
49
- description: "true = built-in browser (default, no setup, login persists). false = user's Chrome (keeps login, needs one-time debug setup; opens URLs in new tab). Default true when omitted."
50
40
  }
51
41
  },
52
42
  required: ["command"]
53
43
  }
54
44
 
55
45
  AGENT_BROWSER_BIN = "agent-browser"
56
- DEFAULT_SESSION_NAME = "clacky"
57
- CHROME_DEBUG_PORT = 9222
58
46
  BROWSER_COMMAND_TIMEOUT = 30
59
- CHROME_DEBUG_PAGE = "chrome://inspect/#remote-debugging"
60
47
  MIN_AGENT_BROWSER_VERSION = "0.20.0"
61
48
 
62
- def execute(command:, session: nil, isolated: nil, working_dir: nil)
63
- # Handle explicit install command
64
- if command.strip == "install"
65
- return do_install_agent_browser
49
+ # Inline config reads ~/.clacky/browser.yml, falls back to built-in defaults.
50
+ #
51
+ # Example ~/.clacky/browser.yml:
52
+ # headed: true # show browser window (default: true)
53
+ # session_name: clacky # persistent session name (default: clacky)
54
+ # auto_connect: false # false = built-in browser (default), true = user's Chrome
55
+ class BrowserConfig
56
+ USER_CONFIG_FILE = File.join(Dir.home, ".clacky", "browser.yml")
57
+
58
+ DEFAULTS = {
59
+ "headed" => true,
60
+ "session_name" => "clacky",
61
+ "auto_connect" => false
62
+ }.freeze
63
+
64
+ attr_reader :headed, :session_name, :auto_connect
65
+
66
+ def initialize(attrs = {})
67
+ merged = DEFAULTS.merge(attrs)
68
+ @headed = merged["headed"]
69
+ @session_name = merged["session_name"]
70
+ @auto_connect = merged["auto_connect"]
66
71
  end
67
72
 
68
- if !agent_browser_installed?
69
- return {
70
- error: "agent-browser not installed",
71
- message: "agent-browser is required for browser automation but is not installed.",
72
- instructions: "Tell the user: 'agent-browser is not installed. It's required for browser automation. Run `browser(command: \"install\")` to install it — this may take a minute. Would you like me to install it now?' Wait for user confirmation before calling install."
73
- }
73
+ def self.load
74
+ data = File.exist?(USER_CONFIG_FILE) ? YAML.safe_load(File.read(USER_CONFIG_FILE)) || {} : {}
75
+ new(data)
76
+ rescue StandardError
77
+ new
74
78
  end
79
+ end
75
80
 
76
- if agent_browser_outdated?
77
- current = `agent-browser --version 2>/dev/null`.strip.split.last
78
- return {
79
- error: "agent-browser version too old",
80
- message: "agent-browser #{current} is installed but version >= #{MIN_AGENT_BROWSER_VERSION} is required.",
81
- instructions: "Tell the user: 'agent-browser needs to be upgraded from #{current} to #{MIN_AGENT_BROWSER_VERSION}+. Run `browser(command: \"install\")` to upgrade — this may take a minute. Would you like me to upgrade it now?' Wait for user confirmation before calling install."
82
- }
81
+ def execute(command:, working_dir: nil)
82
+ unless agent_browser_ready?
83
+ return not_ready_response
83
84
  end
84
85
 
85
- # Default to built-in browser (isolated=true). Only use user's Chrome when explicitly isolated=false.
86
- use_auto_connect = isolated == false
87
- persistent_session_name = use_auto_connect ? nil : DEFAULT_SESSION_NAME
88
-
89
- we_launched_chrome = false
90
- if use_auto_connect && !chrome_debug_running?
91
- launch_result = ensure_chrome_debug_ready
92
- if launch_result == :not_installed
93
- use_auto_connect = false
94
- persistent_session_name = DEFAULT_SESSION_NAME
95
- elsif launch_result
96
- we_launched_chrome = true
97
- else
98
- return chrome_setup_instructions
99
- end
100
- end
86
+ cfg = BrowserConfig.load
101
87
 
102
- build_opts = {
103
- auto_connect: use_auto_connect,
104
- session_name: persistent_session_name,
105
- headed: use_auto_connect ? false : true
106
- }
88
+ # In auto_connect mode, open commands become new tabs in user's Chrome
107
89
  effective_command = command
108
- if use_auto_connect && (m = command.strip.match(/\A(open|goto|navigate)\s+(.+)\z/i))
90
+ if cfg.auto_connect && (m = command.strip.match(/\A(open|goto|navigate)\s+(.+)\z/i))
109
91
  effective_command = "tab new #{m[2].strip}"
110
92
  end
111
- full_command = build_command(effective_command, session, **build_opts)
112
-
113
- result = Shell.new.execute(command: full_command, hard_timeout: BROWSER_COMMAND_TIMEOUT, working_dir: working_dir)
114
-
115
- if !result[:success] && session_closed_error?(result) && persistent_session_name
116
- full_command = build_command(
117
- effective_command, session,
118
- auto_connect: use_auto_connect,
119
- session_name: nil,
120
- headed: use_auto_connect ? false : true
121
- )
122
- result = Shell.new.execute(command: full_command, hard_timeout: BROWSER_COMMAND_TIMEOUT, working_dir: working_dir)
123
- end
124
93
 
125
- if use_auto_connect && !result[:success] && connection_error?(result)
126
- if we_launched_chrome
127
- result = Shell.new.execute(command: full_command, hard_timeout: BROWSER_COMMAND_TIMEOUT, working_dir: working_dir)
128
- end
129
- if !result[:success] && connection_error?(result)
130
- open_chrome_remote_debugging_page
131
- return chrome_setup_instructions
132
- end
133
- end
134
-
135
- if use_auto_connect && !result[:success] && timeout?(result)
136
- return chrome_setup_instructions(timeout: true)
94
+ full_command = build_command(effective_command,
95
+ auto_connect: cfg.auto_connect,
96
+ session_name: cfg.auto_connect ? nil : cfg.session_name,
97
+ headed: cfg.headed)
98
+
99
+ result = Shell.new.execute(command: full_command,
100
+ hard_timeout: BROWSER_COMMAND_TIMEOUT,
101
+ working_dir: working_dir)
102
+
103
+ # Session may have been closed — retry without session name
104
+ if !result[:success] && session_closed_error?(result) && cfg.session_name
105
+ full_command = build_command(effective_command,
106
+ auto_connect: cfg.auto_connect,
107
+ session_name: nil,
108
+ headed: cfg.headed)
109
+ result = Shell.new.execute(command: full_command,
110
+ hard_timeout: BROWSER_COMMAND_TIMEOUT,
111
+ working_dir: working_dir)
137
112
  end
138
113
 
139
114
  result[:command] = command
@@ -144,11 +119,7 @@ module Clacky
144
119
 
145
120
  def format_call(args)
146
121
  cmd = args[:command] || args["command"] || ""
147
- session = args[:session] || args["session"]
148
- isolated = args[:isolated] || args["isolated"]
149
- session_label = session ? " [#{session}]" : ""
150
- isolated_label = (isolated != false) ? " [built-in]" : " [user Chrome]"
151
- "browser(#{cmd})#{session_label}#{isolated_label}"
122
+ "browser(#{cmd})"
152
123
  end
153
124
 
154
125
  def format_result(result)
@@ -156,7 +127,7 @@ module Clacky
156
127
  "[Error] #{result[:error][0..80]}"
157
128
  elsif result[:success]
158
129
  stdout = result[:stdout] || ""
159
- lines = stdout.lines.size
130
+ lines = stdout.lines.size
160
131
  "[OK] #{lines > 0 ? "#{lines} lines" : "Done"}"
161
132
  else
162
133
  stderr = result[:stderr] || "Failed"
@@ -165,36 +136,34 @@ module Clacky
165
136
  end
166
137
 
167
138
  MAX_LLM_OUTPUT_CHARS = 6000
168
- # Snapshot-specific limit: accessibility trees can be huge; compress aggressively
169
- MAX_SNAPSHOT_CHARS = 4000
139
+ MAX_SNAPSHOT_CHARS = 4000
170
140
 
171
141
  def format_result_for_llm(result)
172
142
  return result if result[:error]
173
143
 
174
- stdout = result[:stdout] || ""
175
- stderr = result[:stderr] || ""
144
+ stdout = result[:stdout] || ""
145
+ stderr = result[:stderr] || ""
176
146
  command_name = command_name_for_temp(result[:command])
177
147
 
178
148
  compact = {
179
- command: result[:command],
180
- success: result[:success],
149
+ command: result[:command],
150
+ success: result[:success],
181
151
  exit_code: result[:exit_code]
182
152
  }
183
153
 
184
- # Apply snapshot-specific compression before generic truncation
185
154
  if snapshot_command?(result[:command])
186
- stdout = compress_snapshot(stdout)
155
+ stdout = compress_snapshot(stdout)
187
156
  max_chars = MAX_SNAPSHOT_CHARS
188
157
  else
189
158
  max_chars = MAX_LLM_OUTPUT_CHARS
190
159
  end
191
160
 
192
161
  stdout_info = truncate_and_save(stdout, max_chars, "stdout", command_name)
193
- compact[:stdout] = stdout_info[:content]
162
+ compact[:stdout] = stdout_info[:content]
194
163
  compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
195
164
 
196
165
  stderr_info = truncate_and_save(stderr, 500, "stderr", command_name)
197
- compact[:stderr] = stderr_info[:content] unless stderr.empty?
166
+ compact[:stderr] = stderr_info[:content] unless stderr.empty?
198
167
  compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
199
168
 
200
169
  compact
@@ -202,206 +171,71 @@ module Clacky
202
171
 
203
172
  private
204
173
 
205
- # Returns true if this browser command is a snapshot (accessibility tree dump)
206
- def snapshot_command?(command)
207
- return false unless command.is_a?(String)
208
- cmd = command.strip.downcase
209
- cmd == "snapshot" || cmd.start_with?("snapshot ")
174
+ def agent_browser_ready?
175
+ agent_browser_installed? && !agent_browser_outdated?
210
176
  end
211
177
 
212
- # Strip noise from snapshot output to reduce token usage.
213
- #
214
- # What we remove (safe, LLM doesn't need them to interact):
215
- # - "- /url: ..." lines — LLM uses [ref=eN] to click/fill, not URLs
216
- # - "- /placeholder: ..." lines — already shown inline in textbox label
217
- # - "- img" lines with no alt text — zero information
218
- #
219
- # What we keep intact:
220
- # - All [ref=eN] anchors (essential for click/fill commands)
221
- # - All visible text and headings
222
- # - All interactive elements (button, textbox, link, select, etc.)
223
- # - img lines that do have alt text
224
- def compress_snapshot(output)
225
- return output if output.empty?
226
-
227
- lines = output.lines
228
- original_size = lines.size
229
-
230
- compressed = lines.reject do |line|
231
- stripped = line.strip
232
- stripped.start_with?("- /url:", "/url:") ||
233
- stripped.start_with?("- /placeholder:", "/placeholder:") ||
234
- stripped == "- img" ||
235
- stripped.match?(/\A-\s+img\s*\z/)
236
- end
178
+ def not_ready_response
179
+ {
180
+ error: "agent-browser not ready",
181
+ instructions: "Tell the user that browser automation is not set up yet, and ask them to run `/onboard browser` to complete the setup."
182
+ }
183
+ end
237
184
 
238
- # If we removed a meaningful number of lines, append a note
239
- removed = original_size - compressed.size
240
- if removed > 0
241
- compressed << "\n[snapshot compressed: #{removed} /url, /placeholder, empty-img lines removed]\n"
242
- end
185
+ def agent_browser_installed?
186
+ result = Shell.new.execute(command: "which #{AGENT_BROWSER_BIN}")
187
+ result[:success] && !result[:stdout].to_s.strip.empty?
188
+ end
243
189
 
244
- compressed.join
190
+ def agent_browser_outdated?
191
+ result = Shell.new.execute(command: "#{AGENT_BROWSER_BIN} --version")
192
+ version = result[:stdout].to_s.strip.split.last
193
+ return false if version.nil? || version.empty?
194
+ Gem::Version.new(version) < Gem::Version.new(MIN_AGENT_BROWSER_VERSION)
195
+ rescue StandardError
196
+ false
245
197
  end
246
198
 
247
- def build_command(command, session, auto_connect: false, session_name: nil, headed: false)
199
+ def build_command(command, auto_connect: false, session_name: nil, headed: true)
248
200
  parts = [AGENT_BROWSER_BIN]
249
- parts << "--auto-connect" << (auto_connect ? "true" : "false")
250
- parts << "--headed" << (headed ? "true" : "false")
251
- parts += ["--session", Shellwords.escape(session)] if session
201
+ parts << "--auto-connect" if auto_connect
202
+ parts << "--headed" if headed
252
203
  parts += ["--session-name", Shellwords.escape(session_name)] if session_name
253
204
  parts << command
254
205
  parts.join(" ")
255
206
  end
256
207
 
257
- def chrome_debug_running?
258
- TCPSocket.new("127.0.0.1", CHROME_DEBUG_PORT).close
259
- true
260
- rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError
261
- false
262
- end
263
-
264
- def connection_error?(result)
265
- output = "#{result[:stderr]}#{result[:stdout]}"
266
- output.include?("Could not connect") ||
267
- output.include?("No running Chrome instance") ||
268
- output.include?("remote debugging")
269
- end
270
-
271
208
  def session_closed_error?(result)
272
209
  output = "#{result[:stderr]}#{result[:stdout]}"
273
210
  output.include?("has been close") || output.include?("has been closed")
274
211
  end
275
212
 
276
- def timeout?(result)
277
- result[:state] == "TIMEOUT" ||
278
- result[:stderr].to_s.include?("timed out")
279
- end
280
-
281
- def find_chrome
282
- @chrome_path ||= resolve_chrome_path
283
- end
284
-
285
- def resolve_chrome_path
286
- %w[CHROME_PATH CHROME_BIN GOOGLE_CHROME_BIN].each do |var|
287
- path = ENV[var]
288
- return path if path && File.executable?(path)
289
- end
290
-
291
- %w[google-chrome google-chrome-stable chromium chromium-browser].each do |bin|
292
- path = find_in_path(bin)
293
- return path if path
294
- end
295
-
296
- paths = []
297
-
298
- if macos?
299
- paths += [
300
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
301
- "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
302
- "#{Dir.home}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
303
- ]
304
- end
305
-
306
- if linux?
307
- paths += [
308
- "/usr/bin/google-chrome",
309
- "/usr/bin/google-chrome-stable",
310
- "/usr/bin/chromium",
311
- "/usr/bin/chromium-browser",
312
- "/snap/bin/chromium"
313
- ]
314
- end
315
-
316
- paths.find { |path| File.executable?(path) }
317
- end
318
-
319
- def find_in_path(bin)
320
- ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
321
- path = File.join(dir, bin)
322
- return path if File.executable?(path) && !File.directory?(path)
323
- end
324
- nil
325
- end
326
-
327
- def ensure_chrome_debug_ready
328
- executable = find_chrome
329
- return :not_installed unless executable
330
-
331
- spawn_chrome_with_debug_port(executable)
332
- return true
333
- end
334
-
335
- def spawn_chrome_with_debug_port(executable)
336
- pid = Process.spawn(
337
- executable,
338
- "--remote-debugging-port=#{CHROME_DEBUG_PORT}",
339
- "--no-first-run",
340
- "--no-default-browser-check",
341
- out: File::NULL,
342
- err: File::NULL
343
- )
344
- Process.detach(pid)
345
- end
346
-
347
- def open_chrome_remote_debugging_page
348
- if macos?
349
- system("open", "-a", "Google Chrome", CHROME_DEBUG_PAGE)
350
- else
351
- executable = find_chrome
352
- if executable
353
- system("sh", "-c", "#{Shellwords.escape(executable)} #{Shellwords.escape(CHROME_DEBUG_PAGE)} > /dev/null 2>&1 &")
354
- end
355
- end
356
- end
357
-
358
- def macos?
359
- RbConfig::CONFIG["host_os"].include?("darwin")
213
+ def snapshot_command?(command)
214
+ return false unless command.is_a?(String)
215
+ cmd = command.strip.downcase
216
+ cmd == "snapshot" || cmd.start_with?("snapshot ")
360
217
  end
361
218
 
362
- def linux?
363
- RbConfig::CONFIG["host_os"].include?("linux")
364
- end
219
+ # Strip noise from snapshot output to reduce token usage.
220
+ #
221
+ # Removes:
222
+ # - "- /url: ..." lines — LLM uses [ref=eN], not URLs
223
+ # - "- /placeholder: ..." lines — already shown inline in textbox label
224
+ # - bare "- img" lines with no alt text — zero information
225
+ def compress_snapshot(output)
226
+ return output if output.empty?
365
227
 
366
- def chrome_setup_instructions(timeout: false)
367
- base = {
368
- error: "Cannot connect to Chrome browser",
369
- message: "Opened Chrome with #{CHROME_DEBUG_PAGE}. Please enable the 'Allow remote debugging for this browser instance' toggle, then tell me when done. If the page shows 'Server running at: starting...' and connection fails, fully quit Chrome and reopen it, then retry.",
370
- instructions: "Follow this flow with the user: " \
371
- "Phase 1 Chrome has been opened with the inspect page. If Chrome was closed, it should now be open. Ask the user to enable the 'Allow remote debugging for this browser instance' toggle if not already on, then tell you when done. " \
372
- "Phase 2 — Once the user confirms, retry the browser command. The Allow dialog appears once per Chrome session (each time the user reopens Chrome) — this is Chrome's security and cannot be skipped. Ask the user to click Allow when it appears. " \
373
- "Phase 3 — If connection still fails and the page shows 'Server running at: starting...', tell the user to fully quit Chrome and reopen it; the server may be stuck. Once it shows 'Server running at: 127.0.0.1:9222', retry.",
374
- note: "Do NOT retry immediately. Wait for the user to confirm. The toggle persists — if they enabled it before, reopening Chrome is enough. Allow dialog is once per session."
375
- }
376
- if timeout
377
- base[:error] = "Browser command timed out"
378
- base[:message] = "Command timed out. This usually means the Allow dialog is showing — please click Allow in the Chrome dialog, then tell me when done."
379
- base[:instructions] = "Timeout usually means the Allow dialog is waiting. Do NOT retry. Ask the user: 'Please click the Allow button in the Chrome dialog, then tell me when done.' Only retry after the user confirms they clicked Allow."
228
+ lines = output.lines
229
+ orig = lines.size
230
+ filtered = lines.reject do |line|
231
+ s = line.strip
232
+ s.start_with?("- /url:", "/url:", "- /placeholder:", "/placeholder:") ||
233
+ s == "- img" || s.match?(/\A-\s+img\s*\z/)
380
234
  end
381
- base
382
- end
383
235
 
384
- def agent_browser_installed?
385
- !!find_in_path(AGENT_BROWSER_BIN)
386
- end
387
-
388
- def agent_browser_outdated?
389
- version = `agent-browser --version 2>/dev/null`.strip.split.last
390
- return false if version.nil? || version.empty?
391
- Gem::Version.new(version) < Gem::Version.new(MIN_AGENT_BROWSER_VERSION)
392
- rescue StandardError
393
- false
394
- end
395
-
396
- def do_install_agent_browser
397
- script = File.expand_path("../../../../scripts/install_agent_browser.sh", __FILE__)
398
- result = Shell.new.execute(command: "bash #{Shellwords.escape(script)}", hard_timeout: 180)
399
- if result[:success]
400
- version = `agent-browser --version 2>/dev/null`.strip.split.last
401
- { success: true, message: "agent-browser #{version} installed successfully. You can now use browser commands." }
402
- else
403
- { error: "Failed to install agent-browser", message: result[:stdout].to_s.strip }
404
- end
236
+ removed = orig - filtered.size
237
+ filtered << "\n[snapshot compressed: #{removed} /url, /placeholder, empty-img lines removed]\n" if removed > 0
238
+ filtered.join
405
239
  end
406
240
 
407
241
  def command_name_for_temp(command)
@@ -411,33 +245,28 @@ module Clacky
411
245
 
412
246
  def truncate_and_save(output, max_chars, _label, command_name)
413
247
  return { content: "", temp_file: nil } if output.empty?
414
-
415
248
  return { content: output, temp_file: nil } if output.length <= max_chars
416
249
 
417
250
  lines = output.lines
418
251
  return { content: output, temp_file: nil } if lines.length <= 2
419
252
 
420
253
  safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
421
- temp_dir = Dir.mktmpdir
254
+ temp_dir = Dir.mktmpdir
422
255
  temp_file = File.join(temp_dir, "browser_#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
423
256
  File.write(temp_file, output)
424
257
 
425
- notice_overhead = 200
426
- available_chars = max_chars - notice_overhead
427
-
258
+ available = max_chars - 200
428
259
  first_part = []
429
260
  accumulated = 0
430
261
  lines.each do |line|
431
- break if accumulated + line.length > available_chars
262
+ break if accumulated + line.length > available
432
263
  first_part << line
433
264
  accumulated += line.length
434
265
  end
435
266
 
436
267
  notice = "\n\n... [Output truncated: showing #{first_part.size} of #{lines.size} lines, full: #{temp_file} (use grep to search)] ...\n"
437
-
438
268
  { content: first_part.join + notice, temp_file: temp_file }
439
269
  end
440
-
441
270
  end
442
271
  end
443
272
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../block_font"
4
+
5
+ module Clacky
6
+ module UI2
7
+ # Alias for backward compatibility — BlockFont now lives at Clacky::BlockFont.
8
+ BlockFont = Clacky::BlockFont
9
+ end
10
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "pastel"
4
4
  require_relative "../../version"
5
+ require_relative "../block_font"
5
6
 
6
7
  module Clacky
7
8
  module UI2
@@ -33,9 +34,6 @@ module Clacky
33
34
  # Minimum terminal width required for full logo display
34
35
  MIN_WIDTH_FOR_LOGO = 90
35
36
 
36
- # Artii font used for brand name generation
37
- ARTII_FONT = "big"
38
-
39
37
  def initialize
40
38
  @pastel = Pastel.new
41
39
  end
@@ -115,7 +113,7 @@ module Clacky
115
113
  private def logo_content(width)
116
114
  brand = brand_config
117
115
  if brand.branded?
118
- generate_brand_logo(brand.brand_name, width)
116
+ generate_brand_logo(brand, width)
119
117
  else
120
118
  if width >= MIN_WIDTH_FOR_LOGO
121
119
  @pastel.bright_green(LOGO)
@@ -125,28 +123,31 @@ module Clacky
125
123
  end
126
124
  end
127
125
 
128
- # Generate a brand-name ASCII art logo using artii.
129
- # Falls back gracefully when artii is unavailable or terminal too narrow.
130
- private def generate_brand_logo(brand_name, width)
131
- art = artii_render(brand_name)
126
+ # Generate a brand logo using BlockFont (Unicode █ ╗ ╔ style).
127
+ # Renders brand_command as the big ASCII art logo.
128
+ # Shows brand_name as a subtitle when it differs from brand_command.
129
+ # Falls back to plain brand_name text when terminal is too narrow.
130
+ private def generate_brand_logo(brand, width)
131
+ # Use brand_command as the renderable ASCII-safe identifier for the logo.
132
+ # brand_name may contain CJK or special characters unsuitable for block art.
133
+ render_key = brand.brand_command.to_s.strip
134
+ render_key = brand.brand_name.to_s.strip if render_key.empty?
135
+
136
+ art = UI2::BlockFont.render(render_key)
132
137
 
133
- if art && art_fits?(art, width)
134
- @pastel.bright_green(art)
135
- elsif art
136
- # Terminal too narrow for full art — centre-clip or use plain fallback
137
- @pastel.bright_green(brand_name)
138
+ lines = []
139
+ if !art.strip.empty? && art_fits?(art, width)
140
+ lines << @pastel.bright_green(art)
138
141
  else
139
- @pastel.bright_green(brand_name)
142
+ lines << @pastel.bright_green(render_key)
140
143
  end
141
- end
142
144
 
143
- # Render text using artii. Returns nil on any failure.
144
- private def artii_render(text)
145
- require "artii"
146
- a = Artii::Base.new(font: ARTII_FONT)
147
- a.asciify(text)
148
- rescue LoadError, StandardError
149
- nil
145
+ # Show brand_name as subtitle when it differs from the render key
146
+ if brand.brand_name.to_s.strip != render_key
147
+ lines << @pastel.bright_cyan(" #{brand.brand_name}")
148
+ end
149
+
150
+ lines.join("\n")
150
151
  end
151
152
 
152
153
  # Check whether the ASCII art fits within the terminal width.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.2"
4
+ VERSION = "0.9.4"
5
5
  end