openclacky 0.9.34 → 0.9.36

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +14 -10
  5. data/lib/clacky/agent/memory_updater.rb +1 -1
  6. data/lib/clacky/agent/session_serializer.rb +2 -0
  7. data/lib/clacky/agent/skill_manager.rb +21 -9
  8. data/lib/clacky/agent/tool_executor.rb +13 -16
  9. data/lib/clacky/agent/tool_registry.rb +0 -3
  10. data/lib/clacky/agent.rb +85 -55
  11. data/lib/clacky/agent_config.rb +170 -40
  12. data/lib/clacky/brand_config.rb +11 -27
  13. data/lib/clacky/cli.rb +68 -13
  14. data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
  16. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  17. data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
  18. data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
  20. data/lib/clacky/idle_compression_timer.rb +8 -0
  21. data/lib/clacky/json_ui_controller.rb +2 -1
  22. data/lib/clacky/plain_ui_controller.rb +10 -3
  23. data/lib/clacky/platform_http_client.rb +161 -1
  24. data/lib/clacky/server/channel/channel_manager.rb +5 -3
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  26. data/lib/clacky/server/http_server.rb +376 -99
  27. data/lib/clacky/server/scheduler.rb +17 -16
  28. data/lib/clacky/server/session_registry.rb +1 -5
  29. data/lib/clacky/server/web_ui_controller.rb +7 -6
  30. data/lib/clacky/session_manager.rb +22 -0
  31. data/lib/clacky/skill.rb +19 -3
  32. data/lib/clacky/skill_loader.rb +5 -59
  33. data/lib/clacky/tools/browser.rb +25 -73
  34. data/lib/clacky/tools/security.rb +326 -0
  35. data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
  36. data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
  37. data/lib/clacky/tools/terminal/session_manager.rb +208 -0
  38. data/lib/clacky/tools/terminal.rb +892 -0
  39. data/lib/clacky/tools/todo_manager.rb +6 -16
  40. data/lib/clacky/tools/trash_manager.rb +2 -2
  41. data/lib/clacky/ui2/components/input_area.rb +11 -2
  42. data/lib/clacky/ui2/layout_manager.rb +438 -488
  43. data/lib/clacky/ui2/output_buffer.rb +310 -0
  44. data/lib/clacky/ui2/ui_controller.rb +88 -28
  45. data/lib/clacky/ui_interface.rb +1 -1
  46. data/lib/clacky/utils/encoding.rb +1 -1
  47. data/lib/clacky/utils/environment_detector.rb +43 -0
  48. data/lib/clacky/utils/model_pricing.rb +3 -3
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +506 -279
  51. data/lib/clacky/web/app.js +169 -21
  52. data/lib/clacky/web/auth.js +101 -0
  53. data/lib/clacky/web/i18n.js +45 -1
  54. data/lib/clacky/web/index.html +13 -16
  55. data/lib/clacky/web/sessions.js +294 -26
  56. data/lib/clacky/web/skills.js +20 -6
  57. data/lib/clacky/web/tasks.js +54 -2
  58. data/lib/clacky/web/theme.js +58 -20
  59. data/lib/clacky/web/ws.js +11 -2
  60. data/lib/clacky.rb +2 -2
  61. metadata +8 -3
  62. data/lib/clacky/tools/safe_shell.rb +0 -608
  63. data/lib/clacky/tools/shell.rb +0 -522
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52f436f4aa95f2360172d33a3f6703b9106f9568d1c805fbc26248e9b483834c
4
- data.tar.gz: 42469a3ba3c357420b036fc4d877875e030e4dfba9a7d342a377c97991d370a7
3
+ metadata.gz: 8a7f74cba226bc2332676ed9f0a72c27f3fb6b45bafacd60198f7ec9b59a84c7
4
+ data.tar.gz: 7396d2258a38e203a5ffe217f0409fb101487bdb74dad8f224649a09b8adb775
5
5
  SHA512:
