openclacky 0.7.0 → 0.7.2

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/commit/SKILL.md +29 -4
  3. data/.clackyrules +3 -1
  4. data/CHANGELOG.md +103 -2
  5. data/README.md +70 -161
  6. data/bin/clarky +11 -0
  7. data/docs/HOW-TO-USE-CN.md +96 -0
  8. data/docs/HOW-TO-USE.md +94 -0
  9. data/docs/config.example.yml +27 -0
  10. data/docs/deploy_subagent_design.md +540 -0
  11. data/docs/time_machine_design.md +247 -0
  12. data/docs/why-openclacky.md +0 -1
  13. data/lib/clacky/agent/cost_tracker.rb +180 -0
  14. data/lib/clacky/agent/llm_caller.rb +54 -0
  15. data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
  16. data/lib/clacky/agent/message_compressor_helper.rb +534 -0
  17. data/lib/clacky/agent/session_serializer.rb +152 -0
  18. data/lib/clacky/agent/skill_manager.rb +138 -0
  19. data/lib/clacky/agent/system_prompt_builder.rb +96 -0
  20. data/lib/clacky/agent/time_machine.rb +199 -0
  21. data/lib/clacky/agent/tool_executor.rb +434 -0
  22. data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
  23. data/lib/clacky/agent.rb +260 -1370
  24. data/lib/clacky/agent_config.rb +447 -10
  25. data/lib/clacky/cli.rb +275 -98
  26. data/lib/clacky/client.rb +12 -2
  27. data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
  28. data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
  29. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
  30. data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
  31. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
  32. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
  33. data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
  34. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
  35. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
  36. data/lib/clacky/default_skills/new/SKILL.md +2 -2
  37. data/lib/clacky/json_ui_controller.rb +195 -0
  38. data/lib/clacky/providers.rb +107 -0
  39. data/lib/clacky/skill.rb +48 -7
  40. data/lib/clacky/skill_loader.rb +7 -0
  41. data/lib/clacky/tools/edit.rb +105 -48
  42. data/lib/clacky/tools/file_reader.rb +44 -73
  43. data/lib/clacky/tools/invoke_skill.rb +89 -0
  44. data/lib/clacky/tools/list_tasks.rb +54 -0
  45. data/lib/clacky/tools/redo_task.rb +41 -0
  46. data/lib/clacky/tools/safe_shell.rb +1 -1
  47. data/lib/clacky/tools/shell.rb +74 -62
  48. data/lib/clacky/tools/trash_manager.rb +1 -1
  49. data/lib/clacky/tools/undo_task.rb +32 -0
  50. data/lib/clacky/tools/web_fetch.rb +2 -1
  51. data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
  52. data/lib/clacky/ui2/components/inline_input.rb +23 -2
  53. data/lib/clacky/ui2/components/input_area.rb +65 -21
  54. data/lib/clacky/ui2/components/modal_component.rb +199 -62
  55. data/lib/clacky/ui2/layout_manager.rb +75 -25
  56. data/lib/clacky/ui2/line_editor.rb +23 -2
  57. data/lib/clacky/ui2/markdown_renderer.rb +31 -10
  58. data/lib/clacky/ui2/screen_buffer.rb +2 -0
  59. data/lib/clacky/ui2/ui_controller.rb +316 -37
  60. data/lib/clacky/ui2.rb +2 -0
  61. data/lib/clacky/ui_interface.rb +50 -0
  62. data/lib/clacky/utils/arguments_parser.rb +31 -3
  63. data/lib/clacky/utils/file_processor.rb +13 -18
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky.rb +19 -9
  66. data/scripts/install.sh +274 -97
  67. data/scripts/uninstall.sh +12 -12
  68. metadata +40 -13
  69. data/.clacky/skills/test-skill/SKILL.md +0 -15
  70. data/lib/clacky/compression/base.rb +0 -231
  71. data/lib/clacky/compression/standard.rb +0 -339
  72. data/lib/clacky/config.rb +0 -117
  73. /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
  74. /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
  75. /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
  76. /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
  77. /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
  78. /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Tools
