openclacky 1.3.3 → 1.3.4
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 +26 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent.rb +3 -2
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
- data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
- data/lib/clacky/idle_compression_timer.rb +3 -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/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/http_server.rb +150 -103
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +56 -59
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +18 -2
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/skills/store.js +30 -2
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +32 -20
- data/lib/clacky/web/index.html +9 -17
- data/lib/clacky/web/sessions.js +286 -28
- data/lib/clacky/web/settings.js +109 -111
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -2
- metadata +38 -2
- data/lib/clacky/media/output_dir.rb +0 -43
|
@@ -59,7 +59,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
|
|
|
59
59
|
-H "Content-Type: application/json" \
|
|
60
60
|
-d '{
|
|
61
61
|
"prompt": "A clean, modern hero illustration for a tech startup landing page. Soft gradient background, abstract geometric shapes in blue and purple, minimal style, 4K quality.",
|
|
62
|
-
"aspect_ratio": "landscape"
|
|
62
|
+
"aspect_ratio": "landscape",
|
|
63
|
+
"output_dir": "'"$(pwd)"'"
|
|
63
64
|
}'
|
|
64
65
|
```
|
|
65
66
|
|
|
@@ -73,7 +74,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
|
|
|
73
74
|
|----------------|----------|-------------------------------------|-------|
|
|
74
75
|
| `prompt` | yes | string | Be detailed and concrete. See prompt tips below. |
|
|
75
76
|
| `aspect_ratio` | no | `landscape` / `square` / `portrait` | Defaults to `landscape`. |
|
|
76
|
-
| `output_dir` |
|
|
77
|
+
| `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. The image is saved under `<output_dir>/assets/generated/`. |
|
|
77
78
|
| `image` | no | file path / base64 / data URL | A single input image to **edit**. Triggers image-edit mode (see below). |
|
|
78
79
|
| `images` | no | array of the above | Multiple input images for a multi-image edit. Takes precedence over `image`. |
|
|
79
80
|
|
|
@@ -204,7 +205,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
|
|
|
204
205
|
-d '{
|
|
205
206
|
"prompt": "A cinematic drone shot flying over a misty mountain range at sunrise, golden light, 4K.",
|
|
206
207
|
"aspect_ratio": "landscape",
|
|
207
|
-
"duration_seconds": 8
|
|
208
|
+
"duration_seconds": 8,
|
|
209
|
+
"output_dir": "'"$(pwd)"'"
|
|
208
210
|
}'
|
|
209
211
|
```
|
|
210
212
|
|
|
@@ -214,7 +216,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
|
|
|
214
216
|
| `aspect_ratio` | no | `landscape` / `portrait` | Defaults to `landscape` (16:9). |
|
|
215
217
|
| `duration_seconds` | no | 4–8 | Defaults to 8. |
|
|
216
218
|
| `image` | no | `{ "b64_json": "...", "mime_type": "image/png" }` | Optional first frame for image-to-video. |
|
|
217
|
-
| `output_dir` |
|
|
219
|
+
| `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. MP4 saved under `<output_dir>/assets/generated/`. |
|
|
218
220
|
|
|
219
221
|
### Response (success)
|
|
220
222
|
|
|
@@ -262,7 +264,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
|
|
|
262
264
|
-H "Content-Type: application/json" \
|
|
263
265
|
-d '{
|
|
264
266
|
"input": "Hello and welcome to openclacky. Today we will explore...",
|
|
265
|
-
"voice": "Kore"
|
|
267
|
+
"voice": "Kore",
|
|
268
|
+
"output_dir": "'"$(pwd)"'"
|
|
266
269
|
}'
|
|
267
270
|
```
|
|
268
271
|
|
|
@@ -270,7 +273,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
|
|
|
270
273
|
|--------------|----------|---------------------------------|-------|
|
|
271
274
|
| `input` | yes | string | The text to speak. Plain prose works best; you can prefix with style cues like "Say cheerfully:" or "In a calm tone:". |
|
|
272
275
|
| `voice` | no | string voice name | Defaults to `Kore`. Common Gemini voices: `Kore`, `Puck`, `Charon`, `Fenrir`, `Aoede`. |
|
|
273
|
-
| `output_dir` |
|
|
276
|
+
| `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. WAV saved under `<output_dir>/assets/generated/`. |
|
|
274
277
|
|
|
275
278
|
Generation typically takes 2–10 seconds depending on length. The request
|
|
276
279
|
blocks until the WAV is ready.
|
|
@@ -121,7 +121,9 @@ module Clacky
|
|
|
121
121
|
success = @agent.trigger_idle_compression
|
|
122
122
|
|
|
123
123
|
if success && @session_manager
|
|
124
|
-
@session_manager.
|
|
124
|
+
existing = @session_manager.load(@agent.session_id)
|
|
125
|
+
original_updated_at = existing&.dig(:updated_at) ? Time.parse(existing[:updated_at].to_s) : nil
|
|
126
|
+
@session_manager.save(@agent.to_session_data(status: :success, updated_at: original_updated_at))
|
|
125
127
|
end
|
|
126
128
|
|
|
127
129
|
@on_compress&.call(success)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Locales
|
|
5
|
+
EN = {
|
|
6
|
+
"llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
|
|
7
|
+
"llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
|
|
8
|
+
"llm.error.invalid_api_key" => "Invalid API key, please check your configuration",
|
|
9
|
+
"llm.error.403.model_not_allowed" => "This model is not available on your current plan",
|
|
10
|
+
"llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
|
|
11
|
+
"llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
|
|
12
|
+
"llm.error.403.quota_exceeded" => "Quota exceeded, please upgrade your plan",
|
|
13
|
+
"llm.error.403.access_denied" => "Access denied, please check your API key permissions",
|
|
14
|
+
"llm.error.403.default" => "Access denied",
|
|
15
|
+
"llm.error.endpoint_not_found" => "API endpoint not found, please check your service URL",
|
|
16
|
+
"llm.error.rate_limit_429" => "Rate limit exceeded, please wait a moment",
|
|
17
|
+
"llm.error.server_error" => "Service temporarily unavailable (%<status>d), retrying...",
|
|
18
|
+
"llm.error.unexpected" => "Unexpected error (%<status>d)",
|
|
19
|
+
"llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
|
|
20
|
+
"llm.error.bad_request" => "Bad request: invalid parameters. Please check your model configuration",
|
|
21
|
+
"llm.error.request_timeout" => "Request timed out after %<retries>d retries",
|
|
22
|
+
"llm.error.network_failed" => "Network connection failed after %<retries>d retries",
|
|
23
|
+
"llm.error.service_unavailable" => "Service unavailable after %<retries>d retries"
|
|
24
|
+
}.freeze
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "en"
|
|
4
|
+
require_relative "zh"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
module I18n
|
|
8
|
+
LOCALES = {
|
|
9
|
+
"zh" => Clacky::Locales::ZH,
|
|
10
|
+
"en" => Clacky::Locales::EN
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.t(key, **vars)
|
|
14
|
+
table = LOCALES[locale] || LOCALES["en"]
|
|
15
|
+
msg = table[key] || LOCALES["en"][key] || key
|
|
16
|
+
vars.empty? ? msg : format(msg, **vars)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.locale
|
|
20
|
+
return Thread.current[:lang] if Thread.current[:lang]
|
|
21
|
+
|
|
22
|
+
lang = ENV["LC_ALL"] || ENV["LC_MESSAGES"] || ENV["LANG"] || ""
|
|
23
|
+
lang.match?(/\Azh/i) ? "zh" : "en"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Locales
|
|
5
|
+
ZH = {
|
|
6
|
+
"llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
|
|
7
|
+
"llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
|
|
8
|
+
"llm.error.invalid_api_key" => "API 密钥无效,请检查配置",
|
|
9
|
+
"llm.error.403.model_not_allowed" => "当前模型不支持免费试用,请升级套餐或切换其他模型",
|
|
10
|
+
"llm.error.403.api_key_revoked" => "API 密钥已被撤销,请前往控制台重新生成",
|
|
11
|
+
"llm.error.403.api_key_expired" => "API 密钥已过期,请前往控制台重新生成",
|
|
12
|
+
"llm.error.403.quota_exceeded" => "配额已用完,请升级套餐",
|
|
13
|
+
"llm.error.403.access_denied" => "访问被拒绝,请检查 API 密钥权限",
|
|
14
|
+
"llm.error.403.default" => "访问被拒绝",
|
|
15
|
+
"llm.error.endpoint_not_found" => "API 端点不存在,请检查服务地址配置",
|
|
16
|
+
"llm.error.rate_limit_429" => "请求过于频繁,请稍候重试",
|
|
17
|
+
"llm.error.server_error" => "服务暂时不可用(%<status>d),正在重试...",
|
|
18
|
+
"llm.error.unexpected" => "请求失败(%<status>d)",
|
|
19
|
+
"llm.error.html_response" => "服务暂时不可用(收到 HTML 错误页),正在重试...",
|
|
20
|
+
"llm.error.bad_request" => "请求参数有误,请检查模型配置或重试",
|
|
21
|
+
"llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次)",
|
|
22
|
+
"llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次)",
|
|
23
|
+
"llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)"
|
|
24
|
+
}.freeze
|
|
25
|
+
end
|
|
26
|
+
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
|