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
@@ -272,7 +272,7 @@ module Clacky
272
272
  end
273
273
 
274
274
  # Handle built-in commands
275
- if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
275
+ if text&.match?(KNOWN_COMMAND)
276
276
  handle_command(adapter, event, text)
277
277
  return
278
278
  end
@@ -294,6 +294,18 @@ module Clacky
294
294
  sub_count = web_ui_for_session_diag(session_id)
295
295
  Clacky::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]}, text=#{text.inspect}, channel_subs=#{sub_count})")
296
296
 
297
+ if session[:status] == :running && %w[queue steer].include?(@registry.agent_config_input_behavior)
298
+ queued = false
299
+ @registry.with_session(session_id) do |s|
300
+ if s[:status] == :running
301
+ s[:agent].enqueue_input(build_prompt_with_context(event, text), delivery: @registry.agent_config_input_behavior.to_sym, files: files,
302
+ display_text: text, source: :channel, created_at: Time.now.to_f)
303
+ queued = true
304
+ end
305
+ end
306
+ return if queued
307
+ end
308
+
297
309
  # If session is running, interrupt it AND wait for the old thread to
298
310
  # actually unwind before starting a new task. Without the join, two
299
311
  # threads briefly race on the same agent/history and the old thread
@@ -301,7 +313,7 @@ module Clacky
301
313
  # ships to the LLM with no matching tool result — DeepSeek (strict
302
314
  # OpenAI-compat) rejects this with HTTP 400 "insufficient tool
303
315
  # messages following tool_calls message". CLI already waits via
304
- # join(2); we do the same here so all entrypoints behave alike.
316
+ # join(2); if shutdown is slow, retain the input instead of overlapping work.
305
317
  if session[:status] == :running
306
318
  Clacky::Logger.info("[ChannelManager] Session busy, interrupting previous task")
307
319
  old_thread = nil
@@ -310,7 +322,10 @@ module Clacky
310
322
  if old_thread&.alive?
311
323
  old_thread.join(2)
312
324
  if old_thread.alive?
313
- Clacky::Logger.warn("[ChannelManager] previous task did not finish within 2s; continuing anyway (watchdog will escalate)")
325
+ session[:agent].enqueue_input(build_prompt_with_context(event, text), files: files,
326
+ display_text: text, source: :channel, created_at: Time.now.to_f)
327
+ adapter.send_text(event[:chat_id], "The current task is still stopping. Your message remains queued.")
328
+ return
314
329
  end
315
330
  end
316
331
  end
@@ -366,7 +381,7 @@ module Clacky
366
381
  key = channel_key(event)
367
382
 
368
383
  case text
369
- when /\A([\?h]|help)\z/i
384
+ when %r{\A/([??]|h|help)\z}i
370
385
  adapter.send_text(chat_id, COMMAND_HELP)
371
386
 
372
387
  when "/new", "/clear"
@@ -506,15 +521,18 @@ module Clacky
506
521
  list_sessions(adapter, chat_id)
507
522
 
508
523
  else
509
- adapter.send_text(chat_id, "Unknown command. Type ? for help.")
524
+ adapter.send_text(chat_id, "Unknown command. Type /? for help.")
510
525
  end
511
526
  end
512
527
 
513
- KNOWN_COMMAND = %r{\A/(new|clear|model|skills|bind|stop|unbind|status|list)\b}i
528
+ # "/?" is anchored with \z instead of \b because \b never matches after
529
+ # "?" (not a word character), which would let "/?extra" through.
530
+ # "?" is accepted too since Chinese IMEs produce the full-width form.
531
+ KNOWN_COMMAND = %r{\A/(?:[??]\z|(?:h|help|new|clear|model|skills|bind|stop|unbind|status|list)\b)}i
514
532
 
515
533
  COMMAND_HELP = <<~HELP.strip
516
534
  Commands:
517
- ? / h / help - show this help
535
+ /? / /h / /help - show this help
518
536
  /new / /clear - start a new session
519
537
  /model - show current model, cards & quick-switch list
520
538
  /model <n> - switch card by number
@@ -111,7 +111,7 @@ module Clacky
111
111
  end
112
112
  end
113
113
 
114
- def show_tool_result(result)
114
+ def show_tool_result(result, ui: nil)
115
115
  # Suppress — too noisy for IM
116
116
  end
117
117
 
@@ -12,15 +12,18 @@ module Clacky
12
12
  # Translated client-side via `id`. On WSL every favorite targets the
13
13
  # Windows profile (/mnt/<drive>/Users/<name>/...) so they match where a
14
14
  # Windows browser actually saves files; elsewhere they stay under Dir.home.
15
+ # The Windows shell folders win over the guessed profile when available —
16
+ # they are the folders the user actually sees in Explorer.
15
17
  private def dir_picker_places
16
18
  home = Dir.home
17
- win_home = wsl_windows_home
19
+ folders = wsl_shell_folders
20
+ win_home = folders["profile"] || wsl_windows_home
18
21
  places = []
19
22
 
