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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -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/llm_caller.rb +10 -4
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent.rb +3 -2
  8. data/lib/clacky/agent_config.rb +2 -14
  9. data/lib/clacky/api_extension.rb +262 -0
  10. data/lib/clacky/api_extension_loader.rb +156 -0
  11. data/lib/clacky/cli.rb +93 -3
  12. data/lib/clacky/client.rb +38 -13
  13. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  14. data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
  15. data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
  16. data/lib/clacky/idle_compression_timer.rb +3 -1
  17. data/lib/clacky/locales/en.rb +26 -0
  18. data/lib/clacky/locales/i18n.rb +26 -0
  19. data/lib/clacky/locales/zh.rb +26 -0
  20. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  21. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  22. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  23. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  24. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  25. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  26. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  27. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  28. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  29. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  30. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  31. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  32. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  33. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  34. data/lib/clacky/rich_ui.rb +57 -0
  35. data/lib/clacky/rich_ui_controller.rb +3 -1549
  36. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  37. data/lib/clacky/server/http_server.rb +150 -103
  38. data/lib/clacky/server/session_registry.rb +1 -1
  39. data/lib/clacky/shell_hook_loader.rb +1 -1
  40. data/lib/clacky/tools/edit.rb +14 -2
  41. data/lib/clacky/ui2/ui_controller.rb +7 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky/web/app.css +56 -59
  44. data/lib/clacky/web/app.js +65 -7
  45. data/lib/clacky/web/components/onboard.js +18 -2
  46. data/lib/clacky/web/core/aside.js +8 -3
  47. data/lib/clacky/web/core/ext.js +1 -1
  48. data/lib/clacky/web/features/skills/store.js +30 -2
  49. data/lib/clacky/web/features/skills/view.js +32 -1
  50. data/lib/clacky/web/features/workspace/view.js +1 -1
  51. data/lib/clacky/web/i18n.js +32 -20
  52. data/lib/clacky/web/index.html +9 -17
  53. data/lib/clacky/web/sessions.js +286 -28
  54. data/lib/clacky/web/settings.js +109 -111
  55. data/lib/clacky/web/ws-dispatcher.js +7 -3
  56. data/lib/clacky.rb +17 -2
  57. metadata +38 -2
  58. 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` | no | absolute path | Per-call override. When omitted, falls back to the user's `media_output_dir` setting (Settings → Models → Media Output Directory), then to the working directory. The image is always saved into the `assets/generated/` subdirectory of whichever path is used. |
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` | no | absolute path | Per-call override; same fallback chain as `image` (user setting cwd). MP4 saved into the `assets/generated/` subdirectory of whichever path is used. |
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` | no | absolute path | WAV saved under `<output_dir>/assets/generated/`. |
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.save(@agent.to_session_data(status: :success))
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