openclacky 0.9.30 → 0.9.32
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/lib/clacky/agent/llm_caller.rb +5 -5
- data/lib/clacky/agent/memory_updater.rb +1 -1
- data/lib/clacky/agent/session_serializer.rb +2 -1
- data/lib/clacky/agent/skill_auto_creator.rb +119 -0
- data/lib/clacky/agent/skill_evolution.rb +46 -0
- data/lib/clacky/agent/skill_manager.rb +8 -0
- data/lib/clacky/agent/skill_reflector.rb +97 -0
- data/lib/clacky/agent.rb +38 -12
- data/lib/clacky/agent_config.rb +10 -1
- data/lib/clacky/brand_config.rb +23 -0
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/default_skills/onboard/SKILL.md +15 -7
- data/lib/clacky/default_skills/personal-website/publish.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +46 -0
- data/lib/clacky/json_ui_controller.rb +0 -4
- data/lib/clacky/message_history.rb +0 -12
- data/lib/clacky/plain_ui_controller.rb +19 -1
- data/lib/clacky/platform_http_client.rb +2 -4
- data/lib/clacky/providers.rb +12 -1
- data/lib/clacky/server/channel/channel_ui_controller.rb +0 -2
- data/lib/clacky/server/http_server.rb +13 -1
- data/lib/clacky/server/web_ui_controller.rb +55 -29
- data/lib/clacky/tools/shell.rb +91 -170
- data/lib/clacky/ui2/ui_controller.rb +100 -93
- data/lib/clacky/ui_interface.rb +0 -1
- data/lib/clacky/utils/arguments_parser.rb +5 -2
- data/lib/clacky/utils/limit_stack.rb +81 -13
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +247 -51
- data/lib/clacky/web/app.js +11 -3
- data/lib/clacky/web/brand.js +21 -3
- data/lib/clacky/web/creator.js +13 -2
- data/lib/clacky/web/i18n.js +41 -15
- data/lib/clacky/web/index.html +38 -20
- data/lib/clacky/web/sessions.js +256 -57
- data/lib/clacky/web/settings.js +32 -0
- data/lib/clacky/web/skills.js +61 -1
- metadata +4 -1
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -3,41 +3,10 @@
|
|
|
3
3
|
require "tmpdir"
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "../utils/encoding"
|
|
6
|
+
require_relative "../utils/limit_stack"
|
|
6
7
|
|
|
7
8
|
module Clacky
|
|
8
9
|
module Tools
|
|
9
|
-
# A StringIO wrapper that scrubs invalid/undefined bytes to UTF-8 on every
|
|
10
|
-
# write. Shell commands (via popen3) can emit bytes in any encoding
|
|
11
|
-
# (GBK, Latin-1, binary, …). By sanitizing at the earliest possible point
|
|
12
|
-
# we guarantee that every downstream operation — regex matching, line
|
|
13
|
-
# splitting, JSON serialization — never sees invalid byte sequences.
|
|
14
|
-
class EncodingSafeBuffer
|
|
15
|
-
def initialize
|
|
16
|
-
# Use ASCII-8BIT backing store to accept raw bytes from popen3 without
|
|
17
|
-
# encoding conflicts. Scrubbing happens on write; the string method
|
|
18
|
-
# re-labels the result as UTF-8 on the way out so callers (JSON.generate,
|
|
19
|
-
# regex, etc.) always see a properly-tagged UTF-8 string.
|
|
20
|
-
@io = StringIO.new("".b)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def write(data)
|
|
24
|
-
return unless data && !data.empty?
|
|
25
|
-
|
|
26
|
-
# Shell output arrives as binary (ASCII-8BIT) bytes. Use the shared
|
|
27
|
-
# helper which scrubs only genuinely invalid sequences, preserving
|
|
28
|
-
# multibyte characters (e.g. CJK). The result is written as raw bytes
|
|
29
|
-
# into the ASCII-8BIT buffer.
|
|
30
|
-
@io.write(Clacky::Utils::Encoding.to_utf8(data).b)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def string
|
|
34
|
-
# Re-label the accumulated bytes as UTF-8. By this point every byte
|
|
35
|
-
# has already been scrubbed by to_utf8 on write, so force_encoding is
|
|
36
|
-
# safe and avoids an unnecessary copy.
|
|
37
|
-
@io.string.force_encoding("UTF-8")
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
10
|
class Shell < Base
|
|
42
11
|
self.tool_name = "shell"
|
|
43
12
|
self.tool_description = "Execute shell commands in the terminal"
|
|
@@ -90,14 +59,28 @@ module Clacky
|
|
|
90
59
|
'go build'
|
|
91
60
|
].freeze
|
|
92
61
|
|
|
62
|
+
# Maximum characters per line (handles minified CSS/JS/JSON)
|
|
63
|
+
MAX_LINE_CHARS = 500
|
|
64
|
+
# Maximum total characters sent to LLM
|
|
65
|
+
MAX_LLM_OUTPUT_CHARS = 4_000
|
|
66
|
+
# Maximum lines kept in the rolling buffer
|
|
67
|
+
MAX_LLM_OUTPUT_LINES = 500
|
|
68
|
+
|
|
93
69
|
def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000, on_output: nil, working_dir: nil)
|
|
94
70
|
require "open3"
|
|
95
|
-
require "stringio"
|
|
96
71
|
|
|
97
72
|
soft_timeout, hard_timeout = determine_timeouts(command, soft_timeout, hard_timeout)
|
|
98
73
|
|
|
99
|
-
|
|
100
|
-
|
|
74
|
+
# OutputLimitBuffer built on LimitStack:
|
|
75
|
+
# - max_line_chars: truncates each individual line at write-time
|
|
76
|
+
# (prevents single-line minified JSON/CSS from filling the buffer)
|
|
77
|
+
# - max_chars: stops accepting new content once budget is exhausted
|
|
78
|
+
# (prevents runaway output from consuming LLM tokens)
|
|
79
|
+
# - max_size: rolling window keeps the LAST N lines
|
|
80
|
+
# (preserves the most recent/relevant output tail)
|
|
81
|
+
# All limits are enforced at write-time — no post-processing needed.
|
|
82
|
+
stdout_buffer = new_output_buffer
|
|
83
|
+
stderr_buffer = new_output_buffer
|
|
101
84
|
soft_timeout_triggered = false
|
|
102
85
|
|
|
103
86
|
# pgroup: 0 puts the child in its own process group so that Ctrl-C
|
|
@@ -138,12 +121,12 @@ module Clacky
|
|
|
138
121
|
|
|
139
122
|
return format_timeout_result(
|
|
140
123
|
command,
|
|
141
|
-
stdout_buffer.
|
|
142
|
-
stderr_buffer.
|
|
124
|
+
stdout_buffer.to_s,
|
|
125
|
+
stderr_buffer.to_s,
|
|
143
126
|
elapsed,
|
|
144
127
|
:hard_timeout,
|
|
145
128
|
hard_timeout,
|
|
146
|
-
|
|
129
|
+
stdout_buffer.truncated? || stderr_buffer.truncated?
|
|
147
130
|
)
|
|
148
131
|
end
|
|
149
132
|
|
|
@@ -151,8 +134,8 @@ module Clacky
|
|
|
151
134
|
if elapsed > soft_timeout && !soft_timeout_triggered
|
|
152
135
|
soft_timeout_triggered = true
|
|
153
136
|
|
|
154
|
-
interaction = detect_interaction(stdout_buffer.
|
|
155
|
-
detect_interaction(stderr_buffer.
|
|
137
|
+
interaction = detect_interaction(stdout_buffer.to_s) ||
|
|
138
|
+
detect_interaction(stderr_buffer.to_s) ||
|
|
156
139
|
detect_sudo_waiting(command, wait_thr)
|
|
157
140
|
if interaction
|
|
158
141
|
Process.kill('TERM', -wait_thr.pid) rescue nil
|
|
@@ -160,10 +143,10 @@ module Clacky
|
|
|
160
143
|
stderr.close rescue nil
|
|
161
144
|
return format_waiting_input_result(
|
|
162
145
|
command,
|
|
163
|
-
stdout_buffer.
|
|
164
|
-
stderr_buffer.
|
|
146
|
+
stdout_buffer.to_s,
|
|
147
|
+
stderr_buffer.to_s,
|
|
165
148
|
interaction,
|
|
166
|
-
|
|
149
|
+
stdout_buffer.truncated? || stderr_buffer.truncated?
|
|
167
150
|
)
|
|
168
151
|
end
|
|
169
152
|
end
|
|
@@ -178,11 +161,11 @@ module Clacky
|
|
|
178
161
|
data = io.read_nonblock(4096)
|
|
179
162
|
if io == stdout
|
|
180
163
|
utf8 = Clacky::Utils::Encoding.to_utf8(data)
|
|
181
|
-
stdout_buffer.
|
|
164
|
+
stdout_buffer.push_lines(utf8)
|
|
182
165
|
on_output.call(:stdout, utf8) if on_output
|
|
183
166
|
else
|
|
184
167
|
utf8 = Clacky::Utils::Encoding.to_utf8(data)
|
|
185
|
-
stderr_buffer.
|
|
168
|
+
stderr_buffer.push_lines(utf8)
|
|
186
169
|
on_output.call(:stderr, utf8) if on_output
|
|
187
170
|
end
|
|
188
171
|
rescue IO::WaitReadable, EOFError
|
|
@@ -212,7 +195,8 @@ module Clacky
|
|
|
212
195
|
ready[0].each do |io|
|
|
213
196
|
buf = io == stdout ? stdout_buffer : stderr_buffer
|
|
214
197
|
begin
|
|
215
|
-
|
|
198
|
+
chunk = io.read_nonblock(4096)
|
|
199
|
+
buf.push_lines(Clacky::Utils::Encoding.to_utf8(chunk))
|
|
216
200
|
rescue IO::WaitReadable
|
|
217
201
|
# not ready yet, keep waiting
|
|
218
202
|
rescue EOFError
|
|
@@ -223,17 +207,14 @@ module Clacky
|
|
|
223
207
|
rescue StandardError
|
|
224
208
|
end
|
|
225
209
|
|
|
226
|
-
stdout_output = stdout_buffer.string
|
|
227
|
-
stderr_output = stderr_buffer.string
|
|
228
|
-
|
|
229
210
|
{
|
|
230
211
|
command: command,
|
|
231
|
-
stdout:
|
|
232
|
-
stderr:
|
|
212
|
+
stdout: stdout_buffer.to_s,
|
|
213
|
+
stderr: stderr_buffer.to_s,
|
|
233
214
|
exit_code: wait_thr.value.exitstatus,
|
|
234
215
|
success: wait_thr.value.success?,
|
|
235
216
|
elapsed: Time.now - start_time,
|
|
236
|
-
output_truncated:
|
|
217
|
+
output_truncated: stdout_buffer.truncated? || stderr_buffer.truncated?
|
|
237
218
|
}
|
|
238
219
|
ensure
|
|
239
220
|
# Ensure child process group is killed when block exits
|
|
@@ -245,16 +226,16 @@ module Clacky
|
|
|
245
226
|
end
|
|
246
227
|
end
|
|
247
228
|
rescue StandardError => e
|
|
248
|
-
|
|
249
|
-
|
|
229
|
+
stdout_str = stdout_buffer.to_s
|
|
230
|
+
stderr_str = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
250
231
|
|
|
251
232
|
{
|
|
252
233
|
command: command,
|
|
253
|
-
stdout:
|
|
254
|
-
stderr:
|
|
234
|
+
stdout: stdout_str,
|
|
235
|
+
stderr: stderr_str,
|
|
255
236
|
exit_code: -1,
|
|
256
237
|
success: false,
|
|
257
|
-
output_truncated:
|
|
238
|
+
output_truncated: stdout_buffer.truncated?
|
|
258
239
|
}
|
|
259
240
|
end
|
|
260
241
|
end
|
|
@@ -396,21 +377,22 @@ module Clacky
|
|
|
396
377
|
{ type: "password", line: "[sudo] password:" }
|
|
397
378
|
end
|
|
398
379
|
|
|
399
|
-
def format_waiting_input_result(command, stdout, stderr, interaction,
|
|
380
|
+
def format_waiting_input_result(command, stdout, stderr, interaction, truncated)
|
|
400
381
|
{
|
|
401
382
|
command: command,
|
|
402
|
-
stdout:
|
|
403
|
-
stderr:
|
|
383
|
+
stdout: stdout,
|
|
384
|
+
stderr: stderr,
|
|
404
385
|
exit_code: -2,
|
|
405
386
|
success: false,
|
|
406
387
|
state: 'WAITING_INPUT',
|
|
407
388
|
interaction_type: interaction[:type],
|
|
408
|
-
|
|
409
|
-
|
|
389
|
+
interaction: interaction,
|
|
390
|
+
message: format_waiting_message(interaction),
|
|
391
|
+
output_truncated: truncated
|
|
410
392
|
}
|
|
411
393
|
end
|
|
412
394
|
|
|
413
|
-
def format_waiting_message(
|
|
395
|
+
def format_waiting_message(interaction)
|
|
414
396
|
password_hint = if interaction[:type] == "password"
|
|
415
397
|
<<~HINT
|
|
416
398
|
|
|
@@ -423,9 +405,10 @@ module Clacky
|
|
|
423
405
|
HINT
|
|
424
406
|
end
|
|
425
407
|
|
|
408
|
+
# NOTE: Do NOT embed `output` here — the caller already has stdout/stderr
|
|
409
|
+
# in dedicated fields. Including output here would duplicate tokens sent
|
|
410
|
+
# to the LLM (potentially 2×), causing unnecessarily high costs.
|
|
426
411
|
<<~MSG
|
|
427
|
-
#{output}
|
|
428
|
-
|
|
429
412
|
#{'=' * 60}
|
|
430
413
|
[Terminal State: WAITING_INPUT]
|
|
431
414
|
#{'=' * 60}
|
|
@@ -441,42 +424,24 @@ module Clacky
|
|
|
441
424
|
MSG
|
|
442
425
|
end
|
|
443
426
|
|
|
444
|
-
def
|
|
427
|
+
def extract_last_line(output)
|
|
428
|
+
return "" if output.nil? || output.empty?
|
|
429
|
+
output.lines.last&.strip.to_s[0..200]
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, truncated)
|
|
445
433
|
{
|
|
446
434
|
command: command,
|
|
447
|
-
stdout:
|
|
448
|
-
stderr:
|
|
449
|
-
stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
|
|
450
|
-
max_output_lines
|
|
451
|
-
),
|
|
435
|
+
stdout: stdout,
|
|
436
|
+
stderr: stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
|
|
452
437
|
exit_code: -1,
|
|
453
438
|
success: false,
|
|
454
439
|
state: 'TIMEOUT',
|
|
455
440
|
timeout_type: type,
|
|
456
|
-
output_truncated:
|
|
441
|
+
output_truncated: truncated
|
|
457
442
|
}
|
|
458
443
|
end
|
|
459
444
|
|
|
460
|
-
# Truncate output to max_lines and max line length, adding a truncation notice if needed
|
|
461
|
-
def truncate_output(output, max_lines)
|
|
462
|
-
return output if output.nil? || output.empty?
|
|
463
|
-
|
|
464
|
-
output = truncate_long_lines(output, MAX_LINE_CHARS)
|
|
465
|
-
lines = output.lines
|
|
466
|
-
return output if lines.length <= max_lines
|
|
467
|
-
|
|
468
|
-
truncated_lines = lines.first(max_lines)
|
|
469
|
-
truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
|
|
470
|
-
truncated_lines.join + truncation_notice
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
# Check if output was truncated
|
|
474
|
-
def output_truncated?(stdout, stderr, max_lines)
|
|
475
|
-
stdout_lines = stdout&.lines&.length || 0
|
|
476
|
-
stderr_lines = stderr&.lines&.length || 0
|
|
477
|
-
stdout_lines > max_lines || stderr_lines > max_lines
|
|
478
|
-
end
|
|
479
|
-
|
|
480
445
|
def format_call(args)
|
|
481
446
|
cmd = args[:command] || args['command'] || ''
|
|
482
447
|
cmd_parts = cmd.split
|
|
@@ -499,103 +464,59 @@ module Clacky
|
|
|
499
464
|
end
|
|
500
465
|
end
|
|
501
466
|
|
|
502
|
-
# Format result for LLM consumption - limit output size to save tokens
|
|
503
|
-
# Maximum characters to include in LLM output
|
|
504
|
-
MAX_LLM_OUTPUT_CHARS = 4000
|
|
505
|
-
# Maximum characters per line before truncating (handles minified CSS/JS files)
|
|
506
|
-
MAX_LINE_CHARS = 500
|
|
507
|
-
|
|
508
467
|
def format_result_for_llm(result)
|
|
509
|
-
return result if result[:error]
|
|
468
|
+
return result if result[:error]
|
|
510
469
|
|
|
511
470
|
enc = Clacky::Utils::Encoding
|
|
471
|
+
|
|
472
|
+
# stdout/stderr are already size-limited by the LimitStack buffer used
|
|
473
|
+
# during execution (MAX_LINE_CHARS per line, MAX_LLM_OUTPUT_CHARS total,
|
|
474
|
+
# MAX_LLM_OUTPUT_LINES rolling window). No further truncation needed.
|
|
512
475
|
stdout = enc.to_utf8(result[:stdout] || "")
|
|
513
476
|
stderr = enc.to_utf8(result[:stderr] || "")
|
|
514
|
-
exit_code = result[:exit_code] || 0
|
|
515
477
|
|
|
516
478
|
compact = {
|
|
517
479
|
command: enc.to_utf8(result[:command].to_s),
|
|
518
|
-
exit_code: exit_code,
|
|
519
|
-
success: result[:success]
|
|
480
|
+
exit_code: result[:exit_code] || 0,
|
|
481
|
+
success: result[:success],
|
|
482
|
+
stdout: stdout,
|
|
483
|
+
stderr: stderr
|
|
520
484
|
}
|
|
521
485
|
|
|
522
|
-
compact[:elapsed] = result[:elapsed] if result[:elapsed]
|
|
523
|
-
|
|
524
|
-
command_name = extract_command_name(compact[:command])
|
|
525
|
-
|
|
526
|
-
stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
|
|
527
|
-
compact[:stdout] = stdout_info[:content]
|
|
528
|
-
compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
|
|
529
|
-
|
|
530
|
-
stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
|
|
531
|
-
compact[:stderr] = stderr_info[:content]
|
|
532
|
-
compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
|
|
533
|
-
|
|
534
486
|
compact[:output_truncated] = true if result[:output_truncated]
|
|
487
|
+
compact[:elapsed] = result[:elapsed] if result[:elapsed]
|
|
535
488
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
File.basename(first_word, ".*")
|
|
543
|
-
end
|
|
544
|
-
|
|
545
|
-
# Truncate output for LLM and optionally save full content to temp file
|
|
546
|
-
def truncate_and_save(output, max_chars, label, command_name)
|
|
547
|
-
return { content: "", temp_file: nil } if output.empty?
|
|
548
|
-
|
|
549
|
-
output = truncate_long_lines(output, MAX_LINE_CHARS)
|
|
550
|
-
return { content: output, temp_file: nil } if output.length <= max_chars
|
|
551
|
-
|
|
552
|
-
safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
|
|
553
|
-
temp_dir = Dir.mktmpdir
|
|
554
|
-
temp_file = File.join(temp_dir, "#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
|
|
555
|
-
File.write(temp_file, output)
|
|
556
|
-
|
|
557
|
-
lines = output.lines
|
|
558
|
-
return { content: output, temp_file: nil } if lines.length <= 2
|
|
559
|
-
|
|
560
|
-
notice_overhead = 200
|
|
561
|
-
available_chars = max_chars - notice_overhead
|
|
562
|
-
|
|
563
|
-
first_part = []
|
|
564
|
-
accumulated = 0
|
|
565
|
-
lines.each do |line|
|
|
566
|
-
break if accumulated + line.length > available_chars
|
|
567
|
-
first_part << line
|
|
568
|
-
accumulated += line.length
|
|
489
|
+
# Preserve WAITING_INPUT state fields
|
|
490
|
+
if result[:state] == 'WAITING_INPUT'
|
|
491
|
+
compact[:state] = 'WAITING_INPUT'
|
|
492
|
+
compact[:interaction_type] = result[:interaction_type]
|
|
493
|
+
interaction = result[:interaction_type] ? { type: result[:interaction_type], line: result.dig(:interaction, :line) || extract_last_line(stdout) } : { type: 'question', line: extract_last_line(stdout) }
|
|
494
|
+
compact[:message] = format_waiting_message(interaction)
|
|
569
495
|
end
|
|
570
496
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
"\n... [Error output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...\n"
|
|
576
|
-
else
|
|
577
|
-
"\n... [Output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...\n"
|
|
497
|
+
# Preserve TIMEOUT state fields
|
|
498
|
+
if result[:state] == 'TIMEOUT'
|
|
499
|
+
compact[:state] = 'TIMEOUT'
|
|
500
|
+
compact[:timeout_type] = result[:timeout_type]
|
|
578
501
|
end
|
|
579
502
|
|
|
580
|
-
|
|
503
|
+
compact
|
|
581
504
|
end
|
|
582
505
|
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
end.join
|
|
506
|
+
# Create a new output buffer with write-time limits.
|
|
507
|
+
# - max_line_chars: each line is truncated at this length on push
|
|
508
|
+
# (prevents single-line minified JSON/CSS from filling the budget)
|
|
509
|
+
# - max_chars: stops accepting content once total chars are exhausted
|
|
510
|
+
# (hard ceiling on tokens sent to LLM)
|
|
511
|
+
# - max_size: rolling window — keeps the LAST N lines
|
|
512
|
+
# (preserves the most recent, relevant tail of long output)
|
|
513
|
+
private def new_output_buffer
|
|
514
|
+
Clacky::Utils::LimitStack.new(
|
|
515
|
+
max_size: MAX_LLM_OUTPUT_LINES,
|
|
516
|
+
max_line_chars: MAX_LINE_CHARS,
|
|
517
|
+
max_chars: MAX_LLM_OUTPUT_CHARS
|
|
518
|
+
)
|
|
597
519
|
end
|
|
598
|
-
|
|
599
520
|
end
|
|
600
521
|
end
|
|
601
522
|
end
|
|
@@ -398,6 +398,34 @@ module Clacky
|
|
|
398
398
|
# doesn't bleed into the next one, and so the buffer is ready before
|
|
399
399
|
# on_output starts firing (which can happen before show_progress is called).
|
|
400
400
|
@stdout_lines = nil
|
|
401
|
+
|
|
402
|
+
# Special handling for request_user_feedback: render as a readable interactive card
|
|
403
|
+
# with the full question and options, rather than the truncated format_call summary.
|
|
404
|
+
if name.to_s == "request_user_feedback"
|
|
405
|
+
args_data = args.is_a?(String) ? (JSON.parse(args, symbolize_names: true) rescue {}) : args
|
|
406
|
+
args_data = args_data.transform_keys(&:to_sym) if args_data.is_a?(Hash)
|
|
407
|
+
|
|
408
|
+
question = args_data[:question].to_s.strip
|
|
409
|
+
context = args_data[:context].to_s.strip
|
|
410
|
+
options = Array(args_data[:options])
|
|
411
|
+
|
|
412
|
+
theme = ThemeManager.current_theme
|
|
413
|
+
parts = []
|
|
414
|
+
|
|
415
|
+
parts << context unless context.empty?
|
|
416
|
+
parts << question unless question.empty?
|
|
417
|
+
|
|
418
|
+
if options.any?
|
|
419
|
+
parts << ""
|
|
420
|
+
options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
card_text = parts.join("\n")
|
|
424
|
+
output = @renderer.render_system_message(card_text, prefix_newline: true)
|
|
425
|
+
append_output(output)
|
|
426
|
+
return
|
|
427
|
+
end
|
|
428
|
+
|
|
401
429
|
formatted_call = format_tool_call(name, args)
|
|
402
430
|
output = @renderer.render_tool_call(tool_name: name, formatted_call: formatted_call)
|
|
403
431
|
append_output(output)
|
|
@@ -465,30 +493,72 @@ module Clacky
|
|
|
465
493
|
|
|
466
494
|
# Show progress indicator with dynamic elapsed time
|
|
467
495
|
# @param message [String] Progress message (optional, will use random thinking verb if nil)
|
|
468
|
-
#
|
|
496
|
+
# Show or stop a progress indicator.
|
|
497
|
+
#
|
|
498
|
+
# Rendering strategies by progress_type:
|
|
499
|
+
# "thinking" — spinner in yellow (render_working), session bar → 'working' (default)
|
|
500
|
+
# "retrying" — spinner in gray (render_progress), no session bar change;
|
|
501
|
+
# shows "attempt/total" from metadata: { attempt: N, total: M }
|
|
502
|
+
# "idle_compress" — same as "retrying"; background work the user need not watch closely
|
|
503
|
+
#
|
|
504
|
+
# phase:
|
|
505
|
+
# "active" — start/update the progress indicator (default)
|
|
506
|
+
# "done" — stop progress; auto-appends elapsed time to the final line
|
|
507
|
+
#
|
|
508
|
+
# @param message [String, nil] Progress message (nil uses a random thinking verb for "thinking")
|
|
509
|
+
# @param prefix_newline [Boolean] Prepend blank line before new progress line (default: true)
|
|
510
|
+
# @param progress_type [String] "thinking" | "retrying" | "idle_compress"
|
|
511
|
+
# @param phase [String] "active" | "done"
|
|
512
|
+
# @param metadata [Hash] { attempt: N, total: M } for "retrying" type
|
|
469
513
|
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
|
|
470
|
-
#
|
|
514
|
+
# ── phase "done": stop progress and render final state ──────────────────────────
|
|
515
|
+
if phase.to_s == "done"
|
|
516
|
+
elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
|
|
517
|
+
saved_message = @progress_message
|
|
518
|
+
|
|
519
|
+
stop_progress_thread
|
|
520
|
+
@stdout_lines = nil
|
|
521
|
+
|
|
522
|
+
# Prefer caller-supplied message; fall back to elapsed-time summary
|
|
523
|
+
final_msg = if message && !message.to_s.strip.empty?
|
|
524
|
+
message.to_s
|
|
525
|
+
elsif saved_message && elapsed_time > 0
|
|
526
|
+
"#{saved_message}… (#{elapsed_time}s)"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
if final_msg
|
|
530
|
+
update_progress_line(@renderer.render_progress(final_msg))
|
|
531
|
+
else
|
|
532
|
+
clear_progress_line
|
|
533
|
+
end
|
|
534
|
+
return
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# ── "active": start or update spinner ───────────────────────────────────────────
|
|
471
538
|
stop_progress_thread
|
|
472
539
|
|
|
473
|
-
#
|
|
474
|
-
update_sessionbar(status: 'working')
|
|
540
|
+
# "thinking" updates session bar; "retrying"/"idle_compress" are background — leave it alone
|
|
541
|
+
update_sessionbar(status: 'working') if progress_type.to_s == "thinking"
|
|
542
|
+
|
|
543
|
+
# Build display message; "retrying" appends attempt/total from metadata
|
|
544
|
+
attempt = metadata[:attempt]
|
|
545
|
+
total = metadata[:total]
|
|
546
|
+
attempt_suffix = attempt && total ? " (#{attempt}/#{total})" : ""
|
|
475
547
|
|
|
476
|
-
@progress_message
|
|
548
|
+
@progress_message = (message || Clacky::THINKING_VERBS.sample) + attempt_suffix
|
|
477
549
|
@progress_start_time = Time.now
|
|
478
|
-
# Flag used by the progress thread to know when to stop gracefully.
|
|
479
|
-
# Using a flag + join is safe because Thread#kill can interrupt a thread
|
|
480
|
-
# while it holds @render_mutex, causing a permanent deadlock.
|
|
481
550
|
@progress_thread_stop = false
|
|
482
551
|
|
|
483
|
-
#
|
|
552
|
+
# Choose render style: yellow for thinking, gray for retrying/idle
|
|
553
|
+
quiet_type = %w[retrying idle_compress].include?(progress_type.to_s)
|
|
554
|
+
render_active = ->(msg) {
|
|
555
|
+
quiet_type ? @renderer.render_progress(msg) : @renderer.render_working(msg)
|
|
556
|
+
}
|
|
557
|
+
|
|
484
558
|
append_output("") if prefix_newline
|
|
485
|
-
#
|
|
486
|
-
# The background thread will add the Ctrl+O tip as soon as any stdout arrives.
|
|
487
|
-
output = @renderer.render_working("#{@progress_message}… (Ctrl+C to interrupt)")
|
|
488
|
-
append_output(output)
|
|
559
|
+
append_output(render_active.call("#{@progress_message}… (Ctrl+C to interrupt)"))
|
|
489
560
|
|
|
490
|
-
#
|
|
491
|
-
# Also dynamically adds "Ctrl+O to view output" hint once @stdout_lines is populated.
|
|
561
|
+
# Background thread: update elapsed time every 0.5s
|
|
492
562
|
@progress_thread = Thread.new do
|
|
493
563
|
until @progress_thread_stop
|
|
494
564
|
sleep 0.5
|
|
@@ -502,7 +572,7 @@ module Clacky
|
|
|
502
572
|
hint = has_output ?
|
|
503
573
|
"(Ctrl+C to interrupt · Ctrl+O to view output · #{elapsed}s)" :
|
|
504
574
|
"(Ctrl+C to interrupt · #{elapsed}s)"
|
|
505
|
-
update_progress_line(
|
|
575
|
+
update_progress_line(render_active.call("#{@progress_message}… #{hint}"))
|
|
506
576
|
end
|
|
507
577
|
rescue StandardError
|
|
508
578
|
# Silently handle thread errors
|
|
@@ -510,70 +580,10 @@ module Clacky
|
|
|
510
580
|
end
|
|
511
581
|
|
|
512
582
|
# Returns true if a progress indicator is currently active.
|
|
513
|
-
# Used to guard calls to clear_progress so we don't accidentally
|
|
514
|
-
# remove a non-progress line from the output buffer.
|
|
515
583
|
def progress_active?
|
|
516
584
|
@progress_start_time != nil
|
|
517
585
|
end
|
|
518
586
|
|
|
519
|
-
# Clear progress indicator
|
|
520
|
-
def clear_progress
|
|
521
|
-
# Guard: if no progress is active, do nothing.
|
|
522
|
-
# This makes clear_progress idempotent — safe to call multiple times
|
|
523
|
-
# (e.g. once from handle_submit and again from handle_agent_exception).
|
|
524
|
-
return unless progress_active?
|
|
525
|
-
|
|
526
|
-
# Calculate elapsed time before stopping
|
|
527
|
-
elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
|
|
528
|
-
|
|
529
|
-
# Stop the progress thread (blocks until thread exits or timeout)
|
|
530
|
-
stop_progress_thread
|
|
531
|
-
|
|
532
|
-
# stdout buffer no longer needed after command finishes
|
|
533
|
-
@stdout_lines = nil
|
|
534
|
-
|
|
535
|
-
# Update the final progress line to gray (stopped state)
|
|
536
|
-
if @progress_message && elapsed_time > 0
|
|
537
|
-
final_output = @renderer.render_progress("#{@progress_message}… (#{elapsed_time}s)")
|
|
538
|
-
update_progress_line(final_output)
|
|
539
|
-
else
|
|
540
|
-
clear_progress_line
|
|
541
|
-
end
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
# Non-blocking variant of clear_progress, used by handle_submit so the
|
|
545
|
-
# user message appears on screen immediately without waiting for the
|
|
546
|
-
# progress thread to fully exit.
|
|
547
|
-
#
|
|
548
|
-
# Strategy:
|
|
549
|
-
# 1. Snapshot elapsed time and message before touching any state.
|
|
550
|
-
# 2. Signal the progress thread to stop (sets flag + nulls start time)
|
|
551
|
-
# so it will exit on its own next wake-up — no join here.
|
|
552
|
-
# 3. Immediately render the final (gray) progress line or remove it.
|
|
553
|
-
# 4. The thread finishes in the background; clear_progress called later
|
|
554
|
-
# via the interrupt path is idempotent and will be a no-op.
|
|
555
|
-
def clear_progress_nonblocking
|
|
556
|
-
return unless progress_active?
|
|
557
|
-
|
|
558
|
-
elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
|
|
559
|
-
saved_message = @progress_message
|
|
560
|
-
|
|
561
|
-
# Signal thread to stop without joining — it will exit on next loop tick
|
|
562
|
-
@progress_start_time = nil
|
|
563
|
-
@stdout_lines = nil
|
|
564
|
-
@progress_thread_stop = true
|
|
565
|
-
# Detach: let the thread die on its own; we do NOT join here
|
|
566
|
-
@progress_thread = nil
|
|
567
|
-
|
|
568
|
-
# Immediately update the visual progress line (no waiting)
|
|
569
|
-
if saved_message && elapsed_time > 0
|
|
570
|
-
final_output = @renderer.render_progress("#{saved_message}… (#{elapsed_time}s)")
|
|
571
|
-
update_progress_line(final_output)
|
|
572
|
-
else
|
|
573
|
-
clear_progress_line
|
|
574
|
-
end
|
|
575
|
-
end
|
|
576
|
-
|
|
577
587
|
# Stop the fullscreen refresh thread gracefully via flag + join.
|
|
578
588
|
def stop_fullscreen_refresh_thread
|
|
579
589
|
@fullscreen_refresh_stop = true
|
|
@@ -609,14 +619,6 @@ module Clacky
|
|
|
609
619
|
append_output(output)
|
|
610
620
|
end
|
|
611
621
|
|
|
612
|
-
# Show idle compression status (two-phase: start → end).
|
|
613
|
-
# In terminal mode, only the final state is printed.
|
|
614
|
-
def show_idle_status(phase:, message:)
|
|
615
|
-
return unless phase.to_s == "end"
|
|
616
|
-
output = @renderer.render_system_message(message)
|
|
617
|
-
append_output(output)
|
|
618
|
-
end
|
|
619
|
-
|
|
620
622
|
# Show warning message
|
|
621
623
|
# @param message [String] Warning message
|
|
622
624
|
def show_warning(message)
|
|
@@ -1082,16 +1084,21 @@ module Clacky
|
|
|
1082
1084
|
|
|
1083
1085
|
# Handle submit action
|
|
1084
1086
|
private def handle_submit(data)
|
|
1085
|
-
# If progress is currently active,
|
|
1086
|
-
#
|
|
1087
|
-
#
|
|
1088
|
-
# on the user message instead of the progress line.
|
|
1089
|
-
#
|
|
1090
|
-
# We use a non-blocking variant here so that the progress thread's join()
|
|
1091
|
-
# does NOT block the main thread — the user message appears on screen
|
|
1092
|
-
# immediately with no perceptible delay.
|
|
1087
|
+
# If progress is currently active, stop it before appending the user message.
|
|
1088
|
+
# We do this non-blocking (signal thread to stop without joining) so the user
|
|
1089
|
+
# message appears on screen immediately with no perceptible delay.
|
|
1093
1090
|
if progress_active?
|
|
1094
|
-
|
|
1091
|
+
elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
|
|
1092
|
+
saved_message = @progress_message
|
|
1093
|
+
@progress_start_time = nil
|
|
1094
|
+
@stdout_lines = nil
|
|
1095
|
+
@progress_thread_stop = true
|
|
1096
|
+
@progress_thread = nil # detach; thread exits on its next tick
|
|
1097
|
+
if saved_message && elapsed_time > 0
|
|
1098
|
+
update_progress_line(@renderer.render_progress("#{saved_message}… (#{elapsed_time}s)"))
|
|
1099
|
+
else
|
|
1100
|
+
clear_progress_line
|
|
1101
|
+
end
|
|
1095
1102
|
end
|
|
1096
1103
|
|
|
1097
1104
|
# Render user message immediately before running agent
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -35,7 +35,6 @@ module Clacky
|
|
|
35
35
|
# phase: "active" | "done"
|
|
36
36
|
# metadata: extensible hash (e.g., {attempt: 3, total: 10} for retries)
|
|
37
37
|
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
|
|
38
|
-
def clear_progress; end
|
|
39
38
|
|
|
40
39
|
# === State updates ===
|
|
41
40
|
def update_sessionbar(tasks: nil, cost: nil, status: nil); end
|