6
- metadata.gz: 5f12512e1c10dbbe36db63aadfb221c84de40f5e944538b73fa3ebfd61839dbbe11d2906e2cc9c88dd67790b1d4060883805c5f61eeb1b21058a0f57e78732c7
7
- data.tar.gz: 10db3c5f50a2572198fa526fe1de55507b29d97499aae0238d2e8f447aac7ca960ce5159f897e4ba3150d90f8ffde5872848873a5414eb48b641fc91628247dc
6
+ metadata.gz: 7f10c234d4a22d825e3623bff7e0a0faf55af4cea34dc15a939fbde3e6e3792bf1a11cd97d68e0c6890dd37c011814019cfed7dea70c724fcb2fc11b49cff15a
7
+ data.tar.gz: 12dcd062214b42191f33e4b2e09ec62f24cb9f91206451ff23861672a2cc8b680d89e5498cf0da6c53b74d4da2933bd53863666cee608539edb398f016226df5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.36] - 2026-04-24
11
+
12
+ ### Fixed
13
+ - **Session deletion now works correctly**: fixed disk-based session deletion that was failing with proper error handling in the Web UI (C-9d1ea93)
14
+ - **Model switching improved**: better model ID validation and normalization when switching models in Web UI — handles various ID formats correctly (C-b61e22e)
15
+ - **Terminal tool word wrapping**: fixed terminal output word wrapping issues that could break long command outputs (C-5989d02)
16
+ - **Heartbeat mechanism stability**: improved async heartbeat logic in server mode for more reliable connection status tracking (C-5989d02)
17
+
18
+ ### Improved
19
+ - **UI polish**: removed session topbar clutter and added empty state messages for better first-time user experience (C-003d613)
20
+ - **Cleaner logging**: reduced noisy debug logs in skill manager for quieter operation (C-c27bbec)
21
+
22
+ ## [0.9.35] - 2026-04-23
23
+
24
+ ### Added
25
+ - **Unified Terminal tool**: merged the old `safe_shell` and `shell` tools into a single `terminal` tool with persistent PTY sessions — the agent can now keep a shell session alive across tool calls, send input to running prompts, poll long-running commands, and safely interrupt them (`Ctrl+C` / `Ctrl+D`). Replaces 1000+ lines of duplicated shell-handling logic with a cleaner, better-tested implementation.
26
+ - **Access key authentication for server mode**: start the Web UI server with `--access-key <key>` (or `CLACKY_ACCESS_KEY` env var) to require a login before anyone can open sessions — safe to expose the Web UI over the network or to share a running instance
27
+ - **Session debug download**: from the Web UI you can now download a full session bundle (messages, tool calls, config) as a zip for debugging or sharing — useful for bug reports and post-mortems
28
+ - **Scheduler now saves session state**: scheduled/cron tasks now persist their session after each run, so you can inspect what the scheduled task actually did from the Web UI just like a normal session
29
+ - **Web UI visual redesign**: substantial redesign of the sidebar, session list, settings panel, and theme — cleaner spacing, better contrast in both light and dark modes, smoother transitions
30
+ - **Web UI & channel message interrupt**: you can now cancel an in-progress agent reply from the Web UI or from an IM channel (Feishu/WeCom/WeChat) mid-flight instead of waiting for it to finish
31
+ - **Terminal tool UI tips**: the Web UI now surfaces helpful inline tips when the agent is running a terminal command (e.g. "waiting for input", "process still running"), making long-running commands easier to follow
32
+
33
+ ### Improved
34
+ - **Smaller tool descriptions**: trimmed the system-prompt footprint of `terminal`, `browser`, and `todo_manager` tool descriptions by ~40% — fewer tokens burned on every API call, slightly faster startup, and meaningfully cheaper sessions over time
35
+ - **Download fallback for skills & brand assets**: when the primary platform download host is unreachable (common in certain regions), the client now automatically falls back to a secondary URL — skill installs and brand asset fetches succeed in more network environments
36
+ - **Session cost shows "N/A" for unknown-price models**: instead of displaying `$0.00` when a model's pricing isn't registered, sessions now show "N/A" so you can tell the difference between "free call" and "we don't know the cost"
37
+ - **Faster, more accurate progress updates**: removed a delay in the progress spinner so the "Agent is thinking..." and tool-running indicators update immediately on state changes instead of a second later
38
+ - **No Claude-specific skill auto-loading**: removed legacy logic that special-cased loading `.claude/` skills at startup — skill loading is now uniform regardless of provider, reducing surprise behavior and confusing "unknown skill" errors
39
+
40
+ ### Fixed
41
+ - **`file://` links now render and open correctly** (C-5552, C-5553): file:// links are no longer stripped during streaming in the Web UI, and clicking them now opens the file via the backend (including proper foreground focus on WSL via `cmd.exe /c start`). Also fixes silent drop of `file://` links in the CLI.
42
+ - **Idle `Ctrl+C` no longer crashes the CLI**: pressing Ctrl+C while the CLI is idle (no task running) now exits cleanly instead of raising an error
43
+ - **Session pinned status persists correctly** (C-5556): pinning a session in the Web UI now survives server restarts and is correctly restored from disk
44
+ - **Brand skill names follow language switch**: brand-supplied skill names in the Web UI sidebar now update immediately when you toggle the UI language (previously stuck in the initial language until reload)
45
+ - **New sessions get the default model**: fixed a case where newly created sessions could end up on a different model than the configured default; the "lite UI" mode is no longer automatically forced either
46
+
47
+ ### More
48
+ - Large refactor of the UI2 `LayoutManager` + new `OutputBuffer` for cleaner CLI output line handling
49
+ - Agent progress-emission refactor for more consistent spinner/tool state reporting across Web, CLI, and channel UIs
50
+ - Removed the `safe_shell_spec` and `shell_spec` suites; replaced with a single, comprehensive `terminal_spec` (500+ lines of coverage)
51
+
10
52
  ## [0.9.34] - 2026-04-21
