openclacky 1.2.18 → 1.3.0

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/lib/clacky/agent/time_machine.rb +256 -74
  4. data/lib/clacky/agent/tool_executor.rb +12 -0
  5. data/lib/clacky/agent.rb +15 -20
  6. data/lib/clacky/agent_config.rb +18 -0
  7. data/lib/clacky/cli.rb +55 -3
  8. data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
  9. data/lib/clacky/media/base.rb +93 -0
  10. data/lib/clacky/media/gemini.rb +10 -0
  11. data/lib/clacky/media/generator.rb +57 -0
  12. data/lib/clacky/media/openai_compat.rb +160 -0
  13. data/lib/clacky/message_history.rb +12 -7
  14. data/lib/clacky/providers.rb +28 -0
  15. data/lib/clacky/rich_ui_controller.rb +3 -1
  16. data/lib/clacky/server/backup_manager.rb +200 -0
  17. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  18. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  19. data/lib/clacky/server/channel/channel_manager.rb +65 -50
  20. data/lib/clacky/server/http_server.rb +345 -14
  21. data/lib/clacky/server/scheduler.rb +19 -0
  22. data/lib/clacky/server/session_registry.rb +8 -4
  23. data/lib/clacky/session_manager.rb +40 -2
  24. data/lib/clacky/tools/trash_manager.rb +14 -0
  25. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  26. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  27. data/lib/clacky/ui2/ui_controller.rb +150 -19
  28. data/lib/clacky/utils/file_processor.rb +75 -4
  29. data/lib/clacky/version.rb +1 -1
  30. data/lib/clacky/web/app.css +2038 -1147
  31. data/lib/clacky/web/app.js +22 -1
  32. data/lib/clacky/web/backup.js +119 -0
  33. data/lib/clacky/web/billing.js +94 -7
  34. data/lib/clacky/web/channels.js +81 -11
  35. data/lib/clacky/web/design-sample.css +247 -0
  36. data/lib/clacky/web/design-sample.html +127 -0
  37. data/lib/clacky/web/favicon.svg +16 -0
  38. data/lib/clacky/web/i18n.js +159 -31
  39. data/lib/clacky/web/index.html +175 -55
  40. data/lib/clacky/web/logo_nav_dark.png +0 -0
  41. data/lib/clacky/web/onboard.js +114 -28
  42. data/lib/clacky/web/sessions.js +436 -192
  43. data/lib/clacky/web/settings.js +21 -1
  44. data/lib/clacky/web/skills.js +1 -1
  45. data/lib/clacky/web/tasks.js +129 -61
  46. data/lib/clacky/web/utils.js +72 -0
  47. data/lib/clacky/web/ws-dispatcher.js +6 -0
  48. data/lib/clacky.rb +1 -0
  49. metadata +7 -3
  50. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcf1cc94591160df5daf797ece584049cf5c9881bc1e3899e84d8fdee61d330f
4
- data.tar.gz: cde0fb1ea11582f9e4934a68635b44af3bda3cf886619280d9c0afce6a36eb5e
3
+ metadata.gz: 8423d4e64f056251f1763e5cc34502e394f08e2b26de993da5da6a85a88111ef
4
+ data.tar.gz: 62e60aee3f8654e881870117b4716e053ab0f9328bc3898392ff1ed27517474c
5
5
  SHA512:
