openclacky 1.5.11 → 1.5.13

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -96,7 +96,7 @@ module Clacky
96
96
  forward_to_subscribers { |sub| sub.show_user_message(content) if sub.respond_to?(:show_user_message) }
97
97
  end
98
98
 
99
- def show_assistant_message(content, files:, interim: false)
99
+ def show_assistant_message(content, files:, interim: false, created_at: nil)
100
100
  return if (content.nil? || content.to_s.strip.empty?) && files.empty?
101
101
 
102
102
  # Rewrite local image paths (file:// and bare absolute) to /api/local-image
@@ -105,7 +105,7 @@ module Clacky
105
105
  # Channel subscribers receive the original content so they can deliver
106
106
  # local images as native attachments via send_file().
107
107
  web_content = Clacky::Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
108
- emit("assistant_message", content: web_content, files: files)
108
+ emit("assistant_message", content: web_content, files: files, created_at: created_at)
109
109
  forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files, interim: interim) }
110
110
  end
111
111
 
@@ -128,7 +128,8 @@ module Clacky
128
128
  context: context,
129
129
  options: first[:options] || [],
130
130
  questions: questions)
131
- # Don't forward to IM subscribers — they get the formatted text version already
131
+ # IM subscribers render the same questions as plain text — they have no card.
132
+ forward_to_subscribers { |sub| sub.show_tool_call(name, args_data) }
132
133
  return
133
134
  end
134
135
 
@@ -189,8 +190,9 @@ module Clacky
189
190
  # Token usage is internal detail — intentionally not forwarded
190
191
  end
191
192
 
192
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
193
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
193
194
  data = { iterations: iterations, cost: cost }
195
+ data[:task_id] = task_id if task_id
194
196
  data[:duration] = duration if duration
195
197
  data[:cache_stats] = cache_stats if cache_stats
196
198
  data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
@@ -199,7 +201,7 @@ module Clacky
199
201
  forward_to_subscribers do |sub|
200
202
  sub.show_complete(iterations: iterations, cost: cost, duration: duration,
201
203
  cache_stats: cache_stats, awaiting_user_feedback: awaiting_user_feedback,
202
- cost_source: cost_source)
204
+ cost_source: cost_source, task_id: task_id)
203
205
  end
204
206
  end
205
207
 
@@ -34,18 +34,32 @@ module Clacky
34
34
 
35
35
  @last_saved_path = filepath
36
36
 
37
- # Keep only the most recent 200 sessions (best-effort, never block save)
38
- begin
39
- cleanup_by_count(keep: 200, grouped_keep: 200)
40
- cleanup_trash(days: 8)
41
- cleanup_file_trash(days: 8)
42
- rescue StandardError
43
- # Cleanup is non-critical; swallow file errors but never AgentInterrupted
44
- end
37
+ run_cleanup_async
45
38
 
46
39
  filepath
47
40
  end
48
41
 
42
+ # Run the best-effort maintenance chores off the save path. File-trash pruning
43
+ # walks every project's trash dir and can take hundreds of ms once trash grows
44
+ # large; there is no reason to make a session save wait for it. A single flag
45
+ # prevents overlapping sweeps from piling up when saves happen back-to-back.
46
+ private def run_cleanup_async
47
+ return if @cleanup_running
48
+ @cleanup_running = true
49
+
50
+ Clacky::ThreadRegistry.spawn(name: "session-cleanup", daemon: true) do
51
+ begin
52
+ cleanup_by_count(keep: 200, grouped_keep: 200)
53
+ cleanup_trash(days: 8)
54
+ cleanup_file_trash(days: 8)
55
+ rescue StandardError
56
+ # Cleanup is non-critical; swallow file errors but never AgentInterrupted
57
+ ensure
58
+ @cleanup_running = false
59
+ end
60
+ end
61
+ end
62
+
49
63
  # Path of the last saved session file.
50
64
  def last_saved_path
51
65
  @last_saved_path