11
53
 
12
54
  ### Added
@@ -41,7 +41,7 @@ module Clacky
41
41
  token_data = collect_iteration_tokens(usage, iteration_cost)
42
42
 
43
43
  # Update session bar cost in real-time (don't wait for agent.run to finish)
44
- @ui&.update_sessionbar(cost: @total_cost)
44
+ @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
45
45
 
46
46
  # Track cache usage statistics (global)
47
47
  @cache_stats[:total_requests] += 1
@@ -35,12 +35,20 @@ module Clacky
35
35
  # user experiences no extra delay.
36
36
  #
37
37
  # @return [Hash] API response with :content, :tool_calls, :usage, etc.
38
+ # NOTE on progress lifecycle:
39
+ # call_llm intentionally does NOT start or stop the progress indicator.
40
+ # Ownership lives with the caller (Agent#think for normal/compression
41
+ # paths, Agent#trigger_idle_compression for idle compression). This
42
+ # avoids nested active/done pairs clobbering each other — a bug that
43
+ # silently dropped the idle-compression summary line.
44
+ #
45
+ # Inside call_llm we only *update in place* during retries, so the
46
+ # already-live progress slot shows meaningful transient status
47
+ # ("Network failed… attempt 2/10", etc.).
38
48
  private def call_llm
39
49
  # Transition :fallback_active → :probing if cooling-off has expired.
40
50
  @config.maybe_start_probing
41
51
 
42
- @ui&.show_progress
43
-
44
52
  tools_to_send = @tool_registry.all_definitions
45
53
 
46
54
  max_retries = 10
@@ -68,7 +76,6 @@ module Clacky
68
76
  handle_probe_success if @config.probing?
69
77
 
70
78
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
71
- @ui&.show_progress(phase: "done")
72
79
  retries += 1
73
80
 
74
81
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
@@ -90,13 +97,12 @@ module Clacky
90
97
  sleep retry_delay
91
98
  retry
92
99
  else
93
- @ui&.show_progress(phase: "done")
94
- # Don't show_error here — let the outer rescue block handle it to avoid duplicates
100
+ # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
101
+ # Progress cleanup is the caller's responsibility (via its own ensure block).
95
102
  raise AgentError, "[LLM] Network connection failed after #{max_retries} retries: #{e.message}"
96
103
  end
97
104
 
98
105
  rescue RetryableError => e
99
- @ui&.show_progress(phase: "done")
100
106
  retries += 1
101
107
 
102
108
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
@@ -127,12 +133,10 @@ module Clacky
127
133
  sleep retry_delay
128
134
  retry
129
135
  else
130
- @ui&.show_progress(phase: "done")
131
- # Don't show_error here — let the outer rescue block handle it to avoid duplicates
136
+ # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
137
+ # Progress cleanup is the caller's responsibility (via its own ensure block).
132
138
  raise AgentError, "[LLM] Service unavailable after #{current_max} retries"
