openclacky 1.3.3 → 1.3.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. data/lib/clacky/media/output_dir.rb +0 -43
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui_interface"
4
+
5
+ module Clacky
6
+ # A UI controller that swallows every event. Used for detached/background
7
+ # subagents whose intermediate output must never reach a real UI stream
8
+ # (e.g. the WebUI chat transcript). All UIInterface methods are inherited
9
+ # as no-ops, so nothing this agent emits is broadcast anywhere.
10
+ class NullUIController
11
+ include Clacky::UIInterface
12
+ end
13
+ end
@@ -117,7 +117,7 @@ module Clacky
117
117
  puts_line("[warn] #{message}")
118
118
  end
119
119
 
120
- def show_error(message, code: nil, top_up_url: nil)
120
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
121
121
  puts_line("[error] #{message}")
122
122
  end
123
123
 
@@ -88,6 +88,30 @@ module Clacky
88
88
  "or-tts-gemini-2-5-pro" => "Gemini 2.5 Pro TTS"
89
89
  },
90
90
  "default_audio_model" => "or-tts-gemini-2-5-flash",
91
+ # Speech-to-text models served by the openclacky gateway, which
92
+ # routes them to Vertex AI Gemini (generateContent with inline
93
+ # audio parts). The gateway returns transcription text.
94
+ "stt_models" => [
95
+ "or-stt-gemini-3-5-flash",
96
+ "or-stt-gemini-1-5-pro"
97
+ ],
98
+ "stt_model_aliases" => {
99
+ "or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
100
+ "or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
101
+ },
102
+ "default_stt_model" => "or-stt-gemini-3-5-flash",
103
+ # Video understanding models served by the openclacky gateway, which
104
+ # routes video frames to Gemini (generateContent with inline image
105
+ # parts). The gateway returns analysis text.
106
+ "video_understanding_models" => [
107
+ "or-gemini-3-5-flash",
108
+ "or-gemini-3-1-pro"
109
+ ],
110
+ "video_understanding_model_aliases" => {
111
+ "or-gemini-3-5-flash" => "Gemini 3.5 Flash",
112
+ "or-gemini-3-1-pro" => "Gemini 3.1 Pro"
113
+ },
114
+ "default_video_understanding_model" => "or-gemini-3-5-flash",
91
115
  # Default OCR sidecar — used when the primary model is text-only.
92
116
  # Candidates are derived from the provider's vision-capable models;
93
117
  # this just picks the cheap+fast default to surface in "auto" mode.
@@ -418,7 +442,7 @@ module Clacky
418
442
 
419
443
  }.freeze
420
444
 
421
- MEDIA_KINDS = %w[image video audio].freeze
445
+ MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
422
446
 
423
447
  class << self
424
448
  # Check if a provider preset exists
@@ -549,11 +573,33 @@ module Clacky
549
573
  preset&.dig("audio_model_aliases") || {}
550
574
  end
551
575
 
576
+ def stt_models(provider_id)
577
+ preset = PRESETS[provider_id]
578
+ preset&.dig("stt_models") || []
579
+ end
580
+
581
+ def stt_model_aliases(provider_id)
582
+ preset = PRESETS[provider_id]
583
+ preset&.dig("stt_model_aliases") || {}
584
+ end
585
+
586
+ def video_understanding_models(provider_id)
587
+ preset = PRESETS[provider_id]
588
+ preset&.dig("video_understanding_models") || []
589
+ end
590
+
591
+ def video_understanding_model_aliases(provider_id)
592
+ preset = PRESETS[provider_id]
593
+ preset&.dig("video_understanding_model_aliases") || {}
594
+ end
595
+
552
596
  def media_model_aliases(provider_id, kind)
553
597
  case kind.to_s
554
598
  when "image" then image_model_aliases(provider_id)
555
599
  when "video" then video_model_aliases(provider_id)
556
600
  when "audio" then audio_model_aliases(provider_id)
601
+ when "stt" then stt_model_aliases(provider_id)
602
+ when "video_understanding" then video_understanding_model_aliases(provider_id)
557
603
  else {}
