openclacky 1.5.13 → 1.5.15

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -1
@@ -17,7 +17,7 @@ module Clacky
17
17
  #
18
18
  # class MyDashboardExt < Clacky::ApiExtension
19
19
  # get "/summary" do
20
- # json(sessions: session_manager.list.size)
20
+ # json(sessions: session_manager.all_sessions.size)
21
21
  # end
22
22
  # end
23
23
  #
@@ -272,9 +272,10 @@ module Clacky
272
272
  # controls the user-facing bubble shown in place of the raw prompt.
273
273
  # When `project_id` is given, the project's working_dir is inherited
274
274
  # (unless an explicit working_dir overrides it) and the session is
275
- # associated with the project.
275
+ # associated with the project. `model_id` selects a configured model by
276
+ # its stable id; omitting it keeps the current default model.
276
277
  def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
277
- source: :manual, display_message: nil, project_id: nil)
278
+ source: :manual, display_message: nil, project_id: nil, model_id: nil)
278
279
  error!("server not ready", status: 503) unless @http_server
279
280
 
280
281
  src = source.to_s
@@ -290,6 +291,14 @@ module Clacky
290
291
  project = project_id ? project_manager&.find(project_id) : nil
291
292
  error!("Project not found", status: 404) if project_id && project.nil?
292
293
 
294
+ if model_id
295
+ model_id = model_id.to_s.strip
296
+ model_id = nil if model_id.empty?
297
+ end
298
+ if model_id && !agent_config.models.any? { |model| model["id"] == model_id }
299
+ error!("Model not found in configuration", status: 400)
300
+ end
301
+
293
302
  working_dir = File.expand_path(project[:working_dir]) if working_dir.nil? && project && project[:working_dir].to_s.strip != ""
294
303
 
295
304
  session_id = @http_server.send(
@@ -297,7 +306,8 @@ module Clacky
297
306
  name: name,
298
307
  working_dir: working_dir,
299
308
  profile: profile,
300
- source: source
309
+ source: source,
310
+ model_id: model_id
301
311
  )
302
312
 
303
313
  if project_id
@@ -309,9 +319,11 @@ module Clacky
309
319
  end
310
320
  end
311
321
 
312
- submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
322
+ # Announce creation only after identity/placement metadata is persisted,
323
+ # and before submit_task can emit ordinary running-state updates.
324
+ @http_server.send(:broadcast_session_update, session_id, created: true)
313
325
 
314
- @http_server.send(:broadcast_session_update, session_id)
326
+ submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
315
327
 
316
328
  session_id
317
329
  end
@@ -22,7 +22,7 @@ module Clacky
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
23
23
  API_KEYS = %w[id handler].freeze
24
24
  SKILL_KEYS = %w[id dir protected].freeze
25
- AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar hidden tools].freeze
25
+ AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills disabled_skills avatar hidden tools].freeze
26
26
  CHANNEL_KEYS = %w[id platform adapter].freeze
27
27
  PATCH_KEYS = %w[target file fingerprint on_mismatch].freeze
28
28
  HOOK_KEYS = %w[event file].freeze
@@ -41,8 +41,10 @@ module Clacky
41
41
  emit("tool_call", name: name, args: args_data)
42
42
  end
43
43
 
44
- def show_tool_result(result)
45
- emit("tool_result", result: result)
44
+ def show_tool_result(result, ui: nil)
45
+ data = { result: result }
46
+ data[:ui] = ui if ui
47
+ emit("tool_result", **data)
46
48
  end
47
49
 
48
50
  def show_tool_error(error)
@@ -308,38 +308,69 @@ module Clacky
308
308
  end
309
309
 
310
310
  ext = mime_type.split(";").first.split("/").last.then { |e| e == "mpeg" ? "mp3" : e }