133
139
  end
134
- ensure
135
- @ui&.show_progress(phase: "done")
136
140
  end
137
141
 
138
142
  # Track cost and collect token usage data.
@@ -130,7 +130,7 @@ module Clacky
130
130
  For each qualifying topic:
131
131
  a. If a matching file exists → read it with `file_reader(path: "~/.clacky/memories/<filename>")`, then write an updated version (merge new + old, drop stale)
132
132
  b. If no matching file → create a new one at `~/.clacky/memories/<new-filename>.md`
133
- Use the `write` tool to save each file. Do NOT use `safe_shell` or `file_reader` to list the directory.
133
+ Use the `write` tool to save each file. Do NOT use `terminal` or `file_reader` to list the directory.
134
134
 
135
135
  ## Hard constraints (CRITICAL)
136
136
  - Each file MUST stay under 4000 characters of content (after the frontmatter)
@@ -10,6 +10,7 @@ module Clacky
10
10
  def restore_session(session_data)
11
11
  @session_id = session_data[:session_id]
12
12
  @name = session_data[:name] || ""
13
+ @pinned = session_data[:pinned] || false
13
14
  @history = MessageHistory.new(session_data[:messages] || [])
14
15
  @todos = session_data[:todos] || [] # Restore todos from session
15
16
  @iterations = session_data.dig(:stats, :total_iterations) || 0
@@ -77,6 +78,7 @@ module Clacky
77
78
  {
78
79
  session_id: @session_id,
79
80
  name: @name,
81
+ pinned: @pinned,
80
82
  created_at: @created_at,
81
83
  updated_at: Time.now.iso8601,
82
84
  working_dir: @working_dir,
@@ -68,15 +68,27 @@ module Clacky
68
68
  all_skills = all_skills.reject(&:invalid?)
69
69
  auto_invocable = all_skills.select(&:model_invocation_allowed?)
70
70
 
71
- # Enforce system prompt injection limit to control token usage
71
+ # Enforce system prompt injection limit to control token usage.
72
+ # Warn only when the set of dropped skills *changes* — this message
73
+ # is otherwise emitted once per agent turn (build_skill_context is
74
+ # called during every system prompt assembly) and floods the log.
72
75
  if auto_invocable.size > MAX_CONTEXT_SKILLS
73
- dropped = auto_invocable.size - MAX_CONTEXT_SKILLS
74
- Clacky::Logger.warn(
75
- "Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
76
- "only injecting first #{MAX_CONTEXT_SKILLS} (#{dropped} dropped). " \
77
- "Remove unused skills to restore full visibility."
78
- )
79
- auto_invocable = auto_invocable.first(MAX_CONTEXT_SKILLS)
76
+ kept = auto_invocable.first(MAX_CONTEXT_SKILLS)
77
+ dropped = auto_invocable.drop(MAX_CONTEXT_SKILLS)
78
+ dropped_names = dropped.map(&:identifier)
79
+ signature = dropped_names.sort.join(",")
80
+
81
+ if @skill_limit_warned_signature != signature
82
+ @skill_limit_warned_signature = signature
83
+ Clacky::Logger.warn(
84
+ "Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
85
+ "only injecting first #{MAX_CONTEXT_SKILLS} " \
86
+ "(#{dropped.size} dropped — will NOT be auto-discovered by the agent: " \
87
+ "#{dropped_names.join(", ")}). " \
88
+ "Remove unused skills to restore full visibility."
89
+ )
90
+ end
91
+ auto_invocable = kept
80
92
  end
81
93
 
82
94
  return "" if auto_invocable.empty?
@@ -456,7 +468,7 @@ module Clacky
456
468
  # the real cumulative spend across all subagents
457
469
  subagent_cost = result[:total_cost_usd] || 0.0
458
470
  @total_cost += subagent_cost
459
- @ui&.update_sessionbar(cost: @total_cost)
471
+ @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
460
472
 
461
473
  # Log completion
462
474
  @ui&.show_info("Subagent completed: #{result[:iterations]} iterations, $#{subagent_cost.round(4)} (total: $#{@total_cost.round(4)})")
@@ -21,7 +21,7 @@ module Clacky
21
21
  # confirm_all → human present, truly wait for user input
22
22
  true
23
23
  when :confirm_safes
24
- # Use SafeShell integration for safety check
24
+ # Use Security module to check auto-execution safety
25
25
  is_safe_operation?(tool_name, tool_params)
26
26
  else
27
27
  false
@@ -33,13 +33,14 @@ module Clacky
33
33
  # @param tool_params [Hash, String] Tool parameters
34
34
  # @return [Boolean] true if safe operation
35
35
  def is_safe_operation?(tool_name, tool_params = {})
36
- # For shell commands, use SafeShell to check safety
37
- if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
36
+ # For terminal commands, defer to Security layer for the verdict.
37
+ if tool_name.to_s.downcase == 'terminal'
38
38
  params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
39
39
  command = params[:command] || params['command']
40
- return false unless command
40
+ # No command = session_id continuation / kill / action → safe by default.
41
+ return true unless command
41
42
 
42
- return Tools::SafeShell.command_safe_for_auto_execution?(command)
43
+ return Clacky::Tools::Security.command_safe_for_auto_execution?(command)
43
44
  end
44
45
 
45
46
  if tool_name.to_s.downcase == 'edit' || tool_name.to_s.downcase == 'write'
@@ -140,10 +141,10 @@ module Clacky
140
141
  else
141
142
  "Write(#{filename}) - create new"
142
143
  end
143
- when "shell", "safe_shell"
144
+ when "terminal"
144
145
  cmd = args[:command] || ''
145
146
  display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
146
- "#{call[:name]}(\"#{display_cmd}\")"
147
+ "terminal(\"#{display_cmd}\")"
147
148
  else
148
149
  "Allow #{call[:name]}"
149
150
  end
@@ -241,7 +242,7 @@ module Clacky
241
242
  # @return [Boolean] true if tool is potentially slow
242
243
  private def potentially_slow_tool?(tool_name, args)
243
244
  case tool_name.to_s.downcase
244
- when 'shell', 'safe_shell'
245
+ when 'terminal'
245
246
  # Check if the command is a slow command
246
247
  command = args[:command] || args['command']
247
248
  return false unless command
@@ -257,24 +258,20 @@ module Clacky
257
258
  /make\s+(test|build)/,
258
259
  /pytest/,
259
260
  /jest/,
260
- /sleep\s+\d+/ # sleep command with duration
261
+ /sleep\s+\d+/
261
262
  ]
