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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cef597eb5d6c066192ba6c2c587e0dcd62fdf2dd9ed422161f71718acbb7f25
4
- data.tar.gz: 03bcd29f23eacd7b49a37db0494f7964757391ac52e8f3ef9787c49a6b2f8efb
3
+ metadata.gz: ca259b2f17df9695e21c4d4c4f18ee83affec0fc41d67db829d903e1c3bb008d
4
+ data.tar.gz: b0bfd31378c6b452a7aef246d3d316242a91fa6366f543c45edcff8c3f44ac6c
5
5
  SHA512:
6
- metadata.gz: 7543c4f518bf5a2e20778472e60909b5eda8f727015b66aad37c69b2d256b53ba3cd5126d2b385a06456a45a6531fe4fdaad7c6d7f8b247aeb2a76bf1446a9f1
7
- data.tar.gz: d6ceb974a12d29096ae65bcbed3d6c6467880005101664919357572201846792faf4f89956ff5ca568519d74960674e214a39c61333e527741a9eb38083fcbf1
6
+ metadata.gz: cfcb5b28264aeb2acb6b121b89c395e4e9241b3a3135c2e9fb7d565365ed263c701f939448a2c01cb229359cca11402b36b081d4edff6231ce8e46e72d253498
7
+ data.tar.gz: 95cca310ebd8e0a23b1f1e5aed241b6e5fbc19aa206fdb16301dfd3b90f07e33424a44686c10b2d6cf4d3344c75eab2c033d7b3e4e7ef6c9a40b7879dd07a88b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,88 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.13] - 2026-09-03
8
+
9
+ ### Added
10
+ - Add Gemini 3.8 Flash model and set it as the trial default
11
+ - Add or-stt-gemini-3-8-flash speech-to-text model; default STT and video understanding to 3.8
12
+ - Add OpenAI Responses API as a fourth API format (#490 - @kylezhang)
13
+ - Add access-key API and config-reload API
14
+ - Add Claude Fable 5.1, GLM-5.3-Flash and qwen3.8-max models with pricing
15
+ - Default the Web UI to the OS/browser language on first visit
16
+ - Make the workspace file tree resizable
17
+ - Expose mounted drives in the directory picker
18
+ - Personalize the new-session page title and empty-chat greeting with the AI's name
19
+ - Localise built-in setup session titles
20
+
21
+ ### Improved
22
+ - Replace native selects with shared CustomSelect dropdowns across settings
23
+ - Overhaul the git & time-machine panel
24
+ - Improve the directory picker (#521)
25
+
26
+ ### Fixed
27
+ - Merge OpenClacky billing across all configured API keys
28
+ - Preserve custom provider and API format when editing a model
29
+ - Reclaim PTY file descriptors when a session shell dies
30
+ - Wrap ANSI-coloured CJK text without IndexError
31
+ - Prevent history edits while a session is running (#520)
32
+ - Preserve attachment badges after compression (#516)
33
+ - Flag hidden agents instead of dropping them
34
+ - Install upgrades next to the running gem's base directory
35
+ - Skip extension auto-sync for administrators (#515)
36
+ - Support the mention menu with Windows IME (#513)
37
+ - Validate extension version input (#514)
38
+ - Keep mention chips anchored and on one line while typing
39
+ - Fall back to uncolored markdown for CJK tables
40
+ - Stabilise billing toolbar layout (#517)
41
+ - Light the group unread dot for backend-created sessions (#518)
42
+ - Translate maximum reasoning effort (#522)
43
+ - Harden WSL Windows profile detection in the directory picker
44
+ - Wrap download errors so the CDN fallback fires
45
+ - Surface the git tab badge eagerly
46
+ - Show the meeting panel only in general sessions
47
+ - Whitelist the config top-level key in the extension verifier
48
+
49
+ ### More
50
+ - Extract shared ModelPicker model dropdown component
51
+ - Refine dropdown chevrons and submenu selection
52
+ - Adjust sidebar panel order and labels
53
+ - Update trial model copy to Gemini 3.7 Flash
54
+
55
+ ## [1.5.12] - 2026-08-27
56
+
57
+ ### Added
58
+ - Add VS Code-style file tree with inline file viewer
59
+ - Add quick-access sidebar for directory picker with WSL support
60
+ - Add Web UI @mention references for files, directories and sessions
61
+ - Add Gemini 3.7 Flash model with STT/video support; switch OCR default to DeepSeek vision
62
+ - Add timestamps to assistant messages
63
+ - Merge platform usage API data with local billing records
64
+ - Refresh git panel on session completion
65
+ - Allow disabling local extensions
66
+ - Expose task mappings in core events (#496 - @YeEmrick)
67
+
68
+ ### Improved
69
+ - Move file-trash sweep off the session save path and speed up session creation
70
+ - Make recharge link a visible button on model cards
71
+
72
+ ### Fixed
73
+ - Fix ask_user free-text input: render without options, guard IME composition, route Enter, render as text in IM channels, keep questions concise (#497 - @afumu)
74
+ - Fix advisor recommendations: send structured options, keep card state per session, dismiss button no longer permanently suppresses
75
+ - Fix home favorite pointing at the Windows profile under WSL
76
+ - Fix session list pagination and pin truncation
77
+ - Preserve task ids in compressed history (#500 - @YeEmrick)
78
+ - Clear stale media model override when reverting to auto
79
+ - Normalize gemini-3.7-flash name for pricing
80
+ - Fix sidebar scrollbar visibility and session list styling
81
+ - Fix messages loading created_at
82
+ - Render parallel fan-out subagent phases as flat, isolated cards
83
+ - Preserve subagent transcripts when an interrupted fan-out is resumed
84
+ - Fix spinner in subagent raise process
85
+ - Truncate messages carrying the system_injected flag
86
+ - Stop reporting awaiting_feedback for question-mark endings
87
+ - Detach channel_ui and keep channel_info unique on /bind and /unbind
88
+
7
89
  ## [1.5.11] - 2026-08-23
8
90
 
9
91
  ### Added
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ module Clacky
7
+ # Access key for server-mode authentication.
8
+ #
9
+ # Two sources, in priority order:
10
+ # 1. ~/.clacky/access_key — a file holding the raw key, writable from
11
+ # outside the process (e.g. a sandbox host pushing a key into a
12
+ # pre-started container). This is what makes rotation possible:
13
+ # ENV is a boot-time snapshot the running process can never change.
14
+ # 2. CLACKY_ACCESS_KEY env var — the original bootstrap mechanism.
15
+ module AccessKey
16
+ ENV_VAR = "CLACKY_ACCESS_KEY"
17
+
18
+ def self.resolve
19
+ from_file || from_env
20
+ end
21
+
22
+ def self.from_file
23
+ return nil unless File.exist?(key_file)
24
+
25
+ key = File.read(key_file).strip
26
+ key.empty? ? nil : key
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def self.from_env
32
+ key = ENV.fetch(ENV_VAR, "").strip
33
+ key.empty? ? nil : key
34
+ end
35
+
36
+ def self.write(key)
37
+ key = key.to_s.strip
38
+ raise ArgumentError, "access key must not be blank" if key.empty?
39
+
40
+ FileUtils.mkdir_p(File.dirname(key_file))
41
+ File.write(key_file, "#{key}\n")
42
+ FileUtils.chmod(0o600, key_file)
43
+ key
44
+ end
45
+
46
+ def self.generate
47
+ SecureRandom.hex(32)
48
+ end
49
+
50
+ def self.configured?
51
+ !resolve.nil?
52
+ end
53
+
54
+ # Resolved lazily so specs that stub Dir.home in a before-hook still work.
55
+ def self.key_file
56
+ File.join(Dir.home, ".clacky", "access_key")
57
+ end
58
+ end
59
+ end
@@ -136,11 +136,13 @@ module Clacky
136
136
  # This ensures all API calls are recorded for statistics purposes.
137
137
  billing_model = model || current_model
138
138
  effective_cost = cost || 0.0 # Use 0 if pricing is unknown
139
+ provider = @config.provider_id_for(@config.current_model)
139
140
 
140
141
  record = Billing::BillingRecord.new(
141
142
  session_id: @session_id,
142
143
  timestamp: Time.now,
143
144
  model: billing_model,
145
+ provider: provider,
144
146
  prompt_tokens: usage[:prompt_tokens] || 0,
145
147
  completion_tokens: usage[:completion_tokens] || 0,
146
148
  cache_read_tokens: usage[:cache_read_input_tokens] || 0,
@@ -624,10 +624,12 @@ module Clacky
624
624
 
625
625
  case role
626
626
  when "user"
627
- lines << "## User"
627
+ display_files = display_files_for_archive(msg)
628
+ lines << (msg[:task_id] ? "## User [Task #{msg[:task_id]}]" : "## User")
628
629
  lines << ""
629
- lines << format_message_content(content)
630
+ lines << format_archived_user_content(content, display_files)
630
631
  lines << ""
632
+ append_display_files_line(lines, display_files)
631
633
  append_ext_events_line(lines, msg)
632
634
  when "assistant"
633
635
  # If this message is itself a compressed summary, annotate the header
@@ -676,6 +678,55 @@ module Clacky
676
678
  lines
677
679
  end
678
680
 
681
+ # Serialize only the lightweight UI metadata needed to reconstruct file
682
+ # badges after compression. File paths, previews, sizes, MIME types, and
683
+ # contents deliberately stay out of the chunk archive.
684
+ def display_files_for_archive(msg)
685
+ files = Array(msg[:display_files]).dup
686
+ Array(msg[:content]).each do |block|
687
+ next unless block.is_a?(Hash)
688
+ next unless %w[image image_url].include?((block[:type] || block["type"]).to_s)
689
+
690
+ name = block[:image_name] || block["image_name"]
691
+ if name.nil? || name.to_s.strip.empty?
692
+ path = block[:image_path] || block["image_path"]
693
+ name = File.basename(path.to_s).sub(/\A[0-9a-f]{16}_/, "") unless path.to_s.empty?
694
+ end
695
+ name = "image" if name.nil? || name.to_s.strip.empty?
696
+ files << { name: name, type: "image" }
697
+ end
698
+
699
+ files.filter_map do |file|
700
+ next unless file.is_a?(Hash)
701
+
702
+ name = file[:name] || file["name"]
703
+ next if name.nil? || name.to_s.strip.empty?
704
+
705
+ type = file[:type] || file["type"] || "file"
706
+ type = "file" if type.to_s.strip.empty?
707
+ { name: name.to_s, type: type.to_s }
708
+ end.uniq { |file| [file[:name], file[:type]] }
709
+ end
710
+
711
+ # Once an image has a badge in the archive, omit the generic [image_url]
712
+ # placeholder from the visible replay text. Text blocks remain unchanged.
713
+ def format_archived_user_content(content, display_files)
714
+ return format_message_content(content) unless content.is_a?(Array)
715
+ return format_message_content(content) unless display_files.any? { |file| file[:type] == "image" }
716
+
717
+ visible_blocks = content.reject do |block|
718
+ block.is_a?(Hash) && %w[image image_url].include?((block[:type] || block["type"]).to_s)
719
+ end
720
+ format_message_content(visible_blocks)
721
+ end
722
+
723
+ def append_display_files_line(lines, files)
724
+ return if files.empty?
725
+
726
+ lines << "_Display files: #{files.to_json}_"
727
+ lines << ""
728
+ end
729
+
679
730
  # Serialize persisted extension events into the chunk MD so they survive
680
731
  # compression and can be replayed in place. Mirrors the "_Tool calls:"
681
732
  # convention; parse_chunk_md_to_rounds reads it back.
@@ -391,7 +391,11 @@ module Clacky
391
391
  preview_path: f[:preview_path] || f["preview_path"] }
392
392
  }
393
393
  all_files = image_files + disk_files
394
- ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
394
+ references = Array(msg[:display_references])
395
+ ref_options = references.empty? ? {} : { references: references }
396
+ task_options = msg[:task_id] ? { task_id: msg[:task_id] } : {}
397
+ ui.show_user_message(raw_text, **task_options, created_at: msg[:created_at], files: all_files,
398
+ **ref_options,
395
399
  editable: round[:editable] != false,
396
400
  skill_command: msg[:skill_command],
397
401
  skill_command_display: msg[:skill_command_display])
@@ -490,30 +494,34 @@ module Clacky
490
494
  current_role = nil
491
495
  current_lines = []
492
496
  current_nested_chunk = nil # chunk reference from a Compressed Summary heading
497
+ current_task_id = nil
493
498
 
494
499
  raw.each_line do |line|
495
500
  stripped = line.chomp
496
501
  if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
497
502
  # Nested chunk reference — record it, treat as assistant section
498
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
503
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
499
504
  current_role = "assistant"
500
505
  current_lines = []
501
506
  current_nested_chunk = File.join(chunk_dir, m[1])
502
- elsif stripped.match?(/\A## (User|Assistant)/)
503
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
504
- current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
507
+ current_task_id = nil
508
+ elsif (m = stripped.match(/\A## (User|Assistant)(?: \[Task ([1-9]\d*)\])?\z/))
509
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
510
+ current_role = m[1].downcase
505
511
  current_lines = []
506
512
  current_nested_chunk = nil
513
+ current_task_id = m[2]&.to_i
507
514
  elsif stripped.match?(/\A### Tool Result:/)
508
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
515
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
509
516
  current_role = "tool"
510
517
  current_lines = []
511
518
  current_nested_chunk = nil
519
+ current_task_id = nil
512
520
  else
513
521
  current_lines << line
514
522
  end
515
523
  end
516
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
524
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
517
525
 
518
526
  # Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
519
527
  sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
@@ -526,6 +534,7 @@ module Clacky
526
534
  sections.each do |sec|
527
535
  text = sec[:lines].join.strip
528
536
  text, sec_ext_events = extract_ext_events_from_text(text)
537
+ text, sec_display_files = extract_display_files_from_text(text)
529
538
 
530
539
  # Nested chunk: expand it recursively, prepend before current rounds
531
540
  if sec[:nested_chunk]
@@ -538,20 +547,23 @@ module Clacky
538
547
  next
539
548
  end
540
549
 
541
- next if text.empty? && sec_ext_events.empty?
550
+ next if text.empty? && sec_ext_events.empty? && sec_display_files.empty?
542
551
 
543
552
  if sec[:role] == "user"
544
553
  round_index += 1
545
554
  # Synthetic timestamp: spread rounds backwards from archived_at
546
555
  synthetic_ts = base_time - (sections.size - round_index) * 1.0
556
+ user_msg = {
557
+ role: "user",
558
+ content: text,
559
+ created_at: synthetic_ts,
560
+ ext_events: sec_ext_events,
561
+ _from_chunk: true
562
+ }
563
+ user_msg[:task_id] = sec[:task_id] if sec[:task_id]
564
+ user_msg[:display_files] = sec_display_files unless sec_display_files.empty?
547
565
  current_round = {
548
- user_msg: {
549
- role: "user",
550
- content: text,
551
- created_at: synthetic_ts,
552
- ext_events: sec_ext_events,
553
- _from_chunk: true
554
- },
566
+ user_msg: user_msg,
555
567
  events: [],
556
568
  # editable: false — this message was archived into a chunk MD and no
557
569
  # longer exists in the active in-memory @history, so it cannot be
@@ -608,6 +620,35 @@ module Clacky
608
620
  end
609
621
 
610
622
 
623
+ # Pull lightweight attachment badge metadata out of a chunk section.
624
+ # The marker is internal archive data and must not appear in replayed text.
625
+ # Only name + type are accepted; paths and file contents are never restored.
626
+ def extract_display_files_from_text(text)
627
+ return [text, []] unless text.include?("_Display files:")
628
+
629
+ files = []
630
+ kept = text.each_line.reject do |line|
631
+ match = line.strip.match(/\A_Display files:\s*(\[.*\])_?\z/i)
632
+ next false unless match
633
+
634
+ parsed = JSON.parse(match[1]) rescue []
635
+ Array(parsed).each do |file|
636
+ next unless file.is_a?(Hash)
637
+
638
+ name = file["name"] || file[:name]
639
+ next if name.nil? || name.to_s.strip.empty?
640
+
641
+ type = file["type"] || file[:type] || "file"
642
+ type = "file" if type.to_s.strip.empty?
643
+ files << { name: name.to_s, type: type.to_s }
644
+ end
645
+ true
646
+ end
647
+
648
+ [kept.join.strip, files]
649
+ end
650
+
651
+
611
652
  # Pull the "_Ext events: type | {json}; ..._" line out of a chunk section,
612
653
  # returning the remaining text plus the parsed events. Counterpart to
613
654
  # MessageCompressorHelper#append_ext_events_line.
@@ -733,7 +774,7 @@ module Clacky
733
774
  else
734
775
  raw_text
735
776
  end
736
- ui.show_assistant_message(text, files: [])
777
+ ui.show_assistant_message(text, files: [], created_at: msg[:created_at])
737
778
  end
738
779
 
739
780
  # Tool calls embedded in assistant message
@@ -761,6 +802,11 @@ module Clacky
761
802
  # Emit token usage stored on this message (for history replay display)
762
803
  ui.show_token_usage(msg[:token_usage]) if msg[:token_usage]
763
804
 
805
+ # Interrupted fan-out anchors its captured subagent trails onto the
806
+ # last message, which is usually this assistant turn (its tool result
807
+ # never got written). Replay them here so they survive a reload.
808
+ replay_subagent_transcript(msg, ui)
809
+
764
810
  when "user"
765
811
  # Anthropic-format tool results (role: user, content: array of tool_result blocks)
766
812
  if msg[:content].is_a?(Array)
@@ -817,8 +863,12 @@ module Clacky
817
863
  events = transcript[:events] || transcript["events"] || []
818
864
  return if events.empty?
819
865
 
866
+ skill = transcript[:skill] || transcript["skill"]
867
+ supports_phase = ui.respond_to?(:phase_start) && ui.respond_to?(:phase_end)
868
+ pid = ui.phase_start(kind: "fanout_subagent", label: skill) if supports_phase
869
+
820
870
  ui.show_subagent_start(
821
- skill: transcript[:skill] || transcript["skill"],
871
+ skill: skill,
822
872
  iterations: transcript[:iterations] || transcript["iterations"],
823
873
  cost_usd: transcript[:cost_usd] || transcript["cost_usd"]
824
874
  )
@@ -831,7 +881,7 @@ module Clacky
831
881
  case role
832
882
  when "assistant"
833
883
  text = extract_text_from_content(content).to_s.strip
834
- ui.show_assistant_message(text, files: []) unless text.empty?
884
+ ui.show_assistant_message(text, files: [], created_at: ev[:created_at] || ev["created_at"]) unless text.empty?
835
885
  Array(tool_calls).each do |tc|
836
886
  name = tc[:name] || tc["name"] || ""
837
887
  args_raw = tc[:arguments] || tc["arguments"] || {}
@@ -844,6 +894,7 @@ module Clacky
844
894
  end
845
895
 
846
896
  ui.show_subagent_end
897
+ ui.phase_end(pid) if supports_phase && pid
847
898
  end
848
899
 
849
900
  # Replace the system message in @messages with a freshly built system prompt.
@@ -928,7 +979,9 @@ module Clacky
928
979
 
929
980
  mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
930
981
  ext = mime_type.split("/").last
931
- { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
982
+ name = block[:image_name] || block["image_name"]
983
+ name = "image_#{idx + 1}.#{ext}" if name.nil? || name.to_s.strip.empty?
984
+ { name: name, mime_type: mime_type, data_url: url, path: path }
932
985
  end
933
986
  end
934
987
 
@@ -529,7 +529,15 @@ module Clacky
529
529
  private def looks_binary?(path)
530
530
  return false if path.nil? || !File.exist?(path)
531
531
  sample = File.binread(path, 8000)
532
- sample.include?("\x00") || !sample.dup.force_encoding("UTF-8").valid_encoding?
532
+ return true if sample.include?("\x00")
533
+ sample.force_encoding("UTF-8")
534
+ # The 8000-byte window can cut a multibyte char in half; drop up to
535
+ # three dangling tail bytes before judging the encoding.
536
+ 3.times do
537
+ break if sample.valid_encoding?
538
+ sample = sample.byteslice(0, sample.bytesize - 1)
539
+ end
540
+ !sample.valid_encoding?
533
541
  rescue StandardError
534
542
  true
535
543
  end
@@ -589,6 +597,7 @@ module Clacky
589
597
 
590
598
  tasks << {
591
599
  task_id: task_id,
600
+ parent_task_id: @task_parents[task_id].to_i,
592
601
  summary: summary,
593
602
  started_at: meta[:started_at],
594
603
  ended_at: meta[:ended_at],