20
23
  [["home", win_home || home],
21
- ["desktop", File.join(win_home || home, "Desktop")],
22
- ["downloads", File.join(win_home || home, "Downloads")],
23
- ["documents", File.join(win_home || home, "Documents")]].each do |id, path|
24
+ ["desktop", folders["desktop"] || File.join(win_home || home, "Desktop")],
25
+ ["downloads", folders["downloads"] || File.join(win_home || home, "Downloads")],
26
+ ["documents", folders["documents"] || File.join(win_home || home, "Documents")]].each do |id, path|
24
27
  places << { id: id, path: path, kind: "favorite" } if Dir.exist?(path)
25
28
  end
26
29
 
@@ -28,6 +31,66 @@ module Clacky
28
31
  places
29
32
  end
30
33
 
34
+ # Windows "shell folders" — the profile plus Desktop/Documents/Downloads —
35
+ # are routinely redirected away from C:\Users\<name>: OneDrive's "back up
36
+ # folders", Huawei PC Manager's file migration (which moves a profile to
37
+ # D:\HuaweiMoveData\Users\<name>), group-policy folder redirection. Guessing
38
+ # "<profile>/Desktop" then lands on the empty leftover shell, so the picker
39
+ # shows an empty directory while Explorer is full of files. Ask Windows for
40
+ # the real paths; the profile guess stays as the fallback for every entry.
41
+ # Cached — the spawn costs 0.5–1.5s and these paths never change while the
42
+ # process runs.
43
+ private def wsl_shell_folders
44
+ return @wsl_shell_folders if defined?(@wsl_shell_folders)
45
+
46
+ @wsl_shell_folders = wsl? ? (wsl_shell_folders_from_powershell || {}) : {}
47
+ end
48
+
49
+ # One PowerShell run for all four paths so the picker pays the process
50
+ # spawn once. Returns nil when interop is unavailable or nothing resolved,
51
+ # so callers fall back to guessing from the profile.
52
+ private def wsl_shell_folders_from_powershell
53
+ script = '$f=[Environment];' \
54
+ '$f::GetFolderPath("UserProfile");' \
55
+ '$f::GetFolderPath("Desktop");' \
56
+ '$f::GetFolderPath("MyDocuments");' \
57
+ 'try { (New-Object -ComObject Shell.Application).NameSpace("shell:Downloads").Self.Path } catch { "" }'
58
+ require "open3"
59
+ out, _err, status = Open3.capture3(
60
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script, binmode: true
61
+ )
62
+ return nil unless status.success?
63
+
64
+ lines = Utils::Encoding.pty_to_utf8(out).split(/\r?\n/)
65
+ folders = {}
66
+ %w[profile desktop documents downloads].each_with_index do |key, index|
67
+ path = wsl_path_of(lines[index])
68
+ folders[key] = path if path
69
+ end
70
+ folders.empty? ? nil : folders
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ # "D:\Users\leo\Desktop" → "/mnt/d/Users/leo/Desktop". wslpath does the
76
+ # conversion so custom mount roots are honoured. Nil for blank values, UNC
77
+ # shares (WSL can't reach them) and paths not mounted in this distro.
78
+ private def wsl_path_of(win_path)
79
+ value = win_path.to_s.strip
80
+ return nil unless value.match?(%r{\A[A-Za-z]:[\\/]})
81
+
82
+ require "open3"
83
+ out, _err, status = Open3.capture3("wslpath", "-a", "-u", value, binmode: true)
84
+ return nil unless status.success?
85
+
86
+ linux = Utils::Encoding.pty_to_utf8(out).strip
87
+ return nil unless linux.start_with?("/") && Dir.exist?(linux)
88
+
89
+ linux
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
31
94
  # Drive letters exposed to the picker so Windows users can reach D:/E:/…
32
95
  # directly. Under WSL these are the /mnt/<drive> mounts; on native
33
96
  # Windows the actual drive letters. Empty on macOS/Linux.
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "open3"
4
4
 
5
+ require_relative "../utils/encoding"
6
+
5
7
  module Clacky
6
8
  module Server
7
9
  # Read-mostly git operations scoped to a session's working directory, backing
@@ -14,9 +16,15 @@ module Clacky
14
16
 
15
17
  # Run a git subcommand in `dir` with argv-style args (no shell). Returns
16
18
  # [stdout, stderr, success_bool]. Never raises on git failure.
19
+ #
20
+ # `core.quotePath=false` keeps git from octal-escaping non-ASCII paths, so
21
+ # the parsers below (and the path comparisons in `restore`) see the same
22
+ # bytes the caller passed in. Output is then normalised to UTF-8 because
23
+ # git inherits the locale, and under a non-UTF-8 locale the raw bytes come
24
+ # back tagged US-ASCII, where String#split would raise on multibyte paths.
17
25
  def git(dir, *args)
18
- out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
19
- [out, err, status.success?]
26
+ out, err, status = Open3.capture3("git", "-C", dir.to_s, "-c", "core.quotePath=false", *args)
27
+ [Utils::Encoding.to_utf8(out), Utils::Encoding.to_utf8(err), status.success?]
20
28
  rescue StandardError => e
21
29
  ["", e.message, false]
22
30
  end