262
263
 
263
264
  slow_patterns.any? { |pattern| command.match?(pattern) }
264
265
  when 'web_fetch', 'web_search'
265
- true # Network operations can be slow
266
+ true
266
267
  else
267
- false # Most file operations are fast
268
+ false
268
269
  end
269
270
  end
270
271
 
271
- # Build progress message for tool execution
272
- # @param tool_name [String] Name of the tool
273
- # @param args [Hash] Tool arguments
274
- # @return [String] Progress message
275
272
  private def build_tool_progress_message(tool_name, args)
276
273
  case tool_name.to_s.downcase
277
- when 'shell', 'safe_shell'
274
+ when 'terminal'
278
275
  "Running command"
279
276
  when 'web_fetch'
280
277
  "Fetching web page"
@@ -11,9 +11,6 @@ module Clacky
11
11
  end
12
12
 
13
13
  def get(name)
14
- # Handle shell alias to safe_shell for backward compatibility
15
- name = 'safe_shell' if name == 'shell' && @tools.key?('safe_shell') && !@tools.key?('shell')
16
-
17
14
  @tools[name] || raise(Clacky::ToolCallError, "Tool not found: #{name}")
18
15
  end
19
16
 
data/lib/clacky/agent.rb CHANGED
@@ -43,6 +43,7 @@ module Clacky
43
43
  attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
44
44
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
45
45
  :status, :error, :updated_at, :source
46
+ attr_accessor :pinned
46
47
 
47
48
  def permission_mode
48
49
  @config&.permission_mode&.to_s || ""
@@ -57,6 +58,7 @@ module Clacky
57
58
  @hooks = HookManager.new
58
59
  @session_id = session_id
59
60
  @name = ""
