swarm_cli 2.1.13 → 3.0.0.alpha2

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/exe/swarm3 +11 -0
  4. data/lib/swarm_cli/v3/activity_indicator.rb +168 -0
  5. data/lib/swarm_cli/v3/ansi_colors.rb +70 -0
  6. data/lib/swarm_cli/v3/cli.rb +721 -0
  7. data/lib/swarm_cli/v3/command_completer.rb +112 -0
  8. data/lib/swarm_cli/v3/display.rb +607 -0
  9. data/lib/swarm_cli/v3/dropdown.rb +130 -0
  10. data/lib/swarm_cli/v3/event_renderer.rb +161 -0
  11. data/lib/swarm_cli/v3/file_completer.rb +143 -0
  12. data/lib/swarm_cli/v3/raw_input_reader.rb +304 -0
  13. data/lib/swarm_cli/v3/reboot_tool.rb +123 -0
  14. data/lib/swarm_cli/v3/text_input.rb +235 -0
  15. data/lib/swarm_cli/v3.rb +52 -0
  16. metadata +30 -245
  17. data/exe/swarm +0 -6
  18. data/lib/swarm_cli/cli.rb +0 -201
  19. data/lib/swarm_cli/command_registry.rb +0 -61
  20. data/lib/swarm_cli/commands/mcp_serve.rb +0 -130
  21. data/lib/swarm_cli/commands/mcp_tools.rb +0 -148
  22. data/lib/swarm_cli/commands/migrate.rb +0 -55
  23. data/lib/swarm_cli/commands/run.rb +0 -173
  24. data/lib/swarm_cli/config_loader.rb +0 -98
  25. data/lib/swarm_cli/formatters/human_formatter.rb +0 -811
  26. data/lib/swarm_cli/formatters/json_formatter.rb +0 -62
  27. data/lib/swarm_cli/interactive_repl.rb +0 -895
  28. data/lib/swarm_cli/mcp_serve_options.rb +0 -44
  29. data/lib/swarm_cli/mcp_tools_options.rb +0 -59
  30. data/lib/swarm_cli/migrate_options.rb +0 -54
  31. data/lib/swarm_cli/migrator.rb +0 -132
  32. data/lib/swarm_cli/options.rb +0 -151
  33. data/lib/swarm_cli/ui/components/agent_badge.rb +0 -33
  34. data/lib/swarm_cli/ui/components/content_block.rb +0 -120
  35. data/lib/swarm_cli/ui/components/divider.rb +0 -57
  36. data/lib/swarm_cli/ui/components/panel.rb +0 -62
  37. data/lib/swarm_cli/ui/components/usage_stats.rb +0 -70
  38. data/lib/swarm_cli/ui/formatters/cost.rb +0 -49
  39. data/lib/swarm_cli/ui/formatters/number.rb +0 -58
  40. data/lib/swarm_cli/ui/formatters/text.rb +0 -77
  41. data/lib/swarm_cli/ui/formatters/time.rb +0 -73
  42. data/lib/swarm_cli/ui/icons.rb +0 -36
  43. data/lib/swarm_cli/ui/renderers/event_renderer.rb +0 -188
  44. data/lib/swarm_cli/ui/state/agent_color_cache.rb +0 -45
  45. data/lib/swarm_cli/ui/state/depth_tracker.rb +0 -40
  46. data/lib/swarm_cli/ui/state/spinner_manager.rb +0 -170
  47. data/lib/swarm_cli/ui/state/usage_tracker.rb +0 -62
  48. data/lib/swarm_cli/version.rb +0 -5
  49. data/lib/swarm_cli.rb +0 -46
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module V3
5
+ # Command completion logic for slash commands.
6
+ #
7
+ # CommandCompleter provides methods to extract command targets from text buffers
8
+ # and find matching slash commands. It has no knowledge of the UI or display.
9
+ #
10
+ # @example Extracting command word
11
+ # buffer = "/mem"
12
+ # cursor = 4 # After "mem"
13
+ # result = CommandCompleter.extract_command_word(buffer, cursor)
14
+ # # => ["", "/mem", ""]
15
+ #
16
+ # @example Finding matches
17
+ # matches = CommandCompleter.find_matches("/mem")
18
+ # # => ["/memory"]
19
+ class CommandCompleter
20
+ # Available slash commands
21
+ COMMANDS = [
22
+ "/clear",
23
+ "/memory",
24
+ "/defrag",
25
+ "/queue",
26
+ "/reboot",
27
+ "/exit",
28
+ "/quit",
29
+ ].freeze
30
+
31
+ class << self
32
+ # Extract the command at cursor position.
33
+ #
34
+ # Searches backward from cursor to find a `/` at the start of the line,
35
+ # then extracts from that `/` to the cursor position.
36
+ #
37
+ # @param buffer [String] the full text buffer
38
+ # @param cursor [Integer] the cursor position (0-based index)
39
+ #
40
+ # @return [Array<String>, nil] [pre, target, post] or nil if no / found
41
+ # - pre: text before the /
42
+ # - target: the / and characters up to cursor
43
+ # - post: text after cursor
44
+ #
45
+ # @example Cursor at end of command
46
+ # extract_command_word("/mem", 4)
47
+ # # => ["", "/mem", ""]
48
+ #
49
+ # @example Cursor in middle of command
50
+ # extract_command_word("/memory", 4)
51
+ # # => ["", "/mem", "ory"]
52
+ #
53
+ # @example No / at start of line
54
+ # extract_command_word("check /mem", 10)
55
+ # # => nil (slash not at start)
56
+ def extract_command_word(buffer, cursor)
57
+ # Command must be at start of line
58
+ return unless buffer.start_with?("/")
59
+
60
+ # Find end of command after cursor (next space or end of buffer)
61
+ target_end = cursor
62
+ target_end += 1 while target_end < buffer.length && buffer[target_end] !~ /\s/
63
+
64
+ pre = ""
65
+ target = buffer[0...cursor]
66
+ post = buffer[cursor...target_end] || ""
67
+
68
+ [pre, target, post]
69
+ end
70
+
71
+ # Find matching commands for a query (with / prefix).
72
+ #
73
+ # @param target [String] the search target (must start with /)
74
+ # @param max [Integer] maximum number of results to return
75
+ # @param skills [Array<String>] additional skill names to include
76
+ #
77
+ # @return [Array<String>] array of matching commands (with / prefix)
78
+ #
79
+ # @example Exact match
80
+ # find_matches("/clear")
81
+ # # => ["/clear"]
82
+ #
83
+ # @example Prefix match
84
+ # find_matches("/me")
85
+ # # => ["/memory"]
86
+ #
87
+ # @example With skills
88
+ # find_matches("/an", skills: ["analyze", "annotate"])
89
+ # # => ["/analyze", "/annotate"]
90
+ #
91
+ # @example Multiple matches
92
+ # find_matches("/")
93
+ # # => ["/clear", "/memory", "/defrag", "/queue", "/reboot"]
94
+ def find_matches(target, max: 5, skills: [])
95
+ return [] unless target.start_with?("/")
96
+
97
+ query = target.downcase
98
+
99
+ # Combine built-in commands with skill names
100
+ skill_commands = skills.map { |name| "/#{name}" }
101
+ all_commands = COMMANDS + skill_commands
102
+
103
+ # Find commands that start with the query
104
+ matches = all_commands.select { |cmd| cmd.downcase.start_with?(query) }
105
+
106
+ # Sort by length (shorter = more specific)
107
+ matches.sort_by(&:length).first(max)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module V3
5
+ # Mutex-protected coordinator for terminal output.
6
+ #
7
+ # Owns a {TextInput} and an {ActivityIndicator}, and ensures all screen
8
+ # writes are serialized so agent events and user keystrokes never visually
9
+ # collide. The input line (prompt + buffer) is always visible at the bottom;
10
+ # agent output "slides in" above it.
11
+ #
12
+ # The footer is composed of (top to bottom):
13
+ # 1. Optional working indicator (blank line + "Working..." line)
14
+ # 2. Horizontal separator
15
+ # 3. Prompt line (may wrap and span multiple lines)
16
+ class Display
17
+ # @return [TextInput] the text input model (read-only access for reader)
18
+ attr_reader :text_input
19
+
20
+ # @param io [IO] output stream (defaults to $stdout, use StringIO for tests)
21
+ # @param width [Integer, nil] terminal width override (nil = auto-detect)
22
+ def initialize(io: $stdout, width: nil)
23
+ @io = io
24
+ @width_override = width
25
+ @mutex = Mutex.new
26
+ @active = false
27
+ @text_input = TextInput.new
28
+ @indicator = ActivityIndicator.new(on_tick: -> { tick })
29
+ # Tracks which row of the prompt area the terminal cursor is on
30
+ # (0 = first line of prompt, relative to prompt start).
31
+ # Used by reposition_cursor to navigate from current position.
32
+ @terminal_cursor_row = 0
33
+ @hint_message = nil
34
+ @hint_timer = nil
35
+ @status_message = nil # Persistent status message
36
+ @dropdown = nil # Dropdown instance when autocomplete is active
37
+ end
38
+
39
+ # Start showing the input footer.
40
+ #
41
+ # @return [void]
42
+ def activate
43
+ @mutex.synchronize do
44
+ @active = true
45
+ redraw_footer
46
+ end
47
+ end
48
+
49
+ # Stop showing the input footer and clear it from the screen.
50
+ #
51
+ # @return [void]
52
+ def deactivate
53
+ @mutex.synchronize do
54
+ return unless @active
55
+
56
+ clear_footer
57
+ @active = false
58
+ end
59
+ end
60
+
61
+ # Signal that the agent is working.
62
+ #
63
+ # @return [void]
64
+ def start_working
65
+ @mutex.synchronize do
66
+ if @active
67
+ clear_footer
68
+ @indicator.start
69
+ redraw_footer
70
+ else
71
+ @indicator.start
72
+ end
73
+ end
74
+ end
75
+
76
+ # Update the activity indicator status suffix.
77
+ #
78
+ # @param text [String, nil] status text to show
79
+ # @return [void]
80
+ def update_activity(text)
81
+ @mutex.synchronize do
82
+ return unless @indicator.working?
83
+
84
+ @indicator.status = text
85
+ end
86
+ end
87
+
88
+ # Signal that the agent has finished working.
89
+ #
90
+ # @return [void]
91
+ def stop_working
92
+ @mutex.synchronize do
93
+ return unless @indicator.working?
94
+
95
+ clear_footer if @active
96
+ @indicator.stop
97
+ redraw_footer if @active
98
+ end
99
+ end
100
+
101
+ # Echo a typed character at the cursor position.
102
+ #
103
+ # @param char [String] single character
104
+ # @return [void]
105
+ def type_char(char)
106
+ @mutex.synchronize do
107
+ old_lines = @text_input.wrapped_lines(terminal_width)
108
+ @text_input.type_char(char)
109
+
110
+ # Always redraw to ensure bottom separator is present
111
+ redraw_prompt_with_separator_from(old_lines)
112
+ end
113
+ end
114
+
115
+ # Handle a backspace keypress.
116
+ #
117
+ # @return [void]
118
+ def backspace
119
+ @mutex.synchronize do
120
+ old_lines = @text_input.wrapped_lines(terminal_width)
121
+ @text_input.backspace
122
+
123
+ # Always redraw to ensure bottom separator is present
124
+ redraw_prompt_with_separator_from(old_lines)
125
+ end
126
+ end
127
+
128
+ # Move cursor left.
129
+ #
130
+ # @return [void]
131
+ def move_left
132
+ @mutex.synchronize do
133
+ @text_input.move_left
134
+ reposition_cursor
135
+ end
136
+ end
137
+
138
+ # Move cursor right.
139
+ #
140
+ # @return [void]
141
+ def move_right
142
+ @mutex.synchronize do
143
+ @text_input.move_right
144
+ reposition_cursor
145
+ end
146
+ end
147
+
148
+ # Move cursor up one line within multiline buffer.
149
+ #
150
+ # @return [Boolean] true if cursor moved, false if already on first line
151
+ def move_up
152
+ @mutex.synchronize do
153
+ old = @text_input.cursor
154
+ @text_input.move_up
155
+ moved = @text_input.cursor != old
156
+ reposition_cursor if moved
157
+ moved
158
+ end
159
+ end
160
+
161
+ # Move cursor down one line within multiline buffer.
162
+ #
163
+ # @return [Boolean] true if cursor moved, false if already on last line
164
+ def move_down
165
+ @mutex.synchronize do
166
+ old = @text_input.cursor
167
+ @text_input.move_down
168
+ moved = @text_input.cursor != old
169
+ reposition_cursor if moved
170
+ moved
171
+ end
172
+ end
173
+
174
+ # Finalize the current input line and return the buffer text.
175
+ #
176
+ # @return [String] the submitted text
177
+ def enter
178
+ @mutex.synchronize do
179
+ line = @text_input.submit
180
+ clear_footer if @active
181
+ write("#{@text_input.render}#{line}\n")
182
+ redraw_footer if @active
183
+ flush
184
+ line
185
+ end
186
+ end
187
+
188
+ # Replace the buffer contents (for history navigation).
189
+ #
190
+ # @param text [String, nil] replacement text
191
+ # @return [void]
192
+ def replace_buffer(text)
193
+ @mutex.synchronize do
194
+ old_lines = @text_input.wrapped_lines(terminal_width)
195
+ @text_input.replace(text)
196
+ redraw_prompt_with_separator_from(old_lines) if @active
197
+ end
198
+ end
199
+
200
+ # Clear the input buffer (for double-ESC).
201
+ #
202
+ # @return [void]
203
+ def clear_input
204
+ @mutex.synchronize do
205
+ old_lines = @text_input.wrapped_lines(terminal_width)
206
+ @text_input.replace("")
207
+ @hint_message = nil # Clear hint directly
208
+ clear_hint_timer
209
+ redraw_prompt_with_separator_from(old_lines) if @active
210
+ end
211
+ end
212
+
213
+ # Show a temporary hint message below the input area.
214
+ #
215
+ # @param message [String] the hint text to display
216
+ # @param duration [Float] seconds to show the hint (default 1.5)
217
+ # @return [void]
218
+ def show_hint(message, duration: 1.5)
219
+ @mutex.synchronize do
220
+ clear_hint_timer
221
+ @hint_message = message
222
+
223
+ if @active
224
+ old_lines = @text_input.wrapped_lines(terminal_width)
225
+ redraw_prompt_with_separator_from(old_lines)
226
+ end
227
+
228
+ @hint_timer = Thread.new do
229
+ sleep(duration)
230
+ @mutex.synchronize do
231
+ @hint_message = nil
232
+ @hint_timer = nil
233
+
234
+ if @active
235
+ old_lines = @text_input.wrapped_lines(terminal_width)
236
+ redraw_prompt_with_separator_from(old_lines)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ # Clear the hint message immediately.
244
+ #
245
+ # @return [void]
246
+ def clear_hint
247
+ return unless @hint_message
248
+
249
+ clear_hint_timer
250
+ @hint_message = nil
251
+
252
+ if @active
253
+ old_lines = @text_input.wrapped_lines(terminal_width)
254
+ redraw_prompt_with_separator_from(old_lines)
255
+ end
256
+ end
257
+
258
+ # Show a persistent status message in the status area.
259
+ # Unlike hints, status messages don't auto-clear.
260
+ #
261
+ # @param message [String] the status text to display
262
+ # @return [void]
263
+ def show_status(message)
264
+ @mutex.synchronize do
265
+ @status_message = message
266
+
267
+ if @active
268
+ old_lines = @text_input.wrapped_lines(terminal_width)
269
+ redraw_prompt_with_separator_from(old_lines)
270
+ end
271
+ end
272
+ end
273
+
274
+ # Clear the persistent status message.
275
+ #
276
+ # @return [void]
277
+ def clear_status
278
+ return unless @status_message
279
+
280
+ @status_message = nil
281
+
282
+ if @active
283
+ old_lines = @text_input.wrapped_lines(terminal_width)
284
+ redraw_prompt_with_separator_from(old_lines)
285
+ end
286
+ end
287
+
288
+ # Show dropdown in status area.
289
+ #
290
+ # @param dropdown [Dropdown] the dropdown to display
291
+ # @return [void]
292
+ def show_dropdown(dropdown)
293
+ @mutex.synchronize do
294
+ @dropdown = dropdown
295
+ @hint_message = nil # Clear hints when dropdown shows
296
+ @status_message = nil # Clear status when dropdown shows
297
+
298
+ if @active
299
+ old_lines = @text_input.wrapped_lines(terminal_width)
300
+ redraw_prompt_with_separator_from(old_lines)
301
+ end
302
+ end
303
+ end
304
+
305
+ # Navigate dropdown selection to next item.
306
+ #
307
+ # @return [void]
308
+ def dropdown_select_next
309
+ @mutex.synchronize do
310
+ return unless @dropdown
311
+
312
+ @dropdown.select_next
313
+
314
+ if @active
315
+ old_lines = @text_input.wrapped_lines(terminal_width)
316
+ redraw_prompt_with_separator_from(old_lines)
317
+ end
318
+ end
319
+ end
320
+
321
+ # Navigate dropdown selection to previous item.
322
+ #
323
+ # @return [void]
324
+ def dropdown_select_previous
325
+ @mutex.synchronize do
326
+ return unless @dropdown
327
+
328
+ @dropdown.select_previous
329
+
330
+ if @active
331
+ old_lines = @text_input.wrapped_lines(terminal_width)
332
+ redraw_prompt_with_separator_from(old_lines)
333
+ end
334
+ end
335
+ end
336
+
337
+ # Get selected item and close dropdown.
338
+ #
339
+ # @return [String, nil] the selected item or nil if no dropdown active
340
+ def dropdown_accept
341
+ @mutex.synchronize do
342
+ return unless @dropdown
343
+
344
+ selected = @dropdown.selected_item
345
+ @dropdown = nil
346
+
347
+ if @active
348
+ old_lines = @text_input.wrapped_lines(terminal_width)
349
+ redraw_prompt_with_separator_from(old_lines)
350
+ end
351
+
352
+ selected
353
+ end
354
+ end
355
+
356
+ # Get first item and close dropdown (for Tab behavior).
357
+ #
358
+ # @return [String, nil] the first item or nil if no dropdown active
359
+ def dropdown_accept_first
360
+ @mutex.synchronize do
361
+ return unless @dropdown
362
+
363
+ first = @dropdown.first_item
364
+ @dropdown = nil
365
+
366
+ if @active
367
+ old_lines = @text_input.wrapped_lines(terminal_width)
368
+ redraw_prompt_with_separator_from(old_lines)
369
+ end
370
+
371
+ first
372
+ end
373
+ end
374
+
375
+ # Close dropdown without selection.
376
+ #
377
+ # @return [void]
378
+ def dropdown_close
379
+ @mutex.synchronize do
380
+ return unless @dropdown
381
+
382
+ @dropdown = nil
383
+
384
+ if @active
385
+ old_lines = @text_input.wrapped_lines(terminal_width)
386
+ redraw_prompt_with_separator_from(old_lines)
387
+ end
388
+ end
389
+ end
390
+
391
+ # Check whether dropdown is active.
392
+ #
393
+ # @return [Boolean] true if dropdown is shown
394
+ def dropdown_active?
395
+ @mutex.synchronize { !@dropdown.nil? }
396
+ end
397
+
398
+ # Return a copy of the current input buffer (thread-safe).
399
+ #
400
+ # @return [String]
401
+ def current_buffer
402
+ @mutex.synchronize { @text_input.buffer.dup }
403
+ end
404
+
405
+ # Print agent output above the always-visible footer.
406
+ #
407
+ # @param text [String] output text to display
408
+ # @return [void]
409
+ def agent_print(text)
410
+ @mutex.synchronize do
411
+ if @active
412
+ clear_footer
413
+ write(text)
414
+ write("\n") unless text.end_with?("\n")
415
+ redraw_footer
416
+ else
417
+ write(text)
418
+ write("\n") unless text.end_with?("\n")
419
+ end
420
+ flush
421
+ end
422
+ end
423
+
424
+ private
425
+
426
+ # Called by the ActivityIndicator ticker thread.
427
+ def tick
428
+ @mutex.synchronize do
429
+ return unless @indicator.working? && @active
430
+
431
+ clear_footer
432
+ redraw_footer
433
+ end
434
+ end
435
+
436
+ def terminal_width
437
+ @width_override || IO.console&.winsize&.last || 80
438
+ end
439
+
440
+ def cursor_at_end?
441
+ @text_input.cursor == @text_input.buffer.length
442
+ end
443
+
444
+ # Clear the entire footer from the screen.
445
+ # Navigates from the tracked cursor row, not from an assumed position.
446
+ def clear_footer
447
+ width = terminal_width
448
+ @text_input.wrapped_lines(width)
449
+
450
+ # From current cursor row, move up to start of indicator/separator area
451
+ # @terminal_cursor_row is the line within the text input (0 = first line)
452
+ # We need to move up (cursor_row + 1) to reach the top separator
453
+ lines_above_cursor = @terminal_cursor_row + 1
454
+ lines_above_cursor += @indicator.footer_lines
455
+ lines_above_cursor += status_area_lines # Account for status area if shown
456
+
457
+ lines_above_cursor.times { write("\e[A") }
458
+ write("\r\e[J")
459
+ @terminal_cursor_row = 0
460
+ end
461
+
462
+ # Redraw the full footer: indicator, top separator, prompt, bottom separator, status area, then position cursor.
463
+ def redraw_footer
464
+ indicator_line = @indicator.render
465
+ write(indicator_line) if indicator_line
466
+ write_separator
467
+ write(@text_input.render)
468
+ write_bottom_separator
469
+ write_status_area
470
+ place_cursor
471
+ flush
472
+ end
473
+
474
+ # Redraw just the prompt line (single-line, cursor at end).
475
+ def redraw_prompt_line
476
+ write("\r\e[K#{@text_input.render}")
477
+ write_bottom_separator
478
+ place_cursor
479
+ flush
480
+ end
481
+
482
+ # Redraw separator + prompt area using a pre-captured line count.
483
+ #
484
+ # @param old_prompt_lines [Integer] number of wrapped lines before the change
485
+ # @param had_hint [Boolean] whether a hint was shown in the previous display (unused, kept for compatibility)
486
+ def redraw_prompt_with_separator_from(old_prompt_lines, had_hint: false)
487
+ # Navigate up from tracked cursor position to top separator
488
+ # @terminal_cursor_row is 0 for first line of input, 1 for second line, etc.
489
+ # We need to move up (cursor_row + 1) to reach the top separator
490
+ # The status area is BELOW the cursor, so it doesn't affect upward navigation
491
+ up = @terminal_cursor_row + 1
492
+ up.times { write("\e[A") }
493
+ write("\r\e[J")
494
+ write_separator
495
+ write(@text_input.render)
496
+ write_bottom_separator
497
+ write_status_area
498
+ place_cursor
499
+ flush
500
+ end
501
+
502
+ def write_separator
503
+ write(ANSIColors.dim("\u{2500}" * terminal_width))
504
+ write("\n")
505
+ end
506
+
507
+ def write_bottom_separator
508
+ write("\n")
509
+ write(ANSIColors.dim("\u{2500}" * terminal_width))
510
+ end
511
+
512
+ # Write the status area (dropdown, hints, or persistent status).
513
+ # Dropdown takes priority, then hints, then status messages.
514
+ def write_status_area
515
+ if @dropdown
516
+ # Render dropdown (multiple lines)
517
+ @dropdown.render.each do |line|
518
+ write("\n")
519
+ write(line)
520
+ end
521
+ elsif @hint_message
522
+ write("\n")
523
+ write(ANSIColors.dim(@hint_message))
524
+ elsif @status_message
525
+ write("\n")
526
+ write(ANSIColors.italic(@status_message))
527
+ end
528
+ end
529
+
530
+ # Count how many lines the status area occupies
531
+ def status_area_lines
532
+ if @dropdown
533
+ @dropdown.render.size
534
+ elsif @hint_message || @status_message
535
+ 1
536
+ else
537
+ 0
538
+ end
539
+ end
540
+
541
+ def clear_hint_timer
542
+ return unless @hint_timer
543
+
544
+ @hint_timer.kill
545
+ @hint_timer = nil
546
+ end
547
+
548
+ # Position the terminal cursor at the TextInput cursor location
549
+ # and update @terminal_cursor_row tracking.
550
+ #
551
+ # After rendering, terminal cursor is at the end of the bottom separator line (or status area if shown).
552
+ # We need to move up to the correct position in the text input.
553
+ def place_cursor
554
+ width = terminal_width
555
+ extra_lines = @text_input.wrapped_lines(width)
556
+ cursor_row, cursor_col = @text_input.cursor_position(width)
557
+
558
+ # wrapped_lines returns EXTRA lines from wrapping (0 for single line)
559
+ # Total terminal lines = extra_lines + 1 (for the base line)
560
+ # We're on the separator line (or status area), need to move up to cursor_row
561
+ # From separator/status to last text line = 1 line up (+ status_area_lines if shown)
562
+ # From last text line to cursor_row = (extra_lines - cursor_row) lines up
563
+ up = (extra_lines - cursor_row) + 1
564
+ up += status_area_lines # Extra lines for status area
565
+ up.times { write("\e[A") } if up > 0
566
+
567
+ # Move to cursor column
568
+ write("\r")
569
+ write("\e[#{cursor_col}C") if cursor_col > 0
570
+
571
+ @terminal_cursor_row = cursor_row
572
+ end
573
+
574
+ # Reposition cursor for arrow key movement (no content change).
575
+ # Uses tracked @terminal_cursor_row to navigate from current position.
576
+ def reposition_cursor
577
+ width = terminal_width
578
+ return if width <= 0
579
+
580
+ cursor_row, cursor_col = @text_input.cursor_position(width)
581
+
582
+ # Navigate from current row to target row
583
+ row_delta = cursor_row - @terminal_cursor_row
584
+ if row_delta < 0
585
+ (-row_delta).times { write("\e[A") }
586
+ elsif row_delta > 0
587
+ row_delta.times { write("\e[B") }
588
+ end
589
+
590
+ # Move to cursor column
591
+ write("\r")
592
+ write("\e[#{cursor_col}C") if cursor_col > 0
593
+
594
+ @terminal_cursor_row = cursor_row
595
+ flush
596
+ end
597
+
598
+ def write(text)
599
+ @io.write(text)
600
+ end
601
+
602
+ def flush
603
+ @io.flush
604
+ end
605
+ end
606
+ end
607
+ end