6
- metadata.gz: 2395d1e2b130021001ebad6aafa7eb11d5a884201852030e203e237b8e91b6ef9c67b7d50b691661ccae66b6baccd941e8e9c6bf92ef2addd490166c57b06ab2
7
- data.tar.gz: 32cad874d49b8df4892081a5e5ade95115fdb58c9c52fd88cd95d130aba0ae65068adf0966ab259cdf383bb8293fb6d4f43c5f0a9177844ac295355c1dcac94e
6
+ metadata.gz: d675c6b981a1fc5f24bbee79dd19dc273817a7f4e233c2ed86a42def4f2fd2876cbc845a9f693383d7f4b67d39f6a62df1eafa65def2a8ace7bb876559c6aba5
7
+ data.tar.gz: 48adb156c4c8ab26d2908537ac409c161a56b1f6fa5aa2146bdaa5df6b11c2b333e5a04a6aafc4404f1c94ce210fe0c23854d0f86f9cc509f1a27a781f9aa007
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-06-17
9
+
10
+ ### Added
11
+ - Complete Web UI overhaul: skeleton loading, task card layout, new session dialog, onboarding flow, inline image preview, and redesigned session bar
12
+ - Multimedia generation: video generation, text-to-speech (TTS), and OCR via vision models
13
+ - IM channel management UI — bind and unbind channels directly in Web UI
14
+ - `/model` command in terminal UI for quick model switching
15
+ - AI-key device login flow
16
+ - Session sharing and backup support
17
+
18
+ ### Improved
19
+ - Terminal output auto-collapses on completion with normalized result display
20
+ - Billing page mobile layout, global tooltip, and logo animation polish
21
+ - Live chat history API replaces group buffer polling for real-time sync
22
+
23
+ ### Fixed
24
+ - Sidebar scrolling back to active session on content updates
25
+ - Channel key missing arbitration on session restore
26
+ - Race condition in model switching
27
+ - Channel keys and info out of sync on bind/unbind
28
+
8
29
  ## [1.2.18] - 2026-06-13
9
30
 
10
31
  ### Added
@@ -1,10 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "set"
5
+
3
6
  module Clacky
4
7
  class Agent
5
- # Time Machine module for task history management with undo/redo support
6
- # Stores complete file snapshots (AFTER state) to support message compression
8
+ # Time Machine module for task history management with undo/redo support.
9
+ #
10
+ # Snapshots capture the BEFORE state of each file the moment a task first
11
+ # touches it (via record_file_before_change). task-N/ therefore holds
12
+ # "what every file looked like just before task N changed it" — including
13
+ # an .absent marker for files that did not yet exist. Restoring to task T
14
+ # replays the earliest BEFORE recorded in any task after T, which equals
15
+ # the on-disk state at the end of task T.
7
16
  module TimeMachine
17
+ # Marker file written alongside a snapshot path when the original file
18
+ # did not exist before the task changed it. Restoring such an entry
19
+ # deletes the file instead of copying content back.
20
+ ABSENT_MARKER = ".clacky-absent"
21
+
22
+ # Root directory holding per-session file snapshots.
23
+ def self.snapshots_root
24
+ File.join(Dir.home, ".clacky", "snapshots")
25
+ end
26
+
27
+ # Snapshot directory for a single session.
28
+ def self.session_dir(session_id)
29
+ File.join(snapshots_root, session_id.to_s)
30
+ end
31
+
32
+ # Remove all snapshots for a session. Safe to call when none exist.
33
+ def self.delete_session_snapshots(session_id)
34
+ return if session_id.to_s.empty?
35
+
36
+ FileUtils.rm_rf(session_dir(session_id))
37
+ end
38
+
8
39
  # Initialize Time Machine state
9
40
  private def init_time_machine
10
41
  @task_parents ||= {} # { task_id => parent_id }
@@ -15,6 +46,12 @@ module Clacky
15
46
  # Start a new task and establish parent relationship
16
47
  # Made public for testing
17
48
  def start_new_task
49
+ # Before the currently-active task stops being the latest, freeze its
50
+ # end-of-task disk state into an AFTER snapshot. Without this, a task
51
+ # that later gets superseded by a sibling branch would have no record
52
+ # of its result, making a forward switch back to it impossible.
53
+ checkpoint_latest_task_after
54
+
18
55
  parent_id = @active_task_id
19
56
  @current_task_id += 1
20
57
  @active_task_id = @current_task_id
@@ -26,101 +63,242 @@ module Clacky
26
63
  # before it can write to history.
27
64
  @task_thread = Thread.current
28
65
 
66
+ @latest_after_dirty = true
67
+
29
68
  @current_task_id