61
+ @pinned = false
60
62
  @history = MessageHistory.new
61
63
  @todos = [] # Store todos in memory
62
64
  @iterations = 0
@@ -129,14 +131,21 @@ module Clacky
129
131
  @hooks.add(event, &block)
130
132
  end
131
133
 
132
- # Switch to a different model by index
133
- # @param index [Integer] Model index (0-based)
134
+ # Switch this session to a different model, identified by its stable
135
+ # runtime id. Ids survive list reorders, additions, and field edits,
136
+ # which is why we no longer expose an index-based API.
137
+ # @param id [String] Model id (see AgentConfig#parse_models)
134
138
  # @return [Boolean] true if switched successfully, false otherwise
135
- def switch_model(index)
136
- # Switch config to the model by index
137
- return false unless @config.switch_model(index)
138
-
139
- # Re-create client for new model
139
+ def switch_model_by_id(id)
140
+ return false unless @config.switch_model_by_id(id)
141
+
142
+ rebuild_client_for_current_model!
143
+ true
144
+ end
145
+
146
+ # Rebuild the underlying Client (and dependent components) to pick up
147
+ # credentials/model name from the currently-selected model in @config.
148
+ private def rebuild_client_for_current_model!
140
149
  @client = Clacky::Client.new(
141
150
  @config.api_key,
142
151
  base_url: @config.base_url,
@@ -145,11 +154,9 @@ module Clacky
145
154
  )
146
155
  # Update message compressor with new client and model
147
156
  @message_compressor = MessageCompressor.new(@client, model: current_model)
148
-
157
+
149
158
  # Inject a new session context to notify the AI of the model switch
150
159
  inject_session_context
151
-
152
- true
153
160
  end
154
161
 
155
162
  # Change the working directory for this session
@@ -188,6 +195,14 @@ module Clacky
188
195
  end
189
196
 
190
197
  def run(user_input, files: [])
198
+ # Show the "thinking" indicator as early as possible so the user gets
199
+ # immediate feedback after sending a message. Without this the UI stays
200
+ # silent during synchronous setup work (system prompt assembly, file
201
+ # parsing, history compression checks) before the first LLM call. The
202
+ # subsequent `think` call will re-emit show_progress, which is an
203
+ # idempotent update on the same progress UI element.
204
+ @ui&.show_progress
205
+
191
206
  # Start new task for Time Machine
192
207
  task_id = start_new_task
193
208
 
@@ -477,6 +492,11 @@ module Clacky
477
492
  # Always clean up memory update messages, even if interrupted or error occurred
478
493
  cleanup_memory_messages
479
494
 
495
+ # Safety net: ensure any lingering progress spinner is stopped.
496
+ # Normal paths close their own spinners; this guards against exceptions
497
+ # raised between a progress slot's active/done pair.
498
+ @ui&.show_progress(phase: "done")
499
+
480
500
  # Shred any decrypted-script tmpdirs created during this run for encrypted brand skills.
481
501
  # This covers the inline-injection path; the subagent path shreds immediately after
482
502
  # subagent.run returns (see execute_skill_with_subagent).
@@ -491,6 +511,12 @@ module Clacky
491
511
  raise AgentError, "API key is not configured"
492
512
  end
493
513
 
514
+ # Ensure a thinking progress indicator is live for the duration of this
515
+ # LLM turn. This is idempotent — if `run` already started one at task
516
+ # entry (or a previous iteration left one running), the UI recognizes
517
+ # the bare reentry and preserves the existing spinner.
518
+ @ui&.show_progress
519
+
494
520
  # Check if compression is needed
495
521
  compression_context = compress_messages_if_needed(force: false)
496
522
 
@@ -500,6 +526,14 @@ module Clacky
500
526
  @ui&.show_info(
501
527
  "Message history compression starting (~#{compression_context[:original_token_count]} tokens, #{compression_context[:original_message_count]} messages) - Level #{compression_context[:compression_level]}"
502
528
  )
529
+ # Take over the progress slot with a compression-specific message.
530
+ # handle_compression_response will close it with the summary line on
531
+ # success; on failure the outer ensure below finalizes it.
532
+ @ui&.show_progress(
533
+ "Compressing message history...",
534
+ progress_type: "idle_compress",
535
+ phase: "active"
536
+ )
503
537
  compression_message = compression_context[:compression_message]