@@ -337,17 +351,20 @@ module Clacky
337
351
  # Returns count of soft-deleted sessions.
338
352
  def cleanup_by_count(keep:, grouped_keep: 200)
339
353
  # Fast path: if total session files can't exceed any single group's cap,
340
- # skip the expensive all_sessions() full-scan (read + JSON.parse every file).
341
- # min() is correct because groups are mutually exclusive — any single
342
- # group's count ≤ non_pinned ≤ file_count ≤ min(keep, grouped_keep).
343
- file_count = Dir.glob(File.join(@sessions_dir, "*.json")).size
344
- return 0 if file_count <= [keep, grouped_keep].min
354
+ # skip the scan entirely. min() is correct because groups are mutually
355
+ # exclusive — any single group's count ≤ non_pinned ≤ file_count.
356
+ files = Dir.glob(File.join(@sessions_dir, "*.json"))
357
+ return 0 if files.size <= [keep, grouped_keep].min
345
358
 
346
- non_pinned = all_sessions.reject { |s| s[:pinned] }
359
+ # Read only the header of each file (cheap) instead of parsing the whole
360
+ # multi-MB payload. The header carries everything we need to rank and
361
+ # group sessions except project_id, which is resolved lazily below.
362
+ metas = files.filter_map { |f| m = load_session_metadata(f); m && m.merge(_path: f) }
363
+ .sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse
347
364
 
348
- groups = non_pinned.group_by do |s|
349
- (s[:project_id] || !GROUPED_SOURCES.include?(s[:source].to_s)) ? "regular" : s[:source].to_s
350
- end
365
+ non_pinned = metas.reject { |s| s[:pinned] }
366
+
367
+ groups = non_pinned.group_by { |s| group_key_for(s) }
351
368
 
352
369
  victims = groups.flat_map do |source, sessions|
353
370
  sessions[(source == "regular" ? keep : grouped_keep)..] || []
@@ -357,6 +374,18 @@ module Clacky
357
374
  victims.size
358
375
  end
359
376
 
377
+ # A session belongs to the "regular" pool unless it is a folded grouped
378
+ # source (cron/ext) with no project. project_id only matters for cron/ext
379
+ # sessions, and it lives deep in the file, so we pay for a full parse only
380
+ # for those — everything else is decided from the cheap header alone.
381
+ private def group_key_for(meta)
382
+ source = meta[:source].to_s
383
+ return "regular" unless GROUPED_SOURCES.include?(source)
384
+
385
+ full = load_session_file(meta[:_path])
386
+ (full && full[:project_id]) ? "regular" : source
387
+ end
388
+
360
389
  # ── Session trash (delegates to Tools::TrashManager) ──────────────
361
390
  # All business logic lives in Clacky::Tools::TrashManager; SessionManager
362
391
  # only provides the sessions_dir context and filesystem helpers used there.
@@ -464,6 +493,34 @@ module Clacky
464
493
  nil
465
494
  end
466
495
 
496
+ # Read only the small scalar header of a session file without parsing the
497
+ # (potentially multi-MB) messages / time_machine payload. Session JSON is
498
+ # written with a stable field order, so the fields we care about here
499
+ # (session_id, pinned, created_at, updated_at, source) always sit in the
500
+ # first few hundred bytes, before any large array/object. `project_id`
501
+ # lives much later and is intentionally NOT read — callers that need it
502
+ # fall back to load_session_file for the rare candidate that requires it.
503
+ #
504
+ # Returns a Hash with symbol keys, or nil if the header can't be read.
505
+ METADATA_HEADER_BYTES = 16 * 1024
506
+ def load_session_metadata(filepath)
507
+ head = File.open(filepath, "r") { |f| f.read(METADATA_HEADER_BYTES) }
508
+ return nil unless head
509
+
510
+ sid = head[/"session_id"\s*:\s*"([^"]*)"/, 1]
511
+ return nil unless sid
512
+
513
+ {
514
+ session_id: sid,
515
+ pinned: head[/"pinned"\s*:\s*(true|false)/, 1] == "true",
516
+ created_at: head[/"created_at"\s*:\s*"([^"]*)"/, 1],
517
+ updated_at: head[/"updated_at"\s*:\s*"([^"]*)"/, 1],
518
+ source: head[/"source"\s*:\s*"([^"]*)"/, 1]
519
+ }
520
+ rescue Errno::ENOENT
521
+ nil
522
+ end
523
+
467
524
  # Remove Time Machine snapshots that no longer belong to any known session.