311
- filename = "chunk.#{ext}"
312
- audio_data = Base64.decode64(audio_base64)
313
- boundary = "----FormBoundary#{SecureRandom.hex(8)}"
314
- # A multipart body is a byte stream: build it in binary so UTF-8 text
315
- # parts (e.g. a non-ASCII vocabulary prompt) don't clash with the
316
- # ASCII-8BIT audio bytes.
317
- body = "".b
318
- body << "--#{boundary}\r\n".b
319
- body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n".b
320
- body << "Content-Type: #{mime_type.split(';').first}\r\n\r\n".b
321
- body << audio_data.b
322
- body << "\r\n--#{boundary}\r\n".b
323
- body << "Content-Disposition: form-data; name=\"model\"\r\n\r\n".b
324
- body << @model.to_s.b
325
- unless prompt.to_s.strip.empty?
326
- body << "\r\n--#{boundary}\r\n".b
327
- body << "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n".b
328
- body << prompt.to_s.strip.b
329
- end
330
- body << "\r\n--#{boundary}--\r\n".b
331
311
 
332
- begin
333
- response = stt_connection.post("audio/transcriptions") do |req|
334
- req.headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
335
- req.headers["Authorization"] = "Bearer #{@api_key}"
336
- req.body = body
312
+ # OpenRouter caps multipart uploads at 25 MB and redirects larger files
313
+ # to base64 JSON via input_audio. Plain OpenAI-compatible providers keep
314
+ # multipart (incl. the openclacky gateway, which only parses multipart
315
+ # on its /audio/transcriptions today — enable the JSON flavor there once
316
+ # the server supports it).
317
+ if provider_id == Clacky::Providers::OPENROUTER_ID
318
+ ext = "m4a" if ext == "mp4" # OpenRouter wants the audio format, not the container
319
+ payload = {
320
+ model: @model,
321
+ input_audio: {
322
+ data: audio_base64, # raw base64 bytes, NOT a data URI
323
+ format: ext
324
+ }
325
+ }
326
+ payload[:prompt] = prompt.to_s.strip unless prompt.to_s.strip.empty?
327
+
328
+ begin
329
+ response = stt_json_connection.post("audio/transcriptions") do |req|
330
+ req.headers["Content-Type"] = "application/json"
331
+ req.headers["Authorization"] = "Bearer #{@api_key}"
332
+ req.body = JSON.generate(payload)
333
+ end
334
+ rescue Faraday::Error => e
335
+ return transcription_error_response(
336
+ error: "HTTP request failed: #{e.message}",
337
+ error_type: "network_error", provider: provider_id
338
+ )
339
+ end
340
+ else
341
+ filename = "chunk.#{ext}"
342
+ audio_data = Base64.decode64(audio_base64)
343
+ boundary = "----FormBoundary#{SecureRandom.hex(8)}"
344
+ # A multipart body is a byte stream: build it in binary so UTF-8 text
345
+ # parts (e.g. a non-ASCII vocabulary prompt) don't clash with the
346
+ # ASCII-8BIT audio bytes.
347
+ body = "".b
348
+ body << "--#{boundary}\r\n".b
349
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n".b
350
+ body << "Content-Type: #{mime_type.split(';').first}\r\n\r\n".b
351
+ body << audio_data.b
352
+ body << "\r\n--#{boundary}\r\n".b
353
+ body << "Content-Disposition: form-data; name=\"model\"\r\n\r\n".b
354
+ body << @model.to_s.b
355
+ unless prompt.to_s.strip.empty?
356
+ body << "\r\n--#{boundary}\r\n".b
357
+ body << "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n".b
358
+ body << prompt.to_s.strip.b
359
+ end
360
+ body << "\r\n--#{boundary}--\r\n".b
361
+
362
+ begin
363
+ response = stt_connection.post("audio/transcriptions") do |req|
364
+ req.headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
365
+ req.headers["Authorization"] = "Bearer #{@api_key}"
366
+ req.body = body
367
+ end
368
+ rescue Faraday::Error => e
369
+ return transcription_error_response(
370
+ error: "HTTP request failed: #{e.message}",
371
+ error_type: "network_error", provider: provider_id
372
+ )
337
373
  end
338
- rescue Faraday::Error => e
339
- return transcription_error_response(
340
- error: "HTTP request failed: #{e.message}",
341
- error_type: "network_error", provider: provider_id
342
- )
343
374
  end
344
375
 
345
376
  unless response.success?
@@ -479,6 +510,15 @@ module Clacky
479
510
  end
480
511
  end
481
512
 
