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
@@ -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
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+ require_relative "base_component"
5
+
6
+ module Clacky
7
+ module RichUI
8
+ class RichWorkPanel
9
+ include Components::BaseComponent
10
+
11
+ attr_accessor :width, :height
12
+
13
+ def initialize
14
+ @plan = ""
15
+ @activities = []
16
+ @tasks = 0
17
+ @cost = 0.0
18
+ end
19
+
20
+ def update_plan(text)
21
+ @plan = text.to_s
22
+ end
23
+
24
+ def update_activities(activities)
25
+ @activities = Array(activities).last(8)
26
+ end
27
+
28
+ def update_stats(tasks, cost)
29
+ @tasks = tasks.to_i
30
+ @cost = cost.to_f
31
+ end
32
+
33
+ def render
34
+ lines = []
35
+ lines << @plan unless @plan.empty?
36
+ unless @activities.empty?
37
+ lines << "" unless lines.empty?
38
+ @activities.each do |a|
39
+ marker = status_marker(a[:status] || :pending)
40
+ lines << "#{marker} #{a[:label]}"
41
+ end
42
+ end
43
+ lines << "" unless lines.empty?
44
+ lines << muted("#{@tasks} tasks · $#{@cost.round(4)}")
45
+ lines.join("\n")
46
+ end
47
+ end
48
+
49
+ class RichTasksPanel
50
+ include Components::BaseComponent
51
+
52
+ attr_accessor :width, :height
53
+
54
+ def initialize
55
+ @tasks = []
56
+ end
57
+
58
+ def set_tasks(tasks)
59
+ @tasks = Array(tasks)
60
+ end
61
+
62
+ def has_tasks?
63
+ !@tasks.empty?
64
+ end
65
+
66
+ def render
67
+ return muted("No active tasks") if @tasks.empty?
68
+
69
+ lines = []
70
+ done_count = 0
71
+ total = @tasks.length
72
+ @tasks.each do |task|
73
+ label = task_label(task)
74
+ status = task_status(task)
75
+ done_count += 1 if %i[done completed].include?(status)
76
+ lines << "#{status_marker(status)} #{label}"
77
+ end
78
+ lines << "" unless lines.empty?
79
+ lines << muted("#{done_count}/#{total} done")
80
+ lines.join("\n")
81
+ end
82
+
83
+ private def task_label(task)
84
+ case task
85
+ when Hash
86
+ (task[:label] || task["label"] || task[:title] || task["title"] ||
87
+ task[:content] || task["content"] || task[:task] || task["task"]).to_s
88
+ else
89
+ task.to_s
90
+ end
91
+ end
92
+
93
+ def task_status(task)
94
+ case task
95
+ when Hash then (task[:status] || task["status"] || :pending).to_sym
96
+ else :pending
97
+ end
98
+ end
99
+ end
100
+
101
+ class RichContextPanel
102
+ include Components::BaseComponent
103
+
104
+ attr_accessor :width, :height
105
+
106
+ def initialize
107
+ @token_usage = nil
108
+ end
109
+
110
+ def update_tokens(data)
111
+ @token_usage = data
112
+ end
113
+
114
+ def render
115
+ return muted("No token data") unless @token_usage
116
+
117
+ input = @token_usage[:prompt_tokens] || @token_usage[:input] || 0
118
+ output = @token_usage[:completion_tokens] || @token_usage[:output] || 0
119
+ total = @token_usage[:total_tokens] || @token_usage[:total] || (input + output)
120
+ cost = @token_usage[:cost]
121
+
122
+ lines = []
123
+ lines << "#{muted("prompt:")} #{input} tok"
124
+ lines << "#{muted("output:")} #{output} tok"
125
+ lines << "#{muted("total:")} #{total} tok"
126
+ if cost
127
+ lines << ""
128
+ lines << "#{muted("cost:")} $#{cost.round(4)}"
129
+ end
130
+ lines.join("\n")
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+
5
+ module Clacky
6
+ module RichUI
7
+ class RichStatusView
8
+ SPINNER = ['|', '/', '-', '\\'].freeze
9
+
10
+ attr_accessor :width, :height
11
+
12
+ def initialize(shell)
13
+ @shell = shell
14
+ @spinner_index = 0
15
+ @width = 0
16
+ @height = 1
17
+ end
18
+
19
+ def render
20
+ theme = @shell.theme
21
+ clacky = @shell.clacky_controller
22
+ return [""] unless clacky
23
+
24
+ status = clacky.status || "idle"
25
+ tasks = clacky.tasks_count || 0
26
+ cost = clacky.total_cost || 0.0
27
+ turn = clacky.turn_active
28
+ ctrlc = clacky.ctrl_c_warning
29
+
30
+ mode = clacky.config&.dig(:mode) || "agent"
31
+ model = clacky.config&.dig(:model) || "—"
32
+ latency = clacky.latest_latency
33
+ model_str = latency ? "#{model} (#{latency})" : model
34
+ meta_right = "#{mode} · #{model_str}"
35
+
36
+ if ctrlc
37
+ line = "#{theme.style("⏎", :error)} #{theme.style(ctrlc, :error)}"
38
+ elsif turn
39
+ @spinner_index = (@spinner_index + 1) % SPINNER.length
40
+ spinner = theme.style(SPINNER[@spinner_index], :accent)
41
+ label = clacky.work_label || "working…"
42
+ right = "#{meta_right} · #{tasks} tasks · $#{cost.round(4)}"
43
+ left = "#{spinner} #{theme.style(label, :body)}"
44
+ else
45
+ right = "#{meta_right} · #{tasks} tasks · $#{cost.round(4)} · Ctrl+C quit"
46
+ left = theme.style(status || "idle", :accent)
47
+ end
48
+ space = [@width - visible_len(left) - visible_len(right) - 2, 1].max
49
+ line = "#{left}#{" " * space}#{theme.style(right, :muted)}"
50
+ [line]
51
+ end
52
+
53
+ private def visible_len(text)
54
+ text.to_s.gsub(/\e\[[0-9;:]*m/, "").length
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+
5
+ module Clacky
6
+ module RichUI
7
+ class ThinkingLiveView
8
+ SPINNER = ['|', '/', '-', '\\'].freeze
9
+
10
+ attr_accessor :width, :height
11
+ attr_reader :start_time
12
+
13
+ def initialize(shell)
14
+ @shell = shell
15
+ @status = :idle # :idle, :thinking, :done
16
+ @text = +""
17
+ @start_time = nil
18
+ @spinner_index = 0
19
+ @width = 0
20
+ @height = 0
21
+ end
22
+
23
+ def desired_height
24
+ @status == :idle ? 0 : 6
25
+ end
26
+
27
+ def start_thinking
28
+ @status = :thinking
29
+ @start_time = Time.now
30
+ @text = +""
31
+ @shell.live&.refresh
32
+ end
33
+
34
+ def append_text(delta)
35
+ @text << delta.to_s
36
+ @shell.live&.refresh
37
+ end
38
+
39
+ def finish_thinking
40
+ @status = :done
41
+ @shell.live&.refresh
42
+ end
43
+
44
+ def idle!
45
+ @status = :idle
46
+ @text = +""
47
+ @start_time = nil
48
+ @shell.live&.refresh
49
+ end
50
+
51
+ def render
52
+ theme = @shell.theme
53
+ case @status
54
+ when :idle
55
+ [""]
56
+ when :thinking
57
+ elapsed = @start_time ? (Time.now - @start_time).round(1) : 0.0
58
+ @spinner_index = (@spinner_index + 1) % SPINNER.length
59
+ spinner = theme.style(SPINNER[@spinner_index], :thinking)
60
+ time_str = theme.style("#{elapsed}s", :accent)
61
+ header = " #{spinner} #{theme.style("Thinking", :thinking)} #{time_str}"
62
+ lines = [header]
63
+ visible = @text.to_s.split("\n").last(5)
64
+ visible.each { |l| lines << " #{theme.style(l, :thinking)}" }
65
+ (5 - visible.length).times { lines << "" }
66
+ lines
67
+ when :done
68
+ elapsed = @start_time ? (Time.now - @start_time).round(1) : 0.0
69
+ header = " #{theme.style("Thinking done", :thinking)} #{theme.style("#{elapsed}s", :accent)}"
70
+ lines = [header]
71
+ visible = @text.to_s.split("\n").last(4)
72
+ visible.each { |l| lines << " #{theme.style(l, :muted)}" }
73
+ (4 - visible.length).times { lines << "" }
74
+ lines
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module RichUI
5
+ # Lightweight id-based entry tracker for RubyRich Transcript entries.
6
+ #
7
+ # Replaces the fragile @tool_ids stack with explicit id-based tracking.
8
+ # RubyRich's AgentShell already returns stable ids from start_tool_call
9
+ # and accepts ids on finish_tool_call / update_tool_call / remove_entry.
10
+ # EntryTracker wraps these with a semantic API and is ready for future
11
+ # expansion (tracking markdown blocks, thinking entries, etc.).
12
+ class EntryTracker
13
+ def initialize
14
+ @tool_stack = [] # ordered tool_call ids (push on start, pop on finish/error)
15
+ @entries = {} # id => { type:, ... } for future cross-type tracking
16
+ end
17
+
18
+ # Record a newly started tool call.
19
+ # Returns the id for chaining convenience.
20
+ def register_tool(id)
21
+ @tool_stack << id
22
+ @entries[id] = { type: :tool_call }
23
+ id
24
+ end
25
+
26
+ # Pop and return the most recent tool_call id.
27
+ # Returns nil when the stack is empty (tool output without a preceding call).
28
+ def pop_tool_id
29
+ id = @tool_stack.pop
30
+ @entries.delete(id) if id
31
+ id
32
+ end
33
+
34
+ # The most recent tool_call id without popping.
35
+ def current_tool_id
36
+ @tool_stack.last
37
+ end
38
+
39
+ # Are there any pending (unfinished) tool calls?
40
+ def pending_tool?
41
+ !@tool_stack.empty?
42
+ end
43
+
44
+ # Remove a specific entry by id.
45
+ def remove(id)
46
+ @entries.delete(id)
47
+ @tool_stack.delete(id)
48
+ end
49
+
50
+ # Number of pending tool calls.
51
+ def pending_count
52
+ @tool_stack.length
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module RichUI
5
+ class LayoutAdapter
6
+ def initialize(shell)
7
+ @shell = shell
8
+ end
9
+
10
+ def clear_output
11
+ @shell.transcript.store.entries.clear
12
+ @shell.viewport.scroll_to_bottom
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module RichUI
5
+ class ProgressHandleAdapter
6
+ def initialize(handle)
7
+ @handle = handle
8
+ end
9
+
10
+ def update(message: nil, metadata: nil)
11
+ _ = metadata
12
+ @handle.update(message.to_s) if message
13
+ end
14
+
15
+ def finish(final_message: nil)
16
+ final_message ? @handle.finish(final_message.to_s) : @handle.finish
17
+ end
18
+
19
+ def cancel
20
+ @handle.cancel
21
+ end
22
+ end
23
+ end
24
+ end