openclacky 0.5.6 → 0.6.0
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 +43 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +245 -340
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +156 -397
- data/lib/clacky/client.rb +68 -36
- data/lib/clacky/gitignore_parser.rb +26 -12
- data/lib/clacky/model_pricing.rb +6 -2
- data/lib/clacky/session_manager.rb +6 -2
- data/lib/clacky/tools/glob.rb +65 -9
- data/lib/clacky/tools/grep.rb +4 -120
- data/lib/clacky/tools/run_project.rb +5 -0
- data/lib/clacky/tools/safe_shell.rb +49 -13
- data/lib/clacky/tools/shell.rb +1 -49
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +38 -26
- data/lib/clacky/ui2/README.md +214 -0
- data/lib/clacky/ui2/components/base_component.rb +163 -0
- data/lib/clacky/ui2/components/common_component.rb +89 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1029 -0
- data/lib/clacky/ui2/components/message_component.rb +76 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +137 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
- data/lib/clacky/ui2/layout_manager.rb +331 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/screen_buffer.rb +238 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +99 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
- data/lib/clacky/ui2/ui_controller.rb +720 -0
- data/lib/clacky/ui2/view_renderer.rb +160 -0
- data/lib/clacky/ui2.rb +37 -0
- data/lib/clacky/utils/file_ignore_helper.rb +126 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -6
- metadata +38 -6
- data/lib/clacky/ui/banner.rb +0 -155
- data/lib/clacky/ui/enhanced_prompt.rb +0 -786
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require_relative "../theme_manager"
|
|
6
|
+
require_relative "../line_editor"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
module UI2
|
|
10
|
+
module Components
|
|
11
|
+
# InputArea manages the fixed input area at the bottom of the screen
|
|
12
|
+
# Enhanced with multi-line support, image paste, and more
|
|
13
|
+
class InputArea
|
|
14
|
+
include LineEditor
|
|
15
|
+
|
|
16
|
+
attr_accessor :row
|
|
17
|
+
attr_reader :cursor_position, :line_index, :images, :tips_message, :tips_type
|
|
18
|
+
|
|
19
|
+
def initialize(row: 0)
|
|
20
|
+
@row = row
|
|
21
|
+
@lines = [""]
|
|
22
|
+
@line_index = 0
|
|
23
|
+
@cursor_position = 0
|
|
24
|
+
@history = []
|
|
25
|
+
@history_index = -1
|
|
26
|
+
@pastel = Pastel.new
|
|
27
|
+
@width = TTY::Screen.width
|
|
28
|
+
|
|
29
|
+
@images = []
|
|
30
|
+
@max_images = 3
|
|
31
|
+
@paste_counter = 0
|
|
32
|
+
@paste_placeholders = {}
|
|
33
|
+
@last_ctrl_c_time = nil
|
|
34
|
+
@tips_message = nil
|
|
35
|
+
@tips_type = :info
|
|
36
|
+
@tips_timer = nil
|
|
37
|
+
@last_render_row = nil
|
|
38
|
+
|
|
39
|
+
# Paused state - when InlineInput is active
|
|
40
|
+
@paused = false
|
|
41
|
+
|
|
42
|
+
# Session bar info
|
|
43
|
+
@sessionbar_info = {
|
|
44
|
+
working_dir: nil,
|
|
45
|
+
mode: nil,
|
|
46
|
+
model: nil,
|
|
47
|
+
tasks: 0,
|
|
48
|
+
cost: 0.0,
|
|
49
|
+
status: 'idle' # Workspace status: 'idle' or 'working'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Animation state for working status
|
|
53
|
+
@animation_frame = 0
|
|
54
|
+
@last_animation_update = Time.now
|
|
55
|
+
@working_frames = ["❄", "❅", "❆"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get current theme from ThemeManager
|
|
59
|
+
def theme
|
|
60
|
+
UI2::ThemeManager.current_theme
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get prompt symbol from theme
|
|
64
|
+
def prompt
|
|
65
|
+
"#{theme.symbol(:user)} "
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def required_height
|
|
69
|
+
# When paused (InlineInput active), don't take up any space
|
|
70
|
+
return 0 if @paused
|
|
71
|
+
|
|
72
|
+
height = 1 # Session bar (top)
|
|
73
|
+
height += 1 # Separator after session bar
|
|
74
|
+
height += @images.size
|
|
75
|
+
|
|
76
|
+
# Calculate height considering wrapped lines
|
|
77
|
+
@lines.each_with_index do |line, idx|
|
|
78
|
+
prefix = if idx == 0
|
|
79
|
+
prompt
|
|
80
|
+
else
|
|
81
|
+
" " * prompt.length
|
|
82
|
+
end
|
|
83
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
84
|
+
available_width = [@width - prefix_width, 20].max # At least 20 chars
|
|
85
|
+
wrapped_segments = wrap_line(line, available_width)
|
|
86
|
+
height += wrapped_segments.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
height += 1 # Bottom separator
|
|
90
|
+
height += 1 if @tips_message
|
|
91
|
+
height
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Update session bar info
|
|
95
|
+
# @param working_dir [String] Working directory
|
|
96
|
+
# @param mode [String] Permission mode
|
|
97
|
+
# @param model [String] AI model name
|
|
98
|
+
# @param tasks [Integer] Number of completed tasks
|
|
99
|
+
# @param cost [Float] Total cost
|
|
100
|
+
# @param status [String] Workspace status ('idle' or 'working')
|
|
101
|
+
def update_sessionbar(working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, status: nil)
|
|
102
|
+
@sessionbar_info[:working_dir] = working_dir if working_dir
|
|
103
|
+
@sessionbar_info[:mode] = mode if mode
|
|
104
|
+
@sessionbar_info[:model] = model if model
|
|
105
|
+
@sessionbar_info[:tasks] = tasks if tasks
|
|
106
|
+
@sessionbar_info[:cost] = cost if cost
|
|
107
|
+
@sessionbar_info[:status] = status if status
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def input_buffer
|
|
111
|
+
@lines.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_key(key)
|
|
115
|
+
# Ignore input when paused (InlineInput is active)
|
|
116
|
+
return { action: nil } if @paused
|
|
117
|
+
|
|
118
|
+
old_height = required_height
|
|
119
|
+
|
|
120
|
+
result = case key
|
|
121
|
+
when Hash
|
|
122
|
+
if key[:type] == :rapid_input
|
|
123
|
+
insert_text(key[:text])
|
|
124
|
+
clear_tips
|
|
125
|
+
end
|
|
126
|
+
{ action: nil }
|
|
127
|
+
when :enter then handle_enter
|
|
128
|
+
when :newline then newline; { action: nil }
|
|
129
|
+
when :backspace then backspace; { action: nil }
|
|
130
|
+
when :delete then delete_char; { action: nil }
|
|
131
|
+
when :left_arrow, :ctrl_b then cursor_left; { action: nil }
|
|
132
|
+
when :right_arrow, :ctrl_f then cursor_right; { action: nil }
|
|
133
|
+
when :up_arrow then handle_up_arrow
|
|
134
|
+
when :down_arrow then handle_down_arrow
|
|
135
|
+
when :home, :ctrl_a then cursor_home; { action: nil }
|
|
136
|
+
when :end, :ctrl_e then cursor_end; { action: nil }
|
|
137
|
+
when :ctrl_k then kill_to_end; { action: nil }
|
|
138
|
+
when :ctrl_u then kill_to_start; { action: nil }
|
|
139
|
+
when :ctrl_w then kill_word; { action: nil }
|
|
140
|
+
when :ctrl_c then handle_ctrl_c
|
|
141
|
+
when :ctrl_d then handle_ctrl_d
|
|
142
|
+
when :ctrl_v then handle_paste
|
|
143
|
+
when :shift_tab then { action: :toggle_mode }
|
|
144
|
+
when :escape then { action: nil }
|
|
145
|
+
else
|
|
146
|
+
if key.is_a?(String) && key.length >= 1 && key.ord >= 32
|
|
147
|
+
insert_char(key)
|
|
148
|
+
end
|
|
149
|
+
{ action: nil }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
new_height = required_height
|
|
153
|
+
if new_height != old_height
|
|
154
|
+
result[:height_changed] = true
|
|
155
|
+
result[:new_height] = new_height
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def render(start_row:, width: nil)
|
|
162
|
+
@width = width || TTY::Screen.width
|
|
163
|
+
@last_render_row = start_row # Save for tips auto-clear
|
|
164
|
+
|
|
165
|
+
# When paused, don't render anything (InlineInput is active)
|
|
166
|
+
return if @paused
|
|
167
|
+
|
|
168
|
+
current_row = start_row
|
|
169
|
+
|
|
170
|
+
# Session bar at top
|
|
171
|
+
render_sessionbar(current_row)
|
|
172
|
+
current_row += 1
|
|
173
|
+
|
|
174
|
+
# Separator after session bar
|
|
175
|
+
render_separator(current_row)
|
|
176
|
+
current_row += 1
|
|
177
|
+
|
|
178
|
+
# Images
|
|
179
|
+
@images.each_with_index do |img_path, idx|
|
|
180
|
+
move_cursor(current_row, 0)
|
|
181
|
+
filename = File.basename(img_path)
|
|
182
|
+
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
183
|
+
content = @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize}) (Ctrl+D to delete)")
|
|
184
|
+
print_with_padding(content)
|
|
185
|
+
current_row += 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Input lines with auto-wrap support
|
|
189
|
+
@lines.each_with_index do |line, idx|
|
|
190
|
+
prefix = if idx == 0
|
|
191
|
+
prompt_text = theme.format_symbol(:user) + " "
|
|
192
|
+
prompt_text
|
|
193
|
+
else
|
|
194
|
+
" " * prompt.length
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Calculate available width for text (excluding prefix)
|
|
198
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
199
|
+
available_width = @width - prefix_width
|
|
200
|
+
|
|
201
|
+
# Wrap line if needed
|
|
202
|
+
wrapped_segments = wrap_line(line, available_width)
|
|
203
|
+
|
|
204
|
+
wrapped_segments.each_with_index do |segment_info, wrap_idx|
|
|
205
|
+
move_cursor(current_row, 0)
|
|
206
|
+
|
|
207
|
+
segment_text = segment_info[:text]
|
|
208
|
+
segment_start = segment_info[:start]
|
|
209
|
+
segment_end = segment_info[:end]
|
|
210
|
+
|
|
211
|
+
content = if wrap_idx == 0
|
|
212
|
+
# First wrapped line includes prefix
|
|
213
|
+
if idx == @line_index
|
|
214
|
+
"#{prefix}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
|
|
215
|
+
else
|
|
216
|
+
"#{prefix}#{theme.format_text(segment_text, :user)}"
|
|
217
|
+
end
|
|
218
|
+
else
|
|
219
|
+
# Continuation lines have indent matching prefix width
|
|
220
|
+
continuation_indent = " " * prefix_width
|
|
221
|
+
if idx == @line_index
|
|
222
|
+
"#{continuation_indent}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
|
|
223
|
+
else
|
|
224
|
+
"#{continuation_indent}#{theme.format_text(segment_text, :user)}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
print_with_padding(content)
|
|
229
|
+
current_row += 1
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Bottom separator
|
|
234
|
+
render_separator(current_row)
|
|
235
|
+
current_row += 1
|
|
236
|
+
|
|
237
|
+
# Tips bar (if any)
|
|
238
|
+
if @tips_message
|
|
239
|
+
move_cursor(current_row, 0)
|
|
240
|
+
content = format_tips(@tips_message, @tips_type)
|
|
241
|
+
print_with_padding(content)
|
|
242
|
+
current_row += 1
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Position cursor at current edit position
|
|
246
|
+
position_cursor(start_row)
|
|
247
|
+
flush
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def position_cursor(start_row)
|
|
251
|
+
# Calculate which wrapped line the cursor is on
|
|
252
|
+
cursor_row = start_row + 2 + @images.size # session_bar + separator + images
|
|
253
|
+
|
|
254
|
+
# Add rows for lines before current line
|
|
255
|
+
@lines[0...@line_index].each_with_index do |line, idx|
|
|
256
|
+
prefix = if idx == 0
|
|
257
|
+
prompt
|
|
258
|
+
else
|
|
259
|
+
" " * prompt.length
|
|
260
|
+
end
|
|
261
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
262
|
+
available_width = [@width - prefix_width, 20].max
|
|
263
|
+
wrapped_segments = wrap_line(line, available_width)
|
|
264
|
+
cursor_row += wrapped_segments.size
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Find which wrapped segment of current line contains cursor
|
|
268
|
+
current = current_line
|
|
269
|
+
prefix = if @line_index == 0
|
|
270
|
+
prompt
|
|
271
|
+
else
|
|
272
|
+
" " * prompt.length
|
|
273
|
+
end
|
|
274
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
275
|
+
available_width = [@width - prefix_width, 20].max
|
|
276
|
+
wrapped_segments = wrap_line(current, available_width)
|
|
277
|
+
|
|
278
|
+
# Find cursor segment and position within segment
|
|
279
|
+
cursor_segment_idx = 0
|
|
280
|
+
cursor_pos_in_segment = @cursor_position
|
|
281
|
+
|
|
282
|
+
wrapped_segments.each_with_index do |segment, idx|
|
|
283
|
+
if @cursor_position >= segment[:start] && @cursor_position < segment[:end]
|
|
284
|
+
cursor_segment_idx = idx
|
|
285
|
+
cursor_pos_in_segment = @cursor_position - segment[:start]
|
|
286
|
+
break
|
|
287
|
+
elsif @cursor_position >= segment[:end] && idx == wrapped_segments.size - 1
|
|
288
|
+
# Cursor at very end
|
|
289
|
+
cursor_segment_idx = idx
|
|
290
|
+
cursor_pos_in_segment = segment[:end] - segment[:start]
|
|
291
|
+
break
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
cursor_row += cursor_segment_idx
|
|
296
|
+
|
|
297
|
+
# Calculate display width of text before cursor in this segment
|
|
298
|
+
chars = current.chars
|
|
299
|
+
segment_start = wrapped_segments[cursor_segment_idx][:start]
|
|
300
|
+
text_in_segment_before_cursor = chars[segment_start...(segment_start + cursor_pos_in_segment)].join
|
|
301
|
+
display_width = calculate_display_width(text_in_segment_before_cursor)
|
|
302
|
+
|
|
303
|
+
cursor_col = prefix_width + display_width
|
|
304
|
+
move_cursor(cursor_row, cursor_col)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def set_tips(message, type: :info)
|
|
308
|
+
# Cancel existing timer if any
|
|
309
|
+
if @tips_timer&.alive?
|
|
310
|
+
@tips_timer.kill
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
@tips_message = message
|
|
314
|
+
@tips_type = type
|
|
315
|
+
|
|
316
|
+
# Auto-clear tips after 2 seconds
|
|
317
|
+
@tips_timer = Thread.new do
|
|
318
|
+
sleep 2
|
|
319
|
+
# Clear tips from state and screen
|
|
320
|
+
@tips_message = nil
|
|
321
|
+
# Tips row: start_row + session_bar(1) + separator(1) + images + lines + separator(1)
|
|
322
|
+
tips_row = @last_render_row + 2 + @images.size + @lines.size + 1
|
|
323
|
+
move_cursor(tips_row, 0)
|
|
324
|
+
clear_line
|
|
325
|
+
flush
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def clear_tips
|
|
330
|
+
# Cancel timer if any
|
|
331
|
+
if @tips_timer&.alive?
|
|
332
|
+
@tips_timer.kill
|
|
333
|
+
end
|
|
334
|
+
@tips_message = nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Pause input area (when InlineInput is active)
|
|
338
|
+
def pause
|
|
339
|
+
@paused = true
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Resume input area (when InlineInput is done)
|
|
343
|
+
def resume
|
|
344
|
+
@paused = false
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Check if paused
|
|
348
|
+
def paused?
|
|
349
|
+
@paused
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def current_content
|
|
353
|
+
text = expand_placeholders(@lines.join("\n"))
|
|
354
|
+
|
|
355
|
+
# If both text and images are empty, return empty string
|
|
356
|
+
return "" if text.empty? && @images.empty?
|
|
357
|
+
|
|
358
|
+
# Format user input with color and spacing from theme
|
|
359
|
+
symbol = theme.format_symbol(:user)
|
|
360
|
+
content = theme.format_text(text, :user)
|
|
361
|
+
|
|
362
|
+
result = "\n#{symbol} #{content}\n"
|
|
363
|
+
|
|
364
|
+
# Append image information if present
|
|
365
|
+
if @images && @images.any?
|
|
366
|
+
@images.each_with_index do |img_path, idx|
|
|
367
|
+
filename = File.basename(img_path)
|
|
368
|
+
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
369
|
+
result += @pastel.dim(" [Image #{idx + 1}] #{filename} (#{filesize})") + "\n"
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
result
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def current_value
|
|
377
|
+
expand_placeholders(@lines.join("\n"))
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def empty?
|
|
381
|
+
@lines.all?(&:empty?) && @images.empty?
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def multiline?
|
|
385
|
+
@lines.size > 1
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def has_images?
|
|
389
|
+
@images.any?
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def set_prompt(prompt)
|
|
393
|
+
prompt = prompt
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# --- Public editing methods ---
|
|
397
|
+
|
|
398
|
+
def insert_char(char)
|
|
399
|
+
chars = current_line.chars
|
|
400
|
+
chars.insert(@cursor_position, char)
|
|
401
|
+
@lines[@line_index] = chars.join
|
|
402
|
+
@cursor_position += 1
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def backspace
|
|
406
|
+
if @cursor_position > 0
|
|
407
|
+
chars = current_line.chars
|
|
408
|
+
chars.delete_at(@cursor_position - 1)
|
|
409
|
+
@lines[@line_index] = chars.join
|
|
410
|
+
@cursor_position -= 1
|
|
411
|
+
elsif @line_index > 0
|
|
412
|
+
prev_line = @lines[@line_index - 1]
|
|
413
|
+
current = @lines[@line_index]
|
|
414
|
+
@lines.delete_at(@line_index)
|
|
415
|
+
@line_index -= 1
|
|
416
|
+
@cursor_position = prev_line.chars.length
|
|
417
|
+
@lines[@line_index] = prev_line + current
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def delete_char
|
|
422
|
+
chars = current_line.chars
|
|
423
|
+
return if @cursor_position >= chars.length
|
|
424
|
+
chars.delete_at(@cursor_position)
|
|
425
|
+
@lines[@line_index] = chars.join
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def cursor_left
|
|
429
|
+
@cursor_position = [@cursor_position - 1, 0].max
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def cursor_right
|
|
433
|
+
@cursor_position = [@cursor_position + 1, current_line.chars.length].min
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def cursor_home
|
|
437
|
+
@cursor_position = 0
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def cursor_end
|
|
441
|
+
@cursor_position = current_line.chars.length
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def clear
|
|
445
|
+
@lines = [""]
|
|
446
|
+
@line_index = 0
|
|
447
|
+
@cursor_position = 0
|
|
448
|
+
@history_index = -1
|
|
449
|
+
@images = []
|
|
450
|
+
@paste_counter = 0
|
|
451
|
+
@paste_placeholders = {}
|
|
452
|
+
clear_tips
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def submit
|
|
456
|
+
text = current_value
|
|
457
|
+
imgs = @images.dup
|
|
458
|
+
add_to_history(text) unless text.empty?
|
|
459
|
+
clear
|
|
460
|
+
{ text: text, images: imgs }
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def history_prev
|
|
464
|
+
return if @history.empty?
|
|
465
|
+
if @history_index == -1
|
|
466
|
+
@history_index = @history.size - 1
|
|
467
|
+
else
|
|
468
|
+
@history_index = [@history_index - 1, 0].max
|
|
469
|
+
end
|
|
470
|
+
load_history_entry
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def history_next
|
|
474
|
+
return if @history_index == -1
|
|
475
|
+
@history_index += 1
|
|
476
|
+
if @history_index >= @history.size
|
|
477
|
+
@history_index = -1
|
|
478
|
+
@lines = [""]
|
|
479
|
+
@line_index = 0
|
|
480
|
+
@cursor_position = 0
|
|
481
|
+
else
|
|
482
|
+
load_history_entry
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
private
|
|
487
|
+
|
|
488
|
+
# Wrap a line into multiple segments based on available width
|
|
489
|
+
# Considers display width of characters (multi-byte characters like Chinese)
|
|
490
|
+
# @param line [String] The line to wrap
|
|
491
|
+
# @param max_width [Integer] Maximum display width per wrapped line
|
|
492
|
+
# @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
|
|
493
|
+
def wrap_line(line, max_width)
|
|
494
|
+
return [{ text: "", start: 0, end: 0 }] if line.empty?
|
|
495
|
+
return [{ text: line, start: 0, end: line.length }] if max_width <= 0
|
|
496
|
+
|
|
497
|
+
segments = []
|
|
498
|
+
chars = line.chars
|
|
499
|
+
segment_start = 0
|
|
500
|
+
current_width = 0
|
|
501
|
+
current_end = 0
|
|
502
|
+
|
|
503
|
+
chars.each_with_index do |char, idx|
|
|
504
|
+
char_width = char_display_width(char)
|
|
505
|
+
|
|
506
|
+
# If adding this character exceeds max width, complete current segment
|
|
507
|
+
if current_width + char_width > max_width && current_end > segment_start
|
|
508
|
+
segments << {
|
|
509
|
+
text: chars[segment_start...current_end].join,
|
|
510
|
+
start: segment_start,
|
|
511
|
+
end: current_end
|
|
512
|
+
}
|
|
513
|
+
segment_start = idx
|
|
514
|
+
current_end = idx + 1
|
|
515
|
+
current_width = char_width
|
|
516
|
+
else
|
|
517
|
+
current_end = idx + 1
|
|
518
|
+
current_width += char_width
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Add the last segment
|
|
523
|
+
if current_end > segment_start
|
|
524
|
+
segments << {
|
|
525
|
+
text: chars[segment_start...current_end].join,
|
|
526
|
+
start: segment_start,
|
|
527
|
+
end: current_end
|
|
528
|
+
}
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Calculate display width of a single character
|
|
535
|
+
# @param char [String] Single character
|
|
536
|
+
# @return [Integer] Display width (1 or 2)
|
|
537
|
+
def char_display_width(char)
|
|
538
|
+
code = char.ord
|
|
539
|
+
# East Asian Wide and Fullwidth characters take 2 columns
|
|
540
|
+
if (code >= 0x1100 && code <= 0x115F) ||
|
|
541
|
+
(code >= 0x2329 && code <= 0x232A) ||
|
|
542
|
+
(code >= 0x2E80 && code <= 0x303E) ||
|
|
543
|
+
(code >= 0x3040 && code <= 0xA4CF) ||
|
|
544
|
+
(code >= 0xAC00 && code <= 0xD7A3) ||
|
|
545
|
+
(code >= 0xF900 && code <= 0xFAFF) ||
|
|
546
|
+
(code >= 0xFE10 && code <= 0xFE19) ||
|
|
547
|
+
(code >= 0xFE30 && code <= 0xFE6F) ||
|
|
548
|
+
(code >= 0xFF00 && code <= 0xFF60) ||
|
|
549
|
+
(code >= 0xFFE0 && code <= 0xFFE6) ||
|
|
550
|
+
(code >= 0x1F300 && code <= 0x1F9FF) ||
|
|
551
|
+
(code >= 0x20000 && code <= 0x2FFFD) ||
|
|
552
|
+
(code >= 0x30000 && code <= 0x3FFFD)
|
|
553
|
+
2
|
|
554
|
+
else
|
|
555
|
+
1
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Strip ANSI escape codes from a string
|
|
560
|
+
# @param text [String] Text with ANSI codes
|
|
561
|
+
# @return [String] Text without ANSI codes
|
|
562
|
+
def strip_ansi_codes(text)
|
|
563
|
+
text.gsub(/\e\[[0-9;]*m/, '')
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Print content and pad with spaces to clear any remaining characters from previous render
|
|
567
|
+
# This avoids flickering from clear_line while ensuring old content is erased
|
|
568
|
+
def print_with_padding(content)
|
|
569
|
+
# Calculate visible width (strip ANSI codes for width calculation)
|
|
570
|
+
visible_content = content.gsub(/\e\[[0-9;]*m/, '')
|
|
571
|
+
visible_width = calculate_display_width(visible_content)
|
|
572
|
+
|
|
573
|
+
# Print content
|
|
574
|
+
print content
|
|
575
|
+
|
|
576
|
+
# Pad with spaces if needed to clear old content
|
|
577
|
+
remaining = @width - visible_width
|
|
578
|
+
print " " * remaining if remaining > 0
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def handle_enter
|
|
582
|
+
text = current_value.strip
|
|
583
|
+
|
|
584
|
+
# Handle commands (with or without slash)
|
|
585
|
+
if text.start_with?('/')
|
|
586
|
+
case text
|
|
587
|
+
when '/clear'
|
|
588
|
+
clear
|
|
589
|
+
return { action: :clear_output }
|
|
590
|
+
when '/help'
|
|
591
|
+
return { action: :help }
|
|
592
|
+
when '/exit', '/quit'
|
|
593
|
+
return { action: :exit }
|
|
594
|
+
else
|
|
595
|
+
set_tips("Unknown command: #{text} (Available: /clear, /help, /exit)", type: :warning)
|
|
596
|
+
return { action: nil }
|
|
597
|
+
end
|
|
598
|
+
elsif text == '?'
|
|
599
|
+
return { action: :help }
|
|
600
|
+
elsif text == 'exit' || text == 'quit'
|
|
601
|
+
return { action: :exit }
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
if text.empty? && @images.empty?
|
|
605
|
+
return { action: nil }
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
content_to_display = current_content
|
|
609
|
+
result_text = current_value
|
|
610
|
+
result_images = @images.dup
|
|
611
|
+
|
|
612
|
+
add_to_history(result_text) unless result_text.empty?
|
|
613
|
+
clear
|
|
614
|
+
|
|
615
|
+
{ action: :submit, data: { text: result_text, images: result_images, display: content_to_display } }
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def handle_up_arrow
|
|
619
|
+
if multiline?
|
|
620
|
+
unless cursor_up
|
|
621
|
+
history_prev
|
|
622
|
+
end
|
|
623
|
+
else
|
|
624
|
+
# Navigate history when single line (empty or not)
|
|
625
|
+
history_prev
|
|
626
|
+
end
|
|
627
|
+
{ action: nil }
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def handle_down_arrow
|
|
631
|
+
if multiline?
|
|
632
|
+
unless cursor_down
|
|
633
|
+
history_next
|
|
634
|
+
end
|
|
635
|
+
else
|
|
636
|
+
# Navigate history when single line (empty or not)
|
|
637
|
+
history_next
|
|
638
|
+
end
|
|
639
|
+
{ action: nil }
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def handle_ctrl_c
|
|
643
|
+
{ action: :interrupt }
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def handle_ctrl_d
|
|
647
|
+
if has_images?
|
|
648
|
+
if @images.size == 1
|
|
649
|
+
@images.clear
|
|
650
|
+
else
|
|
651
|
+
@images.shift
|
|
652
|
+
end
|
|
653
|
+
clear_tips
|
|
654
|
+
{ action: nil }
|
|
655
|
+
elsif empty?
|
|
656
|
+
{ action: :exit }
|
|
657
|
+
else
|
|
658
|
+
{ action: nil }
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def handle_paste
|
|
663
|
+
pasted = paste_from_clipboard
|
|
664
|
+
if pasted[:type] == :image
|
|
665
|
+
if @images.size < @max_images
|
|
666
|
+
@images << pasted[:path]
|
|
667
|
+
clear_tips
|
|
668
|
+
else
|
|
669
|
+
set_tips("Maximum #{@max_images} images allowed. Delete an image first (Ctrl+D).", type: :warning)
|
|
670
|
+
end
|
|
671
|
+
else
|
|
672
|
+
insert_text(pasted[:text])
|
|
673
|
+
clear_tips
|
|
674
|
+
end
|
|
675
|
+
{ action: nil }
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def insert_text(text)
|
|
679
|
+
return if text.nil? || text.empty?
|
|
680
|
+
|
|
681
|
+
text_lines = text.split(/\r\n|\r|\n/)
|
|
682
|
+
|
|
683
|
+
if text_lines.size > 1
|
|
684
|
+
@paste_counter += 1
|
|
685
|
+
placeholder = "[##{@paste_counter} Paste Text]"
|
|
686
|
+
@paste_placeholders[placeholder] = text
|
|
687
|
+
|
|
688
|
+
chars = current_line.chars
|
|
689
|
+
chars.insert(@cursor_position, *placeholder.chars)
|
|
690
|
+
@lines[@line_index] = chars.join
|
|
691
|
+
@cursor_position += placeholder.length
|
|
692
|
+
else
|
|
693
|
+
chars = current_line.chars
|
|
694
|
+
text.chars.each_with_index do |c, i|
|
|
695
|
+
chars.insert(@cursor_position + i, c)
|
|
696
|
+
end
|
|
697
|
+
@lines[@line_index] = chars.join
|
|
698
|
+
@cursor_position += text.length
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def newline
|
|
703
|
+
chars = current_line.chars
|
|
704
|
+
@lines[@line_index] = chars[0...@cursor_position].join
|
|
705
|
+
@lines.insert(@line_index + 1, chars[@cursor_position..-1]&.join || "")
|
|
706
|
+
@line_index += 1
|
|
707
|
+
@cursor_position = 0
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def cursor_up
|
|
711
|
+
return false if @line_index == 0
|
|
712
|
+
@line_index -= 1
|
|
713
|
+
@cursor_position = [@cursor_position, current_line.chars.length].min
|
|
714
|
+
true
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def cursor_down
|
|
718
|
+
return false if @line_index >= @lines.size - 1
|
|
719
|
+
@line_index += 1
|
|
720
|
+
@cursor_position = [@cursor_position, current_line.chars.length].min
|
|
721
|
+
true
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def kill_to_end
|
|
725
|
+
chars = current_line.chars
|
|
726
|
+
@lines[@line_index] = chars[0...@cursor_position].join
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def kill_to_start
|
|
730
|
+
chars = current_line.chars
|
|
731
|
+
@lines[@line_index] = chars[@cursor_position..-1]&.join || ""
|
|
732
|
+
@cursor_position = 0
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def kill_word
|
|
736
|
+
chars = current_line.chars
|
|
737
|
+
pos = @cursor_position - 1
|
|
738
|
+
|
|
739
|
+
while pos >= 0 && chars[pos] =~ /\s/
|
|
740
|
+
pos -= 1
|
|
741
|
+
end
|
|
742
|
+
while pos >= 0 && chars[pos] =~ /\S/
|
|
743
|
+
pos -= 1
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
delete_start = pos + 1
|
|
747
|
+
chars.slice!(delete_start...@cursor_position)
|
|
748
|
+
@lines[@line_index] = chars.join
|
|
749
|
+
@cursor_position = delete_start
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def load_history_entry
|
|
753
|
+
return unless @history_index >= 0 && @history_index < @history.size
|
|
754
|
+
entry = @history[@history_index]
|
|
755
|
+
@lines = entry.split("\n")
|
|
756
|
+
@lines = [""] if @lines.empty?
|
|
757
|
+
@line_index = @lines.size - 1
|
|
758
|
+
@cursor_position = current_line.chars.length
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def add_to_history(entry)
|
|
762
|
+
@history << entry
|
|
763
|
+
@history = @history.last(100) if @history.size > 100
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def paste_from_clipboard
|
|
767
|
+
case RbConfig::CONFIG["host_os"]
|
|
768
|
+
when /darwin/i
|
|
769
|
+
paste_from_clipboard_macos
|
|
770
|
+
when /linux/i
|
|
771
|
+
paste_from_clipboard_linux
|
|
772
|
+
else
|
|
773
|
+
{ type: :text, text: "" }
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def paste_from_clipboard_macos
|
|
778
|
+
has_image = system("osascript -e 'try' -e 'the clipboard as «class PNGf»' -e 'on error' -e 'return false' -e 'end try' >/dev/null 2>&1")
|
|
779
|
+
|
|
780
|
+
if has_image
|
|
781
|
+
temp_dir = Dir.tmpdir
|
|
782
|
+
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
783
|
+
temp_path = File.join(temp_dir, temp_filename)
|
|
784
|
+
|
|
785
|
+
script = <<~APPLESCRIPT
|
|
786
|
+
set png_data to the clipboard as «class PNGf»
|
|
787
|
+
set the_file to open for access POSIX file "#{temp_path}" with write permission
|
|
788
|
+
write png_data to the_file
|
|
789
|
+
close access the_file
|
|
790
|
+
APPLESCRIPT
|
|
791
|
+
|
|
792
|
+
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
793
|
+
|
|
794
|
+
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
795
|
+
return { type: :image, path: temp_path }
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
text = `pbpaste 2>/dev/null`.to_s
|
|
800
|
+
text.force_encoding('UTF-8')
|
|
801
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
802
|
+
{ type: :text, text: text }
|
|
803
|
+
rescue => e
|
|
804
|
+
{ type: :text, text: "" }
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def paste_from_clipboard_linux
|
|
808
|
+
if system("which xclip >/dev/null 2>&1")
|
|
809
|
+
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
810
|
+
text.force_encoding('UTF-8')
|
|
811
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
812
|
+
{ type: :text, text: text }
|
|
813
|
+
elsif system("which xsel >/dev/null 2>&1")
|
|
814
|
+
text = `xsel --clipboard --output 2>/dev/null`.to_s
|
|
815
|
+
text.force_encoding('UTF-8')
|
|
816
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
817
|
+
{ type: :text, text: text }
|
|
818
|
+
else
|
|
819
|
+
{ type: :text, text: "" }
|
|
820
|
+
end
|
|
821
|
+
rescue => e
|
|
822
|
+
{ type: :text, text: "" }
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def current_line
|
|
826
|
+
@lines[@line_index] || ""
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def expand_placeholders(text)
|
|
830
|
+
super(text, @paste_placeholders)
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def render_line_with_cursor(line)
|
|
834
|
+
chars = line.chars
|
|
835
|
+
before_cursor = chars[0...@cursor_position].join
|
|
836
|
+
cursor_char = chars[@cursor_position] || " "
|
|
837
|
+
after_cursor = chars[(@cursor_position + 1)..-1]&.join || ""
|
|
838
|
+
|
|
839
|
+
"#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Render a segment of a line with cursor if cursor is in this segment
|
|
843
|
+
# @param line [String] Full line text
|
|
844
|
+
# @param segment_start [Integer] Start position of segment in line (char index)
|
|
845
|
+
# @param segment_end [Integer] End position of segment in line (char index)
|
|
846
|
+
# @return [String] Rendered segment with cursor if applicable
|
|
847
|
+
def render_line_segment_with_cursor(line, segment_start, segment_end)
|
|
848
|
+
chars = line.chars
|
|
849
|
+
segment_chars = chars[segment_start...segment_end]
|
|
850
|
+
|
|
851
|
+
# Check if cursor is in this segment
|
|
852
|
+
if @cursor_position >= segment_start && @cursor_position < segment_end
|
|
853
|
+
# Cursor is in this segment
|
|
854
|
+
cursor_pos_in_segment = @cursor_position - segment_start
|
|
855
|
+
before_cursor = segment_chars[0...cursor_pos_in_segment].join
|
|
856
|
+
cursor_char = segment_chars[cursor_pos_in_segment] || " "
|
|
857
|
+
after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
|
|
858
|
+
|
|
859
|
+
"#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
|
|
860
|
+
elsif @cursor_position == segment_end && segment_end == line.length
|
|
861
|
+
# Cursor is at the very end of the line, show it in last segment
|
|
862
|
+
segment_text = segment_chars.join
|
|
863
|
+
"#{@pastel.white(segment_text)}#{@pastel.on_white(@pastel.black(' '))}"
|
|
864
|
+
else
|
|
865
|
+
# Cursor is not in this segment, just format normally
|
|
866
|
+
theme.format_text(segment_chars.join, :user)
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def render_separator(row)
|
|
871
|
+
move_cursor(row, 0)
|
|
872
|
+
content = @pastel.dim("─" * @width)
|
|
873
|
+
print_with_padding(content)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
def render_sessionbar(row)
|
|
877
|
+
move_cursor(row, 0)
|
|
878
|
+
|
|
879
|
+
# If no sessionbar info, just render a separator
|
|
880
|
+
unless @sessionbar_info[:working_dir]
|
|
881
|
+
content = @pastel.dim("─" * @width)
|
|
882
|
+
print_with_padding(content)
|
|
883
|
+
return
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
parts = []
|
|
887
|
+
separator = @pastel.dim(" │ ")
|
|
888
|
+
|
|
889
|
+
# Workspace status with animation
|
|
890
|
+
if @sessionbar_info[:status]
|
|
891
|
+
status_indicator = get_status_indicator(@sessionbar_info[:status])
|
|
892
|
+
status_theme_key = status_theme_key_for(@sessionbar_info[:status])
|
|
893
|
+
parts << "#{status_indicator} #{theme.format_text(@sessionbar_info[:status], status_theme_key)}"
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# Working directory (shortened if too long)
|
|
897
|
+
if @sessionbar_info[:working_dir]
|
|
898
|
+
dir_display = shorten_path(@sessionbar_info[:working_dir])
|
|
899
|
+
parts << @pastel.bright_cyan(dir_display)
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
# Permission mode
|
|
903
|
+
if @sessionbar_info[:mode]
|
|
904
|
+
mode_color = mode_color_for(@sessionbar_info[:mode])
|
|
905
|
+
parts << @pastel.public_send(mode_color, @sessionbar_info[:mode])
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# Model
|
|
909
|
+
if @sessionbar_info[:model]
|
|
910
|
+
parts << @pastel.bright_white(@sessionbar_info[:model])
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# Tasks count
|
|
914
|
+
parts << @pastel.yellow("#{@sessionbar_info[:tasks]} tasks")
|
|
915
|
+
|
|
916
|
+
# Cost
|
|
917
|
+
cost_display = format("$%.1f", @sessionbar_info[:cost])
|
|
918
|
+
parts << @pastel.yellow(cost_display)
|
|
919
|
+
|
|
920
|
+
session_line = " " + parts.join(separator)
|
|
921
|
+
print_with_padding(session_line)
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def shorten_path(path)
|
|
925
|
+
return path if path.length <= 40
|
|
926
|
+
|
|
927
|
+
# Replace home directory with ~
|
|
928
|
+
home = ENV["HOME"]
|
|
929
|
+
if home && path.start_with?(home)
|
|
930
|
+
path = path.sub(home, "~")
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# If still too long, show last parts
|
|
934
|
+
if path.length > 40
|
|
935
|
+
parts = path.split("/")
|
|
936
|
+
if parts.length > 3
|
|
937
|
+
".../" + parts[-3..-1].join("/")
|
|
938
|
+
else
|
|
939
|
+
path[0..40] + "..."
|
|
940
|
+
end
|
|
941
|
+
else
|
|
942
|
+
path
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def mode_color_for(mode)
|
|
947
|
+
case mode.to_s
|
|
948
|
+
when /auto_approve/
|
|
949
|
+
:bright_magenta
|
|
950
|
+
when /confirm_safes/
|
|
951
|
+
:bright_yellow
|
|
952
|
+
when /confirm_edits/
|
|
953
|
+
:bright_green
|
|
954
|
+
when /plan_only/
|
|
955
|
+
:bright_blue
|
|
956
|
+
else
|
|
957
|
+
:white
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def status_theme_key_for(status)
|
|
962
|
+
case status.to_s.downcase
|
|
963
|
+
when 'idle'
|
|
964
|
+
:info # Use info color for idle state
|
|
965
|
+
when 'working'
|
|
966
|
+
:progress # Use progress color for working state
|
|
967
|
+
else
|
|
968
|
+
:info
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def get_status_indicator(status)
|
|
973
|
+
case status.to_s.downcase
|
|
974
|
+
when 'working'
|
|
975
|
+
# Update animation frame if enough time has passed
|
|
976
|
+
now = Time.now
|
|
977
|
+
if now - @last_animation_update >= 0.3
|
|
978
|
+
@animation_frame = (@animation_frame + 1) % @working_frames.length
|
|
979
|
+
@last_animation_update = now
|
|
980
|
+
end
|
|
981
|
+
@working_frames[@animation_frame]
|
|
982
|
+
else
|
|
983
|
+
"●" # Idle indicator
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def format_tips(message, type)
|
|
988
|
+
# Limit message length to prevent line wrapping
|
|
989
|
+
# Reserve space for prefix like "[Warn] " (about 8 chars) and some margin
|
|
990
|
+
max_length = @width - 10
|
|
991
|
+
if message.length > max_length
|
|
992
|
+
message = message[0...(max_length - 3)] + "..."
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
case type
|
|
996
|
+
when :warning
|
|
997
|
+
@pastel.dim("[") + @pastel.yellow("Warn") + @pastel.dim("] ") + @pastel.yellow(message)
|
|
998
|
+
when :error
|
|
999
|
+
@pastel.dim("[") + @pastel.red("Error") + @pastel.dim("] ") + @pastel.red(message)
|
|
1000
|
+
else
|
|
1001
|
+
@pastel.dim("[") + @pastel.cyan("Info") + @pastel.dim("] ") + @pastel.white(message)
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def format_filesize(size)
|
|
1006
|
+
if size < 1024
|
|
1007
|
+
"#{size}B"
|
|
1008
|
+
elsif size < 1024 * 1024
|
|
1009
|
+
"#{(size / 1024.0).round(1)}KB"
|
|
1010
|
+
else
|
|
1011
|
+
"#{(size / 1024.0 / 1024.0).round(1)}MB"
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def move_cursor(row, col)
|
|
1016
|
+
print "\e[#{row + 1};#{col + 1}H"
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
def clear_line
|
|
1020
|
+
print "\e[2K"
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def flush
|
|
1024
|
+
$stdout.flush
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1029
|
+
end
|