468
525
  # Snapshots are keyed by full session_id; session files are named by the
469
526
  # 8-char id prefix, so a snapshot dir is an orphan when its prefix matches
@@ -6,6 +6,7 @@ module Clacky
6
6
  self.tool_name = "ask_user"
7
7
  self.tool_description = <<~DESC
8
8
  Ask the user to choose when you cannot infer the answer and guessing would waste work.
9
+ Keep every question concise: one sentence, no more than 80 characters.
9
10
 
10
11
  Every question must have `options` — this tool is for choices, not open prompts.
11
12
  Need freeform input (a path, a name)? Just ask in your reply instead.
@@ -27,7 +28,7 @@ module Clacky
27
28
  properties: {
28
29
  question: {
29
30
  type: "string",
30
- description: "The question text."
31
+ description: "A concise question in one sentence, no more than 80 characters."
31
32
  },
32
33
  description: {
33
34
  type: "string",
@@ -56,7 +57,7 @@ module Clacky
56
57
  },
57
58
  question: {
58
59
  type: "string",
59
- description: "Single-question shorthand."
60
+ description: "Single-question shorthand: one sentence, no more than 80 characters."
60
61
  },
61
62
  context: {
62
63
  type: "string",
@@ -813,6 +813,9 @@ module Clacky
813
813
  spawn_env = {
814
814
  "TERM" => "xterm-256color",
815
815
  "PS1" => " ",
816
+ # PTY makes git/less page interactively; force cat, scoped to our shell only.
817
+ "GIT_PAGER" => "cat",
818
+ "PAGER" => "cat",
816
819
  # Prevent our sub-shell from polluting the user's ~/.zsh_history
817
820
  # (or ~/.bash_history). We fork a full interactive login shell to
818
821
  # get rbenv/nvm/brew-shellenv/mise loaded, but every command we
@@ -873,7 +876,8 @@ module Clacky
873
876
  end
874
877
 
875
878
  marker_token = SecureRandom.hex(8)
876
- reader_thread = start_reader_thread(reader, log_io)
879
+ session_box = []
880
+ reader_thread = start_reader_thread(reader, log_io, session_box)
877
881
 
878
882
  session = SessionManager.register(
879
883
  pid: pid, command: command, cwd: cwd || Dir.pwd,
@@ -883,6 +887,7 @@ module Clacky
883
887
  mode: "shell", marker_token: marker_token,
884
888
  shell_name: shell_name
885
889
  )
890
+ session_box << session
886
891
 
887
892
  # Give the shell a moment to print its startup banner (zsh -l -i
888
893
  # loads a lot of stuff), then drain whatever noise it wrote so the
@@ -902,8 +907,21 @@ module Clacky
902
907
  session
903
908
  end
904
909
 
905
- # Background thread: drain PTY → log file.
906
- private def start_reader_thread(reader, log_io)
910
+ # Background thread: drain PTY → log file. It doubles as the session's
911
+ # death watcher: when the child exits, read_nonblock raises EIO and the
912
+ # thread ends up in the ensure block below. Releasing the PTY fds and
913
+ # forgetting the session there means a dead session's master fd is
914
+ # reclaimed the moment its child dies — even when the calling agent
915
+ # never comes back to poll or kill it, which used to leak the fd until
916
+ # the process hit EMFILE ("Too many open files").
917
+ #
918
+ # `session_box` is a one-element array filled by spawn_shell right
919
+ # after SessionManager.register (the session cannot exist before the
920
+ # thread starts); an empty box means registration never happened and
921
+ # spawn_shell itself owns the cleanup. Every close is rescue-guarded
922
+ # and SessionManager.forget is a plain Hash#delete, so this whole
923
+ # block is a no-op when the main thread's cleanup_session ran first.
924
+ private def start_reader_thread(reader, log_io, session_box)
907
925
  Clacky::ThreadRegistry.spawn(name: "terminal-pty-reader") do
908
926
  loop do
909
927
  break if reader.closed? || log_io.closed?
@@ -923,6 +941,15 @@ module Clacky
923
941
  end
924
942
  ensure
925
943
  log_io.close rescue nil
944
+ if (session = session_box[0])
945
+ # Flip the status first so a main thread parked in
946
+ # read_until_marker's poll loop notices the death immediately
947
+ # (its refresh() no longer finds the session in the registry).
948
+ session.status = "exited" unless %w[exited killed].include?(session.status)
949
+ session.writer.close rescue nil
950
+ session.reader.close rescue nil
951
+ SessionManager.forget(session.id)
952
+ end
926
953
  end
927
954
  end
928
955
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "tty-screen"
4
4
  require_relative "theme_manager"
5
+ require_relative "strings_cjk_patch"
5
6
 
6
7
  module Clacky
7
8
  module UI2
@@ -21,14 +22,24 @@ module Clacky
21
22
  # Get current theme colors
22
23
  theme = ThemeManager.current_theme
23
24
 
25
+ width = TTY::Screen.width - 4 # Leave some margin
26
+
24
27
  # Configure tty-markdown with custom theme and symbols
25
28
  parsed = TTY::Markdown.parse(content,
26
29
  theme: theme_colors,
27
30
  symbols: custom_symbols,
28
- width: TTY::Screen.width - 4 # Leave some margin
31
+ width: width
29
32
  )
30
33
 
31
34
  parsed
35
+ rescue IndexError => e
36
+ # Safety net behind strings_cjk_patch; retry uncolored if wrap still fails.
37
+ warn "[markdown] color render failed (#{e.class}), retrying without color" if ENV["CLACKY_DEBUG"]
38
+ begin
39
+ TTY::Markdown.parse(content, color: :never, symbols: custom_symbols, width: width)
40
+ rescue StandardError
41
+ content
42
+ end
32
43
  rescue StandardError => e
33
44
  warn "[markdown] render failed: #{e.class}: #{e.message}" if ENV["CLACKY_DEBUG"]
34
45
  content
@@ -59,20 +70,12 @@ module Clacky
59
70
  def theme_colors
60
71
  theme = ThemeManager.current_theme
61
72
 
62
- # Map our theme colors to tty-markdown's expected format
63
- # Note: theme.colors values are already arrays, so we need to flatten when adding styles
73
+ # tty-markdown accepts a single :header key for all header levels
74
+ # (h1 additionally underlines). Code block colors come from rouge.
64
75
  {
65
- # Headers use info color (cyan/blue)
66
- h1: Array(theme.colors[:info]) + [:bold],
67
- h2: Array(theme.colors[:info]) + [:bold],
68
- h3: Array(theme.colors[:info]),
69
- h4: Array(theme.colors[:info]),
70
- h5: Array(theme.colors[:info]),
71
- h6: Array(theme.colors[:info]),
76
+ header: [:bright_cyan, :bold],
72
77
  # Horizontal rule - make it subtle (dim gray)
73
78
  hr: [:bright_black],
74
- # Code blocks use dim color
75
- code: Array(theme.colors[:thinking]),
76
79
  # Links use success color (green)
77
80
  link: Array(theme.colors[:success]),
78
81
  # Lists use default text color
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strings"
4
+
5
+ # strings 0.2.1 (latest release, 2021) tracks ANSI reinsertion offsets in
6
+ # display width (CJK chars count as 2) but applies them as String character
7
+ # indexes via String#insert, so any colored CJK content that needs wrapping
8
+ # raises IndexError. These methods mirror upstream with offsets tracked in
9
+ # character count instead of display width.
10
+ module Strings
11
+ module Wrap
12
+ module_function
13
+
14
+ def format_line(text_line, wrap_at, ansi_stack)
15
+ lines = []
16
+ line = []
17
+ word = []
18
+ ansi = []
19
+ ansi_matched = false
20
+ word_length = 0
21
+ line_length = 0
22
+ word_chars = 0
23
+ line_chars = 0
24
+ char_length = 0
25
+ text_length = display_width(text_line)
26
+ total_length = 0
27
+
28
+ UnicodeUtils.each_grapheme(text_line) do |char|
29
+ if char == Strings::ANSI::CSI || ansi.length > 0
30
+ ansi << char
31
+ if Strings::ANSI.only_ansi?(ansi.join)
32
+ ansi_matched = true
33
+ elsif ansi_matched
34
+ ansi_stack << [ansi[0...-1].join, line_chars + word_chars]
35
+ ansi_matched = false
36
+
37
+ if ansi.last == Strings::ANSI::CSI
38
+ ansi = [ansi.last]
39
+ else
40
+ ansi = []
41
+ end
42
+ end
43
+ next if ansi.length > 0
44
+ end
45
+
46
+ char_length = display_width(char)
47
+ total_length += char_length
48
+ if line_length + word_length + char_length <= wrap_at
49
+ if char == SPACE || total_length == text_length
50
+ line << word.join + char
51
+ line_length += word_length + char_length
52
+ line_chars += word_chars + char.size
53
+ word = []
54
+ word_length = 0
55
+ word_chars = 0
56
+ else
57
+ word << char
58
+ word_length += char_length
59
+ word_chars += char.size
60
+ end
61
+ next
62
+ end
63
+
64
+ if char == SPACE
65
+ lines << insert_ansi(line.join, ansi_stack)
66
+ line = []
67
+ line_length = 0
68
+ line_chars = 0
69
+ word << char
70
+ word_length += char_length
71
+ word_chars += char.size
72
+ elsif word_length + char_length <= wrap_at
73
+ lines << insert_ansi(line.join, ansi_stack)
74
+ line = [word.join + char]
75
+ line_length = word_length + char_length
76
+ line_chars = word_chars + char.size
77
+ word = []
78
+ word_length = 0
79
+ word_chars = 0
80
+ else # hyphenate word - too long to fit a line
81
+ lines << insert_ansi(word.join, ansi_stack)
82
+ line_length = 0
83
+ line_chars = 0
84
+ word = [char]
85
+ word_length = char_length
86
+ word_chars = char.size
87
+ end
88
+ end
89
+ lines << insert_ansi(line.join, ansi_stack) unless line.empty?
90
+ lines << insert_ansi(word.join, ansi_stack) unless word.empty?
91
+ lines
92
+ end
93
+
94
+ def insert_ansi(string, ansi_stack = [])
95
+ return string if ansi_stack.empty?
96
+ return string if string.empty?
97
+
98
+ new_stack = []
99
+ output = string.dup
100
+ length = string.size
101
+ matched_reset = false
102
+ ansi_reset = Strings::ANSI::RESET
103
+
104
+ ansi_stack.reverse_each do |ansi|
105
+ pos = ansi[1]
106
+ # Offsets carried over from a previous (longer) line can exceed this
107
+ # line; such codes belong before the wrapped content.
108
+ pos = 0 if pos > length
109
+ if ansi[0] =~ /#{Regexp.quote(ansi_reset)}/
110
+ matched_reset = true
111
+ output.insert(pos, ansi_reset)
112
+ next
113
+ elsif !matched_reset # ansi without reset
114
+ matched_reset = false
115
+ new_stack << ansi # keep the ansi
116
+ next if pos == length
117
+ if output.end_with?(NEWLINE)
118
+ output.insert(-2, ansi_reset)
119
+ else
120
+ output.insert(-1, ansi_reset)
121
+ end
122
+ end
123
+
124
+ output.insert(pos, ansi[0])
125
+ end
126
+
127
+ ansi_stack.replace(new_stack)
128
+
129
+ output
130
+ end
131
+ end
132
+ end
@@ -31,7 +31,7 @@ module Clacky
31
31
  COLORS = {
32
32
  # Format: [symbol_color, dark_bg_text, light_bg_text]
33
33
  user: [:bright_black, :white, :black], # User prompt and input
34
- assistant: [:bright_green, :bright_black, :bright_black], # AI response - keep green hacker style
34
+ assistant: [:bright_green, :bright_white, :black], # AI response
35
35
  tool_call: [:bright_cyan, :cyan, :cyan], # Tool execution
36
36
  tool_result: [:bright_cyan, :bright_black, :bright_black], # Tool output
37
37
  tool_denied: [:bright_yellow, :yellow, :yellow], # Denied actions
@@ -28,7 +28,7 @@ module Clacky
28
28
  COLORS = {
29
29
  # Format: [symbol_color, dark_bg_text, light_bg_text]
30
30
  user: [:bright_black, :bright_black, :black], # User prompt and input
31
- assistant: [:green, :bright_black, :bright_black], # AI response
31
+ assistant: [:green, :bright_white, :black], # AI response
32
32
  tool_call: [:cyan, :cyan, :cyan], # Tool execution
33
33
  tool_result: [:cyan, :bright_black, :bright_black], # Tool output
34
34
  tool_denied: [:yellow, :yellow, :yellow], # Denied actions
@@ -448,7 +448,7 @@ module Clacky
448
448
 
449
449
  # Show assistant message
450
450
  # @param content [String] Message content
451
- def show_assistant_message(content, files:, interim: false)
451
+ def show_assistant_message(content, files:, interim: false, created_at: nil)
452
452
  # Filter out thinking tags from models like MiniMax M2.1 that use <think>...</think>
453
453
  filtered_content = filter_thinking_tags(content)
454
454
  return if filtered_content.nil? || filtered_content.strip.empty?
@@ -629,7 +629,7 @@ module Clacky
629
629
  # @param duration [Float] Duration in seconds
630
630
  # @param cache_stats [Hash] Cache statistics
631
631
  # @param awaiting_user_feedback [Boolean] Whether agent is waiting for user feedback
632
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
632
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
633
633
  # Update status back to 'idle' when task is complete
634
634
  update_sessionbar(status: 'idle')
635
635
 
@@ -9,7 +9,7 @@ module Clacky
9
9
  # === Output display ===
10
10
  # @param content [String] text portion of the assistant reply (file:// links stripped)
11
11
  # @param files [Array<Hash>] extracted file refs: [{ name:, path:, inline: }]
12
- def show_assistant_message(content, files:, interim: false); end
12
+ def show_assistant_message(content, files:, interim: false, created_at: nil); end
13
13
  def show_feedback_request(question, context, options, questions: nil); end
14
14
  def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil); end
15
15
  def show_subagent_end; end
@@ -33,7 +33,7 @@ module Clacky
33
33
  # (e.g. /goal auto-switches to auto_approve). UIs that render the mode
34
34
  # in a status bar override this.
35
35
  def update_permission_mode(mode); end
36
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil); end
36
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil); end
37
37
  def append_output(content); end
38
38
 
39
39
  # === Status messages ===
@@ -7,6 +7,23 @@ module Clacky
7
7
  # Pricing per 1M tokens (MTok) in USD
8
8
  # All pricing is based on official API documentation
9
9
  PRICING_TABLE = {
10
+ # Claude Fable 5.1 — same input/output as Fable 5, but cache read is
11
+ # $0.25/MTok (vs $1.00 for Fable 5). Source: Anthropic pricing table.
12
+ "claude-fable-5-1" => {
13
+ input: {
14
+ default: 10.00, # $10/MTok for prompts ≤ 200K tokens
15
+ over_200k: 10.00
16
+ },
17
+ output: {
18
+ default: 50.00, # $50/MTok for prompts ≤ 200K tokens
19
+ over_200k: 50.00
20
+ },
21
+ cache: {
22
+ write: 12.50, # $12.50/MTok cache write (5-min tier)
23
+ read: 0.25 # $0.25/MTok cache read
24
+ }
25
+ },
26
+
10
27
  # Claude 4.5 models - tiered pricing based on prompt length
11
28
  "claude-fable-5" => {
12
29
  input: {
@@ -408,6 +425,46 @@ module Clacky
408
425
  }
409
426
  },
410
427
 
428
+ # Gemini 3.8 Flash. Flat pricing, 1M context, 64K max output.
429
+ # Same list price as 3.7/3.6 Flash; intro rate $0.75/$3.75 through
430
+ # 2026-12-31 before settling at $1.50/$7.50 (list price shown).
431
+ # Cache write billed at input rate (Vertex doesn't expose a separate
432
+ # cache-write charge in the OpenAI shim usage response).
433
+ "gemini-3.8-flash" => {
434
+ input: {
435
+ default: 1.50,
436
+ over_200k: 1.50
437
+ },
438
+ output: {
439
+ default: 7.50,
440
+ over_200k: 7.50
441
+ },
442
+ cache: {
443
+ write: 1.50,
444
+ read: 0.15
445
+ }
446
+ },
447
+
448
+ # Gemini 3.7 Flash. Flat pricing, 1M context, 64K max output.
449
+ # Same list price as 3.6 Flash; both carry an intro rate of $0.75/$3.75
450
+ # through 2026-12-31 before settling at $1.50/$7.50 (list price shown).
451
+ # Cache write billed at input rate (Vertex doesn't expose a separate
452
+ # cache-write charge in the OpenAI shim usage response).
453
+ "gemini-3.7-flash" => {
454
+ input: {
455
+ default: 1.50,
456
+ over_200k: 1.50
457
+ },
458
+ output: {
459
+ default: 7.50,
460
+ over_200k: 7.50
461
+ },
462
+ cache: {
463
+ write: 1.50,
464
+ read: 0.15
465
+ }
466
+ },
467
+
411
468
  # Gemini 3.6 Flash (GA 2026-07-21). Flat pricing, 1M context, 64K max output.
412
469
  # Source: https://deepmind.google/models/gemini/flash/
413
470
  # Cache write billed at input rate (Vertex doesn't expose a separate
@@ -597,6 +654,18 @@ module Clacky
597
654
  # GLM-5.3 shares GLM-5.2's base model (all gains are post-training per
598
655
  # Z.ai's release notes) and Z.ai's pricing page does not list a separate
599
656
  # GLM-5.3 row yet, so it is billed at the GLM-5.2 flat rate.
657
+ # GLM-5.3-Flash: GLM-5's first natively-multimodal model (image/video/
658
+ # file input). Listed at the non-promotional Z.ai rates — input $0.15,
659
+ # output $0.50, cache read $0.03 per 1M tokens; the 50%-off launch promo
660
+ # ($0.075 / $0.25 / $0.015, ends 2026-09-09) is ignored per the
661
+ # "displayed ≤ actual" rule. Cache write bills at the input miss rate
662
+ # (storage is "Limited-time Free").
663
+ "glm-5.3-flash" => {
664
+ input: { default: 0.15, over_200k: 0.15 },
665
+ output: { default: 0.50, over_200k: 0.50 },
666
+ cache: { write: 0.15, read: 0.03 }
667
+ },
668
+
600
669
  "glm-5.3" => {
601
670
  input: { default: 1.40, over_200k: 1.40 },
602
671
  output: { default: 4.40, over_200k: 4.40 },
@@ -704,6 +773,15 @@ module Clacky
704
773
  # - cache.read = official explicit-cache-hit price.
705
774
  # - When a model has NO published explicit-cache price (e.g. qwen3.6-27b,
706
775
  # qwen-plus-latest), cache.write/read fall back to the input rate.
776
+ # qwen3.8-max: NOT tiered (single flat tier). Reuses qwen3.7-max's
777
+ # international list rates (2.5 / 7.5 / 3.125 / 0.25) — the billing
778
+ # page hasn't listed qwen3.8-max yet, so this is a safe upper bound.
779
+ "qwen3.8-max" => {
780
+ input: { default: 2.5, over_200k: 2.5 },
781
+ output: { default: 7.5, over_200k: 7.5 },
782
+ cache: { write: 3.125, read: 0.25 }
783
+ },
784
+
707
785
  # qwen3.7-max: NOT tiered (single flat tier per Alibaba's definition).
708
786
  # List price: input 2.5, output 7.5, explicit write 3.125, explicit read 0.25.
709
787
  "qwen3.7-max" => {
@@ -888,6 +966,8 @@ module Clacky
888
966
  # Support both dot and dash separators (e.g., "4.5", "4-5", "4-6")
889
967
  # Also handles Bedrock cross-region prefixes (e.g. "jp.anthropic.claude-sonnet-4-6")
890
968
  case model
969
+ when /claude.*fable.*5[.-]1/i
970
+ "claude-fable-5-1"
891
971
  when /claude.*fable.*5/i
892
972
  "claude-fable-5"
893
973
  # Claude Sonnet 5 / Opus 5 (2026) — anchored on the literal "sonnet-5"
@@ -955,6 +1035,8 @@ module Clacky
955
1035
  # (mainland bigmodel.cn vs intl z.ai) the user configured.
956
1036
  # Strict anchored match so unrelated strings like "glm-5-x-foo"
957
1037
  # don't silently borrow a nearby model's rate.
1038
+ when /^glm-5\.3-flash$/i
1039
+ "glm-5.3-flash"
958
1040
  when /^glm-5\.3$/i
959
1041
  "glm-5.3"
960
1042
  when /^glm-5\.2$/i
@@ -978,10 +1060,12 @@ module Clacky
978
1060
  "minimax-m2.5"
979
1061
 
980
1062
  # Qwen (Alibaba DashScope) — strict anchored match per registered
981
- # model id in providers.rb. qwen3.7-* is the latest flagship line;
982
- # qwen3.6-* are the previous generation; qwen-plus-latest is the
983
- # rolling alias for the latest Qwen-Plus release; qwen3-vl-plus is
984
- # the multimodal SKU (replaces the retired qwen-vl-plus/max).
1063
+ # model id in providers.rb. qwen3.8-max is the latest flagship;
1064
+ # qwen3.7-* / qwen3.6-* are previous generations; qwen-plus-latest
1065
+ # is the rolling alias for the latest Qwen-Plus release; qwen3-vl-plus
1066
+ # is the multimodal SKU (replaces the retired qwen-vl-plus/max).
1067
+ when /^qwen3\.8-max$/i
1068
+ "qwen3.8-max"
985
1069
  when /^qwen3\.7-max$/i
986
1070
  "qwen3.7-max"
987
1071
  when /^qwen3\.7-plus$/i
@@ -1007,6 +1091,10 @@ module Clacky
1007
1091
  "gemini-3-flash"
1008
1092
  when /^or-gemini-3-6-flash$/i, /^gemini-3\.6-flash$/i
1009
1093
  "gemini-3.6-flash"
1094
+ when /^or-gemini-3-7-flash$/i, /^gemini-3\.7-flash$/i
1095
+ "gemini-3.7-flash"
1096
+ when /^or-gemini-3-8-flash$/i, /^gemini-3\.8-flash$/i
1097
+ "gemini-3.8-flash"
1010
1098
 
1011
1099
  # OpenAI GPT-5.x models - match various dashed/dotted/compact forms
1012
1100
  # (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.11"
4
+ VERSION = "1.5.13"
5
5
  end