5
+ # Tool for listing task history (Time Machine feature)
6
+ class ListTasks < Base
7
+ self.tool_name = "list_tasks"
8
+ self.tool_description = "List recent tasks in the task history with summaries. " \
9
+ "Shows current task, past tasks, and future tasks (after undo). " \
10
+ "Use when user wants to see task history or choose which task to undo/redo to."
11
+ self.tool_category = "time_machine"
12
+ self.tool_parameters = {
13
+ type: "object",
14
+ properties: {
15
+ limit: {
16
+ type: "integer",
17
+ description: "Maximum number of recent tasks to show (default: 10)",
18
+ default: 10
19
+ }
20
+ }
21
+ }
22
+
23
+ def execute(agent:, limit: 10, **_args)
24
+ history = agent.get_task_history(limit: limit)
25
+
26
+ if history.empty?
27
+ return "No task history available."
28
+ end
29
+
30
+ lines = ["Task History:"]
31
+ history.each do |task|
32
+ indicator = case task[:status]
33
+ when :current then "→"
34
+ when :past then " "
35
+ when :future then "↯"
36
+ end
37
+
38
+ branch_indicator = task[:has_branches] ? " ⎇" : ""
39
+ lines << "#{indicator}#{branch_indicator} Task #{task[:task_id]}: #{task[:summary]}"
40
+ end
41
+
42
+ lines.join("\n")
43
+ end
44
+
45
+ def format_call(limit: 10, **_args)
46
+ "Listing task history (limit: #{limit})..."
47
+ end
48
+
49
+ def format_result(result)
50
+ result
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Tools
5
+ # Tool for redoing a task after undo (Time Machine feature)
6
+ class RedoTask < Base
7
+ self.tool_name = "redo_task"
8
+ self.tool_description = "Redo to a specific task after undo. Restores files to that task's state. " \
9
+ "Use when user wants to go forward to a future task or switch to a different branch."
10
+ self.tool_category = "time_machine"
11
+ self.tool_parameters = {
12
+ type: "object",
13
+ properties: {
14
+ task_id: {
15
+ type: "integer",
16
+ description: "The task ID to redo to (must be greater than current active task)"
17
+ }
18
+ },
19
+ required: ["task_id"]
20
+ }
21
+
22
+ def execute(agent:, task_id:, **_args)
23
+ result = agent.switch_to_task(task_id)
24
+
25
+ if result[:success]
26
+ result[:message]
27
+ else
28
+ "Error: #{result[:message]}"
29
+ end
30
+ end
31
+
32
+ def format_call(task_id:, **_args)
33
+ "Redoing to task #{task_id}..."
34
+ end
35
+
36
+ def format_result(result)
37
+ result
38
+ end
39
+ end
40
+ end
41
+ end
@@ -4,7 +4,7 @@ require "shellwords"
4
4
  require "json"
5
5
  require "fileutils"
6
6
  require_relative "shell"
7
- require_relative "../trash_directory"
7
+ require_relative "../utils/trash_directory"
8
8
 
9
9
  module Clacky
10
10
  module Tools
@@ -66,96 +66,108 @@ module Clacky
66
66
  stdout_buffer = StringIO.new
67
67
  stderr_buffer = StringIO.new
68
68
  soft_timeout_triggered = false
69
+ process_pid = nil
69
70
 
70
71
  begin
71
72
  Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
73
+ process_pid = wait_thr.pid
72
74
  start_time = Time.now
73
75
 
74
76
  stdout.sync = true
75
77
  stderr.sync = true
76
78
 
77
- loop do
78
- elapsed = Time.now - start_time
79
-
80
- if elapsed > hard_timeout
81
- Process.kill('TERM', wait_thr.pid) rescue nil
82
- sleep 0.5
83
- Process.kill('KILL', wait_thr.pid) if wait_thr.alive? rescue nil
84
-
85
- return format_timeout_result(
86
- command,
87
- stdout_buffer.string,
88
- stderr_buffer.string,
89
- elapsed,
90
- :hard_timeout,
91
- hard_timeout,
92
- max_output_lines
93
- )
94
- end
95
-
96
- if elapsed > soft_timeout && !soft_timeout_triggered
97
- soft_timeout_triggered = true
79
+ begin
80
+ loop do
81
+ elapsed = Time.now - start_time
98
82
 