504
538
  @history.append(compression_message)
505
539
  compression_handled = false
@@ -518,13 +552,24 @@ module Clacky
518
552
  # (with the user's new message as the last entry), producing consecutive user messages
519
553
  # that confuse the LLM into echoing compression instructions.
520
554
  @compression_level -= 1
555
+ # Close the compression progress slot so the spinner does not linger.
556
+ @ui&.show_progress(phase: "done")
521
557
  end
522
558
  end
523
559
  return nil
524
560
  end
525
561
 
526
- # Normal LLM call
527
- response = call_llm
562
+ # Normal LLM call. call_llm no longer manages the progress lifecycle;
563
+ # we keep the spinner live across the call and finalize it here so the
564
+ # UI transitions cleanly to the assistant message that follows.
565
+ response = nil
566
+ begin
567
+ response = call_llm
568
+ rescue
569
+ # Ensure the spinner is stopped on any error path before it bubbles up.
570
+ @ui&.show_progress(phase: "done")
571
+ raise
572
+ end
528
573
 
529
574
  # Handle truncated responses (when max_tokens limit is reached)
530
575
  if response[:finish_reason] == "length"
@@ -533,6 +578,7 @@ module Clacky
533
578
 
534
579
  if @task_truncation_count >= 3
535
580
  # Too many truncations - task is too complex
581
+ @ui&.show_progress(phase: "done")
536
582
  @ui&.show_error("Response truncated multiple times. Task is too complex.")
537
583
 
538
584
  # Create a response that tells the user to break down the task
@@ -578,6 +624,9 @@ module Clacky
578
624
  truncated: true
579
625
  })
580
626
 
627
+ # Close the current spinner so the warning appears cleanly;
628
+ # the recursive think() call below will reopen a new one.
629
+ @ui&.show_progress(phase: "done")
581
630
  @ui&.show_warning("Response truncated (#{@task_truncation_count}/3). Retrying with smaller steps...")
582
631
 
583
632
  # Recursively retry
@@ -600,6 +649,11 @@ module Clacky
600
649
  msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
601
650
  @history.append(msg)
602
651
 
652
+ # Close the thinking spinner before returning. The caller (run loop)
653
+ # is about to render the assistant message and/or tool invocations,
654
+ # which should appear after the spinner disappears.
655
+ @ui&.show_progress(phase: "done")
656
+
603
657
  response
604
658
  end
605
659
 
@@ -686,46 +740,24 @@ module Clacky
686
740
  args[:agent] = self
687
741
  end
688
742
 
689
- # For safe_shell, skip safety check if user has already confirmed
690
- if call[:name] == "safe_shell" || call[:name] == "shell"
691
- args[:skip_safety_check] = true
692
- end
693
-
694
743
  # Inject working_dir so tools don't rely on Dir.chdir global state
695
744
  args[:working_dir] = @working_dir if @working_dir
696
745
 
697
- # Automatic progress display after 2 seconds for any tool execution
746
+ # Show progress immediately for every tool execution so the user
747
+ # always knows the agent is working. (Previously we deferred this by
748
+ # 2 seconds to avoid flicker in the legacy CLI TUI; that trade-off is
749
+ # no longer desirable now that progress is a first-class UI state in
750
+ # the Web UI and structured JSON UIs.)
698
751
  progress_shown = false
699
- progress_timer = nil
700
-
701
752
  if @ui
702
753
  progress_message = build_tool_progress_message(call[:name], args)
703
-
704
- # For shell/safe_shell: inject on_output callback for real-time stdout streaming.
705
- # The callback fires immediately on each read_nonblock chunk — no polling delay.
706
- if (call[:name] == "shell" || call[:name] == "safe_shell") &&
707
- @ui.respond_to?(:show_tool_stdout)
708
- args[:on_output] = ->(stream, data) {
709
- @ui.show_tool_stdout([data]) if stream == :stdout
710
- }
711
- end
712
-
713
- progress_timer = Thread.new do
714
- sleep 2
715
- @ui.show_progress(progress_message, prefix_newline: false)
716
- progress_shown = true
717
- # Streaming is handled by on_output callback — no polling loop needed here
718
- end
754
+ @ui.show_progress(progress_message, prefix_newline: false)
755
+ progress_shown = true
719
756
  end