513
+ # STT JSON body carries a large base64 payload; upstream may need longer
514
+ # to decode/transcribe it than the 30s multipart path.
515
+ private def stt_json_connection
516
+ Faraday.new(url: normalized_base_url) do |f|
517
+ f.options.timeout = 120
518
+ f.options.open_timeout = 10
519
+ end
520
+ end
521
+
482
522
  private def vu_connection
483
523
  Faraday.new(url: normalized_base_url) do |f|
484
524
  f.options.timeout = 60
@@ -26,6 +26,8 @@ module Clacky
26
26
  # the same Clacky proxy on a different endpoint. The *model prefix* is
27
27
  # the source of truth for which upstream format the proxy expects:
28
28
  #
29
+ # abs-gpt-* → OpenAI-compatible (POST /chat/completions) — GPT models
30
+ # on Bedrock use the OpenAI endpoint, not Converse
29
31
  # abs-* → Bedrock Converse (POST /model/{id}/converse)
30
32
  # dsk-* → OpenAI-compatible (POST /chat/completions)
31
33
  # or-* → OpenAI-compatible (POST /chat/completions)
@@ -38,7 +40,10 @@ module Clacky
38
40
  # will route correctly without touching this file.
39
41
  def self.bedrock_api_key?(api_key, model)
40
42
  return true if api_key.to_s.start_with?("ABSK")
41
- model.to_s.start_with?("abs-")
43
+ model_str = model.to_s
44
+ # abs-gpt-* is served through Bedrock's OpenAI-compatible endpoint.
45
+ return false if model_str.start_with?("abs-gpt-")
46
+ model_str.start_with?("abs-")
42
47
  end
43
48
 
44
49
  module_function
@@ -217,9 +217,13 @@ module Clacky
217
217
  # unchanged, preserving the provider default for all models.
218
218
  #
219
219
  # Single source of truth for reasoning param mapping — shared with
220
- # MessageFormat::OpenAIResponses (the Responses API accepts the same
221
- # top-level fields).
222
- def self.apply_reasoning_params(body, model, reasoning_effort)
220
+ # MessageFormat::OpenAIResponses.
221
+ #
222
+ # @param responses_api [Boolean] emit the generic effort as the Responses
223
+ # API's nested `reasoning: { effort: ... }`. The top-level
224
+ # `reasoning_effort` field is Chat Completions only — the Responses API
225
+ # rejects it with "Unknown parameter: 'reasoning_effort'".
226
+ def self.apply_reasoning_params(body, model, reasoning_effort, responses_api: false)
223
227
  effort_str = reasoning_effort.to_s
224
228
 
225
229
  if model.to_s.match?(/\Aglm-5[-.]3/i)
@@ -306,9 +310,22 @@ module Clacky
306
310
  when "xhigh" then "max"
307
311
  else effort_str
308
312
  end
309
- body[:reasoning_effort] = effort unless effort.empty?
313
+ assign_effort(body, effort, responses_api)
310
314
  elsif reasoning_effort && !effort_str.empty?
311
- body[:reasoning_effort] = effort_str
315
+ assign_effort(body, effort_str, responses_api)
316
+ end
317
+ end
318
+
319
+ # Write the resolved effort into the field shape the target API expects.
320
+ # Chat Completions takes the top-level reasoning_effort; the Responses API
321
+ # takes reasoning.effort and errors out on the flat field.
322
+ private_class_method def self.assign_effort(body, effort, responses_api)
323
+ return if effort.nil? || effort.empty?
324
+
325
+ if responses_api
326
+ body[:reasoning] = { effort: effort }
327
+ else
328
+ body[:reasoning_effort] = effort
312
329
  end
313
330
  end
314
331
 
@@ -53,7 +53,7 @@ module Clacky
53
53
  body[:tools] = convert_tools_to_responses_format(tools)
54
54
  end
55
55
 
56
- OpenAI.apply_reasoning_params(body, model, reasoning_effort)
56
+ OpenAI.apply_reasoning_params(body, model, reasoning_effort, responses_api: true)
57
57
 
58
58
  body
59
59
  end
@@ -156,8 +156,12 @@ module Clacky
156
156
  { type: "input_text", text: text }
157
157
  when "image_url"
158
158
  if vision_supported