30
69
  end
31
70
 
32
- # Save snapshots of modified files (AFTER state)
33
- # @param modified_files [Array<String>] List of file paths that were modified
71
+ # Record a file's BEFORE state for the current task, the first time the
72
+ # task touches it. Call this immediately before a tool mutates the file.
73
+ # Subsequent calls within the same task are no-ops so the earliest state
74
+ # (the true "before this task" snapshot) is preserved.
34
75
  # Made public for testing
35
- def save_modified_files_snapshot(modified_files)
36
- return if modified_files.nil? || modified_files.empty?
37
-
38
- snapshot_dir = File.join(
39
- Dir.home,
40
- ".clacky",
41
- "snapshots",
42
- @session_id,
43
- "task-#{@current_task_id}"
44
- )
45
- FileUtils.mkdir_p(snapshot_dir)
46
-
47
- modified_files.each do |file_path|
48
- next unless File.exist?(file_path)
49
-
50
- # Save file content to snapshot
51
- relative_path = file_path.start_with?(@working_dir) ?
52
- file_path.sub(@working_dir + "/", "") : File.basename(file_path)
53
-
54
- snapshot_file = File.join(snapshot_dir, relative_path)
55
- FileUtils.mkdir_p(File.dirname(snapshot_file))
56
- FileUtils.cp(file_path, snapshot_file)
76
+ def record_file_before_change(file_path)
77
+ return if @current_task_id.to_i <= 0
78
+
79
+ full_path = File.expand_path(file_path.to_s, @working_dir)
80
+ rel = snapshot_relative_path(full_path)
81
+ before_dir = File.join(TimeMachine.session_dir(@session_id), "task-#{@current_task_id}", "before")
82
+ snapshot_file = File.join(before_dir, rel)
83
+ marker_file = "#{snapshot_file}.#{ABSENT_MARKER}"
84
+
85
+ # Already recorded for this task — keep the earliest capture.
86
+ return if File.exist?(snapshot_file) || File.exist?(marker_file)
87
+
88
+ # A fresh change to the latest task invalidates its stale AFTER checkpoint.
89
+ @latest_after_dirty = true
90
+
91
+ FileUtils.mkdir_p(File.dirname(snapshot_file))
92
+ if File.exist?(full_path)
93
+ FileUtils.cp(full_path, snapshot_file)
94
+ else
95
+ # File did not exist before this task — mark it so a restore deletes it.
96
+ FileUtils.touch(marker_file)
57
97
  end
58
- rescue StandardError => e
59
- # Silently handle errors in tests
98
+ rescue StandardError
99
+ # Snapshotting must never break the actual file operation.
60
100
  end
61
101
 