720
757
 
721
758
  begin
722
759
  result = tool.execute(**args)
723
760
  ensure
724
- # Cancel timer and clear progress if shown
725
- if progress_timer
726
- progress_timer.kill
727
- progress_timer.join
728
- end
729
761
  @ui&.show_progress(phase: "done") if progress_shown
730
762
  end
731
763
 
@@ -927,7 +959,7 @@ module Clacky
927
959
  end
928
960
 
929
961
  private def register_builtin_tools
930
- @tool_registry.register(Tools::SafeShell.new)
962
+ @tool_registry.register(Tools::Terminal.new)
931
963
  @tool_registry.register(Tools::FileReader.new)
932
964
  @tool_registry.register(Tools::Write.new)
933
965
  @tool_registry.register(Tools::Edit.new)
@@ -936,7 +968,6 @@ module Clacky
936
968
  @tool_registry.register(Tools::WebSearch.new)
937
969
  @tool_registry.register(Tools::WebFetch.new)
938
970
  @tool_registry.register(Tools::TodoManager.new)
939
- # @tool_registry.register(Tools::RunProject.new) # temporarily disabled
940
971
  @tool_registry.register(Tools::RequestUserFeedback.new)
941
972
  @tool_registry.register(Tools::InvokeSkill.new)
942
973
  @tool_registry.register(Tools::UndoTask.new)
@@ -961,16 +992,16 @@ module Clacky
961
992
  if model == "lite"
962
993
  # Special keyword: use lite model if available, otherwise fall back to default
963
994
  lite_model = subagent_config.lite_model
964
- if lite_model
965
- model_index = subagent_config.models.index(lite_model)
966
- subagent_config.switch_model(model_index) if model_index
995
+ if lite_model && lite_model["id"]
996
+ subagent_config.switch_model_by_id(lite_model["id"])
967
997
  end
968
998
  # If no lite model, just use current (default) model
969
999
  else
970
- # Regular model name lookup
971
- model_index = subagent_config.model_names.index(model)
972
- if model_index
973
- subagent_config.switch_model(model_index)
1000
+ # Regular model name lookup — find the first model with a matching
1001
+ # name and switch by its stable id.
1002
+ target = subagent_config.models.find { |m| m["model"] == model }
1003
+ if target && target["id"]
1004
+ subagent_config.switch_model_by_id(target["id"])
974
1005
  else
975
1006
  raise AgentError, "Model '#{model}' not found in config. Available models: #{subagent_config.model_names.join(', ')}"
976
1007
  end
@@ -1250,13 +1281,13 @@ module Clacky
1250
1281
  # [Download report](file:///path/to/file.pdf)
1251
1282
  # ![chart](file:///path/to/chart.png)
1252
1283
  #
1253
- # Returns { text: String, files: Array<{name:, path:, inline:}> }
1254
- # File links are stripped from the returned text.
1284
+ # Returns { text: String (original content, unmodified),
1285
+ # files: Array<{name:, path:, inline:}> }
1255
1286
  private def parse_file_links(content)
1256
1287
  return { text: content, files: [] } if content.nil? || content.empty?
1257
1288
 
1258
1289
  files = []
1259
- text = content.gsub(/(!?)\[([^\]]*)\]\(file:\/\/([^)]+)\)/) do
1290
+ content.scan(/(!?)\[([^\]]*)\]\(file:\/\/([^)]+)\)/) do
1260
1291
  inline = $1 == "!"
1261
1292
  # URL-decode percent-encoded characters (e.g. Chinese filenames encoded by AI)
1262
1293
  raw_path = CGI.unescape($3)
@@ -1264,9 +1295,8 @@ module Clacky
1264
1295
  path = File.expand_path(raw_path)
1265
1296
  Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} expanded=#{path.inspect} exist=#{File.exist?(path)}")
1266
1297
  files << { name: name, path: path, inline: inline }
1267
- ""
1268
1298
  end
1269
- { text: text.strip, files: files }
1299
+ { text: content, files: files }
1270
1300
  end
1271
1301
 
1272
1302
  # Emit assistant message to UI, parsing any embedded file:// links first.