159
- # Responses API uses input_image with image_url sub-field
160
- { type: "input_image", image_url: block[:image_url] }
159
+ # Responses API takes the URL as a plain string — the Chat
160
+ # Completions { url: ... } wrapper fails schema validation with
161
+ # "Invalid 'input': value did not match any expected variant".
162
+ url = block[:image_url]
163
+ url = url[:url] || url["url"] if url.is_a?(Hash)
164
+ { type: "input_image", image_url: url }
161
165
  else
162
166
  { type: "input_text", text: "[Image content removed - current model does not support vision input]" }
163
167
  end
@@ -56,7 +56,7 @@ module Clacky
56
56
  puts_line("[tool] #{display}")
57
57
  end
58
58
 
59
- def show_tool_result(result)
59
+ def show_tool_result(result, ui: nil)
60
60
  text = result.to_s.strip
61
61
  return if text.empty?
62
62
 
@@ -3,7 +3,7 @@
3
3
  - Ask clarifying questions if requirements are unclear.
4
4
  - Break down complex tasks into manageable steps.
5
5
  - **USE TOOLS to create/modify files** — don't just return content.
6
- - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply. Use the raw file path as-is (keep non-ASCII characters and spaces literal); never URL-encode it.
6
+ - When the user asks to send/download a file or you generate one for them, append `[filename](file:///absolute/path/to/file)` at the end of your reply. Use the exact absolute path the write tool returned — don't rewrite or abbreviate it with `~` (keep non-ASCII characters and spaces literal); never URL-encode it.
7
7
 
8
8
  ## Tool Usage Rules
9
9
 
@@ -29,8 +29,12 @@ module Clacky
29
29
  "name" => "OpenClacky",
30
30
  "base_url" => "https://api.openclacky.com",
31
31
  "api" => "bedrock",