99
- # L1: Check for interaction patterns
100
- interaction = detect_interaction(stdout_buffer.string)
101
- if interaction
83
+ if elapsed > hard_timeout
102
84
  Process.kill('TERM', wait_thr.pid) rescue nil
103
- return format_waiting_input_result(
85
+ sleep 0.5
86
+ Process.kill('KILL', wait_thr.pid) if wait_thr.alive? rescue nil
87
+
88
+ return format_timeout_result(
104
89
  command,
105
90
  stdout_buffer.string,
106
91
  stderr_buffer.string,
107
- interaction,
92
+ elapsed,
93
+ :hard_timeout,
94
+ hard_timeout,
108
95
  max_output_lines
109
96
  )
110
97
  end
111
- end
112
98
 
113
- break unless wait_thr.alive?
99
+ if elapsed > soft_timeout && !soft_timeout_triggered
100
+ soft_timeout_triggered = true
101
+
102
+ # L1: Check for interaction patterns
103
+ interaction = detect_interaction(stdout_buffer.string)
104
+ if interaction
105
+ Process.kill('TERM', wait_thr.pid) rescue nil
106
+ return format_waiting_input_result(
107
+ command,
108
+ stdout_buffer.string,
109
+ stderr_buffer.string,
110
+ interaction,
111
+ max_output_lines
112
+ )
113
+ end
114
+ end
114
115
 
115
- begin
116
- ready = IO.select([stdout, stderr], nil, nil, 0.1)
117
- if ready
118
- ready[0].each do |io|
119
- begin
120
- data = io.read_nonblock(4096)
121
- if io == stdout
122
- stdout_buffer.write(data)
123
- else
124
- stderr_buffer.write(data)
116
+ break unless wait_thr.alive?
117
+
118
+ begin
119
+ ready = IO.select([stdout, stderr], nil, nil, 0.1)
120
+ if ready
121
+ ready[0].each do |io|
122
+ begin
123
+ data = io.read_nonblock(4096)
124
+ if io == stdout
125
+ stdout_buffer.write(data)
126
+ else
127
+ stderr_buffer.write(data)
128
+ end
129
+ rescue IO::WaitReadable, EOFError
125
130
  end
126
- rescue IO::WaitReadable, EOFError
127
131
  end
128
132
  end
133
+ rescue StandardError => e
129
134
  end
130
- rescue StandardError => e
135
+
136
+ sleep 0.1
131
137
  end
132
138
 
133
- sleep 0.1
134
- end
139
+ begin
140
+ stdout_buffer.write(stdout.read)
141
+ rescue StandardError
142
+ end
143
+ begin
144
+ stderr_buffer.write(stderr.read)
145
+ rescue StandardError
146
+ end
135
147
 
136
- begin
137
- stdout_buffer.write(stdout.read)
138
- rescue StandardError
139
- end
140
- begin
141
- stderr_buffer.write(stderr.read)
142
- rescue StandardError
148
+ stdout_output = stdout_buffer.string
149
+ stderr_output = stderr_buffer.string
150
+
151
+ {
152
+ command: command,
153
+ stdout: truncate_output(stdout_output, max_output_lines),
154
+ stderr: truncate_output(stderr_output, max_output_lines),
155
+ exit_code: wait_thr.value.exitstatus,
156
+ success: wait_thr.value.success?,
157
+ elapsed: Time.now - start_time,
158
+ output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
159
+ }
160
+ ensure
161
+ # Ensure child process is killed when block exits (for any reason: exception, return, etc.)
162
+ if wait_thr&.alive?
163
+ Process.kill('TERM', wait_thr.pid) rescue nil
164
+ sleep 0.1
165
+ Process.kill('KILL', wait_thr.pid) if wait_thr.alive? rescue nil
166
+ end
143
167
  end
144
-
145
- stdout_output = stdout_buffer.string
146
- stderr_output = stderr_buffer.string
147
-
148
- {
149
- command: command,
150
- stdout: truncate_output(stdout_output, max_output_lines),
151
- stderr: truncate_output(stderr_output, max_output_lines),
152
- exit_code: wait_thr.value.exitstatus,
153
- success: wait_thr.value.success?,
154
- elapsed: Time.now - start_time,
155
- output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
156
- }
157
168
  end
158
169
  rescue StandardError => e
170
+ # Handle other errors
159
171
  stdout_output = stdout_buffer.string
160
172
  stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
161
173
 
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
  require "fileutils"
5
5
  require_relative "base"
6
- require_relative "../trash_directory"
6
+ require_relative "../utils/trash_directory"
7
7
 
8
8
  module Clacky
9
9
  module Tools
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Tools
5
+ # Tool for undoing the last task (Time Machine feature)
6
+ class UndoTask < Base
7
+ self.tool_name = "undo_task"
8
+ self.tool_description = "Undo the last task and restore files to previous state. " \
9
+ "Use when user wants to go back to previous state or undo recent changes."
10
+ self.tool_category = "time_machine"
11
+ self.tool_parameters = {}
12
+
13
+ def execute(agent:, **_args)
14
+ result = agent.undo_last_task
15
+
16
+ if result[:success]
17
+ result[:message]
18
+ else
19
+ "Error: #{result[:message]}"
20
+ end
21
+ end
22
+
23
+ def format_call(**_args)
24
+ "Undoing last task..."
25
+ end
26
+
27
+ def format_result(result)
28
+ result
29
+ end
30
+ end
31
+ end
32
+ end
@@ -211,7 +211,8 @@ module Clacky
211
211
  compact[:content] = result[:content]
212
212
  compact[:truncated] = true
213
213
  compact[:temp_file] = result[:temp_file]
214
- compact[:message] = "[Content truncated - full content saved to temp file. Use file_reader to read it if needed.]"
214
+ compact[:message] = "[Content truncated - full content saved to: #{result[:temp_file]}. " \
215
+ "Use grep to search keywords, or file_reader with start_line/end_line to read sections.]"
215
216
  else
216
217
  compact[:content] = result[:content]
217
218
  compact[:truncated] = result[:truncated] || false
@@ -14,6 +14,8 @@ module Clacky
14
14
  # System commands available by default
15
15
  SYSTEM_COMMANDS = [
16
16
  { command: "/clear", description: "Clear chat history and restart session" },
17
+ { command: "/config", description: "Open configuration (models, API keys, settings)" },
18
+ { command: "/undo", description: "Undo the last task and restore previous state" },
17
19
  { command: "/help", description: "Show help information" },
18
20
  { command: "/exit", description: "Exit the chat session" },
19
21
  { command: "/quit", description: "Quit the application" }
@@ -46,7 +48,8 @@ module Clacky
46
48
  {
47
49
  command: skill.slash_command,
48
50
  description: skill.description || "No description available",
49
- type: :skill
51
+ type: :skill,
52
+ argument_hint: skill.argument_hint
50
53
  }
51
54
  end
52
55
 
@@ -104,6 +107,13 @@ module Clacky
104
107
  cmd ? cmd[:command] : nil
105
108
  end
106
109
 
110
+ # Get the argument hint for the currently selected command
111
+ # @return [String, nil] Argument hint string or nil if none
112
+ def selected_argument_hint
113
+ cmd = selected_command
114
+ cmd ? cmd[:argument_hint] : nil
115
+ end
116
+
107
117
  # Check if there are any suggestions to show
108
118
  # @return [Boolean]
109
119
  def has_suggestions?
@@ -189,8 +199,8 @@ module Clacky
189
199
  @filtered_commands = @commands.select do |cmd|
190
200
  # Remove leading / for comparison
191
201
  cmd_name = cmd[:command].sub(/^\//, "")
192
- cmd_name.downcase.start_with?(filter_lower) ||
193
- cmd[:description].downcase.include?(filter_lower)
202
+ # Only match command name, not description
203
+ cmd_name.downcase.start_with?(filter_lower)
194
204
  end
195
205
  end
196
206
  end
@@ -20,6 +20,7 @@ module Clacky
20
20
  @result_queue = nil
21
21
  @paste_counter = 0
22
22
  @paste_placeholders = {}
23
+ @continuation_prompt = "> " # Continuation prompt for wrapped lines
23
24
  end
24
25
 
25
26
  # Activate inline input and wait for user input
@@ -92,6 +93,8 @@ module Clacky
92
93
  { action: :update }
93
94
  when :shift_tab
94
95
  handle_shift_tab
96
+ when :ctrl_o
97
+ handle_toggle_expand
95
98
  when :ctrl_c
96
99
  handle_cancel
97
100
  when :escape
@@ -110,8 +113,10 @@ module Clacky
110
113
  # @return [String] Rendered line (may wrap to multiple lines)
111
114
  def render
112
115
  width = TTY::Screen.width
116
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
117
+ content_width = effective_content_width(width)
113
118
  prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
114
- available_width = width - prompt_width
119
+ available_width = content_width - prompt_width
115
120
 
116
121
  # Get wrapped segments
117
122
  wrapped_segments = wrap_line(@line, available_width)
@@ -142,12 +147,23 @@ module Clacky
142
147
  cursor_column(@prompt)
143
148
  end
144
149
 
150
+ # Get cursor position for display (considering line wrapping and continuation prompt)
151
+ # @param width [Integer] Terminal width
152
+ # @return [Array<Integer>] Row and column position (0-indexed)
153
+ def cursor_position_for_display(width = TTY::Screen.width)
154
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
155
+ content_width = effective_content_width(width)
156
+ cursor_position_with_wrap(@prompt, content_width, @continuation_prompt)
157
+ end
158
+
145
159
  # Get the number of lines this input will occupy when rendered
146
160
  # @param width [Integer] Terminal width
147
161
  # @return [Integer] Number of lines
148
162
  def line_count(width = TTY::Screen.width)
163
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
164
+ content_width = effective_content_width(width)
149
165
  prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
150
- available_width = width - prompt_width
166
+ available_width = content_width - prompt_width
151
167
  return 1 if available_width <= 0
152
168
 
153
169
  segments = wrap_line(@line, available_width)
@@ -200,6 +216,11 @@ module Clacky
200
216
 
201
217
  { action: :toggle_mode }
202
218
  end
219
+
220
+ private def handle_toggle_expand
221
+ # Toggle expansion of diff display
222
+ { action: :toggle_expand }
223
+ end
203
224
  end
204
225
  end
205
226
  end
@@ -101,6 +101,8 @@ module Clacky
101
101
  height += @images.size
102
102
 
103
103
  # Calculate height considering wrapped lines
104
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
105
+ content_width = effective_content_width(@width)
104
106
  @lines.each_with_index do |line, idx|
105
107
  prefix = if idx == 0
106
108
  prompt
@@ -108,7 +110,7 @@ module Clacky
108
110
  " " * prompt.length
109
111
  end
110
112
  prefix_width = calculate_display_width(strip_ansi_codes(prefix))
111
- available_width = [@width - prefix_width, 20].max # At least 20 chars
113
+ available_width = [content_width - prefix_width, 20].max # At least 20 chars
112
114
  wrapped_segments = wrap_line(line, available_width)
113
115
  height += wrapped_segments.size
114
116
  end
@@ -169,7 +171,7 @@ module Clacky
169
171
  @command_suggestions.select_next
170
172
  return { action: nil }
171
173
  when :enter
172
- # Accept selected command
174
+ # Accept selected command and submit immediately
173
175
  if @command_suggestions.has_suggestions?
174
176
  selected = @command_suggestions.selected_command_text
175
177
  if selected
@@ -178,7 +180,8 @@ module Clacky
178
180
  @line_index = 0
179
181
  @cursor_position = selected.length
180
182
  @command_suggestions.hide
181
- return { action: nil }
183
+ # Submit the command immediately
184
+ return handle_enter
182
185
  end
183
186
  end
184
187
  # Fall through to normal enter handling if no suggestion
@@ -186,20 +189,30 @@ module Clacky
186
189
  @command_suggestions.hide
187
190
  return { action: nil }
188
191
  when :tab
189
- # Tab also accepts the suggestion
192
+ # Tab accepts the currently highlighted suggestion
190
193
  if @command_suggestions.has_suggestions?
191
194
  selected = @command_suggestions.selected_command_text
192
195
  if selected
193
- @lines = [selected]
196
+ hint = @command_suggestions.selected_argument_hint
197
+ completed = "#{selected} "
198
+ @lines = [completed]
194
199
  @line_index = 0
195
- @cursor_position = selected.length
200
+ @cursor_position = completed.length
196
201
  @command_suggestions.hide
202
+ # Show argument hint as a tip if available
203
+ set_tips("Usage: #{selected} #{hint}", type: :info) if hint && !hint.empty?
197
204
  return { action: nil }
198
205
  end
199
206
  end
200
207
  end
201
208
  end
202
209
 
210
+ # Tab with no visible suggestions: trigger slash-command completion
211
+ if key == :tab
212
+ trigger_tab_completion
213
+ return { action: nil }
214
+ end
215
+
203
216
  result = case key
204
217
  when Hash
205
218
  if key[:type] == :rapid_input
@@ -230,12 +243,16 @@ module Clacky
230
243
  when :ctrl_c then handle_ctrl_c
231
244
  when :ctrl_d then handle_ctrl_d
232
245
  when :ctrl_v then handle_paste
246
+ when :ctrl_o then { action: :toggle_expand }
233
247
  when :shift_tab then { action: :toggle_mode }
234
248
  when :escape
235
249
  if @command_suggestions.visible
236
250
  @command_suggestions.hide
251
+ { action: nil }
252
+ else
253
+ # Trigger time machine when ESC is pressed and suggestions not visible
254
+ { action: :time_machine }
237
255
  end
238
- { action: nil }
239
256
  else
240
257
  if key.is_a?(String) && key.length >= 1 && key.ord >= 32
241
258
  insert_char(key)
@@ -318,6 +335,8 @@ module Clacky
318
335
  def position_cursor(start_row)
319
336
  # Calculate which wrapped line the cursor is on
320
337
  cursor_row = start_row + 2 + @images.size # session_bar + separator + images
338
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
339
+ content_width = effective_content_width(@width)
321
340
 
322
341
  # Add rows for lines before current line
323
342
  @lines[0...@line_index].each_with_index do |line, idx|
@@ -327,7 +346,7 @@ module Clacky
327
346
  " " * prompt.length
328
347
  end
329
348
  prefix_width = calculate_display_width(strip_ansi_codes(prefix))
330
- available_width = [@width - prefix_width, 20].max
349
+ available_width = [content_width - prefix_width, 20].max
331
350
  wrapped_segments = wrap_line(line, available_width)
332
351
  cursor_row += wrapped_segments.size
333
352
  end
@@ -340,7 +359,7 @@ module Clacky
340
359
  " " * prompt.length
341
360
  end
342
361
  prefix_width = calculate_display_width(strip_ansi_codes(prefix))
343
- available_width = [@width - prefix_width, 20].max
362
+ available_width = [content_width - prefix_width, 20].max
344
363
  wrapped_segments = wrap_line(current, available_width)
345
364
 
346
365
  # Find cursor segment and position within segment
@@ -610,9 +629,9 @@ module Clacky
610
629
  # Shows suggestions when input starts with /
611
630
  private def update_command_suggestions
612
631
  return unless @command_suggestions
613
-
632
+
614
633
  current = current_line.strip
615
-
634
+
616
635
  # Check if we should show suggestions (input starts with /)
617
636
  if current.start_with?('/') && @line_index == 0
618
637
  # Extract the filter text (everything after /)
@@ -623,16 +642,37 @@ module Clacky
623
642
  end
624
643
  end
625
644
 
645
+ # Trigger tab completion: show all commands or filter by current slash input
646
+ # Called when Tab is pressed and no suggestions dropdown is visible
647
+ private def trigger_tab_completion
648
+ return unless @command_suggestions
649
+
650
+ current = current_line.strip
651
+
652
+ if current.empty?
653
+ # Empty input: type "/" and show all commands
654
+ insert_char("/")
655
+ @command_suggestions.show("")
656
+ elsif current.start_with?("/")
657
+ # Already typing a slash command: show/refresh filtered suggestions
658
+ filter_text = current[1..-1] || ""
659
+ @command_suggestions.show(filter_text)
660
+ end
661
+ # Tab on normal text has no effect
662
+ end
663
+
626
664
  # Render all input lines with auto-wrap support
627
665
  # @param start_row [Integer] Starting row position
628
666
  # @return [Integer] Next available row after rendering all lines
629
667
  def render_input_lines(start_row)
630
668
  current_row = start_row
669
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
670
+ content_width = effective_content_width(@width)
631
671
 
632
672
  @lines.each_with_index do |line, line_idx|
633
673
  prefix = calculate_line_prefix(line_idx)
634
674
  prefix_width = calculate_display_width(strip_ansi_codes(prefix))
635
- available_width = @width - prefix_width
675
+ available_width = content_width - prefix_width
636
676
  wrapped_segments = wrap_line(line, available_width)
637
677
 
638
678
  wrapped_segments.each_with_index do |segment_info, wrap_idx|
@@ -748,6 +788,11 @@ module Clacky
748
788
  def handle_enter
749
789
  text = current_value.strip
750
790
 
791
+ # Prepare display content and data BEFORE clearing
792
+ content_to_display = current_content
793
+ result_text = current_value
794
+ result_images = @images.dup
795
+
751
796
  # Handle commands (with or without slash)
752
797
  if text.start_with?('/')
753
798
  # Check if it's a command (single slash followed by English letters only)
@@ -755,10 +800,13 @@ module Clacky
755
800
  if text =~ /^\/([a-zA-Z-]+)$/
756
801
  case text
757
802
  when '/clear'
803
+ add_to_history(result_text) unless result_text.empty?
758
804
  clear
759
- return { action: :clear_output }
805
+ return { action: :clear_output, data: { text: result_text, images: result_images, display: content_to_display } }
760
806
  when '/help'
761
- return { action: :help }
807
+ add_to_history(result_text) unless result_text.empty?
808
+ clear
809
+ return { action: :help, data: { text: result_text, images: result_images, display: content_to_display } }
762
810
  when '/exit', '/quit'
763
811
  return { action: :exit }
764
812
  else
@@ -768,7 +816,9 @@ module Clacky
768
816
  end
769
817
  # If it's not a command pattern (e.g., /xxx/xxxx), treat as normal input
770
818
  elsif text == '?'
771
- return { action: :help }
819
+ add_to_history(result_text) unless result_text.empty?
820
+ clear
821
+ return { action: :help, data: { text: result_text, images: result_images, display: content_to_display } }
772
822
  elsif text == 'exit' || text == 'quit'
773
823
  return { action: :exit }
774
824
  end
@@ -777,10 +827,6 @@ module Clacky
777
827
  return { action: nil }
778
828
  end
779
829
 
780
- content_to_display = current_content
781
- result_text = current_value
782
- result_images = @images.dup
783
-
784
830
  add_to_history(result_text) unless result_text.empty?
785
831
  clear
786
832
 
@@ -1168,8 +1214,6 @@ module Clacky
1168
1214
  :magenta
1169
1215
  when /confirm_safes/
1170
1216
  :cyan
1171
- when /confirm_edits/
1172
- :green
1173
1217
  when /plan_only/
1174
1218
  :blue
1175
1219
  else