558
604
  end
559
605
  end
@@ -599,13 +645,15 @@ module Clacky
599
645
 
600
646
  # Unified entry for media model lookup by kind.
601
647
  # @param provider_id [String]
602
- # @param kind [String] one of "image" / "video" / "audio"
648
+ # @param kind [String] one of "image" / "video" / "audio" / "stt"
603
649
  # @return [Array<String>]
604
650
  def media_models(provider_id, kind)
605
651
  case kind.to_s
606
652
  when "image" then image_models(provider_id)
607
653
  when "video" then video_models(provider_id)
608
654
  when "audio" then audio_models(provider_id)
655
+ when "stt" then stt_models(provider_id)
656
+ when "video_understanding" then video_understanding_models(provider_id)
609
657
  else []
610
658
  end
611
659
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+
5
+ module Clacky
6
+ module RichUI
7
+ module Components
8
+ # BaseComponent provides shared rendering primitives for RichUI components.
9
+ # Used by sidebar panels and dialogs to eliminate duplicated ANSI-color helpers.
10
+ module BaseComponent
11
+ # Render muted (dim) text commonly used for secondary info
12
+ def muted(text)
13
+ "#{RubyRich::AnsiCode.color(:black, true)}#{text}#{RubyRich::AnsiCode.reset}"
14
+ end
15
+
16
+ # Render colored text with a named color
17
+ def colored(text, color)
18
+ "#{RubyRich::AnsiCode.color(color, true)}#{text}#{RubyRich::AnsiCode.reset}"
19
+ end
20
+
21
+ # Status marker symbol for todo / activity items
22
+ def status_marker(status)
23
+ case status
24
+ when :done, :completed
25
+ colored("✓", :green)
26
+ when :running, :in_progress, :active
27
+ colored("●", :blue)
28
+ when :failed, :error
29
+ colored("!", :red)
30
+ else
31
+ muted("○")
32
+ end
33
+ end
34
+
35
+ # Truncate text to a maximum length, appending "…" when cut
36
+ def truncate(text, limit = 40)
37
+ return "" if text.nil? || text.empty?
38
+
39
+ text.length > limit ? "#{text[0...limit]}…" : text
40
+ end
41
+
42
+ # Theme accessor for future theme switching.
43
+ # Currently defaults to agent_dark; can be overridden per-component.
44
+ def theme
45
+ @theme ||= RubyRich::Theme.agent_dark
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+ require_relative "../base_component"
5
+
6
+ module Clacky
7
+ module RichUI
8
+ class ApprovalDialog
9
+ include Clacky::RichUI::Components::BaseComponent
10
+
11
+ RISK_LEVELS = {
12
+ low: { label: "Low", color: :green, bar: "●○○○" },
13
+ medium: { label: "Medium", color: :yellow, bar: "●●○○" },
14
+ high: { label: "High", color: :yellow, bar: "●●●○" },
15
+ critical: { label: "Critical", color: :red, bar: "●●●●" }
16
+ }.freeze
17
+
18
+ CATEGORY_COLORS = {
19
+ file: :blue, shell: :yellow, network: :cyan, paid: :magenta
20
+ }.freeze
21
+
22
+ CHOICES = [
23
+ { key: :approve, label: "Approve", color: :green },
24
+ { key: :deny, label: "Deny", color: :red },
25
+ { key: :always_allow, label: "Always allow", color: :cyan }
26
+ ].freeze
27
+
28
+ attr_accessor :width, :height
29
+
30
+ def initialize(tool_name:, message:, params: {}, risk: :medium, category: :file)
31
+ @tool_name = tool_name
32
+ @message = message
33
+ @params = params
34
+ @risk = RISK_LEVELS[risk] || RISK_LEVELS[:medium]
35
+ @category = category
36
+ @category_color = CATEGORY_COLORS[category] || :blue
37
+ @selected_index = 0
38
+ @width = 72
39
+ @height = [params.length + 10, 12].max
40
+ @event_listeners = {}
41
+ @mutex = Mutex.new
42
+ @condition = ConditionVariable.new
43
+ @finished = false
44
+ @result = nil
45
+ @panel = RubyRich::Panel.new("", title: "Approval", border_style: @risk[:color], title_align: :center)
46
+ @layout = RubyRich::Layout.new(name: :approval_dialog, width: @width, height: @height)
47
+ @layout.update_content(@panel)
48
+ @layout.calculate_dimensions(@width, @height)
49
+ wire_keys
50
+ end
51
+
52
+ def finish(value)
53
+ @mutex.synchronize do
54
+ @result = value
55
+ @finished = true
56
+ @condition.signal
57
+ end
58
+ true
59
+ end
60
+
61
+ def wait
62
+ @mutex.synchronize { @condition.wait(@mutex) until @finished }
63
+ @result
64
+ end
65
+
66
+ def key(event_name, priority = 0, &block)
67
+ @event_listeners[event_name] ||= []
68
+ @event_listeners[event_name] << { priority: priority, block: block }
69
+ @event_listeners[event_name].sort_by! { |l| -l[:priority] }
70
+ end
71
+
72
+ def notify_listeners(event_data)
73
+ Array(@event_listeners[event_data[:name]]).each { |l| l[:block].call(event_data, nil) }
74
+ end
75
+
76
+ def render_to_buffer
77
+ @panel.content = render_content
78
+ @layout.calculate_dimensions(@width, @height)
79
+ @layout.render_to_buffer
80
+ end
81
+
82
+ private def wire_keys
83
+ key(:left, 100) { move_selection(-1); true }
84
+ key(:right, 100) { move_selection(1); true }
85
+ key(:string, 100) do |event, _live|
86
+ case event[:value]
87
+ when "h" then move_selection(-1)
88
+ when "l" then move_selection(1)
89
+ end
90
+ true
91
+ end
92
+ key(:enter, 100) do
93
+ sel = CHOICES[@selected_index]
94
+ finish(sel ? sel[:key] : :deny)
95
+ end
96
+ key(:escape, 100) { finish(:deny) }
97
+ key(:ctrl_c, 100) { finish(:deny) }
98
+ end
99
+
100
+ def move_selection(delta)
101
+ @selected_index = (@selected_index + delta) % CHOICES.length
102
+ end
103
+
104
+ def render_content
105
+ risk = @risk
106
+ lines = []
107
+ lines << ""
108
+ lines << " #{colored("Tool:", :body)} #{colored(@tool_name, :accent)} #{category_badge}"
109
+ lines << " #{colored("Risk:", :body)} #{colored(risk[:label], risk[:color])} #{colored(risk[:bar], risk[:color])}"
110
+ lines << " #{colored("Info:", :body)} #{colored(@message, :body)}"
111
+
112
+ unless @params.empty?
113
+ lines << ""
114
+ @params.each do |key, value|
115
+ val = value.to_s
116
+ val = "#{val[0..50]}..." if val.length > 54
117
+ lines << " #{muted("#{key}:")} #{colored(val, :body)}"
118
+ end
119
+ end
120
+
121
+ lines << ""
122
+ lines << render_choices
123
+ lines << ""
124
+ lines.join("\n")
125
+ end
126
+
127
+ def render_choices
128
+ CHOICES.each_with_index.map do |choice, i|
129
+ selected = i == @selected_index
130
+ prefix = selected ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset}" : " "
131
+ label = selected ? colored(choice[:label], choice[:color]) : muted(choice[:label])
132
+ "#{prefix} [#{label}]"
133
+ end.join(" ")
134
+ end
135
+
136
+ def category_badge
137
+ label = @category.to_s.capitalize
138
+ colored("[#{label}]", @category_color)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+ require_relative "../base_component"
5
+
6
+ module Clacky
7
+ module RichUI
8
+ class ConfigMenuDialog
9
+ include Clacky::RichUI::Components::BaseComponent
10
+
11
+ attr_accessor :width, :height
12
+
13
+ def initialize(choices:, selected_index: 0, title: "Model Configuration", width: 86)
14
+ @choices = choices
15
+ @selected_index = selected_index
16
+ @width = width
17
+ @height = [choices.length + 7, 12].max
18
+ @event_listeners = {}
19
+ @mutex = Mutex.new
20
+ @condition = ConditionVariable.new
21
+ @finished = false
22
+ @result = nil
23
+ @panel = RubyRich::Panel.new("", title: title, border_style: :cyan, title_align: :center)
24
+ @layout = RubyRich::Layout.new(name: :config_dialog, width: @width, height: @height)
25
+ @layout.update_content(@panel)
26
+ @layout.calculate_dimensions(@width, @height)
27
+ end
28
+
29
+ def selected_choice
30
+ @choices[@selected_index]
31
+ end
32
+
33
+ def move_up
34
+ move(-1)
35
+ end
36
+
37
+ def move_down
38
+ move(1)
39
+ end
40
+
41
+ def finish(value)
42
+ @mutex.synchronize do
43
+ @result = value
44
+ @finished = true
45
+ @condition.signal
46
+ end
47
+ true
48
+ end
49
+
50
+ def wait
51
+ @mutex.synchronize { @condition.wait(@mutex) until @finished }
52
+ @result
53
+ end
54
+
55
+ def key(event_name, priority = 0, &block)
56
+ @event_listeners[event_name] ||= []
57
+ @event_listeners[event_name] << { priority: priority, block: block }
58
+ @event_listeners[event_name].sort_by! { |listener| -listener[:priority] }
59
+ end
60
+
61
+ def notify_listeners(event_data)
62
+ Array(@event_listeners[event_data[:name]]).each { |listener| listener[:block].call(event_data, nil) }
63
+ end
64
+
65
+ def render_to_buffer
66
+ @panel.content = render_content
67
+ @layout.calculate_dimensions(@width, @height)
68
+ @layout.render_to_buffer
69
+ end
70
+
71
+ def move(delta)
72
+ return if @choices.empty?
73
+
74
+ index = @selected_index
75
+ loop do
76
+ index = (index + delta) % @choices.length
77
+ break unless @choices[index][:disabled]
78
+ break if index == @selected_index
79
+ end
80
+ @selected_index = index
81
+ end
82
+
83
+ def render_content
84
+ lines = [""]
85
+ @choices.each_with_index do |choice, index|
86
+ lines << choice_line(choice, selected: index == @selected_index)
87
+ end
88
+ lines << ""
89
+ lines << "#{muted("↑↓/jk: Navigate")} • #{muted("Enter: Select")} • #{muted("Esc/q: Cancel")}"
90
+ lines.join("\n")
91
+ end
92
+
93
+ def choice_line(choice, selected:)
94
+ return " #{muted(choice[:label])}" if choice[:disabled]
95
+
96
+ prefix = selected ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset} " : " "
97
+ label = selected ? RubyRich::AnsiCode.color(:white, true) + choice[:label] + RubyRich::AnsiCode.reset : choice[:label]
98
+ "#{prefix}#{label}"
99
+ end
100
+
101
+ private :move,
102
+ :render_content,
103
+ :choice_line
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+ require_relative "../base_component"
5
+
6
+ module Clacky
7
+ module RichUI
8
+ class FormDialog
9
+ include Clacky::RichUI::Components::BaseComponent
10
+
11
+ attr_accessor :width, :height
12
+
13
+ def initialize(title:, fields:, width: 92)
14
+ @title = title
15
+ @fields = fields
16
+ @field_index = 0
17
+ @editors = fields.map do |field|
18
+ RubyRich::LineEditor.new.tap { |editor| editor.value = field[:default].to_s }
19
+ end
20
+ @width = width
21
+ @height = [fields.length * 3 + 8, 16].max
22
+ @event_listeners = {}
23
+ @mutex = Mutex.new
24
+ @condition = ConditionVariable.new
25
+ @finished = false
26
+ @result = nil
27
+ @panel = RubyRich::Panel.new("", title: title, border_style: :cyan, title_align: :center)
28
+ @layout = RubyRich::Layout.new(name: :form_dialog, width: @width, height: @height)
29
+ @layout.update_content(@panel)
30
+ @layout.calculate_dimensions(@width, @height)
31
+ wire_default_keys
32
+ end
33
+
34
+ def finish(value)
35
+ @mutex.synchronize do
36
+ @result = value
37
+ @finished = true
38
+ @condition.signal
39
+ end
40
+ true
41
+ end
42
+
43
+ def wait
44
+ @mutex.synchronize { @condition.wait(@mutex) until @finished }
45
+ @result
46
+ end
47
+
48
+ def key(event_name, priority = 0, &block)
49
+ @event_listeners[event_name] ||= []
50
+ @event_listeners[event_name] << { priority: priority, block: block }
51
+ @event_listeners[event_name].sort_by! { |listener| -listener[:priority] }
52
+ end
53
+
54
+ def notify_listeners(event_data)
55
+ listeners = Array(@event_listeners[event_data[:name]])
56
+ listeners.each { |listener| listener[:block].call(event_data, nil) }
57
+ end
58
+
59
+ def render_to_buffer
60
+ @panel.content = render_content
61
+ @layout.calculate_dimensions(@width, @height)
62
+ @layout.render_to_buffer
63
+ end
64
+
65
+ def wire_default_keys
66
+ key(:string, 100) { |event, _live| current_editor.insert(event[:value]); true }
67
+ key(:paste, 100) { |event, _live| current_editor.insert(event[:value]); true }
68
+ key(:backspace, 100) { current_editor.backspace; true }
69
+ key(:delete, 100) { current_editor.delete; true }
70
+ key(:left, 100) { current_editor.move_left; true }
71
+ key(:right, 100) { current_editor.move_right; true }
72
+ key(:ctrl_a, 100) { current_editor.buffer_start; true }
73
+ key(:ctrl_e, 100) { current_editor.buffer_end; true }
74
+ key(:up, 100) { move_field(-1); true }
75
+ key(:down, 100) { move_field(1); true }
76
+ key(:tab, 100) { move_field(1); true }
77
+ key(:shift_tab, 100) { move_field(-1); true }
78
+ key(:enter, 100) { finish(values); true }
79
+ end
80
+
81
+ def current_editor
82
+ @editors[@field_index]
83
+ end
84
+
85
+ def move_field(delta)
86
+ @field_index = (@field_index + delta) % @fields.length
87
+ end
88
+
89
+ def values
90
+ @fields.each_with_index.to_h { |field, index| [field[:name].to_sym, @editors[index].value] }
91
+ end
92
+
93
+ def render_content
94
+ lines = [""]
95
+ @fields.each_with_index do |field, index|
96
+ focused = index == @field_index
97
+ marker = focused ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset}" : " "
98
+ label = focused ? "#{RubyRich::AnsiCode.color(:white, true)}#{field[:label]}#{RubyRich::AnsiCode.reset}" : field[:label]
99
+ lines << "#{marker} #{label}"
100
+ lines << " #{render_field_value(field, @editors[index], focused: focused)}"
101
+ lines << ""
102
+ end
103
+ lines << "#{muted("Tab/↑↓: Field")} • #{muted("Enter: Save")} • #{muted("Esc: Cancel")}"
104
+ lines.join("\n")
105
+ end
106
+
107
+ def render_field_value(field, editor, focused:)
108
+ raw = editor.value
109
+ text = if field[:mask] && !raw.empty?
110
+ "*" * raw.length
111
+ elsif raw.empty?
112
+ field[:placeholder].to_s
113
+ else
114
+ raw
115
+ end
116
+ color = raw.empty? ? :black : (focused ? :cyan : :white)
117
+ "#{RubyRich::AnsiCode.color(color, true)}#{text}#{RubyRich::AnsiCode.reset}"
118
+ end
119
+
120
+ private :wire_default_keys,
121
+ :current_editor,
122
+ :move_field,
123
+ :values,
124
+ :render_content,
125
+ :render_field_value
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+ require_relative "sidebar_panels"
5
+
6
+ module Clacky
7
+ module RichUI
8
+ class RichSidebar
9
+ MODES = %i[work tasks context auto hidden].freeze
10
+ PANEL_HEIGHT_RATIOS = { 1 => [1.0], 2 => [0.5, 0.5], 3 => [0.34, 0.33, 0.33] }.freeze
11
+ PANEL_NAMES = { work: "Work", tasks: "Tasks", context: "Context" }.freeze
12
+
13
+ attr_accessor :width, :height
14
+ attr_reader :mode
15
+
16
+ def initialize
17
+ @mode = :auto
18
+ @panels = {
19
+ work: RichWorkPanel.new,
20
+ tasks: RichTasksPanel.new,
21
+ context: RichContextPanel.new
22
+ }
23
+ @width = 0
24
+ @height = 0
25
+ end
26
+
27
+ def update_plan(text)
28
+ @panels[:work].update_plan(text)
29
+ self
30
+ end
31
+
32
+ def set_tasks(tasks)
33
+ @panels[:tasks].set_tasks(tasks)
34
+ self
35
+ end
36
+
37
+ def update_context(token_data)
38
+ @panels[:context].update_tokens(token_data)
39
+ self
40
+ end
41
+
42
+ def update_work_activities(activities)
43
+ @panels[:work].update_activities(activities)
44
+ self
45
+ end
46
+
47
+ def update_work_stats(tasks, cost)
48
+ @panels[:work].update_stats(tasks, cost)
49
+ end
50
+
51
+ # Returns the tasks list from the tasks panel (for tests/assertions)
52
+ def tasks
53
+ @panels[:tasks].instance_variable_get(:@tasks)
54
+ end
55
+
56
+ def set_mode(mode)
57
+ @mode = MODES.include?(mode) ? mode : :auto
58
+ end
59
+
60
+ def render
61
+ visible = visible_panels
62
+ return [""] if visible.empty?
63
+
64
+ heights = panel_heights(visible)
65
+ panel_lines = visible.each_with_index.flat_map do |key, i|
66
+ panel = @panels[key]
67
+ panel.width = [@width - 2, 1].max
68
+ panel.height = heights[i]
69
+ p = RubyRich::Panel.new(panel.render, title: PANEL_NAMES[key], border_style: :blue, title_align: :left)
70
+ p.width = @width
71
+ p.height = heights[i]
72
+ p.render
73
+ end
74
+ panel_lines.first(@height)
75
+ end
76
+
77
+ private def visible_panels
78
+ case @mode
79
+ when :work then [:work]
80
+ when :tasks then [:tasks]
81
+ when :context then [:context]
82
+ when :hidden then []
83
+ when :auto
84
+ @panels.select { |_key, panel| panel_has_content?(panel) }.keys
85
+ else
86
+ []
87
+ end
88
+ end
89
+
90
+ def panel_heights(visible)
91
+ max_h = [@height, 1].max
92
+ # Context panel gets exactly 6 lines; remaining space split among others
93
+ ctx_idx = visible.index(:context)
94
+ if ctx_idx
95
+ ctx_h = [6, max_h / [visible.length, 1].max].min
96
+ other_count = visible.length - 1
97
+ other_h = other_count > 0 ? (max_h - ctx_h) / other_count : 0
98
+ visible.each_with_index.map { |_, i| i == ctx_idx ? ctx_h : [other_h, 1].max }
99
+ else
100
+ h = max_h / visible.length
101
+ visible.map { [h, 1].max }
102
+ end
103
+ end
104
+
105
+ def panel_has_content?(panel)
106
+ case panel
107
+ when RichWorkPanel
108
+ true # Always show — shows "0 tasks · $0.0000" when empty
109
+ when RichTasksPanel
110
+ panel.has_tasks?
111
+ when RichContextPanel
112
+ true # Always show — shows "No token data" when empty
113
+ else
114
+ false
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end