32
- "default_model" => "abs-claude-sonnet-5",
32
+ "default_model" => "or-gemini-3-8-flash",
33
33
  "models" => [
34
+ "abs-gpt-6-astra",
35
+ "abs-gpt-5.6-sol",
36
+ "abs-gpt-5.6-terra",
37
+ "abs-gpt-5.6-luna",
34
38
  "abs-claude-fable-5-1",
35
39
  "abs-claude-fable-5",
36
40
  "abs-claude-opus-5",
@@ -41,9 +45,8 @@ module Clacky
41
45
  "abs-claude-sonnet-4-6",
42
46
  "abs-claude-sonnet-4-5",
43
47
  "abs-claude-haiku-4-5",
48
+ "dsk-deepseek-flash",
44
49
  "dsk-deepseek-v4-pro",
45
- "dsk-deepseek-v4-flash",
46
- "dsk-deepseek-v4-flash-vision-exp",
47
50
  "or-gemini-3-1-pro",
48
51
  "or-gemini-3-8-flash",
49
52
  "or-gemini-3-7-flash",
@@ -134,23 +137,28 @@ module Clacky
134
137
  # Default OCR sidecar — used when the primary model is text-only.
135
138
  # Candidates are derived from the provider's vision-capable models;
136
139
  # this just picks the cheap+fast default to surface in "auto" mode.
137
- "default_ocr_model" => "dsk-deepseek-v4-flash-vision-exp",
140
+ "default_ocr_model" => "or-gemini-3-8-flash",
138
141
  # Provider-level default: the Claude family served here is vision-capable.
139
142
  "capabilities" => { "vision" => true }.freeze,
140
143
  # Model-level overrides: DeepSeek models routed through this provider
141
- # are text-only, except the flash-vision-exp variant which accepts
142
- # image input; images uploaded for text-only models must be downgraded
143
- # to disk refs. Gemini 3.1 Pro keeps the provider-default vision=true
144
+ # are text-only except V4.1 Flash, which is natively multimodal;
145
+ # images uploaded for text-only models must be downgraded to disk
146
+ # refs. Gemini 3.1 Pro keeps the provider-default vision=true
144
147
  # (it accepts image/audio/video input natively via OpenRouter).
145
148
  "model_capabilities" => {
146
- "dsk-deepseek-v4-pro" => { "vision" => false }.freeze,
147
- "dsk-deepseek-v4-flash" => { "vision" => false }.freeze,
148
- "dsk-deepseek-v4-flash-vision-exp" => { "vision" => true }.freeze
149
+ "dsk-deepseek-flash" => { "vision" => true }.freeze,
150
+ "dsk-deepseek-v4-pro" => { "vision" => false }.freeze
151
+ }.freeze,
152
+ # Bedrock GPT models (abs-gpt-*) are served through the OpenAI
153
+ # Responses API — their Chat Completions endpoint rejects function
154
+ # tools for reasoning models (GPT-6 Astra).
155
+ "model_api_overrides" => {
156
+ /\Aabs-gpt-/ => "openai-responses"
149
157
  }.freeze,
150
158
  # Per-primary lite pairing: keys are "strong" primary models, values
151
159
  # are the lite sidekick to auto-inject when that primary is the
152
160
  # default. Lite is consumed by some subagents for cheap/fast work;
153
- # weak models (haiku / v4-flash / 3-5-flash) ARE the lite tier
161
+ # weak models (haiku / flash / 3-5-flash) ARE the lite tier
154
162
  # themselves, so they're intentionally not listed here as keys —
155
163
  # no injection happens when the default model is already lite-class.
156
164
  "lite_models" => {
@@ -163,7 +171,9 @@ module Clacky
163
171
  "abs-claude-sonnet-5" => "abs-claude-haiku-4-5",
164
172
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
165
173
  "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
166
- "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
174
+ "abs-gpt-5.6-sol" => "abs-gpt-5.6-luna",
175
+ "abs-gpt-5.6-terra" => "abs-gpt-5.6-luna",
176
+ "dsk-deepseek-v4-pro" => "dsk-deepseek-flash",
167
177
  "or-gemini-3-1-pro" => "or-gemini-3-6-flash"
168
178
  },
169
179
  # Fallback chain: if a model is unavailable, try the next one in order.
@@ -266,14 +276,17 @@ module Clacky
266
276
  # deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
267
277
  # and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
268
278
  "models" => [
279
+ "deepseek-flash",
269
280
  "deepseek-v4-pro",
270
281
  "deepseek-v4-flash",
271
282
  "deepseek-v4-flash-vision-exp",
272
283
  ],
273
284
  # DeepSeek V4 API is text-only across all models, except the
274
- # flash-vision-exp variant which accepts image input.
285
+ # flash-vision-exp variant which accepts image input. V4.1 Flash
286
+ # (deepseek-flash) is natively multimodal.
275
287
  "capabilities" => { "vision" => false }.freeze,
276
288
  "model_capabilities" => {
289
+ "deepseek-flash" => { "vision" => true }.freeze,
277
290
  "deepseek-v4-flash-vision-exp" => { "vision" => true }.freeze
278
291
  }.freeze,
279
292
  "website_url" => "https://platform.deepseek.com/api_keys"
@@ -420,6 +433,7 @@ module Clacky
420
433
  "api" => "openai-completions",
421
434
  "default_model" => "gpt-5.5",
422
435
  "models" => [
436
+ "gpt-6-astra",
423
437
  "gpt-5.6-sol",
424
438
  "gpt-5.6-terra",
425
439
  "gpt-5.6-luna",
@@ -703,6 +717,23 @@ module Clacky
703
717
 
704
718
  }.freeze
705
719
 
720
+ # Canonical provider identifiers. Mirror the PRESETS keys above; keep both
721
+ # in sync when adding or renaming a provider.
722
+ OPENCLACKY_ID = "openclacky"
723
+ OPENROUTER_ID = "openrouter"
724
+ DEEPSEEKV4_ID = "deepseekv4"
725
+ GLM_ID = "glm"
726
+ KIMI_ID = "kimi"
727
+ KIMI_CODING_ID = "kimi-coding"
728
+ MINIMAX_ID = "minimax"
729
+ ANTHROPIC_ID = "anthropic"
730
+ OPENAI_ID = "openai"
731
+ QWEN_ID = "qwen"
732
+ MIMO_ID = "mimo"
733
+ VOLCENGINE_ARK_ID = "volcengine-ark"
734
+ OLLAMA_ID = "ollama"
735
+ ORCAROUTER_ID = "orcarouter"
736
+
706
737
  MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
707
738
 
708
739
  # Per-model maximum output token limits.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Clacky
6
+ module RichUI
7
+ # Extend the composer's attachment area rather than adding a layout sibling.
8
+ # Keep pending entries out of @attachments so submitting/deleting a draft
9
+ # cannot accidentally upload or consume queued messages.
10
+ module ComposerGuidance
11
+ attr_accessor :guidance_lines_provider
12
+
13
+ def desired_height
14
+ super + pending_guidance_lines.size
15
+ end
16
+
17
+ def native_cursor_position
18
+ return nil unless focused?
19
+
20
+ editor_row, editor_col = @editor.cursor_visual_position(width: [inner_width - 2, 1].max)
21
+ attachments = render_attachments
22
+ raw_row = attachments.size + editor_row
23
+ lines = attachments + render_input_lines
24
+ lines.concat(render_menu_lines) if menu_open?
25
+ height = [@height.to_i, 1].max
26
+ visible_row = raw_row - [lines.size - height, 0].max
27
+ return nil if visible_row.negative? || visible_row >= height
28
+
29
+ [visible_row, 2 + editor_col]
30
+ end
31
+
32
+ private def pending_guidance_lines
33
+ @guidance_lines_provider&.call || []
34
+ end
35
+
36
+ private def render_attachments
37
+ super + pending_guidance_lines.map do |line|
38
+ used = 0
39
+ clipped = line.each_char.take_while do |char|
40
+ used += Unicode::DisplayWidth.of(char)
41
+ used <= [inner_width - 1, 0].max
42
+ end.join
43
+ clipped += "…" if clipped.length < line.length && inner_width.positive?
44
+ clipped
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -6,6 +6,7 @@ require "base64"
6
6
  require "set"
7
7
  require "ruby_rich"
8
8
  require_relative "../ui_interface"
9
+ require_relative "../cli_guidance"
9
10
  require_relative "../providers"
10
11
  require_relative "../ui2/components/welcome_banner"
11
12
  require_relative "shell/rich_agent_shell"
@@ -22,6 +23,7 @@ require_relative "components/dialogs/approval_dialog"
22
23
  module Clacky
23
24
  class RichUIController
24
25
  include Clacky::UIInterface
26
+ include Clacky::CliGuidance
25
27
  include Clacky::RichUI::ViewRenderer
26
28
 
27
29
  STREAMING_MARKDOWN_THRESHOLD = 240
@@ -151,6 +153,7 @@ module Clacky
151
153
  def set_skill_loader(skill_loader, agent_profile = nil)
152
154
  return unless skill_loader
153
155
 
156
+ @shell.composer.register_command(name: "/input-mode", description: "Input behavior: steer or interrupt")
154
157
  skills = skill_loader.user_invocable_skills(agent_profile)
155
158
 
156
159
  skills.each do |skill|
@@ -166,6 +169,8 @@ module Clacky
166
169
 
167
170
  def set_agent(_agent, _agent_profile = nil); end
168
171
 
172
+ attr_accessor :queue_input_while_running
173
+
169
174
  def on_input(&block)
170
175
  @input_callback = block
171
176
  end
@@ -245,7 +250,7 @@ module Clacky
245
250
  end
246
251
  end
247
252
 
248
- def show_tool_result(result)
253
+ def show_tool_result(result, ui: nil)
249
254
  if (id = @tracker.pop_tool_id)
250
255
  @shell.finish_tool_call(id, status: :done, output: format_tool_output(result.to_s, :done))
251
256
  update_tool_activity(id, :done)
@@ -457,6 +462,20 @@ module Clacky
457
462
  { model: selected, persist: persist_choice }
458
463
  end
459
464
 
465
+ def dispatch_guidance_action(action, id)
466
+ run_callback_async { @guidance_action&.call(action, id) }
467
+ end
468
+
469
+ def refresh_guidance
470
+ @shell.live&.refresh
471
+ end
472
+
473
+ def show_user_message(content, created_at: nil, files: [], source: :web, steering: false)
474
+ return unless steering
475
+ @shell.add_user_message(content)
476
+ add_file_summary(files) unless files.empty?
477
+ end
478
+
460
479
  def clear_input
461
480
  @shell.composer.editor.clear
462
481
  end
@@ -470,6 +489,7 @@ module Clacky
470
489
  Commands:
471
490
  /clear - Clear output and restart session
472
491
  /exit - Exit application
492
+ /input-mode [steer|interrupt] - View or change input behavior
473
493
 
474
494
  Input:
475
495
  Shift+Enter - New line
@@ -496,7 +516,7 @@ module Clacky
496
516
  when :add
497
517
  new_model = show_model_edit_form(nil, test_callback: test_callback)
498
518
  if new_model
499
- anthropic_format = new_model[:provider] == "anthropic"
519
+ anthropic_format = new_model[:provider] == Clacky::Providers::ANTHROPIC_ID
500
520
  current_config.add_model(
501
521
  model: new_model[:model],
502
522
  api_key: new_model[:api_key],
@@ -628,10 +648,10 @@ module Clacky
628
648
 
629
649
  def wire_shell_callbacks
630
650
  @shell.on_submit do |text, attachments|
631
- reset_task_sidebar_tracking
651
+ reset_task_sidebar_tracking unless guidance_deferred?(text)
632
652
  @ctrl_c_warning = nil
633
653
  files = Array(attachments).map { |attachment| attachment.respond_to?(:to_h) ? attachment.to_h : attachment }
634
- @shell.add_user_message(text)
654
+ @shell.add_user_message(text) unless guidance_deferred?(text)
635
655
  run_callback_async { @input_callback&.call(text, files, display: text) }
636
656
  end
637
657
 
@@ -4,6 +4,7 @@ require "ruby_rich"
4
4
  require_relative "../components/sidebar"
5
5
  require_relative "../components/thinking_live_view"
6
6
  require_relative "../components/status_view"
7
+ require_relative "../components/composer_guidance"
7
8
 
8
9
  module Clacky
9
10
  class RichAgentShell < RubyRich::AgentShell
@@ -80,6 +81,8 @@ module Clacky
80
81
 
81
82
  def attach_agent_controls
82
83
  @composer.instance_variable_set(:@on_interrupt, nil)
84
+ @composer.singleton_class.prepend(RichUI::ComposerGuidance)
85
+ @composer.guidance_lines_provider = -> { @clacky_controller&.guidance_lines || [] }
83
86
  # Register /model command
84
87
  shell_ref = self
85
88
  @composer.register_command(name: "/model", description: "Switch LLM model",
@@ -95,6 +98,16 @@ module Clacky
95
98
  handled ? nil : native_escape.call
96
99
  end
97
100
  # Clear Ctrl+C warning as soon as the user starts typing
101
+ native_ctrl_d = @composer.method(:ctrl_d)
102
+ @composer.define_singleton_method(:ctrl_d) do |live|
103
+ handled = shell.clacky_controller&.request_guidance_action(:remove)
104
+ native_ctrl_d.call(live) unless handled
105
+ end
106
+ @layout.key(:ctrl_g, 250) do |_event, _live|
107
+ @clacky_controller&.request_guidance_action(:send_now) if @composer.focused?
108
+ false
109
+ end
110
+
98
111
  native_insert = @composer.method(:insert_text)
99
112
  @composer.define_singleton_method(:insert_text) do |text|
100
113
  shell.callbacks[:clear_ctrlc]&.call
@@ -6,10 +6,10 @@ require "fileutils"
6
6
  module Clacky
7
7
  # Search provider selection, backed by ~/.clacky/search.yml.
8
8
  #
9
- # The built-in DuckDuckGo/Bing scrapers need no configuration and stay the
10
- # default. Pointing `provider` at a script in ~/.clacky/searchers/ routes
9
+ # The built-in Parallel/DuckDuckGo/Bing providers need no configuration and
10
+ # stay the default. Pointing `provider` at a script in ~/.clacky/searchers/ routes
11
11
  # web_search through an API-backed engine instead, which is what users who
12
- # want higher-quality results (or China-reachable ones) reach for.
12
+ # want a provider-specific API or policy controls reach for.
13
13
  #
14
14
  # search.yml schema:
15
15
  # provider: tavily — basename of a script in ~/.clacky/searchers/;