openclacky 1.3.2 → 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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/docs/rich_ui_guide.md +277 -0
  7. data/docs/rich_ui_refactor_plan.md +396 -0
  8. data/lib/clacky/agent/llm_caller.rb +10 -4
  9. data/lib/clacky/agent/session_serializer.rb +52 -7
  10. data/lib/clacky/agent/time_machine.rb +247 -26
  11. data/lib/clacky/agent.rb +15 -3
  12. data/lib/clacky/api_extension.rb +262 -0
  13. data/lib/clacky/api_extension_loader.rb +156 -0
  14. data/lib/clacky/cli.rb +93 -3
  15. data/lib/clacky/client.rb +38 -13
  16. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  17. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  18. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  19. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  20. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  21. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
  23. data/lib/clacky/idle_compression_timer.rb +3 -1
  24. data/lib/clacky/locales/en.rb +26 -0
  25. data/lib/clacky/locales/i18n.rb +26 -0
  26. data/lib/clacky/locales/zh.rb +26 -0
  27. data/lib/clacky/media/openai_compat.rb +64 -1
  28. data/lib/clacky/message_history.rb +9 -0
  29. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  30. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  31. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  32. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  33. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  34. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  35. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  36. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  37. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  38. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  39. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  41. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  42. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  43. data/lib/clacky/rich_ui.rb +57 -0
  44. data/lib/clacky/rich_ui_controller.rb +3 -1549
  45. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  46. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  47. data/lib/clacky/server/git_panel.rb +115 -0
  48. data/lib/clacky/server/http_server.rb +547 -15
  49. data/lib/clacky/server/server_master.rb +6 -4
  50. data/lib/clacky/server/session_registry.rb +1 -1
  51. data/lib/clacky/shell_hook_loader.rb +1 -1
  52. data/lib/clacky/tools/edit.rb +14 -2
  53. data/lib/clacky/ui2/ui_controller.rb +7 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +511 -101
  56. data/lib/clacky/web/app.js +95 -14
  57. data/lib/clacky/web/components/code-editor.js +197 -0
  58. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  59. data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
  60. data/lib/clacky/web/core/aside.js +117 -0
  61. data/lib/clacky/web/core/ext.js +387 -0
  62. data/lib/clacky/web/features/backup/store.js +92 -0
  63. data/lib/clacky/web/features/backup/view.js +94 -0
  64. data/lib/clacky/web/features/billing/store.js +163 -0
  65. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  66. data/lib/clacky/web/features/brand/store.js +110 -0
  67. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  68. data/lib/clacky/web/features/channels/store.js +103 -0
  69. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  70. data/lib/clacky/web/features/creator/store.js +81 -0
  71. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  72. data/lib/clacky/web/features/mcp/store.js +158 -0
  73. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  74. data/lib/clacky/web/features/model-tester/store.js +77 -0
  75. data/lib/clacky/web/features/model-tester/view.js +7 -0
  76. data/lib/clacky/web/features/profile/store.js +170 -0
  77. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  78. data/lib/clacky/web/features/share/store.js +145 -0
  79. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  80. data/lib/clacky/web/features/skills/store.js +331 -0
  81. data/lib/clacky/web/features/skills/view.js +581 -0
  82. data/lib/clacky/web/features/tasks/store.js +135 -0
  83. data/lib/clacky/web/features/tasks/view.js +241 -0
  84. data/lib/clacky/web/features/trash/store.js +242 -0
  85. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  86. data/lib/clacky/web/features/version/store.js +165 -0
  87. data/lib/clacky/web/features/version/view.js +323 -0
  88. data/lib/clacky/web/features/workspace/store.js +99 -0
  89. data/lib/clacky/web/features/workspace/view.js +305 -0
  90. data/lib/clacky/web/i18n.js +68 -6
  91. data/lib/clacky/web/index.html +113 -62
  92. data/lib/clacky/web/sessions.js +493 -39
  93. data/lib/clacky/web/settings.js +143 -49
  94. data/lib/clacky/web/skills.js +3 -863
  95. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  96. data/lib/clacky/web/ws-dispatcher.js +7 -3
  97. data/lib/clacky.rb +17 -1
  98. metadata +81 -20
  99. data/lib/clacky/web/backup.js +0 -119
  100. data/lib/clacky/web/model-tester.js +0 -66
  101. data/lib/clacky/web/tasks.js +0 -373
  102. data/lib/clacky/web/version.js +0 -449
  103. data/lib/clacky/web/workspace.js +0 -316
  104. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  105. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  106. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  107. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -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
@@ -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