62
- # Restore files to the state at given task
102
+ # Snapshot a task's current on-disk state into its AFTER tree, so a
103
+ # forward switch (redo / branch switch) back to it can be reconstructed.
104
+ # Only the files the task touched (its BEFORE entries) are captured.
105
+ # Defaults to the active task, which holds the live disk state right
106
+ # before we leave it (start_new_task / switch).
107
+ private def checkpoint_latest_task_after(task_id = @active_task_id)
108
+ return if task_id.to_i <= 0
109
+ # Re-snapshotting the latest task is skipped when nothing changed.
110
+ return if task_id == @current_task_id && @latest_after_dirty == false
111
+
112
+ session_root = TimeMachine.session_dir(@session_id)
113
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
114
+ return unless Dir.exist?(before_dir)
115
+
116
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
117
+ FileUtils.rm_rf(after_dir)
118
+
119
+ Dir.glob(File.join(before_dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
120
+ next if File.directory?(path)
121
+
122
+ rel = path.sub(before_dir + "/", "")
123
+ rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
124
+ target = File.join(@working_dir, rel)
125
+ dest = File.join(after_dir, rel)
126
+ if File.exist?(target)
127
+ FileUtils.mkdir_p(File.dirname(dest))
128
+ FileUtils.cp(target, dest)
129
+ else
130
+ FileUtils.mkdir_p(File.dirname(dest))
131
+ FileUtils.touch("#{dest}.#{ABSENT_MARKER}")
132
+ end
133
+ end
134
+ @latest_after_dirty = false if task_id == @current_task_id
135
+ rescue StandardError
136
+ # Checkpointing must never break a restore.
137
+ end
138
+
139
+ # Restore files to the on-disk state at the END of the given task.
140
+ #
141
+ # History is a TREE (undo + a new message forks a sibling branch), so a
142
+ # linear "replay every task after T" model is wrong: a sibling branch's
143
+ # files would leak in or get wrongly deleted. Instead we reconstruct T's
144
+ # end state from the task tree:
145
+ #
146
+ # * Each task owns an AFTER snapshot = the content of the files it
147
+ # touched, as they looked when that task finished.
148
+ # * To rebuild "end of task T", walk T's ancestor chain (T -> root).
149
+ # For every file ever touched in the whole session, the winning
150
+ # content is the closest ancestor (starting at T) whose AFTER holds
151
+ # that file. If no ancestor on the chain ever touched it, the file
152
+ # did not exist at T and is removed.
153
+ #
63
154
  # @param task_id [Integer] Target task ID
64
155
  # Made public for testing
65
156
  def restore_to_task_state(task_id)
66
- # Collect all modified files from task 1 to target task
67
- files_to_restore = {}
68
-
69
- (1..task_id).each do |tid|
70
- snapshot_dir = File.join(
71
- Dir.home,
72
- ".clacky",
73
- "snapshots",
74
- @session_id,
75
- "task-#{tid}"
76
- )
77
-
78
- next unless Dir.exist?(snapshot_dir)
79
-
80
- Dir.glob(File.join(snapshot_dir, "**", "*")).each do |snapshot_file|
81
- next if File.directory?(snapshot_file)
82
-
83
- relative_path = snapshot_file.sub(snapshot_dir + "/", "")
84
- files_to_restore[relative_path] = snapshot_file
85
- end
157
+ # Freeze the task we're leaving so a later forward switch can return.
158
+ checkpoint_latest_task_after
159
+
160
+ session_root = TimeMachine.session_dir(@session_id)
161
+
162
+ # Ancestor chain from the target task up to (and excluding) root 0,
163
+ # ordered nearest-first so the closest writer of each file wins.
164
+ ancestors = []
165
+ tid = task_id
166
+ until tid.nil? || tid <= 0 || ancestors.include?(tid)
167
+ ancestors << tid
168
+ tid = @task_parents[tid]
86
169
  end
87
-
88
- # Restore files
89
- files_to_restore.each do |relative_path, snapshot_file|
90
- target_file = File.join(@working_dir, relative_path)
91
- FileUtils.mkdir_p(File.dirname(target_file))
92
- FileUtils.cp(snapshot_file, target_file)
170
+
171
+ # Every file ever touched by any task in this session.
172
+ all_rels = Set.new
173
+ Dir.glob(File.join(session_root, "task-*", "before", "**", "*"), File::FNM_DOTMATCH).each do |path|
174
+ next if File.directory?(path)
175
+
176
+ rel = path.sub(%r{\A.*/before/}, "")
177
+ rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
178
+ all_rels << rel
179
+ end
180
+
181
+ all_rels.each do |rel|
182
+ action = :delete
183
+ source = nil
184
+ matched = false
185
+
186
+ # Closest ancestor (starting at the target) that produced this file.
187
+ ancestors.each do |aid|
188
+ after_dir = File.join(session_root, "task-#{aid}", "after")
189
+ content_path = File.join(after_dir, rel)
190
+ absent_path = "#{content_path}.#{ABSENT_MARKER}"
191
+
192
+ if File.exist?(content_path)
193
+ action = :restore
194
+ source = content_path
195
+ matched = true
196
+ break
197
+ elsif File.exist?(absent_path)
198
+ action = :delete
199
+ matched = true
200
+ break
201
+ end
202
+ end
203
+
204
+ # No task on the chain produced this file. Restore the session's
205
+ # INITIAL content for it — captured as the earliest BEFORE recorded
206
+ # for this file by any task (BEFORE = state just before that task
207
+ # ran; the smallest task id therefore holds the pre-session state).
208
+ # No BEFORE at all => the file never existed initially, so delete.
209
+ unless matched
210
+ initial = earliest_before_snapshot(session_root, rel)
211
+ if initial
212
+ action = :restore
213
+ source = initial
214
+ else
215
+ action = :delete
216
+ end
217
+ end
218
+
219
+ target = File.join(@working_dir, rel)
220
+ if action == :delete
221
+ FileUtils.rm_f(target)
222
+ else
223
+ FileUtils.mkdir_p(File.dirname(target))
224
+ FileUtils.cp(source, target)
225
+ end
93
226
  end
94
- rescue StandardError => e
95
- # Silently handle errors in tests
227
+ rescue StandardError
96
228
  raise
97
229
  end
98
230
 
99
- # Filter messages to only show tasks up to active_task_id.
100
- # This hides "future" messages when user has undone.
101
- # Returns API-ready array (strips internal fields + handles orphaned tool_calls).
231
+ # The initial (pre-session) content path for a file, taken from the
232
+ # earliest BEFORE snapshot any task recorded for it. Returns the snapshot
233
+ # path to copy back, or nil if the earliest record is an absent marker
234
+ # (file did not exist at the session start).
235
+ private def earliest_before_snapshot(session_root, rel)
236
+ task_ids = Dir.glob(File.join(session_root, "task-*")).filter_map do |dir|
237
+ m = File.basename(dir).match(/\Atask-(\d+)\z/)
238
+ m && m[1].to_i
239
+ end.sort
240
+
241
+ task_ids.each do |tid|
242
+ before_dir = File.join(session_root, "task-#{tid}", "before")
243
+ content_path = File.join(before_dir, rel)
244
+ absent_path = "#{content_path}.#{ABSENT_MARKER}"
245
+ return content_path if File.exist?(content_path)
246
+ return nil if File.exist?(absent_path)
247
+ end
248
+ nil
249
+ end
250
+
251
+ # Relative path used to key a snapshot. Files inside the working dir keep
252
+ # their relative path; anything else falls back to its basename.
253
+ private def snapshot_relative_path(full_path)
254
+ if full_path.start_with?(@working_dir + "/")
255
+ full_path.sub(@working_dir + "/", "")
256
+ else
257
+ File.basename(full_path)
258
+ end
259
+ end
260
+
261
+ # Filter messages to only the active task's ancestor chain.
262
+ # After an undo (and especially after sending a NEW message post-undo,
263
+ # which forks a fresh task off the undone point) the history still holds
264
+ # the abandoned/sibling-branch turns. We must send the LLM only the turns
265
+ # on the path from the root to the active task — never undone siblings.
266
+ # Returns API-ready array (strips internal fields + repairs orphaned
267
+ # tool_calls), so this stays consistent with the normal to_api path.
102
268
  # @param force_reasoning_content_pad [Boolean] forwarded to MessageHistory,
103
269
  # enables one-shot pad-and-retry for thinking-mode providers that
104
270
  # require reasoning_content on every assistant message.
105
271
  # Made public for testing
106
272
  def active_messages(force_reasoning_content_pad: false)
107
- if @active_task_id == @current_task_id
108
- return @history.to_api(force_reasoning_content_pad: force_reasoning_content_pad)
109
- end
273
+ @history.to_api(
274
+ force_reasoning_content_pad: force_reasoning_content_pad,
275
+ task_chain: active_task_chain
276
+ )
277
+ end
110
278
 
111
- stripped = @history.for_task(@active_task_id).map do |msg|
112
- msg.reject { |k, _| MessageHistory::INTERNAL_FIELDS.include?(k) }
279
+ # The set of task IDs on the path from the root to @active_task_id,
280
+ # walked via @task_parents. Used to filter history so undone or
281
+ # sibling-branch turns are excluded from what the LLM sees. Task 0 is the
282
+ # root and is always included when reached (early turns are tagged 0).
283
+ private def active_task_chain
284
+ chain = Set.new
285
+ tid = @active_task_id
286
+ # Guard against a malformed parent map producing a cycle.
287
+ until tid.nil? || chain.include?(tid)
288
+ chain << tid
289
+ break if tid <= 0
290
+ tid = @task_parents[tid]
113
291
  end
114
- # Apply the same reasoning_content padding rule used by to_api so
115
- # Time Machine replays satisfy thinking-mode providers after a
116
- # 400 retry.
117
- MessageHistory.pad_reasoning_content_if_needed(stripped, force: force_reasoning_content_pad)
292
+ chain
118
293
  end
119
294
 
120
- # Undo to parent task
295
+ # Undo to parent task. Task 0 represents the original pre-task state,
296
+ # which is reachable from task 1 thanks to its BEFORE snapshots.
121
297
  def undo_last_task
298
+ return { success: false, message: "Already at root task" } if @active_task_id == 0
299
+
122
300
  parent_id = @task_parents[@active_task_id]
123
- return { success: false, message: "Already at root task" } if parent_id.nil? || parent_id == 0
301
+ return { success: false, message: "Already at root task" } if parent_id.nil?
124
302
 
125
303
  restore_to_task_state(parent_id)
126
304
  @active_task_id = parent_id
@@ -159,6 +337,8 @@ module Clacky
159
337
  def get_task_history(limit: 10)
160
338
  return [] if @current_task_id == 0
161
339
 
340
+ chain = active_task_chain
341
+
162
342
  tasks = []
163
343
  (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
164
344
  # Find first user message for this task
@@ -174,13 +354,15 @@ module Clacky
174
354
  "Task #{task_id}"
175
355
  end
176
356
 
177
- # Determine task status
357
+ # Status relative to the ACTIVE task chain (not a linear id compare),
358
+ # so undone/abandoned branches are flagged distinctly from the path
359
+ # the user is currently on.
178
360
  status = if task_id == @active_task_id
179
361
  :current
180
- elsif task_id < @active_task_id
362
+ elsif chain.include?(task_id)
181
363
  :past
182
364
  else
183
- :future
365
+ :undone
184
366
  end
185
367
 
186
368
  # Check if task has branches (multiple children)
@@ -254,6 +254,18 @@ module Clacky
254
254
  }
255
255
  end
256
256
 
257
+ # Show countdown before auto-executing in auto_approve mode.
258
+ # Gives the user time to see what's happening and Ctrl+C to cancel.
259
+ # @param seconds [Integer] Countdown duration
260
+ private def auto_approve_countdown(seconds: 10)
261
+ return unless @ui
262
+
263
+ seconds.downto(1) do |remaining|
264
+ @ui.show_info(" Auto-executing in #{remaining}s... (Ctrl+C to cancel)", prefix_newline: false)
265
+ sleep 1
266
+ end
267
+ end
268
+
257
269
  # Check if a tool is potentially slow and should show progress
258
270
  # @param tool_name [String] Name of the tool
259
271
  # @param args [Hash] Tool arguments
data/lib/clacky/agent.rb CHANGED
@@ -605,12 +605,6 @@ module Clacky
605
605
 
606
606
  result = build_result
607
607
 
608
- # Save snapshots of modified files for Time Machine
609
- if @modified_files_in_task && !@modified_files_in_task.empty?
610
- save_modified_files_snapshot(@modified_files_in_task)
611
- @modified_files_in_task = [] # Reset for next task
612
- end
613
-
614
608
  # Run skill evolution hooks after main loop completes
615
609
  # Skip if task was interrupted by user (denied tool) or awaiting user feedback
616
610
  # Only for main agent (not subagents) to avoid recursive evolution
@@ -953,8 +947,11 @@ module Clacky
953
947
  end
954
948
  end
955
949
 
956
- # Special handling for request_user_feedback: don't show as tool call
957
- unless call[:name] == "request_user_feedback"
950
+ # Special handling for request_user_feedback
951
+ if call[:name] == "request_user_feedback"
952
+ # In auto_approve mode, give user time to see and cancel before auto-answering
953
+ auto_approve_countdown(seconds: 10) if @config.permission_mode == :auto_approve
954
+ else
958
955
  @ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
959
956
  end
960
957
 
@@ -1004,6 +1001,10 @@ module Clacky
1004
1001
  # instant tools like edit/write/read/glob/grep. Truly slow
1005
1002
  # tools (terminal running a build, web_fetch) exceed the
1006
1003
  # threshold and their final frame is preserved as usual.
1004
+ # Record BEFORE-change snapshots for Time Machine right before the
1005
+ # tool runs, so undo can restore (or delete) any file it touches.
1006
+ record_tool_target_before(call[:name], args)
1007
+
1007
1008
  result = nil
1008
1009
  if @ui
1009
1010
  progress_message = build_tool_progress_message(call[:name], args)
@@ -1018,9 +1019,6 @@ module Clacky
1018
1019
  result = tool.execute(**args)
1019
1020
  end
1020
1021
 
1021
- # Track modified files for Time Machine snapshots
1022
- track_modified_files(call[:name], args)
1023
-
1024
1022
  # Hook: after_tool_use
1025
1023
  @hooks.trigger(:after_tool_use, call, result)
1026
1024
 
@@ -1580,7 +1578,7 @@ module Clacky
1580
1578
 
1581
1579
  image = data_url ? { data_url: data_url } : { path: path }
1582
1580
 
1583
- @ui&.show_progress("OCR...", progress_type: "thinking", phase: "active")
1581
+ @ui&.show_progress("Reading image…", progress_type: "vision", phase: "active")
1584
1582
  begin
1585
1583
  Clacky::Vision::Resolver.new(ocr_entry).describe(image)
1586
1584
  ensure
@@ -1809,17 +1807,14 @@ module Clacky
1809
1807
  @ui&.show_assistant_message(full_content, files: parsed[:files])
1810
1808
  end
1811
1809
 
1812
- # Track modified files for Time Machine snapshots
1813
- # @param tool_name [String] Name of the tool that was executed
1810
+ # Record BEFORE-change snapshots for any file a tool is about to mutate,
1811
+ # so Time Machine can later restore or delete it.
1812
+ # @param tool_name [String] Name of the tool about to be executed
1814
1813
  # @param args [Hash] Arguments passed to the tool
1815
- def track_modified_files(tool_name, args)
1816
- @modified_files_in_task ||= []
1817
-
1814
+ private def record_tool_target_before(tool_name, args)
1818
1815
  case tool_name
1819
1816
  when "write", "edit"
1820
- file_path = args[:path]
1821
- full_path = File.expand_path(file_path, @working_dir)
1822
- @modified_files_in_task << full_path unless @modified_files_in_task.include?(full_path)
1817
+ record_file_before_change(args[:path]) if args[:path]
1823
1818
  end
1824
1819
  end
1825
1820
  end
@@ -825,6 +825,24 @@ module Clacky
825
825
  available = default_provider ? Clacky::Providers.ocr_models(default_provider) : []
826
826
 
827
827
  if raw_entry && raw_entry["disabled"]
828
+ # A disabled OCR sidecar only means "no separate vision model"; it must
829
+ # not override the fact that the chat model may handle images itself.
830
+ anchor = current_model || default
831
+ anchor_provider = anchor && Clacky::Providers.resolve_provider(
832
+ base_url: anchor["base_url"], api_key: anchor["api_key"]
833
+ )
834
+ if anchor && anchor_provider &&
835
+ Clacky::Providers.supports?(anchor_provider, :vision, model_name: anchor["model"])
836
+ return {
837
+ "configured" => true,
838
+ "source" => "primary",
839
+ "model" => anchor["model"],
840
+ "base_url" => anchor["base_url"],
841
+ "provider" => anchor_provider,
842
+ "primary" => true,
843
+ "available" => available
844
+ }
845
+ end
828
846
  return {
829
847
  "configured" => false,
830
848
  "source" => "off",
data/lib/clacky/cli.rb CHANGED
@@ -290,6 +290,54 @@ module Clacky
290
290
  ui_controller.append_output("")
291
291
  end
292
292
 
293
+ # Handle the `/model` slash command — a quick model-card switcher.
294
+ #
295
+ # This is the lightweight counterpart to /config: it only lets the user
296
+ # pick an already-configured model and switches to it (no add/edit/delete).
297
+ # Switching goes through the unified Agent#switch_model_by_id path and
298
+ # also updates the global default so the choice sticks across launches,
299
+ # matching /config's :switch behavior.
300
+ private def handle_model_command(ui_controller, agent_config, agent, session_manager = nil)
301
+ config = agent_config
302
+
303
+ if config.models.empty?
304
+ ui_controller.show_error("No models configured. Run /config to add one.")
305
+ return
306
+ end
307
+
308
+ # Resolve a card's provider sub-models so the picker can offer them in
309
+ # the card's sub-model drawer.
310
+ submodels_for = lambda do |model|
311
+ base_url = model["base_url"]
312
+ provider_id = base_url && Clacky::Providers.find_by_base_url(base_url)
313
+ provider_id ? Clacky::Providers.models(provider_id) : []
314
+ end
315
+
316
+ result = ui_controller.show_model_switch_modal(config, submodels_for)
317
+ return if result.nil?
318
+
319
+ target_id = result[:model_id]
320
+ sub_model = result[:model_name]
321
+
322
+ agent.switch_model_by_id(target_id)
323
+ config.set_default_model_by_id(target_id)
324
+ config.save
325
+
326
+ # Pin (or clear) the per-session sub-model overlay for the chosen card.
327
+ agent.set_session_sub_model(sub_model)
328
+
329
+ # The overlay lives in the session file (not config.yml), so persist it
330
+ # now — otherwise it would be lost if the user quits before the next task.
331
+ session_manager&.save(agent.to_session_data)
332
+
333
+ ui_controller.config[:model] = config.model_name
334
+ ui_controller.update_sessionbar(
335
+ tasks: agent.total_tasks,
336
+ cost: agent.total_cost
337
+ )
338
+ ui_controller.show_success("Switched to model: #{config.model_name}")
339
+ end
340
+
293
341
  private def handle_time_machine_command(ui_controller, agent, session_manager)
294
342
  # Get task history from agent
295
343
  history = agent.get_task_history(limit: 10)
@@ -892,10 +940,11 @@ module Clacky
892
940
  ui_controller.append_output("")
893
941
  end
894
942
 
895
- # Stop UI and exit
943
+ # Stop UI and exit. Each UI decides whether to clear the screen on
944
+ # exit (UI2 keeps it so the resume hint survives; Rich clears).
896
945
  shutting_down = true
897
946
  idle_timer.shutdown
898
- ui_controller.stop(clear_screen: true)
947
+ ui_controller.stop
899
948
  exit(0)
900
949
  end
901
950
 
@@ -913,6 +962,9 @@ module Clacky
913
962
  when "/config"
914
963
  handle_config_command(ui_controller, agent_config, agent)
915
964
  next
965
+ when "/model"
966
+ handle_model_command(ui_controller, agent_config, agent, session_manager)
967
+ next
916
968
  when "/undo"
917
969
  handle_time_machine_command(ui_controller, agent, session_manager)
918
970
  next
@@ -948,7 +1000,7 @@ module Clacky
948
1000
  when "/exit", "/quit"
949
1001
  shutting_down = true
950
1002
  idle_timer.shutdown
951
- ui_controller.stop(clear_screen: true)
1003
+ ui_controller.stop
952
1004
  exit(0)
953
1005
  when "/help"
954
1006
  sleep 0.1