openclacky 1.3.3 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent.rb +3 -2
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
- data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/http_server.rb +150 -103
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +56 -59
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +18 -2
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/skills/store.js +30 -2
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +32 -20
- data/lib/clacky/web/index.html +9 -17
- data/lib/clacky/web/sessions.js +286 -28
- data/lib/clacky/web/settings.js +109 -111
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -2
- metadata +38 -2
- data/lib/clacky/media/output_dir.rb +0 -43
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "set"
|
|
7
|
+
require "ruby_rich"
|
|
8
|
+
require_relative "../ui_interface"
|
|
9
|
+
require_relative "../providers"
|
|
10
|
+
require_relative "../ui2/components/welcome_banner"
|
|
11
|
+
require_relative "shell/rich_agent_shell"
|
|
12
|
+
require_relative "components/sidebar"
|
|
13
|
+
require_relative "components/thinking_live_view"
|
|
14
|
+
require_relative "components/status_view"
|
|
15
|
+
require_relative "layout_adapter"
|
|
16
|
+
require_relative "progress_handle_adapter"
|
|
17
|
+
require_relative "components/dialogs/config_menu_dialog"
|
|
18
|
+
require_relative "components/dialogs/form_dialog"
|
|
19
|
+
require_relative "entry_tracker"
|
|
20
|
+
require_relative "components/dialogs/approval_dialog"
|
|
21
|
+
|
|
22
|
+
module Clacky
|
|
23
|
+
class RichUIController
|
|
24
|
+
include Clacky::UIInterface
|
|
25
|
+
include Clacky::RichUI::ViewRenderer
|
|
26
|
+
|
|
27
|
+
STREAMING_MARKDOWN_THRESHOLD = 240
|
|
28
|
+
STREAMING_MARKDOWN_CHUNK_SIZE = 6
|
|
29
|
+
STREAMING_MARKDOWN_DELAY = 0.03
|
|
30
|
+
|
|
31
|
+
COMMANDS = [
|
|
32
|
+
{ label: "/clear", value: "/clear", description: "Clear output and restart session" },
|
|
33
|
+
{ label: "/config", value: "/config", description: "Open configuration" },
|
|
34
|
+
{ label: "/undo", value: "/undo", description: "Restore a previous task state" },
|
|
35
|
+
{ label: "/help", value: "/help", description: "Show commands" },
|
|
36
|
+
{ label: "/exit", value: "/exit", description: "Exit application", aliases: ["/quit"] }
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :layout, :shell, :running
|
|
40
|
+
attr_reader :status, :tasks_count, :total_cost, :turn_active, :ctrl_c_warning, :work_label, :latest_latency
|
|
41
|
+
attr_accessor :config, :available_models
|
|
42
|
+
|
|
43
|
+
def initialize(config = {})
|
|
44
|
+
@config = {
|
|
45
|
+
working_dir: config[:working_dir],
|
|
46
|
+
mode: config[:mode],
|
|
47
|
+
model: config[:model],
|
|
48
|
+
theme: config[:theme]
|
|
49
|
+
}
|
|
50
|
+
@welcome_banner = Clacky::UI2::Components::WelcomeBanner.new
|
|
51
|
+
@available_models = config[:model_names] || [config[:model] || "unknown"]
|
|
52
|
+
@shell = RichAgentShell.new(
|
|
53
|
+
title: "OpenClacky",
|
|
54
|
+
subtitle: config[:working_dir].to_s,
|
|
55
|
+
model: config[:model].to_s,
|
|
56
|
+
theme: RubyRich::Theme.agent_dark,
|
|
57
|
+
commands: COMMANDS
|
|
58
|
+
)
|
|
59
|
+
@shell.clacky_controller = self
|
|
60
|
+
@layout = RichUI::LayoutAdapter.new(@shell)
|
|
61
|
+
@input_callback = nil
|
|
62
|
+
@interrupt_callback = nil
|
|
63
|
+
@work_label = nil
|
|
64
|
+
@ctrl_c_warning = nil
|
|
65
|
+
@latest_latency = nil
|
|
66
|
+
@always_allow_fingerprints = Set.new
|
|
67
|
+
@mode_toggle_callback = nil
|
|
68
|
+
@model_switch_callback = nil
|
|
69
|
+
@time_machine_callback = nil
|
|
70
|
+
@tasks_count = 0
|
|
71
|
+
@total_cost = 0.0
|
|
72
|
+
@running = false
|
|
73
|
+
@turn_active = false
|
|
74
|
+
@tracker = RichUI::EntryTracker.new
|
|
75
|
+
@todo_items = []
|
|
76
|
+
@explicit_todo_cycle = false
|
|
77
|
+
@tool_activities = []
|
|
78
|
+
@tool_activity_by_id = {}
|
|
79
|
+
@legacy_progress = {}
|
|
80
|
+
@stdout_lines = []
|
|
81
|
+
@callback_threads = []
|
|
82
|
+
@stream_threads = []
|
|
83
|
+
|
|
84
|
+
wire_shell_callbacks
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def initialize_and_show_banner(recent_user_messages: nil)
|
|
88
|
+
@running = true
|
|
89
|
+
@shell.update_status(session_status)
|
|
90
|
+
if recent_user_messages && !recent_user_messages.empty?
|
|
91
|
+
@shell.add_separator("recent session")
|
|
92
|
+
recent_user_messages.each { |message| @shell.add_user_message(message) }
|
|
93
|
+
else
|
|
94
|
+
add_plain_block(render_welcome_banner)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def start
|
|
99
|
+
initialize_and_show_banner unless @running
|
|
100
|
+
start_input_loop
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def start_input_loop
|
|
104
|
+
@running = true
|
|
105
|
+
begin
|
|
106
|
+
@shell.start
|
|
107
|
+
rescue AgentInterrupted
|
|
108
|
+
# Ctrl+C (SIGINT via intr:true) raised AgentInterrupted.
|
|
109
|
+
# Check viewport selection FIRST: when text is selected
|
|
110
|
+
# (program-level drag-select), copy it to the clipboard and
|
|
111
|
+
# clear the highlight, then retry the shell WITHOUT calling
|
|
112
|
+
# the interrupt callback. Otherwise route through the
|
|
113
|
+
# normal interrupt callback (interrupt task / double-tap
|
|
114
|
+
# warning / exit).
|
|
115
|
+
vp = @shell.viewport
|
|
116
|
+
selecting = vp.instance_variable_get(:@selecting)
|
|
117
|
+
has_selection = selecting || vp.selected_text.to_s != ""
|
|
118
|
+
|
|
119
|
+
if has_selection
|
|
120
|
+
vp.send(:stop_selection) if selecting
|
|
121
|
+
vp.instance_variable_set(:@selection_start, nil)
|
|
122
|
+
vp.instance_variable_set(:@selection_end, nil)
|
|
123
|
+
vp.instance_variable_set(:@selected_text, "")
|
|
124
|
+
retry
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# No selection — route through the interrupt callback.
|
|
128
|
+
# The double-tap exit logic lives in the callback (cli.rb).
|
|
129
|
+
input_was_empty = @shell.composer.value.to_s.empty?
|
|
130
|
+
@interrupt_callback&.call(input_was_empty: input_was_empty)
|
|
131
|
+
retry
|
|
132
|
+
end
|
|
133
|
+
ensure
|
|
134
|
+
@running = false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Clears the screen on exit by default — the Rich UI repaints fullscreen
|
|
138
|
+
# and leaves no useful scrollback to preserve.
|
|
139
|
+
def stop(clear_screen: true)
|
|
140
|
+
@running = false
|
|
141
|
+
@shell.stop
|
|
142
|
+
RubyRich::Terminal.clear if clear_screen
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Max description length for slash-menu display. Skill descriptions can be
|
|
146
|
+
# hundreds of chars; RubyRich Composer renders each command as a single line
|
|
147
|
+
# and long lines wrap or clip unpredictably. Truncating at registration is
|
|
148
|
+
# simpler and more reliable than patching the gem's render_command.
|
|
149
|
+
SKILL_DESC_MAX = 50
|
|
150
|
+
|
|
151
|
+
def set_skill_loader(skill_loader, agent_profile = nil)
|
|
152
|
+
return unless skill_loader
|
|
153
|
+
|
|
154
|
+
skills = skill_loader.user_invocable_skills
|
|
155
|
+
skills = skills.select { |s| s.allowed_for_agent?(agent_profile.name) } if agent_profile
|
|
156
|
+
|
|
157
|
+
skills.each do |skill|
|
|
158
|
+
desc = skill.description.to_s
|
|
159
|
+
desc = desc.length > SKILL_DESC_MAX ? "#{desc[0, SKILL_DESC_MAX - 1]}…" : desc
|
|
160
|
+
@shell.composer.register_command(
|
|
161
|
+
name: skill.slash_command,
|
|
162
|
+
description: desc
|
|
163
|
+
# No handler — text falls through to submit callback → CLI → agent
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def set_agent(_agent, _agent_profile = nil); end
|
|
169
|
+
|
|
170
|
+
def on_input(&block)
|
|
171
|
+
@input_callback = block
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def on_interrupt(&block)
|
|
175
|
+
@interrupt_callback = block
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def on_mode_toggle(&block)
|
|
179
|
+
@mode_toggle_callback = block
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def on_model_switch(&block)
|
|
183
|
+
@model_switch_callback = block
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def on_time_machine(&block)
|
|
187
|
+
@time_machine_callback = block
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def append_output(content)
|
|
191
|
+
return if content.nil?
|
|
192
|
+
|
|
193
|
+
@shell.add_markdown(content.to_s)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def log(message, level: :info)
|
|
197
|
+
case level.to_sym
|
|
198
|
+
when :error then show_error(message)
|
|
199
|
+
when :warning, :warn then show_warning(message)
|
|
200
|
+
when :debug then nil
|
|
201
|
+
else show_info(message)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def show_assistant_message(content, files:)
|
|
206
|
+
thinking_text, clean_text = extract_thinking_and_content(content)
|
|
207
|
+
unless thinking_text.to_s.strip.empty?
|
|
208
|
+
# Show live thinking with spinner + timer in fixed area
|
|
209
|
+
@shell.thinking_live.start_thinking
|
|
210
|
+
stream_thinking_live(thinking_text.strip)
|
|
211
|
+
elapsed = @shell.thinking_live.start_time
|
|
212
|
+
elapsed = elapsed ? (Time.now - elapsed).round(1) : 0.0
|
|
213
|
+
@shell.thinking_live.finish_thinking
|
|
214
|
+
# Also add collapsed thinking block for reference (Ctrl+O to expand)
|
|
215
|
+
@shell.add_thinking(thinking_text.strip, status: "#{elapsed}s", collapsed: true)
|
|
216
|
+
# Hide the live area so transcript expands back to full height
|
|
217
|
+
@shell.thinking_live.idle!
|
|
218
|
+
end
|
|
219
|
+
text = clean_text
|
|
220
|
+
stream_thread = nil
|
|
221
|
+
stream_thread = add_conversation_markdown(text) unless text.nil? || text.strip.empty?
|
|
222
|
+
if stream_thread.is_a?(Thread)
|
|
223
|
+
add_file_summary_after(stream_thread, files)
|
|
224
|
+
else
|
|
225
|
+
add_file_summary(files)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Stream thinking text into the live area character by character.
|
|
230
|
+
# After streaming completes, the finished state shows for ~1 second.
|
|
231
|
+
def stream_thinking_live(text, chunk_size: 3, delay: 0.008)
|
|
232
|
+
text.each_char.each_slice(chunk_size) do |chars|
|
|
233
|
+
@shell.thinking_live.append_text(chars.join)
|
|
234
|
+
sleep(delay)
|
|
235
|
+
end
|
|
236
|
+
# Brief pause to show "Thinking done" before next content renders
|
|
237
|
+
sleep(0.6)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def show_tool_call(name, args)
|
|
241
|
+
id = @shell.start_tool_call(name: name.to_s, input: format_args(args), status: :running)
|
|
242
|
+
if id
|
|
243
|
+
@tracker.register_tool(id)
|
|
244
|
+
track_tool_activity(id, tool_activity_label(name, args), :running)
|
|
245
|
+
@work_label = "#{name}…"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def show_tool_result(result)
|
|
250
|
+
if (id = @tracker.pop_tool_id)
|
|
251
|
+
@shell.finish_tool_call(id, status: :done, output: format_tool_output(result.to_s, :done))
|
|
252
|
+
update_tool_activity(id, :done)
|
|
253
|
+
else
|
|
254
|
+
@shell.add_markdown(result.to_s)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def show_tool_stdout(lines)
|
|
259
|
+
@stdout_lines.concat(Array(lines).map(&:to_s))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def show_tool_error(error)
|
|
263
|
+
message = error.is_a?(Exception) ? error.message : error.to_s
|
|
264
|
+
if (id = @tracker.pop_tool_id)
|
|
265
|
+
@shell.finish_tool_call(id, status: :error, output: format_tool_output(message, :error))
|
|
266
|
+
update_tool_activity(id, :error)
|
|
267
|
+
else
|
|
268
|
+
@shell.add_error_message(message)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def show_tool_args(formatted_args)
|
|
273
|
+
append_output("Args: #{formatted_args}")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def show_file_write_preview(path, is_new_file:)
|
|
277
|
+
append_output("#{is_new_file ? "Creating" : "Modifying"} file: #{path || "(unknown)"}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def show_file_edit_preview(path)
|
|
281
|
+
append_output("Editing file: #{path || "(unknown)"}")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def show_file_error(error_message)
|
|
285
|
+
show_error(error_message)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def show_shell_preview(command)
|
|
289
|
+
append_output("$ #{command}")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def show_diff(old_content, new_content, max_lines: 50)
|
|
293
|
+
require "diffy"
|
|
294
|
+
diff = Diffy::Diff.new(old_content, new_content, context: 3).to_s
|
|
295
|
+
stats = parse_diff_stats(diff)
|
|
296
|
+
header = "─── Diff#{stats}#{" " unless stats.empty?}───"
|
|
297
|
+
lines = diff.lines
|
|
298
|
+
visible = lines.take(max_lines).join
|
|
299
|
+
hidden = lines.length - max_lines
|
|
300
|
+
trailer = hidden.positive? ? "\n... (#{hidden} more lines hidden)" : ""
|
|
301
|
+
@shell.add_diff(content: "#{header}\n#{visible}#{trailer}")
|
|
302
|
+
rescue LoadError
|
|
303
|
+
append_output("Old size: #{old_content.bytesize} bytes\nNew size: #{new_content.bytesize} bytes")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def show_token_usage(token_data)
|
|
307
|
+
@shell.show_token_usage(
|
|
308
|
+
input: token_data[:prompt_tokens],
|
|
309
|
+
output: token_data[:completion_tokens],
|
|
310
|
+
total: token_data[:total_tokens],
|
|
311
|
+
cost: token_data[:cost]
|
|
312
|
+
)
|
|
313
|
+
@shell.sidebar.update_context(token_data) if @shell.sidebar
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
|
|
317
|
+
set_idle_status
|
|
318
|
+
return if awaiting_user_feedback || iterations <= 5
|
|
319
|
+
|
|
320
|
+
parts = ["Completed #{iterations} iterations", "cost $#{cost.round(4)}"]
|
|
321
|
+
parts << "#{duration.round(1)}s" if duration
|
|
322
|
+
append_output(parts.join(" · "))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def show_info(message, prefix_newline: true)
|
|
326
|
+
_ = prefix_newline
|
|
327
|
+
@shell.add_system_message(message.to_s)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def show_warning(message)
|
|
331
|
+
@shell.add_system_message("Warning: #{message}")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def show_error(message)
|
|
335
|
+
@shell.add_error_message(message.to_s)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def show_success(message)
|
|
339
|
+
@shell.add_system_message("OK: #{message}")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
|
|
343
|
+
_ = prefix_newline
|
|
344
|
+
type = progress_type.to_s
|
|
345
|
+
if phase.to_s == "done"
|
|
346
|
+
@legacy_progress.delete(type)&.finish(final_message: message)
|
|
347
|
+
return
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
handle = @legacy_progress[type]
|
|
351
|
+
if handle
|
|
352
|
+
handle.update(message: message, metadata: metadata)
|
|
353
|
+
else
|
|
354
|
+
@legacy_progress[type] = start_progress(message: message, style: type == "thinking" ? :primary : :quiet)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def start_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
|
|
359
|
+
_ = quiet_on_fast_finish
|
|
360
|
+
RichUI::ProgressHandleAdapter.new(@shell.start_progress(message || "Working", style: style))
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def with_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
|
|
364
|
+
handle = start_progress(message: message, style: style, quiet_on_fast_finish: quiet_on_fast_finish)
|
|
365
|
+
begin
|
|
366
|
+
yield handle
|
|
367
|
+
ensure
|
|
368
|
+
handle.finish
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
|
|
373
|
+
_ = cost_source
|
|
374
|
+
@latest_latency = nil
|
|
375
|
+
if latency.is_a?(Hash)
|
|
376
|
+
ms = latency[:ttft_ms] || latency[:duration_ms]
|
|
377
|
+
@latest_latency = ms ? "#{(ms / 1000.0).round(1)}s" : nil
|
|
378
|
+
end
|
|
379
|
+
@tasks_count = tasks if tasks
|
|
380
|
+
@total_cost = cost if cost
|
|
381
|
+
@status = status if status
|
|
382
|
+
@shell.update_status(session_status)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def update_todos(todos)
|
|
386
|
+
@todo_items = Array(todos).map { |todo| normalize_todo(todo) }
|
|
387
|
+
@explicit_todo_cycle = true
|
|
388
|
+
refresh_sidebar_tasks
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def set_working_status
|
|
392
|
+
@turn_active = true
|
|
393
|
+
@work_label ||= "working…"
|
|
394
|
+
update_sessionbar(status: "working")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def set_idle_status
|
|
398
|
+
@turn_active = false
|
|
399
|
+
@work_label = nil
|
|
400
|
+
update_sessionbar(status: "idle")
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def request_confirmation(message, default: true)
|
|
404
|
+
tool_name, params = ViewRenderer.parse_tool_info(message)
|
|
405
|
+
risk = ViewRenderer.tool_risk_level(tool_name)
|
|
406
|
+
category = ViewRenderer.tool_category(tool_name)
|
|
407
|
+
|
|
408
|
+
fingerprint = ViewRenderer.build_fingerprint(tool_name, params)
|
|
409
|
+
return true if @always_allow_fingerprints.include?(fingerprint)
|
|
410
|
+
|
|
411
|
+
show_info(message)
|
|
412
|
+
dialog = RichUI::ApprovalDialog.new(
|
|
413
|
+
tool_name: tool_name || "unknown",
|
|
414
|
+
message: message,
|
|
415
|
+
params: params,
|
|
416
|
+
risk: risk,
|
|
417
|
+
category: category
|
|
418
|
+
)
|
|
419
|
+
result = show_blocking_dialog(dialog)
|
|
420
|
+
|
|
421
|
+
case result
|
|
422
|
+
when :approve
|
|
423
|
+
true
|
|
424
|
+
when :always_allow
|
|
425
|
+
@always_allow_fingerprints.add(fingerprint)
|
|
426
|
+
true
|
|
427
|
+
when :deny
|
|
428
|
+
false
|
|
429
|
+
else
|
|
430
|
+
default
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def show_model_switch_dialog
|
|
435
|
+
models = @available_models || [@config[:model] || "unknown"]
|
|
436
|
+
choices = models.each_with_index.map do |name, i|
|
|
437
|
+
current = name == @config[:model]
|
|
438
|
+
{ label: "#{current ? "● " : " "}#{name}", value: name }
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
selected = show_menu_dialog(
|
|
442
|
+
title: "Switch Model",
|
|
443
|
+
choices: choices,
|
|
444
|
+
selected_index: models.index(@config[:model]) || 0
|
|
445
|
+
)
|
|
446
|
+
return nil if selected.nil?
|
|
447
|
+
|
|
448
|
+
persist_choice = show_menu_dialog(
|
|
449
|
+
title: "Apply Scope",
|
|
450
|
+
choices: [
|
|
451
|
+
{ label: "This session only", value: false },
|
|
452
|
+
{ label: "Save permanently", value: true }
|
|
453
|
+
],
|
|
454
|
+
selected_index: 0
|
|
455
|
+
)
|
|
456
|
+
return nil if persist_choice.nil?
|
|
457
|
+
|
|
458
|
+
{ model: selected, persist: persist_choice }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def clear_input
|
|
462
|
+
@shell.composer.editor.clear
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def set_input_tips(message, type: :info)
|
|
466
|
+
update_sessionbar(status: "#{type}: #{message}")
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def show_help
|
|
470
|
+
@shell.add_markdown(<<~HELP)
|
|
471
|
+
Commands:
|
|
472
|
+
/clear - Clear output and restart session
|
|
473
|
+
/exit - Exit application
|
|
474
|
+
|
|
475
|
+
Input:
|
|
476
|
+
Shift+Enter - New line
|
|
477
|
+
Up/Down - History navigation
|
|
478
|
+
Ctrl+C - Interrupt current task
|
|
479
|
+
HELP
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def show_config_modal(current_config, test_callback: nil)
|
|
483
|
+
return nil unless @running
|
|
484
|
+
|
|
485
|
+
loop do
|
|
486
|
+
choices = config_menu_choices(current_config)
|
|
487
|
+
result = show_menu_dialog(
|
|
488
|
+
title: "Model Configuration",
|
|
489
|
+
choices: choices,
|
|
490
|
+
selected_index: config_initial_selection(choices)
|
|
491
|
+
)
|
|
492
|
+
return nil if result.nil?
|
|
493
|
+
|
|
494
|
+
case result[:action]
|
|
495
|
+
when :switch
|
|
496
|
+
return result
|
|
497
|
+
when :add
|
|
498
|
+
new_model = show_model_edit_form(nil, test_callback: test_callback)
|
|
499
|
+
if new_model
|
|
500
|
+
anthropic_format = new_model[:provider] == "anthropic"
|
|
501
|
+
current_config.add_model(
|
|
502
|
+
model: new_model[:model],
|
|
503
|
+
api_key: new_model[:api_key],
|
|
504
|
+
base_url: new_model[:base_url],
|
|
505
|
+
anthropic_format: anthropic_format
|
|
506
|
+
)
|
|
507
|
+
new_id = current_config.models.last["id"]
|
|
508
|
+
return { action: :add, model_id: new_id }
|
|
509
|
+
end
|
|
510
|
+
when :edit
|
|
511
|
+
current_model = current_config.current_model
|
|
512
|
+
edited = show_model_edit_form(current_model, test_callback: test_callback)
|
|
513
|
+
if edited
|
|
514
|
+
current_model["api_key"] = edited[:api_key]
|
|
515
|
+
current_model["model"] = edited[:model]
|
|
516
|
+
current_model["base_url"] = edited[:base_url]
|
|
517
|
+
return { action: :edit, model_id: current_model["id"] }
|
|
518
|
+
end
|
|
519
|
+
when :delete
|
|
520
|
+
if current_config.models.length <= 1
|
|
521
|
+
show_warning("Cannot delete the last model.")
|
|
522
|
+
next
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
current_config.remove_model(current_config.current_model_index)
|
|
526
|
+
new_current = current_config.current_model
|
|
527
|
+
return { action: :delete, model_id: new_current && new_current["id"] }
|
|
528
|
+
when :close
|
|
529
|
+
return nil
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def track_tool_activity(id, label, status)
|
|
535
|
+
activity = { id: id, label: label.to_s, status: status }
|
|
536
|
+
@tool_activities << activity
|
|
537
|
+
@tool_activities.shift while @tool_activities.length > 12
|
|
538
|
+
@tool_activity_by_id[id] = activity
|
|
539
|
+
refresh_sidebar_tasks
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def update_tool_activity(id, status)
|
|
543
|
+
activity = @tool_activity_by_id[id]
|
|
544
|
+
return unless activity
|
|
545
|
+
|
|
546
|
+
activity[:status] = status
|
|
547
|
+
refresh_sidebar_tasks
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def refresh_sidebar_tasks
|
|
551
|
+
@shell.update_tasks(@todo_items)
|
|
552
|
+
@shell.sidebar.update_work_activities(@tool_activities)
|
|
553
|
+
@shell.sidebar.update_work_stats(@tasks_count, @total_cost)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def reset_task_sidebar_tracking
|
|
557
|
+
@todo_items = []
|
|
558
|
+
@explicit_todo_cycle = false
|
|
559
|
+
@tool_activities = []
|
|
560
|
+
@tool_activity_by_id = {}
|
|
561
|
+
refresh_sidebar_tasks
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def add_conversation_markdown(text)
|
|
565
|
+
markdown = normalize_markdown_for_terminal(text)
|
|
566
|
+
return @shell.add_markdown(markdown) unless stream_markdown?(markdown)
|
|
567
|
+
|
|
568
|
+
id = @shell.add_markdown("", streaming: true)
|
|
569
|
+
return @shell.add_markdown(markdown) unless id
|
|
570
|
+
|
|
571
|
+
thread = Thread.new do
|
|
572
|
+
Thread.current.report_on_exception = false
|
|
573
|
+
begin
|
|
574
|
+
markdown.each_char.each_slice(STREAMING_MARKDOWN_CHUNK_SIZE) do |chars|
|
|
575
|
+
@shell.append_to_message(id, chars.join)
|
|
576
|
+
sleep(STREAMING_MARKDOWN_DELAY)
|
|
577
|
+
end
|
|
578
|
+
rescue => e
|
|
579
|
+
Clacky::Logger.warn("[stream_markdown] chunk append failed: #{e.class}: #{e.message}")
|
|
580
|
+
# Fallback: replace the partial stream with the full markdown
|
|
581
|
+
begin
|
|
582
|
+
@shell.replace_message(id, markdown)
|
|
583
|
+
rescue
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
@stream_threads << thread
|
|
589
|
+
@stream_threads.reject! { |item| !item.alive? }
|
|
590
|
+
thread
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def stream_markdown?(text)
|
|
594
|
+
text.length >= STREAMING_MARKDOWN_THRESHOLD
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def add_file_summary_after(stream_thread, files)
|
|
598
|
+
return if Array(files).empty?
|
|
599
|
+
|
|
600
|
+
thread = Thread.new do
|
|
601
|
+
Thread.current.report_on_exception = false
|
|
602
|
+
begin
|
|
603
|
+
stream_thread.join
|
|
604
|
+
add_file_summary(files)
|
|
605
|
+
rescue => e
|
|
606
|
+
Clacky::Logger.warn("[file_summary] thread failed: #{e.class}: #{e.message}")
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
@stream_threads << thread
|
|
610
|
+
@stream_threads.reject! { |item| !item.alive? }
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def add_plain_block(text)
|
|
614
|
+
@shell.transcript.add_block(:markdown, expand_ansi_multiline_spans(text), metadata: { plain: true })
|
|
615
|
+
@shell.viewport.scroll_to_bottom
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def add_file_summary(files)
|
|
619
|
+
items = Array(files).filter_map do |file|
|
|
620
|
+
path = file[:path] || file["path"] || file[:name] || file["name"]
|
|
621
|
+
next if path.to_s.strip.empty?
|
|
622
|
+
|
|
623
|
+
"- `#{path}`"
|
|
624
|
+
end
|
|
625
|
+
return if items.empty?
|
|
626
|
+
|
|
627
|
+
@shell.add_markdown("**Files**\n\n#{items.join("\n")}")
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def wire_shell_callbacks
|
|
631
|
+
@shell.on_submit do |text, attachments|
|
|
632
|
+
reset_task_sidebar_tracking
|
|
633
|
+
@ctrl_c_warning = nil
|
|
634
|
+
files = Array(attachments).map { |attachment| attachment.respond_to?(:to_h) ? attachment.to_h : attachment }
|
|
635
|
+
@shell.add_user_message(text)
|
|
636
|
+
run_callback_async { @input_callback&.call(text, files, display: text) }
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
@shell.on_interrupt do |input_was_empty:|
|
|
640
|
+
@interrupt_callback&.call(input_was_empty: input_was_empty)
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
@shell.on_mode_toggle do |mode|
|
|
644
|
+
@config[:mode] = mode.to_s
|
|
645
|
+
@mode_toggle_callback&.call(mode.to_s)
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
@shell.on_esc do
|
|
649
|
+
handle_esc
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
@shell.callbacks[:clear_ctrlc] = -> { @ctrl_c_warning = nil }
|
|
653
|
+
|
|
654
|
+
@shell.callbacks[:model_switch] = -> {
|
|
655
|
+
Thread.new do
|
|
656
|
+
result = show_model_switch_dialog
|
|
657
|
+
if result
|
|
658
|
+
@config[:model] = result[:model]
|
|
659
|
+
@latest_latency = nil
|
|
660
|
+
@shell.update_status(session_status)
|
|
661
|
+
@model_switch_callback&.call(result[:model], result[:persist])
|
|
662
|
+
end
|
|
663
|
+
rescue => e
|
|
664
|
+
$stderr.puts "[model_switch] #{e.class}: #{e.message}"
|
|
665
|
+
end
|
|
666
|
+
}
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Esc cancellation stack (tui_design.md §2.8).
|
|
670
|
+
# Called from Composer's @on_escape callback (before native escape).
|
|
671
|
+
# Returns true when handled (skip native), false to fall through.
|
|
672
|
+
def handle_esc
|
|
673
|
+
# Layer 1: Close any open dialog or slash menu
|
|
674
|
+
if @shell.layout.dialog
|
|
675
|
+
dialog = @shell.layout.dialog
|
|
676
|
+
dialog.finish(nil) if dialog.respond_to?(:finish)
|
|
677
|
+
@shell.layout.hide_dialog
|
|
678
|
+
return true
|
|
679
|
+
end
|
|
680
|
+
if @shell.composer.menu_open?
|
|
681
|
+
@shell.composer.send(:close_menu)
|
|
682
|
+
return true
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Layer 2: Interrupt running turn
|
|
686
|
+
if @turn_active
|
|
687
|
+
@interrupt_callback&.call(input_was_empty: false)
|
|
688
|
+
return true
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Layer 3: Discard queued draft (future; return true when done)
|
|
692
|
+
|
|
693
|
+
# Layer 4+5: Fall through to Composer's native escape —
|
|
694
|
+
# editor with text → clear, empty editor → focus/no-op
|
|
695
|
+
false
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def session_status
|
|
699
|
+
[
|
|
700
|
+
@status || "idle",
|
|
701
|
+
@config[:mode],
|
|
702
|
+
@config[:model],
|
|
703
|
+
"#{@tasks_count} tasks",
|
|
704
|
+
"$#{@total_cost.round(4)}"
|
|
705
|
+
].compact.join(" · ")
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def run_callback_async(&block)
|
|
709
|
+
@callback_threads.reject! { |thread| !thread.alive? }
|
|
710
|
+
@callback_threads << Thread.new do
|
|
711
|
+
block.call
|
|
712
|
+
rescue StandardError => e
|
|
713
|
+
show_error(e.message)
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def render_welcome_banner
|
|
718
|
+
@welcome_banner.render_full(
|
|
719
|
+
working_dir: @config[:working_dir].to_s,
|
|
720
|
+
mode: @config[:mode].to_s,
|
|
721
|
+
width: terminal_width
|
|
722
|
+
)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def terminal_width
|
|
726
|
+
if defined?(TTY::Screen)
|
|
727
|
+
TTY::Screen.width
|
|
728
|
+
else
|
|
729
|
+
120
|
|
730
|
+
end
|
|
731
|
+
rescue StandardError
|
|
732
|
+
120
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def show_menu_dialog(title:, choices:, selected_index: nil)
|
|
736
|
+
selected_index ||= config_initial_selection(choices)
|
|
737
|
+
dialog = RichUI::ConfigMenuDialog.new(title: title, choices: choices, selected_index: selected_index)
|
|
738
|
+
|
|
739
|
+
dialog.key(:up, 1_000) { dialog.move_up; true }
|
|
740
|
+
dialog.key(:down, 1_000) { dialog.move_down; true }
|
|
741
|
+
dialog.key(:string, 1_000) do |event, _live|
|
|
742
|
+
case event[:value]
|
|
743
|
+
when "k" then dialog.move_up
|
|
744
|
+
when "j" then dialog.move_down
|
|
745
|
+
when "q" then dialog.finish(nil)
|
|
746
|
+
end
|
|
747
|
+
true
|
|
748
|
+
end
|
|
749
|
+
dialog.key(:enter, 1_000) do
|
|
750
|
+
selected = dialog.selected_choice
|
|
751
|
+
dialog.finish(selected && !selected[:disabled] ? selected[:value] : nil)
|
|
752
|
+
end
|
|
753
|
+
dialog.key(:escape, 1_000) { dialog.finish(nil) }
|
|
754
|
+
|
|
755
|
+
show_blocking_dialog(dialog)
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def show_form_dialog(title:, fields:)
|
|
759
|
+
dialog = RichUI::FormDialog.new(title: title, fields: fields)
|
|
760
|
+
dialog.key(:escape, 1_000) { dialog.finish(nil) }
|
|
761
|
+
show_blocking_dialog(dialog)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def show_blocking_dialog(dialog)
|
|
765
|
+
@shell.layout.show_dialog(dialog)
|
|
766
|
+
dialog.wait
|
|
767
|
+
ensure
|
|
768
|
+
@shell.layout.hide_dialog if @shell.layout.dialog.equal?(dialog)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def show_model_edit_form(model, test_callback: nil)
|
|
772
|
+
is_new = model.nil?
|
|
773
|
+
model ||= {}
|
|
774
|
+
selected_provider = nil
|
|
775
|
+
|
|
776
|
+
if is_new
|
|
777
|
+
selected_provider = show_provider_selection
|
|
778
|
+
return nil if selected_provider.nil?
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
provider_preset = selected_provider && selected_provider != "custom" ? Clacky::Providers.get(selected_provider) : nil
|
|
782
|
+
default_model = provider_preset ? provider_preset["default_model"] : model["model"]
|
|
783
|
+
default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
|
|
784
|
+
masked_key = mask_api_key(model["api_key"])
|
|
785
|
+
|
|
786
|
+
fields = [
|
|
787
|
+
{
|
|
788
|
+
name: :api_key,
|
|
789
|
+
label: "API Key #{is_new ? "" : "(current: #{masked_key})"}:",
|
|
790
|
+
default: "",
|
|
791
|
+
mask: true,
|
|
792
|
+
placeholder: is_new ? "required" : "leave blank to keep current"
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: :model,
|
|
796
|
+
label: "Model #{is_new && default_model ? "(default: #{default_model})" : (is_new ? "" : "(current: #{model["model"]})")}:",
|
|
797
|
+
default: default_model || "",
|
|
798
|
+
placeholder: "model name"
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
name: :base_url,
|
|
802
|
+
label: "Base URL #{is_new && default_base_url ? "(default: #{default_base_url})" : (is_new ? "" : "(current: #{model["base_url"]})")}:",
|
|
803
|
+
default: default_base_url || "",
|
|
804
|
+
placeholder: "https://..."
|
|
805
|
+
}
|
|
806
|
+
]
|
|
807
|
+
|
|
808
|
+
title = if is_new && selected_provider && selected_provider != "custom"
|
|
809
|
+
provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
|
|
810
|
+
"Add #{provider_name} Model"
|
|
811
|
+
elsif is_new
|
|
812
|
+
"Add Custom Model"
|
|
813
|
+
else
|
|
814
|
+
"Edit Model"
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
loop do
|
|
818
|
+
result = show_form_dialog(title: title, fields: fields)
|
|
819
|
+
return nil if result.nil?
|
|
820
|
+
|
|
821
|
+
values = merge_model_form_values(
|
|
822
|
+
result,
|
|
823
|
+
model: model,
|
|
824
|
+
default_model: default_model,
|
|
825
|
+
default_base_url: default_base_url
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
validation = validate_model_form(values, is_new: is_new, existing_model: model, test_callback: test_callback)
|
|
829
|
+
if validation[:success]
|
|
830
|
+
return values.merge(provider: selected_provider)
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
show_warning(validation[:error])
|
|
834
|
+
fields.each { |field| field[:default] = result[field[:name]].to_s }
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def show_provider_selection
|
|
839
|
+
choices = Clacky::Providers.list.map { |id, name| { label: name, value: id } }
|
|
840
|
+
choices << { label: "─" * 40, disabled: true }
|
|
841
|
+
choices << { label: "Custom (manual configuration)", value: "custom" }
|
|
842
|
+
show_menu_dialog(title: "Select Provider", choices: choices, selected_index: 0)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
private :track_tool_activity,
|
|
846
|
+
:update_tool_activity,
|
|
847
|
+
:refresh_sidebar_tasks,
|
|
848
|
+
:reset_task_sidebar_tracking,
|
|
849
|
+
:add_conversation_markdown,
|
|
850
|
+
:stream_markdown?,
|
|
851
|
+
:add_file_summary_after,
|
|
852
|
+
:add_plain_block,
|
|
853
|
+
:stream_thinking_live,
|
|
854
|
+
:add_file_summary,
|
|
855
|
+
:wire_shell_callbacks,
|
|
856
|
+
:session_status,
|
|
857
|
+
:run_callback_async,
|
|
858
|
+
:render_welcome_banner,
|
|
859
|
+
:terminal_width,
|
|
860
|
+
:show_menu_dialog,
|
|
861
|
+
:show_form_dialog,
|
|
862
|
+
:show_blocking_dialog,
|
|
863
|
+
:show_model_edit_form,
|
|
864
|
+
:show_provider_selection,
|
|
865
|
+
:show_model_switch_dialog
|
|
866
|
+
|
|
867
|
+
end
|
|
868
|
+
end
|