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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +5 -3
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +77 -11
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +343 -0
- data/lib/clacky/api_extension_loader.rb +168 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +47 -25
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +291 -109
- data/lib/clacky/server/session_registry.rb +5 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +147 -31
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +201 -81
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +19 -16
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +36 -23
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +48 -13
- data/lib/clacky/web/index.html +24 -17
- data/lib/clacky/web/sessions.js +427 -79
- data/lib/clacky/web/settings.js +143 -113
- data/lib/clacky/web/ws-dispatcher.js +18 -6
- data/lib/clacky.rb +27 -5
- metadata +45 -2
- 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
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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
|