Fortran · 348046 bytes Raw Blame History
1 ! ==============================================================================
2 ! Module: readline
3 ! Purpose: Advanced input handling with command history and line editing
4 ! ==============================================================================
5 module readline
6 use shell_types
7 use system_interface
8 use completion, only: get_completion_spec, generate_completions, completion_spec_t, MAX_COMPLETIONS
9 use syntax_highlight, only: highlight_command_line, highlight_single_char, init_syntax_highlighting, MAX_HIGHLIGHT_LEN
10 use abbreviations, only: try_expand_abbreviation
11 use suggestions, only: compute_path_suggestion, compute_history_suggestion, &
12 suggestion_result_t, SUGGEST_NONE
13 use glob, only: pattern_matches
14 use iso_fortran_env, only: input_unit, output_unit, error_unit
15 use iso_c_binding
16 use buffer_ops
17 #ifdef USE_C_STRINGS
18 use fortsh_c_strings
19 #endif
20 #ifdef USE_MEMORY_POOL
21 use string_pool
22 use memory_dashboard
23 #endif
24 implicit none
25
26 ! Module-level terminal state for FZF functions
27 ! Needed because LLVM flang-new's execute_command_line requires cooked mode
28 type(termios_t), save :: module_original_termios
29 logical, save :: module_termios_saved = .false.
30
31 ! Import c_system from builtins module (it already works there)
32 ! We'll reference it via the module instead of defining our own
33 interface
34 function readline_c_system(command) bind(C, name="system")
35 use iso_c_binding
36 integer(c_int) :: readline_c_system
37 character(kind=c_char), intent(in) :: command(*)
38 end function readline_c_system
39 end interface
40
41 ! Note: c_signal, SIG_DFL, and SIGCHLD are imported from system_interface
42
43 ! Constants for special keys
44 integer, parameter :: KEY_ENTER = 10
45 integer, parameter :: KEY_BACKSPACE = 127
46 integer, parameter :: KEY_DELETE = 127 ! Same as backspace on most terminals
47 integer, parameter :: KEY_TAB = 9
48 integer, parameter :: KEY_CTRL_C = 3
49 integer, parameter :: KEY_CTRL_D = 4
50 integer, parameter :: KEY_CTRL_X = 24 ! Process kill mode
51 integer, parameter :: KEY_CTRL_A = 1 ! Home (beginning of line)
52 integer, parameter :: KEY_CTRL_E = 5 ! End (end of line)
53 integer, parameter :: KEY_CTRL_K = 11 ! Kill to end of line
54 integer, parameter :: KEY_CTRL_L = 12 ! Clear screen
55 integer, parameter :: KEY_CTRL_W = 23 ! Kill previous word
56 integer, parameter :: KEY_CTRL_U = 21 ! Kill entire line
57 integer, parameter :: KEY_CTRL_Y = 25 ! Yank (paste) killed text
58 integer, parameter :: KEY_CTRL_F = 6 ! FZF file browser
59 integer, parameter :: KEY_CTRL_B = 2 ! Backward character (same as left arrow)
60 integer, parameter :: KEY_CTRL_R = 18 ! Reverse-i-search
61 integer, parameter :: KEY_CTRL_S = 19 ! Forward-i-search
62 integer, parameter :: KEY_CTRL_G = 7 ! Cancel (alternate to Ctrl+C)
63 integer, parameter :: KEY_CTRL_H = 8 ! FZF history browser
64 integer, parameter :: KEY_CTRL_T = 20 ! Transpose characters
65 integer, parameter :: KEY_CTRL_N = 14 ! Next history (emacs binding)
66 integer, parameter :: KEY_CTRL_P = 16 ! Previous history (emacs binding)
67 integer, parameter :: KEY_ESC = 27
68 integer, parameter :: KEY_UP = 65
69 integer, parameter :: KEY_DOWN = 66
70 integer, parameter :: KEY_RIGHT = 67
71 integer, parameter :: KEY_LEFT = 68
72
73 ! History and line management
74 ! NOTE: The 128-byte limit was based on older flang-new versions.
75 ! Testing with flang-new 21.x shows both fixed-length and allocatable strings
76 ! work correctly with >128 bytes. The C string library provides additional safety.
77 ! MAX_HISTORY can be increased safely because it uses array allocation, not per-element size
78 #ifdef __APPLE__
79 integer, parameter :: MAX_HISTORY = 100 ! Increased from 10 (heap-allocated array, safe)
80 #ifdef USE_C_STRINGS
81 ! C string library enabled - use larger buffers (tested working with flang-new 21.x)
82 integer, parameter :: MAX_LINE_LEN = 1024
83 #else
84 ! Legacy limit for older flang-new versions without C string library
85 integer, parameter :: MAX_LINE_LEN = 128 ! Buffer size - actual limit is 127 chars!
86 #endif
87 #else
88 integer, parameter :: MAX_HISTORY = 1000
89 integer, parameter :: MAX_LINE_LEN = 1024
90 #endif
91
92 ! Glob expansion constants (from glob module)
93 integer, parameter :: MAX_GLOB_MATCHES = 1000
94 ! MAX_TOKEN_LEN is already defined in shell_types
95
96 ! Input state management
97 ! Editing mode constants
98 integer, parameter :: EDITING_MODE_EMACS = 1
99 integer, parameter :: EDITING_MODE_VI = 2
100 integer, parameter :: VI_MODE_INSERT = 1
101 integer, parameter :: VI_MODE_COMMAND = 2
102
103 ! Reduced buffer sizes to prevent static storage issues
104 ! Was causing 204KB allocation (50*4096), now only 25KB (40*256)
105 integer, parameter :: MAX_MENU_ITEM_LEN = 256
106 integer, parameter :: MAX_MENU_ITEMS = 40 ! Increased from 20 for better usability
107 integer, parameter :: MAX_LOCAL_COMPLETIONS = 40 ! Max completions to process locally
108 integer, parameter :: MAX_DIR_ENTRIES = 200 ! Max directory entries (increased for better completion)
109 integer, parameter :: MAX_SCORED_ITEMS = 50 ! Max scored completion items (increased from 30)
110
111 ! Test mode configuration
112 logical, save :: test_mode_enabled = .false.
113 logical, save :: completion_disabled = .false.
114 logical, save :: test_mode_initialized = .false.
115
116 type :: input_state_t
117 #ifdef USE_C_STRINGS
118 ! C string buffers - bypass flang-new 128-byte bug on macOS ARM64
119 ! These allow unlimited string length without heap corruption
120 type(c_string_buffer) :: buffer_c
121 type(c_string_buffer) :: original_buffer_c
122 type(c_string_buffer) :: kill_buffer_c
123 type(c_string_buffer) :: last_completion_buffer_c
124 #else
125 ! Use allocatable strings to avoid stack allocation on macOS
126 character(len=:), allocatable :: buffer
127 character(len=:), allocatable :: original_buffer ! Save original input during history navigation
128 character(len=:), allocatable :: kill_buffer ! Kill ring buffer for cut/paste
129 character(len=:), allocatable :: last_completion_buffer ! Buffer when we last showed completions
130 #endif
131 integer :: length = 0
132 integer :: cursor_pos = 0 ! 0-based position in buffer
133 integer :: history_pos = 0 ! Current position in history (0 = not browsing)
134 integer :: kill_length = 0 ! Length of text in kill buffer
135 logical :: dirty = .false. ! Needs redraw
136 logical :: in_history = .false. ! Currently browsing history
137 logical :: completions_shown = .false. ! Have we shown completion list for current buffer?
138 integer :: last_completion_buffer_len = 0 ! Length of last_completion_buffer (includes trailing spaces!)
139
140 ! Reverse-i-search state
141 logical :: in_search = .false. ! Currently in i-search mode (forward or reverse)
142 logical :: search_forward = .false. ! True = forward, False = reverse
143 character(len=:), allocatable :: search_string ! Current search query
144 integer :: search_length = 0 ! Length of search string
145 integer :: search_match_index = 0 ! Current history match index
146
147 ! Editing mode support
148 integer :: editing_mode = EDITING_MODE_EMACS
149 integer :: vi_mode = VI_MODE_INSERT
150 character(len=:), allocatable :: vi_command_buffer
151 integer :: vi_command_count = 0
152 logical :: vi_repeat_pending = .false.
153
154 ! Advanced vi mode features
155 character(len=:), allocatable :: vi_yank_buffer ! Vi-style yank buffer
156 integer :: vi_yank_length = 0
157 integer :: vi_marks(26) = 0 ! Mark positions for 'a'-'z' (0 = not set)
158 character(len=:), allocatable :: vi_search_pattern
159 integer :: vi_search_length = 0
160 logical :: vi_search_forward = .true.
161 logical :: vi_in_vi_search = .false.
162
163 ! Autosuggestion support (fish-style)
164 ! CRITICAL: Must use fixed-length (NOT deferred-length) for flang-new compatibility
165 character(len=MAX_LINE_LEN) :: suggestion ! Current suggestion from history (fixed-length to avoid flang-new bug)
166 integer :: suggestion_length = 0 ! Length of suggestion
167
168 ! Prefix history search (fish-style up/down arrow with typed prefix)
169 logical :: in_prefix_search = .false. ! Currently in prefix search mode
170 character(len=MAX_LINE_LEN) :: prefix_search_text ! Frozen prefix text
171 integer :: prefix_search_len = 0 ! Length of frozen prefix
172 integer :: prefix_search_idx = 0 ! Current match index in history (0 = at present/original)
173 logical :: prefix_search_flash = .false. ! Transient: flash reverse video on no-match
174
175 ! Menu selection support (zsh/fish-style interactive completion)
176 logical :: in_menu_select = .false. ! Currently in menu selection mode
177 character(len=MAX_MENU_ITEM_LEN) :: menu_items(MAX_MENU_ITEMS) ! Completion items for menu (fixed-length to avoid flang-new bug)
178 integer :: menu_num_items = 0 ! Number of items in menu
179 integer :: menu_total_items = 0 ! Total number of completions available (before truncation)
180 integer :: menu_selection = 1 ! Currently selected item (1-based)
181 character(len=:), allocatable :: menu_prefix ! Command prefix before completion word
182 integer :: menu_prefix_len = 0 ! Actual length of prefix INCLUDING trailing space
183 character(len=MAX_LINE_LEN) :: menu_prompt ! Prompt when in menu mode (fixed-length to avoid flang-new bugs)
184 logical :: skip_cursor_up_on_redraw = .false. ! Skip upward cursor movement on next redraw
185 ! Cached grid layout (avoid recalculating on every navigation)
186 integer :: menu_cols_per_item = 0
187 integer :: menu_items_per_row = 0
188 integer :: menu_num_rows = 0
189
190 ! Process kill mode support (Ctrl-X)
191 logical :: in_process_kill_mode = .false. ! Currently in process kill mode
192 logical :: in_signal_input = .false. ! Entering signal to send
193 integer :: selected_pid = 0 ! PID of selected process
194 character(len=:), allocatable :: selected_process_name ! Name of selected process
195
196 ! Track if initialized
197 logical :: initialized = .false.
198
199 #ifdef USE_MEMORY_POOL
200 ! Pool references for memory management
201 type(string_ref) :: buffer_ref
202 type(string_ref) :: original_buffer_ref
203 type(string_ref) :: kill_buffer_ref
204 type(string_ref) :: last_completion_buffer_ref
205 type(string_ref) :: search_string_ref
206 type(string_ref) :: vi_command_buffer_ref
207 type(string_ref) :: vi_yank_buffer_ref
208 type(string_ref) :: vi_search_pattern_ref
209 type(string_ref) :: menu_prefix_ref
210 type(string_ref) :: selected_process_name_ref
211 #endif
212
213 ! Text selection state (shift phase, Sprint 1)
214 ! Appended at end of type per overview.md pattern #5 — do not reorder.
215 ! Selection range is [min(anchor, cursor_pos) .. max(anchor, cursor_pos))
216 ! measured in BYTES (consistent with cursor_pos and length — pattern #11).
217 integer :: selection_anchor = -1 ! -1 = no anchor set
218 logical :: selection_active = .false. ! .true. iff a selection is live
219 end type input_state_t
220
221 type :: history_t
222 ! Use allocatable array to avoid stack allocation on macOS
223 ! CRITICAL: Must use fixed-length (NOT deferred-length) for flang-new compatibility
224 character(len=MAX_LINE_LEN), allocatable :: lines(:)
225 integer :: count = 0
226 integer :: current = 0 ! Current position in history navigation
227 logical :: initialized = .false.
228 end type history_t
229
230 type(history_t), save :: command_history
231
232 ! Type to hold completion candidates with scores for fuzzy matching
233 type :: scored_completion_t
234 character(len=MAX_LINE_LEN) :: text
235 integer :: score
236 end type scored_completion_t
237
238 ! Module-level HISTCONTROL setting (set by shell)
239 character(len=256), save :: current_histcontrol = ''
240
241 ! Module-level editing mode (set by shell via option_vi)
242 integer, save :: global_editing_mode = EDITING_MODE_EMACS
243
244 ! Fuzzy completion: off by default (prefix-only like bash/zsh)
245 ! Enable with: set -o fuzzy-complete
246 logical, save :: global_fuzzy_complete = .false.
247
248 ! Detect macOS for potential platform-specific workarounds
249 logical, save :: is_macos_system = .false.
250 logical, save :: macos_detected = .false.
251
252 ! Module-level input_state to work around flang-new pointer corruption bug
253 type(input_state_t), save, target :: module_input_state
254 logical, save :: module_input_state_initialized = .false.
255
256 ! Module-level syntax highlighting buffer (fixed-length to avoid flang-new allocatable bugs)
257 character(len=4096), save :: module_highlighted_buffer
258 integer, save :: module_highlighted_len
259
260 ! Track actual cursor screen position (row, col) to fix redraw issues
261 ! Used to know where cursor is on screen vs where buffer says it should be
262 integer, save :: module_cursor_screen_row = 0
263 integer, save :: module_cursor_screen_col = 0
264
265 ! Track whether the search status line is currently displayed below the prompt
266 logical, save :: module_search_status_shown = .false.
267
268 ! Shift-phase selection state (Sprint 1)
269 ! When .true., the next base movement handler call should extend the active
270 ! selection rather than collapse it. Set by the shift-arrow dispatch in
271 ! handle_extended_escape_sequence immediately before calling a base handler,
272 ! cleared immediately after. Module-level rather than per-state so handlers
273 ! don't need a new parameter (avoids flang-new derived-type ABI issues — #6).
274 logical, save :: module_extending_selection = .false.
275
276 ! FORTSH_DEBUG_SELECTION env flag — dumps selection state to stderr when set.
277 ! Probed once at init; cached. Pattern #20 from overview.md.
278 logical, save :: debug_selection = .false.
279 logical, save :: debug_selection_initialized = .false.
280
281 contains
282
283 !============================================================================
284 ! TEST MODE INITIALIZATION
285 !============================================================================
286 ! Initialize test mode from environment variable
287 ! This disables tab completion and syntax highlighting for reliable testing
288 subroutine init_test_mode()
289 character(len=:), allocatable :: test_mode_env, no_completion_env
290
291 if (test_mode_initialized) return
292
293 test_mode_env = get_environment_var('FORTSH_TEST_MODE')
294 test_mode_enabled = (allocated(test_mode_env) .and. trim(test_mode_env) == '1')
295
296 ! Completion can be disabled independently of test mode
297 no_completion_env = get_environment_var('FORTSH_NO_COMPLETION')
298 completion_disabled = (allocated(no_completion_env) .and. trim(no_completion_env) == '1')
299
300 test_mode_initialized = .true.
301 end subroutine init_test_mode
302
303 !============================================================================
304 ! BUFFER OPERATION WRAPPERS - Platform abstraction layer
305 !============================================================================
306 ! These wrappers handle three platforms:
307 ! 1. USE_C_STRINGS (macOS ARM64) - C string buffers for >128 byte support
308 ! 2. USE_MEMORY_POOL (Linux with pooling) - Pooled string references
309 ! 3. Default - Standard Fortran allocatable strings
310 !
311 ! This abstraction keeps the main code clean and platform-agnostic.
312 !============================================================================
313
314 ! Clear main buffer
315 subroutine state_buffer_clear(state)
316 type(input_state_t), intent(inout) :: state
317 #ifdef USE_C_STRINGS
318 call c_string_clear(state%buffer_c)
319 #else
320 #ifdef USE_MEMORY_POOL
321 state%buffer_ref%data = ''
322 #else
323 state%buffer = ''
324 #endif
325 #endif
326 end subroutine state_buffer_clear
327
328 ! Set main buffer from string
329 subroutine state_buffer_set(state, str)
330 type(input_state_t), intent(inout) :: state
331 character(len=*), intent(in) :: str
332 #ifdef USE_C_STRINGS
333 logical :: success
334 success = c_string_set(state%buffer_c, str)
335 if (.not. success) then
336 ! Fallback: truncate to buffer size
337 ! This maintains old behavior on overflow
338 end if
339 #else
340 #ifdef USE_MEMORY_POOL
341 state%buffer_ref%data = str
342 #else
343 state%buffer = str
344 #endif
345 #endif
346 end subroutine state_buffer_set
347
348 ! Get main buffer as string
349 subroutine state_buffer_get(state, str, actual_len)
350 type(input_state_t), intent(in) :: state
351 character(len=*), intent(out) :: str
352 integer, intent(out), optional :: actual_len
353 #ifdef USE_C_STRINGS
354 integer :: len_out
355 call c_string_to_fortran(state%buffer_c, str, len_out)
356 if (present(actual_len)) actual_len = len_out
357 #else
358 #ifdef USE_MEMORY_POOL
359 str = state%buffer_ref%data
360 if (present(actual_len)) actual_len = len_trim(state%buffer_ref%data)
361 #else
362 str = state%buffer
363 if (present(actual_len)) actual_len = len_trim(state%buffer)
364 #endif
365 #endif
366 end subroutine state_buffer_get
367
368 ! Get character at position (1-based)
369 function state_buffer_get_char(state, pos) result(ch)
370 type(input_state_t), intent(in) :: state
371 integer, intent(in) :: pos
372 character(len=1) :: ch
373 #ifdef USE_C_STRINGS
374 ch = c_string_get_char(state%buffer_c, pos)
375 #else
376 #ifdef USE_MEMORY_POOL
377 if (pos >= 1 .and. pos <= len(state%buffer_ref%data)) then
378 ch = state%buffer_ref%data(pos:pos)
379 else
380 ch = ' '
381 end if
382 #else
383 if (pos >= 1 .and. pos <= len(state%buffer)) then
384 ch = state%buffer(pos:pos)
385 else
386 ch = ' '
387 end if
388 #endif
389 #endif
390 end function state_buffer_get_char
391
392 ! Set character at position (1-based)
393 subroutine state_buffer_set_char(state, pos, ch)
394 type(input_state_t), intent(inout) :: state
395 integer, intent(in) :: pos
396 character(len=1), intent(in) :: ch
397 #ifdef USE_C_STRINGS
398 logical :: success
399 success = c_string_set_char(state%buffer_c, pos, ch)
400 #else
401 #ifdef USE_MEMORY_POOL
402 if (pos >= 1 .and. pos <= len(state%buffer_ref%data)) then
403 state%buffer_ref%data(pos:pos) = ch
404 end if
405 #else
406 if (pos >= 1 .and. pos <= len(state%buffer)) then
407 state%buffer(pos:pos) = ch
408 end if
409 #endif
410 #endif
411 end subroutine state_buffer_set_char
412
413 ! Copy main buffer to original_buffer
414 subroutine state_buffer_save(state)
415 type(input_state_t), intent(inout) :: state
416 #ifdef USE_C_STRINGS
417 logical :: success
418 success = c_string_copy(state%original_buffer_c, state%buffer_c)
419 #else
420 #ifdef USE_MEMORY_POOL
421 state%original_buffer_ref%data = state%buffer_ref%data
422 #else
423 state%original_buffer = state%buffer
424 #endif
425 #endif
426 end subroutine state_buffer_save
427
428 ! Restore main buffer from original_buffer
429 subroutine state_buffer_restore(state)
430 type(input_state_t), intent(inout) :: state
431 #ifdef USE_C_STRINGS
432 logical :: success
433 success = c_string_copy(state%buffer_c, state%original_buffer_c)
434 #else
435 #ifdef USE_MEMORY_POOL
436 state%buffer_ref%data = state%original_buffer_ref%data
437 #else
438 state%buffer = state%original_buffer
439 #endif
440 #endif
441 end subroutine state_buffer_restore
442
443 ! Get search string into a fixed-length buffer
444 subroutine get_search_string(state, str, slen)
445 type(input_state_t), intent(in) :: state
446 character(len=*), intent(out) :: str
447 integer, intent(in) :: slen
448 integer :: j
449 str = ''
450 if (slen <= 0) return
451 #ifdef USE_C_STRINGS
452 do j = 1, min(slen, len(str))
453 str(j:j) = state%search_string(j:j)
454 end do
455 #elif defined(USE_MEMORY_POOL)
456 do j = 1, min(slen, len(str))
457 str(j:j) = state%search_string_ref%data(j:j)
458 end do
459 #else
460 do j = 1, min(slen, len(str))
461 str(j:j) = state%search_string(j:j)
462 end do
463 #endif
464 end subroutine get_search_string
465
466 ! Set a character in the search string at position pos
467 subroutine set_search_char(state, pos, ch)
468 type(input_state_t), intent(inout) :: state
469 integer, intent(in) :: pos
470 character, intent(in) :: ch
471 #ifdef USE_C_STRINGS
472 state%search_string(pos:pos) = ch
473 #elif defined(USE_MEMORY_POOL)
474 state%search_string_ref%data(pos:pos) = ch
475 #else
476 state%search_string(pos:pos) = ch
477 #endif
478 end subroutine set_search_char
479
480 ! Clear the search string
481 subroutine clear_search_string(state)
482 type(input_state_t), intent(inout) :: state
483 #ifdef USE_C_STRINGS
484 state%search_string = ''
485 #elif defined(USE_MEMORY_POOL)
486 state%search_string_ref%data = ''
487 #else
488 state%search_string = ''
489 #endif
490 end subroutine clear_search_string
491
492 ! Clear original buffer
493 subroutine state_original_buffer_clear(state)
494 type(input_state_t), intent(inout) :: state
495 #ifdef USE_C_STRINGS
496 call c_string_clear(state%original_buffer_c)
497 #else
498 #ifdef USE_MEMORY_POOL
499 state%original_buffer_ref%data = ''
500 #else
501 state%original_buffer = ''
502 #endif
503 #endif
504 end subroutine state_original_buffer_clear
505
506 ! Clear kill buffer
507 subroutine state_kill_buffer_clear(state)
508 type(input_state_t), intent(inout) :: state
509 #ifdef USE_C_STRINGS
510 call c_string_clear(state%kill_buffer_c)
511 #else
512 #ifdef USE_MEMORY_POOL
513 state%kill_buffer_ref%data = ''
514 #else
515 state%kill_buffer = ''
516 #endif
517 #endif
518 end subroutine state_kill_buffer_clear
519
520 ! Set kill buffer from string
521 subroutine state_kill_buffer_set(state, str)
522 type(input_state_t), intent(inout) :: state
523 character(len=*), intent(in) :: str
524 #ifdef USE_C_STRINGS
525 logical :: success
526 success = c_string_set(state%kill_buffer_c, str)
527 #else
528 #ifdef USE_MEMORY_POOL
529 state%kill_buffer_ref%data = str
530 #else
531 state%kill_buffer = str
532 #endif
533 #endif
534 end subroutine state_kill_buffer_set
535
536 ! Get kill buffer as string
537 subroutine state_kill_buffer_get(state, str)
538 type(input_state_t), intent(in) :: state
539 character(len=*), intent(out) :: str
540 #ifdef USE_C_STRINGS
541 call c_string_to_fortran(state%kill_buffer_c, str)
542 #else
543 #ifdef USE_MEMORY_POOL
544 str = state%kill_buffer_ref%data
545 #else
546 str = state%kill_buffer
547 #endif
548 #endif
549 end subroutine state_kill_buffer_get
550
551 ! Clear last completion buffer
552 subroutine state_last_completion_buffer_clear(state)
553 type(input_state_t), intent(inout) :: state
554 #ifdef USE_C_STRINGS
555 call c_string_clear(state%last_completion_buffer_c)
556 #else
557 #ifdef USE_MEMORY_POOL
558 state%last_completion_buffer_ref%data = ''
559 #else
560 state%last_completion_buffer = ''
561 #endif
562 #endif
563 end subroutine state_last_completion_buffer_clear
564
565 ! Set last completion buffer from main buffer
566 subroutine state_last_completion_buffer_set_from_buffer(state)
567 type(input_state_t), intent(inout) :: state
568 #ifdef USE_C_STRINGS
569 logical :: success
570 success = c_string_copy(state%last_completion_buffer_c, state%buffer_c)
571 #else
572 #ifdef USE_MEMORY_POOL
573 state%last_completion_buffer_ref%data = state%buffer_ref%data(:state%length)
574 #else
575 state%last_completion_buffer = state%buffer(:state%length)
576 #endif
577 #endif
578 state%last_completion_buffer_len = state%length
579 end subroutine state_last_completion_buffer_set_from_buffer
580
581 ! Compare buffer with last completion buffer
582 function state_buffer_equals_last_completion(state) result(equals)
583 type(input_state_t), intent(in) :: state
584 logical :: equals
585 #ifdef USE_C_STRINGS
586 character(len=MAX_LINE_LEN) :: buf, last_buf
587 call c_string_to_fortran(state%buffer_c, buf)
588 call c_string_to_fortran(state%last_completion_buffer_c, last_buf)
589 equals = (trim(buf) == trim(last_buf))
590 #else
591 #ifdef USE_MEMORY_POOL
592 equals = (trim(state%buffer_ref%data(:state%length)) == &
593 trim(state%last_completion_buffer_ref%data(:state%last_completion_buffer_len)))
594 #else
595 integer :: i
596 equals = .true.
597 if (state%length /= state%last_completion_buffer_len) then
598 equals = .false.
599 return
600 end if
601 do i = 1, state%length
602 if (state%buffer(i:i) /= state%last_completion_buffer(i:i)) then
603 equals = .false.
604 return
605 end if
606 end do
607 #endif
608 #endif
609 end function state_buffer_equals_last_completion
610
611 !============================================================================
612 ! END BUFFER OPERATION WRAPPERS
613 !============================================================================
614
615 !============================================================================
616 ! TEXT SELECTION HELPERS (shift phase, Sprint 1)
617 !============================================================================
618 ! Three-state machine:
619 ! - Inactive: selection_anchor = -1, selection_active = .false.
620 ! - Active: selection_anchor in [0, length], selection_active = .true.,
621 ! selected range = [min(anchor, cursor_pos), max(anchor, cursor_pos))
622 !
623 ! Extending vs collapsing:
624 ! - Shift+motion calls set module_extending_selection=.true. before calling
625 ! a base movement handler, then call update_selection_on_shift_motion()
626 ! after to install/extend the selection against the old cursor position.
627 ! - Plain motion handlers check module_extending_selection at the top; if
628 ! .false. and selection_active, they collapse (char motions snap to the
629 ! appropriate edge; word/line motions just clear state and proceed).
630 !============================================================================
631
632 ! Initialize debug flag from environment (idempotent).
633 subroutine init_debug_selection()
634 integer :: status
635 character(len=8) :: env_val
636 if (debug_selection_initialized) return
637 call get_environment_variable('FORTSH_DEBUG_SELECTION', env_val, status=status)
638 debug_selection = (status == 0 .and. trim(env_val) == '1')
639 debug_selection_initialized = .true.
640 end subroutine init_debug_selection
641
642 ! Emit a debug trace line if FORTSH_DEBUG_SELECTION=1.
643 subroutine debug_selection_log(tag, state)
644 use iso_fortran_env, only: error_unit
645 character(len=*), intent(in) :: tag
646 type(input_state_t), intent(in) :: state
647 if (.not. debug_selection_initialized) call init_debug_selection()
648 if (.not. debug_selection) return
649 if (state%selection_active) then
650 write(error_unit, '(a,a,a,i0,a,i0,a,i0,a,l1)') &
651 '[SEL:', trim(tag), '] cursor_pos=', state%cursor_pos, &
652 ' anchor=', state%selection_anchor, &
653 ' length=', state%length, &
654 ' active=', state%selection_active
655 else
656 write(error_unit, '(a,a,a,i0,a,i0,a,l1)') &
657 '[SEL:', trim(tag), '] cursor_pos=', state%cursor_pos, &
658 ' length=', state%length, &
659 ' active=', state%selection_active
660 end if
661 end subroutine debug_selection_log
662
663 ! Clear selection state (no cursor motion, no dirty flag).
664 ! Caller is responsible for setting dirty if a redraw is needed.
665 subroutine collapse_selection(state)
666 type(input_state_t), intent(inout) :: state
667 if (.not. state%selection_active) return
668 state%selection_anchor = -1
669 state%selection_active = .false.
670 call debug_selection_log('collapse', state)
671 end subroutine collapse_selection
672
673 ! Called AFTER a base movement handler has moved the cursor while
674 ! module_extending_selection is .true. Establishes a new selection anchored
675 ! at old_cursor_pos if one isn't already active, or extends the existing
676 ! one. If the motion brings cursor back to anchor, auto-collapses.
677 subroutine update_selection_on_shift_motion(state, old_cursor_pos)
678 type(input_state_t), intent(inout) :: state
679 integer, intent(in) :: old_cursor_pos
680
681 if (state%cursor_pos == old_cursor_pos) then
682 ! No actual motion occurred (e.g. Shift+Left at pos 0). Leave state alone.
683 return
684 end if
685
686 if (.not. state%selection_active) then
687 ! Starting a fresh selection — anchor at the position before this motion.
688 state%selection_anchor = old_cursor_pos
689 state%selection_active = .true.
690 end if
691
692 ! If the motion brought cursor back to anchor, the selection is empty — collapse.
693 if (state%selection_anchor == state%cursor_pos) then
694 call collapse_selection(state)
695 end if
696
697 ! Selection rendering needs a full redraw (Sprint 2 handles the highlight).
698 state%dirty = .true.
699 call debug_selection_log('extend', state)
700 end subroutine update_selection_on_shift_motion
701
702 ! Remove the selected byte range from the buffer, set cursor to the left
703 ! edge, and clear selection state. No-op if selection is not active.
704 ! Unused in Sprint 1 itself, but lands now for use in Sprint 3.
705 subroutine delete_selection(state)
706 type(input_state_t), intent(inout) :: state
707 integer :: sel_start, sel_end, span, i
708 character(len=MAX_LINE_LEN) :: temp_buf
709
710 if (.not. state%selection_active) return
711 if (state%selection_anchor < 0) then
712 ! Defensive: active flag set without an anchor; just clear state.
713 call collapse_selection(state)
714 return
715 end if
716
717 sel_start = min(state%selection_anchor, state%cursor_pos)
718 sel_end = max(state%selection_anchor, state%cursor_pos)
719 span = sel_end - sel_start
720
721 if (span <= 0) then
722 call collapse_selection(state)
723 return
724 end if
725
726 ! Read current buffer, shift bytes after sel_end leftward, rewrite.
727 call state_buffer_get(state, temp_buf)
728 do i = sel_end + 1, state%length
729 call state_buffer_set_char(state, i - span, temp_buf(i:i))
730 end do
731 ! Pad the now-unused tail so stale bytes don't leak on later reads.
732 do i = state%length - span + 1, state%length
733 call state_buffer_set_char(state, i, ' ')
734 end do
735
736 state%length = state%length - span
737 state%cursor_pos = sel_start
738 state%dirty = .true.
739 call collapse_selection(state)
740 call debug_selection_log('delete', state)
741 end subroutine delete_selection
742
743 !============================================================================
744 ! END TEXT SELECTION HELPERS
745 !============================================================================
746
747 ! Initialize input_state_t with allocated strings
748 subroutine init_input_state(state)
749 type(input_state_t), intent(inout) :: state
750
751 #ifdef USE_C_STRINGS
752 ! C strings take precedence on macOS ARM64 (flang-new workaround)
753 ! C string buffer allocations - bypass flang-new 128-byte bug
754 state%buffer_c = c_string_create(MAX_LINE_LEN)
755 state%original_buffer_c = c_string_create(MAX_LINE_LEN)
756 state%kill_buffer_c = c_string_create(MAX_LINE_LEN)
757 state%last_completion_buffer_c = c_string_create(MAX_LINE_LEN)
758 allocate(character(len=MAX_LINE_LEN) :: state%search_string)
759 allocate(character(len=MAX_LINE_LEN) :: state%vi_command_buffer)
760 allocate(character(len=MAX_LINE_LEN) :: state%vi_yank_buffer)
761 allocate(character(len=MAX_LINE_LEN) :: state%vi_search_pattern)
762 allocate(character(len=MAX_LINE_LEN) :: state%menu_prefix)
763 allocate(character(len=256) :: state%selected_process_name)
764 #elif defined(USE_MEMORY_POOL)
765 ! Memory pool path for Linux
766 ! Initialize pool if needed
767 call pool_init()
768
769 ! Use pooled allocations for frequently-used buffers with dashboard tracking
770 state%buffer_ref = pool_get_string(MAX_LINE_LEN)
771 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
772
773 state%original_buffer_ref = pool_get_string(MAX_LINE_LEN)
774 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
775
776 state%kill_buffer_ref = pool_get_string(MAX_LINE_LEN)
777 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
778
779 state%last_completion_buffer_ref = pool_get_string(MAX_LINE_LEN)
780 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
781
782 state%search_string_ref = pool_get_string(MAX_LINE_LEN)
783 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
784
785 state%vi_command_buffer_ref = pool_get_string(MAX_LINE_LEN)
786 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
787
788 state%vi_yank_buffer_ref = pool_get_string(MAX_LINE_LEN)
789 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
790
791 state%vi_search_pattern_ref = pool_get_string(MAX_LINE_LEN)
792 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
793
794 state%menu_prefix_ref = pool_get_string(MAX_LINE_LEN)
795 call dashboard_track_allocation(MOD_READLINE, MAX_LINE_LEN, 3)
796
797 state%selected_process_name_ref = pool_get_string(256)
798 call dashboard_track_allocation(MOD_READLINE, 256, 2)
799
800 ! CHUNK 2: Allocatable strings removed - using pooled refs instead
801 ! These allocations are redundant since we have pooled memory
802 ! Code must now use state%buffer_ref%data instead of state%buffer
803 ! allocate(character(len=MAX_LINE_LEN) :: state%buffer)
804 ! allocate(character(len=MAX_LINE_LEN) :: state%original_buffer)
805 ! allocate(character(len=MAX_LINE_LEN) :: state%kill_buffer)
806 ! allocate(character(len=MAX_LINE_LEN) :: state%last_completion_buffer)
807 ! allocate(character(len=MAX_LINE_LEN) :: state%search_string)
808 ! allocate(character(len=MAX_LINE_LEN) :: state%vi_command_buffer)
809 ! allocate(character(len=MAX_LINE_LEN) :: state%vi_yank_buffer)
810 ! allocate(character(len=MAX_LINE_LEN) :: state%vi_search_pattern)
811 ! allocate(character(len=MAX_LINE_LEN) :: state%menu_prefix)
812 ! allocate(character(len=256) :: state%selected_process_name)
813 #else
814 ! Traditional allocations
815 allocate(character(len=MAX_LINE_LEN) :: state%buffer)
816 allocate(character(len=MAX_LINE_LEN) :: state%original_buffer)
817 allocate(character(len=MAX_LINE_LEN) :: state%kill_buffer)
818 allocate(character(len=MAX_LINE_LEN) :: state%last_completion_buffer)
819 allocate(character(len=MAX_LINE_LEN) :: state%search_string)
820 allocate(character(len=MAX_LINE_LEN) :: state%vi_command_buffer)
821 allocate(character(len=MAX_LINE_LEN) :: state%vi_yank_buffer)
822 allocate(character(len=MAX_LINE_LEN) :: state%vi_search_pattern)
823 ! suggestion is now fixed-length, no allocation needed
824 allocate(character(len=MAX_LINE_LEN) :: state%menu_prefix)
825 allocate(character(len=256) :: state%selected_process_name)
826 #endif
827
828 ! menu_items and menu_prompt are now fixed-length, no allocation needed
829
830 ! Initialize all strings to empty
831 #ifdef USE_MEMORY_POOL
832 ! CHUNK 2: Initialize pooled refs to empty
833 state%buffer_ref%data = ''
834 state%original_buffer_ref%data = ''
835 state%kill_buffer_ref%data = ''
836 state%last_completion_buffer_ref%data = ''
837 state%search_string_ref%data = ''
838 state%vi_command_buffer_ref%data = ''
839 state%vi_yank_buffer_ref%data = ''
840 state%vi_search_pattern_ref%data = ''
841 state%menu_prefix_ref%data = ''
842 state%selected_process_name_ref%data = ''
843 #else
844 #ifdef USE_C_STRINGS
845 ! Initialize C string buffers to empty
846 call c_string_clear(state%buffer_c)
847 call c_string_clear(state%original_buffer_c)
848 call c_string_clear(state%kill_buffer_c)
849 call c_string_clear(state%last_completion_buffer_c)
850 state%search_string = ''
851 state%vi_command_buffer = ''
852 state%vi_yank_buffer = ''
853 state%vi_search_pattern = ''
854 state%menu_prefix = ''
855 state%selected_process_name = ''
856 #else
857 #ifdef USE_MEMORY_POOL
858 state%buffer_ref%data = ''
859 #else
860 state%buffer = ''
861 #endif
862 #ifdef USE_MEMORY_POOL
863 state%original_buffer_ref%data = ''
864 #else
865 state%original_buffer = ''
866 #endif
867 #ifdef USE_MEMORY_POOL
868 state%kill_buffer_ref%data = ''
869 #else
870 state%kill_buffer = ''
871 #endif
872 #ifdef USE_MEMORY_POOL
873 state%last_completion_buffer_ref%data = ''
874 #else
875 state%last_completion_buffer = ''
876 #endif
877 #endif ! USE_C_STRINGS
878 #ifdef USE_MEMORY_POOL
879 state%search_string_ref%data = ''
880 #else
881 state%search_string = ''
882 #endif
883 #ifdef USE_MEMORY_POOL
884 state%vi_command_buffer_ref%data = ''
885 #else
886 state%vi_command_buffer = ''
887 #endif
888 #ifdef USE_MEMORY_POOL
889 state%vi_yank_buffer_ref%data = ''
890 #else
891 state%vi_yank_buffer = ''
892 #endif
893 #ifdef USE_MEMORY_POOL
894 state%vi_search_pattern_ref%data = ''
895 #else
896 state%vi_search_pattern = ''
897 #endif
898 #ifdef USE_MEMORY_POOL
899 state%menu_prefix_ref%data = ''
900 #else
901 state%menu_prefix = ''
902 #endif
903 #ifdef USE_MEMORY_POOL
904 state%selected_process_name_ref%data = ''
905 #else
906 state%selected_process_name = ''
907 #endif
908 #endif ! Close USE_C_STRINGS/MEMORY_POOL buffer initialization
909 ! These are fixed-length, initialize regardless of pooling
910 state%suggestion = ''
911 state%menu_prompt = ''
912 state%menu_items = ''
913
914 ! Initialize numeric fields
915 state%length = 0
916 state%cursor_pos = 0
917 state%history_pos = 0
918 state%kill_length = 0
919 state%search_length = 0
920 state%search_match_index = 0
921 state%editing_mode = global_editing_mode
922 state%vi_mode = VI_MODE_INSERT
923 state%vi_command_count = 0
924 state%vi_yank_length = 0
925 state%vi_marks = 0
926 state%vi_search_length = 0
927 state%suggestion_length = 0
928 state%menu_num_items = 0
929 state%menu_total_items = 0
930 state%menu_selection = 1
931 state%menu_prefix_len = 0
932 state%selected_pid = 0
933
934 ! Initialize logical fields
935 state%dirty = .false.
936 state%in_history = .false.
937 state%completions_shown = .false.
938 state%in_search = .false.
939 state%search_forward = .false.
940 state%vi_repeat_pending = .false.
941 state%vi_search_forward = .true.
942 state%vi_in_vi_search = .false.
943 state%in_menu_select = .false.
944 state%skip_cursor_up_on_redraw = .false.
945 state%in_process_kill_mode = .false.
946 state%in_signal_input = .false.
947
948 ! Set initialized flag
949 state%initialized = .true.
950 end subroutine
951
952 ! Initialize history with allocated array
953 subroutine init_history()
954 if (.not. command_history%initialized) then
955 ! Type already specifies character(len=MAX_LINE_LEN), so just allocate array
956 allocate(command_history%lines(MAX_HISTORY))
957 command_history%lines = ''
958 command_history%count = 0
959 command_history%current = 0
960 command_history%initialized = .true.
961 #ifdef USE_MEMORY_POOL
962 ! Track history array allocation (MAX_HISTORY * MAX_LINE_LEN bytes)
963 call dashboard_track_allocation(MOD_HISTORY, MAX_HISTORY * MAX_LINE_LEN, 5)
964 #endif
965 end if
966 end subroutine
967
968 ! Clean up history allocations
969 subroutine cleanup_history()
970 if (command_history%initialized) then
971 #ifdef USE_MEMORY_POOL
972 ! Track history array deallocation before releasing
973 call dashboard_track_deallocation(MOD_HISTORY, MAX_HISTORY * MAX_LINE_LEN, 5)
974 #endif
975 if (allocated(command_history%lines)) deallocate(command_history%lines)
976 command_history%count = 0
977 command_history%current = 0
978 command_history%initialized = .false.
979 end if
980 end subroutine
981
982 ! Clean up input_state_t allocations
983 subroutine cleanup_input_state(state)
984 type(input_state_t), intent(inout) :: state
985
986 if (state%initialized) then
987 #ifdef USE_MEMORY_POOL
988 ! Release pooled memory with dashboard tracking
989 call pool_release_string(state%buffer_ref)
990 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
991
992 call pool_release_string(state%original_buffer_ref)
993 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
994
995 call pool_release_string(state%kill_buffer_ref)
996 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
997
998 call pool_release_string(state%last_completion_buffer_ref)
999 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
1000
1001 call pool_release_string(state%search_string_ref)
1002 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
1003
1004 call pool_release_string(state%vi_command_buffer_ref)
1005 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
1006
1007 call pool_release_string(state%vi_yank_buffer_ref)
1008 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
1009
1010 call pool_release_string(state%vi_search_pattern_ref)
1011 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
1012
1013 call pool_release_string(state%menu_prefix_ref)
1014 call dashboard_track_deallocation(MOD_READLINE, MAX_LINE_LEN, 3)
1015
1016 call pool_release_string(state%selected_process_name_ref)
1017 call dashboard_track_deallocation(MOD_READLINE, 256, 2)
1018 #elif defined(USE_C_STRINGS)
1019 ! Destroy C string buffers
1020 call c_string_destroy(state%buffer_c)
1021 call c_string_destroy(state%original_buffer_c)
1022 call c_string_destroy(state%kill_buffer_c)
1023 call c_string_destroy(state%last_completion_buffer_c)
1024 if (allocated(state%search_string)) deallocate(state%search_string)
1025 if (allocated(state%vi_command_buffer)) deallocate(state%vi_command_buffer)
1026 if (allocated(state%vi_yank_buffer)) deallocate(state%vi_yank_buffer)
1027 if (allocated(state%vi_search_pattern)) deallocate(state%vi_search_pattern)
1028 if (allocated(state%menu_prefix)) deallocate(state%menu_prefix)
1029 if (allocated(state%selected_process_name)) deallocate(state%selected_process_name)
1030 #else
1031 ! CHUNK 2: Only deallocate allocatable strings when NOT using pooling
1032 ! Deallocate strings
1033 if (allocated(state%buffer)) deallocate(state%buffer)
1034 if (allocated(state%original_buffer)) deallocate(state%original_buffer)
1035 if (allocated(state%kill_buffer)) deallocate(state%kill_buffer)
1036 if (allocated(state%last_completion_buffer)) deallocate(state%last_completion_buffer)
1037 if (allocated(state%search_string)) deallocate(state%search_string)
1038 if (allocated(state%vi_command_buffer)) deallocate(state%vi_command_buffer)
1039 if (allocated(state%vi_yank_buffer)) deallocate(state%vi_yank_buffer)
1040 if (allocated(state%vi_search_pattern)) deallocate(state%vi_search_pattern)
1041 ! suggestion is now fixed-length, no deallocation needed
1042 if (allocated(state%menu_prefix)) deallocate(state%menu_prefix)
1043 if (allocated(state%selected_process_name)) deallocate(state%selected_process_name)
1044 ! menu_items and menu_prompt are now fixed-length, no deallocation needed
1045 #endif
1046 state%initialized = .false.
1047 end if
1048 end subroutine
1049
1050 ! Set the HISTCONTROL setting for history management
1051 subroutine set_histcontrol(histcontrol)
1052 character(len=*), intent(in) :: histcontrol
1053 current_histcontrol = histcontrol
1054 end subroutine
1055
1056 ! Set the global editing mode (vi or emacs)
1057 subroutine set_global_editing_mode(vi_mode)
1058 logical, intent(in) :: vi_mode
1059 if (vi_mode) then
1060 global_editing_mode = EDITING_MODE_VI
1061 else
1062 global_editing_mode = EDITING_MODE_EMACS
1063 end if
1064 end subroutine
1065
1066 subroutine set_global_fuzzy_complete(enabled)
1067 logical, intent(in) :: enabled
1068 global_fuzzy_complete = enabled
1069 end subroutine
1070
1071 ! Check if we're on macOS (called once at startup)
1072 subroutine detect_macos()
1073 character(len=256) :: sysname
1074 integer :: status
1075
1076 if (.not. macos_detected) then
1077 ! First try OSTYPE environment variable
1078 call get_environment_variable("OSTYPE", sysname, status=status)
1079 if (status == 0) then
1080 is_macos_system = (index(sysname, "darwin") > 0)
1081 else
1082 ! Try checking for macOS-specific environment variables
1083 call get_environment_variable("__CF_USER_TEXT_ENCODING", sysname, status=status)
1084 if (status == 0) then
1085 ! This env var is macOS-specific
1086 is_macos_system = .true.
1087 else
1088 ! Check for another Apple-specific env variable
1089 call get_environment_variable("Apple_PubSub_Socket_Render", sysname, status=status)
1090 is_macos_system = (status == 0)
1091 end if
1092 end if
1093 macos_detected = .true.
1094 end if
1095 end subroutine
1096
1097 #ifdef __APPLE__
1098 ! Safe terminal size detection for macOS (avoids get_terminal_size crash on flang-new)
1099 subroutine safe_get_terminal_size(rows, cols)
1100 integer, intent(out) :: rows, cols
1101 character(len=:), allocatable :: cols_str, rows_str
1102 integer :: cols_val, rows_val, ios
1103
1104 ! Default fallback values
1105 cols = 80
1106 rows = 24
1107
1108 ! Try to get columns using tput
1109 cols_str = execute_and_capture('tput cols 2>/dev/null')
1110 if (allocated(cols_str) .and. len_trim(cols_str) > 0) then
1111 read(cols_str, *, iostat=ios) cols_val
1112 if (ios == 0 .and. cols_val > 0 .and. cols_val < 500) then
1113 cols = cols_val
1114 end if
1115 end if
1116
1117 ! Try to get rows using tput
1118 rows_str = execute_and_capture('tput lines 2>/dev/null')
1119 if (allocated(rows_str) .and. len_trim(rows_str) > 0) then
1120 read(rows_str, *, iostat=ios) rows_val
1121 if (ios == 0 .and. rows_val > 0 .and. rows_val < 500) then
1122 rows = rows_val
1123 end if
1124 end if
1125
1126 ! Clean up
1127 if (allocated(cols_str)) deallocate(cols_str)
1128 if (allocated(rows_str)) deallocate(rows_str)
1129 end subroutine safe_get_terminal_size
1130 #endif
1131
1132 ! Enhanced readline with character-by-character input processing
1133 subroutine readline_enhanced(prompt, line, iostat, rprompt, keep_raw)
1134 character(len=*), intent(in) :: prompt
1135 character(len=*), intent(out) :: line
1136 integer, intent(out) :: iostat
1137 character(len=*), intent(in), optional :: rprompt ! Right-side prompt (like zsh)
1138 logical, intent(in), optional :: keep_raw ! Don't restore terminal on exit (for continuation)
1139
1140 ! Use module-level module_input_state directly (avoids flang-new pointer corruption bug)
1141 character :: ch
1142 logical :: success, done, raw_enabled
1143 integer :: char_code
1144 ! Variables for redraw (moved out of block to avoid flang-new crash)
1145 integer :: i_redraw, term_cols, term_rows
1146 integer :: prompt_visual_len, cursor_visual_pos, current_line
1147 integer :: suggestion_display_len, available_space
1148 integer :: current_col, current_row
1149 integer :: highlighted_len ! Actual length of highlighted string
1150 integer :: sel_start, sel_end ! Selection byte range for Sprint 2 rendering
1151 character(len=MAX_LINE_LEN) :: temp_buf ! For buffer extraction
1152 ! Variables for UTF-8 support (moved out of block to avoid flang-new crash)
1153 character(len=4) :: utf8_char
1154 integer :: utf8_num_bytes, utf8_i
1155 logical :: debug_utf8
1156 integer :: debug_stat
1157 ! Variables for RPROMPT (right-side prompt)
1158 integer :: rprompt_visual_len, padding_needed
1159 ! Variables for multiline prompt support
1160 integer :: prompt_line_count
1161
1162 ! Check if UTF-8 debug mode is enabled
1163 call get_environment_variable('FORTSH_DEBUG_UTF8', status=debug_stat)
1164 debug_utf8 = (debug_stat == 0)
1165
1166 ! Initialize module-level input_state on first use (avoids flang-new pointer corruption bug)
1167 if (.not. module_input_state_initialized) then
1168 ! Initialize input state with allocated strings (only on first use)
1169 call init_input_state(module_input_state)
1170 ! Initialize syntax highlighting
1171 call init_syntax_highlighting()
1172 module_input_state_initialized = .true.
1173 else
1174 ! On subsequent calls, just reset the buffer and cursor
1175 #ifdef USE_C_STRINGS
1176 call state_buffer_clear(module_input_state)
1177 #elif defined(USE_MEMORY_POOL)
1178 ! Check if buffer_ref is still valid, reinitialize if not
1179 if (.not. associated(module_input_state%buffer_ref%data)) then
1180 call init_input_state(module_input_state)
1181 else
1182 call state_buffer_clear(module_input_state)
1183 end if
1184 #else
1185 call state_buffer_clear(module_input_state)
1186 #endif
1187 module_input_state%length = 0
1188 module_input_state%cursor_pos = 0
1189 module_input_state%history_pos = 0
1190 module_input_state%in_menu_select = .false.
1191 module_input_state%in_search = .false.
1192 module_input_state%in_process_kill_mode = .false.
1193 module_input_state%in_signal_input = .false.
1194 ! Sync editing mode from global (set -o vi / set -o emacs)
1195 module_input_state%editing_mode = global_editing_mode
1196 end if
1197
1198 ! Initialize variables
1199 iostat = 0
1200 done = .false.
1201 raw_enabled = .false.
1202 highlighted_len = 0
1203
1204 ! Initialize history on first use
1205 call init_history()
1206
1207
1208 ! Try to enable raw mode (only works in interactive mode)
1209 ! If already in raw mode (keep_raw from previous call), skip re-enabling
1210 ! to avoid overwriting module_original_termios with the raw state
1211 if (module_termios_saved) then
1212 ! Already have saved original termios and raw mode is active
1213 raw_enabled = .true.
1214 else
1215 success = enable_raw_mode(module_original_termios)
1216 if (success) then
1217 raw_enabled = .true.
1218 module_termios_saved = .true.
1219 end if
1220 end if
1221
1222
1223 ! Print prompt (and RPROMPT if provided)
1224 prompt_visual_len = visual_length(prompt)
1225 if (prompt_visual_len < 0) prompt_visual_len = 0
1226
1227 ! Count newlines in prompt for multiline prompt support
1228 prompt_line_count = 0
1229 do i_redraw = 1, len_trim(prompt)
1230 if (prompt(i_redraw:i_redraw) == char(10)) prompt_line_count = prompt_line_count + 1
1231 end do
1232 ! Get terminal width for RPROMPT positioning
1233 success = get_terminal_size(term_rows, term_cols)
1234 if (.not. success) term_cols = 80 ! Default fallback
1235
1236 ! Check if we have RPROMPT and enough space
1237 ! Note: multi-line prompt RPROMPT is handled in fortsh.f90 by embedding into the prompt string
1238 if (present(rprompt) .and. len_trim(rprompt) > 0) then
1239 rprompt_visual_len = visual_length(rprompt)
1240 if (rprompt_visual_len < 0) rprompt_visual_len = 0
1241
1242 ! Single-line prompt: place RPROMPT on same line
1243 padding_needed = term_cols - prompt_visual_len - 1 - rprompt_visual_len
1244
1245 if (padding_needed >= 4) then ! Minimum 4 chars gap
1246 write(output_unit, '(a)', advance='no') prompt
1247 write(output_unit, '(a)', advance='no') ' '
1248
1249 ! Save cursor position before printing RPROMPT
1250 write(output_unit, '(a)', advance='no') char(27) // '7'
1251
1252 ! Print padding to right-align RPROMPT
1253 do i_redraw = 1, padding_needed - 1
1254 write(output_unit, '(a)', advance='no') ' '
1255 end do
1256
1257 ! Print RPROMPT
1258 write(output_unit, '(a)', advance='no') trim(rprompt)
1259
1260 ! Restore cursor position (back to after prompt + space)
1261 write(output_unit, '(a)', advance='no') char(27) // '8'
1262 else
1263 ! Not enough space - just print prompt normally
1264 write(output_unit, '(a)', advance='no') prompt
1265 write(output_unit, '(a)', advance='no') ' '
1266 end if
1267 else
1268 ! No RPROMPT - just print prompt
1269 ! In raw mode, bare LF doesn't CR — replace with CR+LF for multi-line prompts
1270 block
1271 integer :: pr_i
1272 do pr_i = 1, len_trim(prompt)
1273 if (prompt(pr_i:pr_i) == char(10)) then
1274 write(output_unit, '(a)', advance='no') char(13) // char(10) ! CR+LF
1275 else
1276 write(output_unit, '(a)', advance='no') prompt(pr_i:pr_i)
1277 end if
1278 end do
1279 end block
1280 write(output_unit, '(a)', advance='no') ' ' ! Space after prompt
1281 end if
1282
1283 flush(output_unit)
1284
1285 module_input_state%menu_prompt = prompt ! Store prompt for menu mode, live preview, and FZF functions
1286
1287 ! Initialize cursor screen position tracking
1288 ! For multiline prompts, cursor starts at row = prompt_line_count (0-indexed from prompt start)
1289 ! Column = prompt_visual_length + 1 (for space after prompt)
1290 module_cursor_screen_row = prompt_line_count
1291 module_cursor_screen_col = prompt_visual_len + 1
1292
1293
1294 ! Log readline state
1295 if (raw_enabled) then
1296 ! Enhanced input processing
1297 do while (.not. done)
1298 ! Read a complete UTF-8 character (1-4 bytes)
1299 success = read_utf8_char(utf8_char, utf8_num_bytes)
1300 if (.not. success) then
1301 iostat = -1
1302 exit
1303 end if
1304
1305 ! If multi-byte UTF-8 character, insert all bytes with correct visual width
1306 if (utf8_num_bytes > 1) then
1307 ! In search mode, ignore multi-byte characters (search uses ASCII only)
1308 if (module_input_state%in_search) cycle
1309 ! Cancel prefix search on any typed character
1310 if (module_input_state%in_prefix_search) call cancel_prefix_search(module_input_state)
1311 ! Multi-byte UTF-8 character (emoji, CJK, etc.)
1312 ! Determine visual width: 3-4 byte UTF-8 is always 2-wide, 2-byte varies
1313 if (utf8_num_bytes >= 3) then
1314 utf8_i = 2 ! Visual width for 3-4 byte UTF-8 (emoji, CJK)
1315 else
1316 utf8_i = utf8_char_width(utf8_char(1:1)) ! 2-byte can be 1 or 2
1317 end if
1318 call insert_utf8_char(module_input_state, utf8_char(1:utf8_num_bytes), utf8_num_bytes, utf8_i)
1319 cycle ! Skip the control character processing below
1320 end if
1321
1322 ! Single-byte character - process normally
1323 ch = utf8_char(1:1)
1324 char_code = iachar(ch)
1325
1326 ! Log every character received
1327 if (char_code == 27) then
1328 else if (char_code < 32 .or. char_code == 127) then
1329 end if
1330
1331 ! Cancel prefix search on any key except escape (arrows handled inside escape handler)
1332 if (module_input_state%in_prefix_search .and. char_code /= KEY_ESC) then
1333 call cancel_prefix_search(module_input_state)
1334 end if
1335
1336 select case(char_code)
1337 case(KEY_ENTER)
1338 ! Enter - accept menu selection, finish input, or accept search
1339 if (module_input_state%in_signal_input) then
1340 ! Send signal to selected process
1341 write(output_unit, '()') ! New line
1342 call send_signal_to_process(module_input_state)
1343 ! Exit signal mode and return to normal prompt
1344 module_input_state%in_signal_input = .false.
1345 module_input_state%in_process_kill_mode = .false.
1346 call state_buffer_clear(module_input_state)
1347 module_input_state%length = 0
1348 module_input_state%cursor_pos = 0
1349 done = .true.
1350 else if (module_input_state%in_process_kill_mode .and. module_input_state%in_menu_select) then
1351 ! Select process from menu
1352 call handle_process_selection(module_input_state)
1353 else if (module_input_state%in_menu_select) then
1354 call handle_menu_navigation(module_input_state, KEY_ENTER, done)
1355 ! If menu selection was accepted, output newline
1356 if (done) then
1357 write(output_unit, '()') ! New line
1358 end if
1359 else if (module_input_state%in_search) then
1360 ! Accept search result and execute immediately (bash behavior)
1361 call accept_search(module_input_state, prompt)
1362 write(output_unit, '(a)', advance='no') char(13) // char(10)
1363 flush(output_unit)
1364 done = .true.
1365 else
1366 ! Clear shadow text (suggestion) from cursor to end of line before newline
1367 if (module_input_state%suggestion_length > 0) then
1368 write(output_unit, '(a)', advance='no') char(27) // '[K'
1369 end if
1370 ! In raw mode, \n alone doesn't CR — must send \r\n explicitly
1371 write(output_unit, '(a)', advance='no') char(13) // char(10)
1372 flush(output_unit)
1373 done = .true.
1374 end if
1375
1376 case(KEY_CTRL_D)
1377 ! Ctrl+D - EOF on empty line, forward delete on non-empty (bash behavior)
1378 if (.not. module_input_state%in_search .and. module_input_state%length == 0) then
1379 iostat = -1
1380 done = .true.
1381 else if (.not. module_input_state%in_search) then
1382 call handle_forward_delete_char(module_input_state)
1383 end if
1384
1385 case(KEY_CTRL_C)
1386 ! Ctrl+C - cancel and clear line (bash-compatible)
1387 if (module_input_state%in_search) then
1388 ! Clean up the search status line first
1389 call cleanup_search_status_line()
1390 module_input_state%in_search = .false.
1391 call clear_search_string(module_input_state)
1392 module_input_state%search_length = 0
1393 module_input_state%search_match_index = 0
1394 end if
1395
1396 ! Move to beginning, clear line, print ^C on new line
1397 write(output_unit, '(a)', advance='no') ESC_MOVE_BOL // ESC_CLEAR_LINE
1398 write(output_unit, '(a)') '^C'
1399
1400 ! Clear buffer and return empty line
1401 module_input_state%length = 0
1402 module_input_state%cursor_pos = 0
1403 module_input_state%dirty = .false. ! Prevent redraw with empty buffer
1404 done = .true.
1405
1406 case(KEY_CTRL_X)
1407 ! Ctrl+X - Enter process kill mode (no-op in search mode)
1408 if (.not. module_input_state%in_search .and. &
1409 .not. module_input_state%in_process_kill_mode) then
1410 call enter_process_kill_mode(module_input_state)
1411 end if
1412
1413 case(KEY_BACKSPACE)
1414 ! Backspace
1415 if (module_input_state%in_signal_input) then
1416 ! For signal mode, delete last char and update display
1417 if (module_input_state%length > 0) then
1418 module_input_state%length = module_input_state%length - 1
1419 module_input_state%cursor_pos = module_input_state%length
1420 call update_signal_display(module_input_state)
1421 end if
1422 else if (module_input_state%in_search) then
1423 ! For search mode, delete last search char and re-search
1424 call search_backspace(module_input_state, prompt)
1425 else
1426 call handle_backspace(module_input_state)
1427 end if
1428
1429 case(KEY_TAB)
1430 ! No-op in search mode
1431 if (module_input_state%in_search) then
1432 continue
1433 else
1434 ! Initialize test mode if needed
1435 if (.not. test_mode_initialized) call init_test_mode()
1436
1437 ! Skip completion if explicitly disabled (FORTSH_NO_COMPLETION=1)
1438 if (completion_disabled) then
1439 ! Completion disabled - do nothing
1440 continue
1441 else if (module_input_state%in_menu_select) then
1442 call handle_menu_navigation(module_input_state, KEY_TAB, done)
1443 else
1444 ! Call separate subroutine to work around macOS ARM64 crash
1445 call handle_tab_key_separate(module_input_state)
1446 ! All completion logic is now handled in the separate subroutine
1447 end if
1448 end if
1449
1450 case(KEY_ESC)
1451 ! Escape sequence - parse it (will route to menu if needed)
1452 call handle_escape_sequence(module_input_state, done, prompt)
1453
1454 case(KEY_CTRL_A)
1455 ! Home - no-op in search mode
1456 if (.not. module_input_state%in_search) call handle_home(module_input_state)
1457
1458 case(KEY_CTRL_E)
1459 ! End - no-op in search mode
1460 if (.not. module_input_state%in_search) call handle_end(module_input_state)
1461
1462 case(KEY_CTRL_F)
1463 ! FZF file browser - no-op in search mode
1464 if (.not. module_input_state%in_search) then
1465 call launch_fzf_file_browser(module_input_state, prompt)
1466 end if
1467
1468 case(KEY_CTRL_B)
1469 ! Backward character - no-op in search mode
1470 if (.not. module_input_state%in_search) call handle_cursor_left(module_input_state)
1471
1472 case(KEY_CTRL_K)
1473 ! Kill to end of line - no-op in search mode
1474 if (.not. module_input_state%in_search) then
1475 if (module_input_state%in_menu_select) then
1476 call exit_menu_select_mode(module_input_state)
1477 end if
1478 call handle_kill_to_end(module_input_state)
1479 end if
1480
1481 case(KEY_CTRL_U)
1482 if (module_input_state%in_search) then
1483 ! Clear search query and restore original buffer
1484 call search_clear_query(module_input_state, prompt)
1485 else
1486 ! Kill entire line (exit menu mode first if active)
1487 if (module_input_state%in_menu_select) then
1488 call exit_menu_select_mode(module_input_state)
1489 end if
1490 call handle_kill_line(module_input_state)
1491 end if
1492
1493 case(KEY_CTRL_W)
1494 if (module_input_state%in_search) then
1495 ! Delete last word from search query
1496 call search_kill_word(module_input_state, prompt)
1497 else
1498 ! Kill previous word (exit menu mode first if active)
1499 if (module_input_state%in_menu_select) then
1500 call exit_menu_select_mode(module_input_state)
1501 end if
1502 call handle_kill_word(module_input_state)
1503 end if
1504
1505 case(KEY_CTRL_Y)
1506 ! Yank - no-op in search mode
1507 if (.not. module_input_state%in_search) call handle_yank(module_input_state)
1508
1509 case(KEY_CTRL_L)
1510 ! Clear screen
1511 if (.not. module_input_state%in_search .and. &
1512 .not. module_input_state%in_menu_select) then
1513 call handle_clear_screen(module_input_state, prompt)
1514 end if
1515
1516 case(KEY_CTRL_R)
1517 ! Reverse-i-search
1518 call handle_isearch(module_input_state, prompt, .false.)
1519 case(KEY_CTRL_S)
1520 ! Forward-i-search
1521 call handle_isearch(module_input_state, prompt, .true.)
1522
1523 case(KEY_CTRL_G)
1524 ! Cancel search if active - restore original buffer and continue editing
1525 if (module_input_state%in_search) then
1526 call cancel_search(module_input_state)
1527 end if
1528
1529 case(KEY_CTRL_H)
1530 ! FZF history browser - no-op in search mode
1531 if (.not. module_input_state%in_search) then
1532 call launch_fzf_history_browser(module_input_state, prompt)
1533 end if
1534
1535 case(KEY_CTRL_P)
1536 ! Previous history (emacs binding, like Up arrow)
1537 if (.not. module_input_state%in_search) then
1538 call handle_history_up(module_input_state)
1539 end if
1540
1541 case(KEY_CTRL_N)
1542 ! Next history (emacs binding, like Down arrow)
1543 if (.not. module_input_state%in_search) then
1544 call handle_history_down(module_input_state)
1545 end if
1546
1547 case(KEY_CTRL_T)
1548 ! Transpose characters - no-op in search mode
1549 if (.not. module_input_state%in_search) call handle_transpose_chars(module_input_state)
1550
1551 case(32:126)
1552 ! Regular printable characters
1553 if (module_input_state%in_signal_input) then
1554 ! Handle signal input for process kill
1555 call handle_signal_input(module_input_state, ch)
1556 else if (module_input_state%in_menu_select) then
1557 ! Exit menu mode and process character normally
1558 call exit_menu_select_mode(module_input_state)
1559 call insert_char_wrapper(module_input_state, ch)
1560 else if (module_input_state%in_search) then
1561 call search_add_char(module_input_state, ch, prompt)
1562 else if (module_input_state%editing_mode == EDITING_MODE_VI .and. &
1563 module_input_state%vi_mode == VI_MODE_COMMAND) then
1564 ! In Vi command mode - route to command handler
1565 call handle_vi_command_mode(module_input_state, char_code)
1566 ! Check if we switched back to insert mode
1567 if (module_input_state%vi_mode == VI_MODE_INSERT) then
1568 call handle_vi_mode_switch(module_input_state, char_code)
1569 end if
1570 else
1571 call insert_char_wrapper(module_input_state, ch)
1572 end if
1573
1574 case default
1575 ! Ignore other control characters for now
1576 end select
1577
1578
1579 ! Redraw line if needed
1580 ! INLINE redraw to avoid gfortran bug on macOS with large derived types
1581 ! Skip redraw when in menu selection mode - menu handles its own display
1582 ! In test mode, skip full redraw to avoid polluting PTY output
1583 if (.not. test_mode_initialized) call init_test_mode()
1584 if (module_input_state%dirty .and. .not. module_input_state%in_menu_select .and. .not. test_mode_enabled) then
1585 ! Search mode: delegate to two-line search display instead of normal redraw
1586 if (module_input_state%in_search) then
1587 call update_search_display(module_input_state, prompt)
1588 module_input_state%dirty = .false.
1589 cycle
1590 end if
1591 ! WORKAROUND: Removed 'block' construct to avoid flang-new crash on macOS ARM64
1592 ! Variables moved to subroutine level
1593
1594 ! Get terminal size for multiline handling
1595 #ifdef __APPLE__
1596 ! WORKAROUND: get_terminal_size crashes on flang-new
1597 ! Use tput command as a safe alternative
1598 call safe_get_terminal_size(term_rows, term_cols)
1599 #else
1600 ! Linux: Use actual terminal size
1601 success = get_terminal_size(term_rows, term_cols)
1602 if (.not. success) then
1603 ! Fallback to reasonable defaults
1604 term_cols = 80
1605 term_rows = 24
1606 end if
1607 #endif
1608
1609 ! Calculate visual length of prompt (excluding ANSI codes)
1610 prompt_visual_len = visual_length(prompt)
1611 if (prompt_visual_len < 0) then
1612 prompt_visual_len = 0
1613 end if
1614
1615 ! Calculate current cursor position (add 1 for space after prompt)
1616 cursor_visual_pos = prompt_visual_len + 1 + module_input_state%cursor_pos
1617
1618 ! Calculate row/col from buffer state not stale screen tracking
1619 current_row = cursor_visual_pos / term_cols
1620 current_col = mod(cursor_visual_pos, term_cols)
1621 ! Calculate where start of prompt is (always row 0, col 0 of prompt line)
1622 ! Move cursor to start of prompt UNLESS we just exited menu mode
1623 if (.not. module_input_state%skip_cursor_up_on_redraw) then
1624 ! Move to start of first line of this command
1625 ! For multiline prompts, we need to move up by both the buffer rows AND the prompt lines
1626 if (current_row + prompt_line_count > 0) then
1627 ! Move up to first line of prompt
1628 do i_redraw = 1, current_row + prompt_line_count
1629 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
1630 end do
1631 end if
1632 ! Move to column 0 of that line
1633 write(output_unit, '(a)', advance='no') char(13) ! Carriage return
1634 else
1635 ! Just move to start of current line
1636 write(output_unit, '(a)', advance='no') char(13) ! Carriage return
1637 end if
1638
1639 ! Clear the skip flag after using it
1640 module_input_state%skip_cursor_up_on_redraw = .false.
1641
1642 ! Clear from cursor to end of screen
1643 write(output_unit, '(a)', advance='no') char(27) // '[J' ! Clear from cursor down
1644
1645 ! Redraw prompt and buffer (replace bare LF with CR+LF for raw mode)
1646 block
1647 integer :: pr_j
1648 do pr_j = 1, len_trim(prompt)
1649 if (prompt(pr_j:pr_j) == char(10)) then
1650 write(output_unit, '(a)', advance='no') char(13) // char(10)
1651 else
1652 write(output_unit, '(a)', advance='no') prompt(pr_j:pr_j)
1653 end if
1654 end do
1655 end block
1656 write(output_unit, '(a)', advance='no') ' ' ! Space after prompt
1657 if (module_input_state%length > 0) then
1658 ! Try syntax highlighting
1659 call state_buffer_get(module_input_state, temp_buf)
1660
1661 ! Shift-phase selection rendering (Sprint 2): when a selection is
1662 ! active, render in three segments — plain prefix, reverse-video
1663 ! selection (ESC[7m...ESC[27m), plain suffix. Syntax highlighting
1664 ! is skipped for the whole line while a selection is live to avoid
1665 ! mis-coloring partial token substrings. Reverse video (#13) is
1666 ! preferred over background colors: proven on Terminal.app,
1667 ! iTerm2, Ghostty, and the mainstream Linux terminals, and already
1668 ! used for prefix search rendering below.
1669 if (module_input_state%selection_active) then
1670 ! Compute and clamp the byte range.
1671 sel_start = min(module_input_state%selection_anchor, module_input_state%cursor_pos)
1672 sel_end = max(module_input_state%selection_anchor, module_input_state%cursor_pos)
1673 if (sel_start < 0) sel_start = 0
1674 if (sel_end > module_input_state%length) sel_end = module_input_state%length
1675 ! Segment 1: plain text from start up to selection start.
1676 if (sel_start > 0) then
1677 write(output_unit, '(a)', advance='no') temp_buf(1:sel_start)
1678 end if
1679 ! Segment 2: selected bytes in reverse video.
1680 if (sel_end > sel_start) then
1681 write(output_unit, '(a)', advance='no') char(27) // '[7m'
1682 write(output_unit, '(a)', advance='no') temp_buf(sel_start+1:sel_end)
1683 write(output_unit, '(a)', advance='no') char(27) // '[27m'
1684 end if
1685 ! Segment 3: plain text from selection end to buffer end.
1686 if (sel_end < module_input_state%length) then
1687 write(output_unit, '(a)', advance='no') temp_buf(sel_end+1:module_input_state%length)
1688 end if
1689
1690 ! In prefix search mode, render prefix in reverse video + rest plain
1691 else if (module_input_state%in_prefix_search .and. &
1692 (module_input_state%prefix_search_idx /= 0 .or. module_input_state%prefix_search_flash)) then
1693 ! Prefix in reverse video
1694 write(output_unit, '(a)', advance='no') char(27) // '[7m'
1695 do i_redraw = 1, module_input_state%prefix_search_len
1696 write(output_unit, '(a)', advance='no') temp_buf(i_redraw:i_redraw)
1697 end do
1698 write(output_unit, '(a)', advance='no') char(27) // '[0m'
1699 ! Clear flash flag after rendering (transient — one frame only)
1700 if (module_input_state%prefix_search_flash) then
1701 module_input_state%prefix_search_flash = .false.
1702 end if
1703 ! Remainder in plain text
1704 if (module_input_state%length > module_input_state%prefix_search_len) then
1705 write(output_unit, '(a)', advance='no') &
1706 temp_buf(module_input_state%prefix_search_len+1:module_input_state%length)
1707 end if
1708 else
1709 call highlight_command_line(temp_buf(:module_input_state%length), &
1710 module_highlighted_buffer, module_highlighted_len, &
1711 module_input_state%length)
1712 if (module_highlighted_len > 0 .and. module_highlighted_len <= len(module_highlighted_buffer)) then
1713 write(output_unit, '(a)', advance='no') module_highlighted_buffer(:module_highlighted_len)
1714 else
1715 ! Fallback to plain text (temp_buf already extracted above)
1716 write(output_unit, '(a)', advance='no') temp_buf(:module_input_state%length)
1717 end if
1718 end if
1719
1720 ! Display autosuggestion if present (only when cursor is at end)
1721 if (module_input_state%suggestion_length > 0 .and. &
1722 module_input_state%cursor_pos == module_input_state%length) then
1723 ! Calculate column position after command (add 1 for space after prompt)
1724 cursor_visual_pos = prompt_visual_len + 1 + module_input_state%length
1725
1726 ! Safety check for term_cols
1727 if (term_cols > 0 .and. term_cols <= 500) then
1728 current_col = mod(cursor_visual_pos, term_cols)
1729 current_row = cursor_visual_pos / term_cols
1730
1731 ! Additional safety: ensure current_col is reasonable
1732 if (current_col < 0) current_col = 0
1733 if (current_col >= term_cols) current_col = term_cols - 1
1734
1735 ! Calculate available space on current line
1736 available_space = term_cols - current_col
1737 if (available_space < 0) available_space = 0
1738 if (available_space > term_cols) available_space = 0
1739
1740 ! CRITICAL: Prevent cursor jumping by ensuring suggestion never causes line wrap
1741 ! The bug: if (prompt + input + suggestion) wraps to next line, then cursor-left
1742 ! commands move cursor on the WRONG line, causing visible cursor jumping.
1743 !
1744 ! Solution:
1745 ! 1. NEVER show suggestions if input has already wrapped (current_row > 0)
1746 ! 2. Limit suggestion to fit on current line with safety margin
1747 ! 3. Leave 2 char margin for ANSI codes
1748 ! 4. Show if we have at least 3 chars of space (enough for 1 char + ANSI codes)
1749 if (current_row == 0 .and. available_space >= 3) then
1750 ! Limit suggestion to available space minus safety margin
1751 suggestion_display_len = min(module_input_state%suggestion_length, available_space - 2)
1752
1753 if (suggestion_display_len < 0) suggestion_display_len = 0
1754 if (suggestion_display_len > MAX_LINE_LEN) suggestion_display_len = 0
1755 if (suggestion_display_len > module_input_state%suggestion_length) suggestion_display_len = 0
1756
1757 ! Show suggestion if we have at least 1 character
1758 if (suggestion_display_len >= 1) then
1759 ! Use bright black (gray) color for suggestions - ANSI code 90
1760 write(output_unit, '(a)', advance='no') char(27) // '[90m'
1761
1762 ! Write character by character to avoid substring temporaries (flang-new crash)
1763 do i_redraw = 1, suggestion_display_len
1764 if (i_redraw <= MAX_LINE_LEN) then
1765 write(output_unit, '(a)', advance='no') module_input_state%suggestion(i_redraw:i_redraw)
1766 end if
1767 end do
1768
1769 write(output_unit, '(a)', advance='no') char(27) // '[0m' ! Reset color
1770
1771 ! Move cursor back using simple cursor-left commands
1772 ! This is safe because we've guaranteed no wrapping above
1773 do i_redraw = 1, suggestion_display_len
1774 write(output_unit, '(a)', advance='no') char(27) // '[D' ! Cursor left
1775 end do
1776 end if
1777 end if
1778 end if
1779 end if
1780 end if
1781
1782 ! Position cursor correctly (if not at end of input)
1783 if (module_input_state%cursor_pos < module_input_state%length) then
1784 ! Cursor not at end - calculate visual width difference
1785 ! IMPORTANT: Must use visual width, not byte count, for UTF-8 support
1786
1787 ! Current cursor position (at end of drawn input)
1788 call cursor_get_row_col(prompt, module_input_state%length, term_cols, current_row, current_col)
1789
1790 ! Desired cursor position
1791 call cursor_get_row_col(prompt, module_input_state%cursor_pos, term_cols, cursor_visual_pos, i_redraw)
1792
1793 ! Move cursor left by VISUAL column difference
1794 ! (Not byte difference - emoji is 4 bytes but 2 visual columns!)
1795 if (current_col > i_redraw) then
1796 do current_line = 1, current_col - i_redraw
1797 write(output_unit, '(a)', advance='no') char(27) // '[D' ! Cursor left
1798 end do
1799 end if
1800 end if
1801
1802 flush(output_unit)
1803
1804 ! Debug: show state before recalculating cursor position
1805 if (debug_utf8) then
1806 write(error_unit, '(a,i0,a,i0,a,i0)') '[REDRAW] BEFORE cursor_get_row_col: cursor_pos=', &
1807 module_input_state%cursor_pos, ' screen_row=', module_cursor_screen_row, ' screen_col=', module_cursor_screen_col
1808 end if
1809
1810 ! Update screen cursor position tracking to match where we actually positioned the cursor
1811 call cursor_get_row_col(prompt, module_input_state%cursor_pos, term_cols, &
1812 module_cursor_screen_row, module_cursor_screen_col)
1813
1814 ! Debug: show state after recalculating cursor position
1815 if (debug_utf8) then
1816 write(error_unit, '(a,i0,a,i0)') '[REDRAW] AFTER cursor_get_row_col: screen_row=', &
1817 module_cursor_screen_row, ' screen_col=', module_cursor_screen_col
1818 end if
1819
1820 module_input_state%dirty = .false.
1821 end if
1822 end do
1823
1824 ! Restore terminal (unless keep_raw requested for continuation prompts)
1825 if (present(keep_raw)) then
1826 if (.not. keep_raw) then
1827 if (.not. restore_terminal(module_original_termios)) then
1828 end if
1829 end if
1830 else
1831 if (.not. restore_terminal(module_original_termios)) then
1832 end if
1833 end if
1834 else
1835 ! Fallback to line-based input
1836 #ifdef USE_C_STRINGS
1837 ! Read into temp buffer, then copy to C string
1838 read(input_unit, '(a)', iostat=iostat) temp_buf
1839 if (iostat == 0) then
1840 module_input_state%length = len_trim(temp_buf)
1841 if (.not. c_string_set(module_input_state%buffer_c, temp_buf(:module_input_state%length))) then
1842 iostat = -1
1843 end if
1844 end if
1845 #else
1846 #ifdef USE_MEMORY_POOL
1847 read(input_unit, '(a)', iostat=iostat) module_input_state%buffer_ref%data
1848 if (iostat == 0) module_input_state%length = len_trim(module_input_state%buffer_ref%data)
1849 #else
1850 read(input_unit, '(a)', iostat=iostat) module_input_state%buffer
1851 if (iostat == 0) module_input_state%length = len_trim(module_input_state%buffer)
1852 #endif
1853 #endif
1854 end if
1855
1856 ! Return the result
1857 if (iostat == 0) then
1858 call state_buffer_get(module_input_state, temp_buf)
1859 line = temp_buf(:module_input_state%length)
1860 ! Note: History addition is now handled in the main loop AFTER expansion
1861 ! This prevents history expansion commands like !! from referencing themselves
1862 else
1863 line = ''
1864 end if
1865
1866 ! Clean up allocated memory in module_input_state
1867 call cleanup_input_state(module_input_state)
1868
1869 ! Note: module_input_state persists as a module variable, no deallocation needed
1870
1871 end subroutine
1872
1873 ! Simple fallback readline - uses standard input for now
1874 ! This is a placeholder for a full readline implementation
1875 subroutine readline_simple(prompt, line, iostat)
1876 character(len=*), intent(in) :: prompt
1877 character(len=*), intent(out) :: line
1878 integer, intent(out) :: iostat
1879
1880 ! Print prompt
1881 write(output_unit, '(a)', advance='no') prompt
1882 write(output_unit, '(a)', advance='no') ' ' ! Space after prompt
1883 flush(output_unit)
1884
1885 ! Read line using standard input (no special key handling yet)
1886 read(input_unit, '(a)', iostat=iostat) line
1887
1888 ! Note: History addition is now handled in the main loop AFTER expansion
1889 end subroutine
1890
1891 ! Enhanced readline with tab completion support
1892 ! Note: This is a simplified version that detects tab in the input
1893 subroutine readline_with_completion(prompt, line, iostat)
1894 character(len=*), intent(in) :: prompt
1895 character(len=*), intent(out) :: line
1896 integer, intent(out) :: iostat
1897
1898 character(len=MAX_LINE_LEN) :: temp_line
1899 character(len=MAX_LINE_LEN) :: completions(MAX_LOCAL_COMPLETIONS)
1900 integer :: num_completions, tab_pos
1901
1902 ! Print prompt
1903 write(output_unit, '(a)', advance='no') prompt
1904 write(output_unit, '(a)', advance='no') ' ' ! Space after prompt
1905 flush(output_unit)
1906
1907 ! Read line using standard input
1908 read(input_unit, '(a)', iostat=iostat) temp_line
1909
1910 if (iostat /= 0) then
1911 line = ''
1912 return
1913 end if
1914
1915 ! Check for tab character in input (simplified detection)
1916 tab_pos = index(temp_line, char(KEY_TAB))
1917 if (tab_pos > 0) then
1918 ! Extract partial input before tab
1919 if (tab_pos == 1) then
1920 temp_line = ''
1921 else
1922 temp_line = temp_line(:tab_pos-1)
1923 end if
1924
1925 ! Perform tab completion
1926 call tab_complete(temp_line, completions, num_completions)
1927
1928 if (num_completions > 0) then
1929 if (num_completions == 1) then
1930 ! Single completion - auto-complete
1931 line = trim(temp_line) // trim(completions(1))
1932 write(output_unit, '(a)') trim(line)
1933 else
1934 ! Multiple completions - show options
1935 call show_completions(completions, num_completions)
1936 line = temp_line
1937 end if
1938 else
1939 line = temp_line
1940 end if
1941 else
1942 line = temp_line
1943 end if
1944
1945 ! Note: History addition is now handled in the main loop AFTER expansion
1946 end subroutine
1947
1948 subroutine add_to_history(line)
1949 character(len=*), intent(in) :: line
1950 ! Call enhanced version with current histcontrol setting
1951 call add_to_history_with_control(line, current_histcontrol)
1952 end subroutine
1953
1954 ! Add command to history with HISTCONTROL support
1955 subroutine add_to_history_with_control(line, histcontrol)
1956 character(len=*), intent(in) :: line
1957 character(len=*), intent(in) :: histcontrol
1958 integer :: i
1959 logical :: ignorespace, ignoredups, ignoreboth, erasedups
1960
1961 ! Parse HISTCONTROL settings
1962 ignorespace = index(histcontrol, 'ignorespace') > 0
1963 ignoredups = index(histcontrol, 'ignoredups') > 0
1964 ignoreboth = index(histcontrol, 'ignoreboth') > 0
1965 erasedups = index(histcontrol, 'erasedups') > 0
1966
1967 ! Apply ignoreboth
1968 if (ignoreboth) then
1969 ignorespace = .true.
1970 ignoredups = .true.
1971 end if
1972
1973 ! Check ignorespace: don't add if line starts with space
1974 if (ignorespace .and. len_trim(line) > 0) then
1975 if (line(1:1) == ' ') return
1976 end if
1977
1978 ! Check ignoredups: don't add if duplicate of last command
1979 if (ignoredups .and. command_history%count > 0) then
1980 if (trim(command_history%lines(command_history%count)) == trim(line)) then
1981 return
1982 end if
1983 end if
1984
1985 ! Check erasedups: remove all previous instances of this command
1986 if (erasedups) then
1987 do i = 1, command_history%count
1988 if (trim(command_history%lines(i)) == trim(line)) then
1989 call delete_history_entry(i)
1990 exit ! Only one match possible after this
1991 end if
1992 end do
1993 end if
1994
1995 ! Shift history if at max capacity
1996 if (command_history%count >= MAX_HISTORY) then
1997 do i = 1, MAX_HISTORY - 1
1998 command_history%lines(i) = command_history%lines(i + 1)
1999 end do
2000 command_history%count = MAX_HISTORY - 1
2001 end if
2002
2003 ! Add new command
2004 command_history%count = command_history%count + 1
2005 command_history%lines(command_history%count) = line
2006
2007 ! Reset current position
2008 command_history%current = command_history%count + 1
2009 end subroutine
2010
2011 ! Delete a history entry by index
2012 subroutine delete_history_entry(index)
2013 integer, intent(in) :: index
2014 integer :: i
2015
2016 if (index < 1 .or. index > command_history%count) return
2017
2018 ! Shift remaining entries down
2019 do i = index, command_history%count - 1
2020 command_history%lines(i) = command_history%lines(i + 1)
2021 end do
2022
2023 ! Decrement count
2024 command_history%count = command_history%count - 1
2025
2026 ! Adjust current position if needed
2027 if (command_history%current > command_history%count + 1) then
2028 command_history%current = command_history%count + 1
2029 end if
2030 end subroutine
2031
2032 subroutine get_history_line(index, line, found)
2033 integer, intent(in) :: index
2034 character(len=*), intent(out) :: line
2035 logical, intent(out) :: found
2036
2037 if (index >= 1 .and. index <= command_history%count) then
2038 line = command_history%lines(index)
2039 found = .true.
2040 else
2041 line = ''
2042 found = .false.
2043 end if
2044 end subroutine
2045
2046 function get_history_count() result(count)
2047 integer :: count
2048 count = command_history%count
2049 end function
2050
2051 ! Show command history (for 'history' builtin)
2052 subroutine show_history()
2053 integer :: i
2054
2055 if (command_history%count == 0) then
2056 ! Bash is silent when history is empty
2057 return
2058 else
2059 do i = 1, command_history%count
2060 write(output_unit, '(i4,2x,a)') i, trim(command_history%lines(i))
2061 end do
2062 end if
2063 end subroutine
2064
2065 ! Clear history
2066 subroutine clear_history()
2067 command_history%count = 0
2068 command_history%current = 0
2069 end subroutine
2070
2071 ! Save history to file
2072 subroutine save_history_to_file(filepath, max_lines)
2073 character(len=*), intent(in) :: filepath
2074 integer, intent(in) :: max_lines
2075 integer :: unit, iostat, i, start_index
2076
2077 ! Create empty file if no history (matches bash behavior)
2078 if (command_history%count == 0) then
2079 open(newunit=unit, file=trim(filepath), status='replace', &
2080 action='write', iostat=iostat)
2081 if (iostat == 0) close(unit)
2082 return
2083 end if
2084
2085 ! Calculate starting index based on max_lines
2086 if (max_lines > 0 .and. command_history%count > max_lines) then
2087 start_index = command_history%count - max_lines + 1
2088 else
2089 start_index = 1
2090 end if
2091
2092 ! Open file for writing (truncate existing)
2093 open(newunit=unit, file=trim(filepath), status='replace', action='write', iostat=iostat)
2094 if (iostat /= 0) then
2095 write(error_unit, '(a)') 'fortsh: warning: could not save history to ' // trim(filepath)
2096 return
2097 end if
2098
2099 ! Write history lines
2100 do i = start_index, command_history%count
2101 write(unit, '(a)', iostat=iostat) trim(command_history%lines(i))
2102 if (iostat /= 0) exit
2103 end do
2104
2105 close(unit)
2106 end subroutine
2107
2108 ! Load history from file
2109 subroutine load_history_from_file(filepath, max_lines)
2110 character(len=*), intent(in) :: filepath
2111 integer, intent(in) :: max_lines
2112 integer :: unit, iostat
2113 character(len=MAX_LINE_LEN) :: line
2114 logical :: file_exists
2115
2116 ! Ensure history is initialized before loading
2117 call init_history()
2118
2119 ! Check if file exists
2120 inquire(file=filepath, exist=file_exists)
2121 if (.not. file_exists) return
2122
2123 ! Open file for reading
2124 open(newunit=unit, file=trim(filepath), status='old', action='read', iostat=iostat)
2125 if (iostat /= 0) return
2126
2127 ! Clear existing history
2128 command_history%count = 0
2129 command_history%current = 0
2130
2131 ! Read lines
2132 do
2133 read(unit, '(a)', iostat=iostat) line
2134 if (iostat /= 0) exit ! EOF or error
2135
2136 ! Skip empty lines
2137 if (len_trim(line) == 0) cycle
2138
2139 ! Add to history (respecting max_lines)
2140 if (max_lines > 0 .and. command_history%count >= max_lines) then
2141 ! Shift history to make room
2142 command_history%lines(1:MAX_HISTORY-1) = command_history%lines(2:MAX_HISTORY)
2143 command_history%count = command_history%count - 1
2144 end if
2145
2146 ! Add to history without duplicate check (loading from file)
2147 command_history%count = command_history%count + 1
2148 command_history%lines(command_history%count) = line
2149 end do
2150
2151 close(unit)
2152 command_history%current = command_history%count + 1
2153 end subroutine
2154
2155 ! Append new history entries to file (for concurrent shells)
2156 subroutine append_history_to_file(filepath, start_index)
2157 character(len=*), intent(in) :: filepath
2158 integer, intent(in) :: start_index
2159 integer :: unit, iostat, i
2160
2161 if (start_index > command_history%count) return
2162
2163 ! Open file for appending
2164 open(newunit=unit, file=trim(filepath), status='old', position='append', action='write', iostat=iostat)
2165 if (iostat /= 0) then
2166 ! File doesn't exist, create it
2167 open(newunit=unit, file=trim(filepath), status='new', action='write', iostat=iostat)
2168 if (iostat /= 0) return
2169 end if
2170
2171 ! Append new entries
2172 do i = start_index, command_history%count
2173 write(unit, '(a)', iostat=iostat) trim(command_history%lines(i))
2174 if (iostat /= 0) exit
2175 end do
2176
2177 close(unit)
2178 end subroutine
2179
2180 ! History expansion functions
2181 function expand_history(input_line) result(expanded_line)
2182 character(len=*), intent(in) :: input_line
2183 character(len=len(input_line)) :: expanded_line
2184
2185 character(len=len(input_line)) :: work_line
2186 integer :: pos, expansion_start, expansion_end, out_pos
2187 character(len=256) :: expansion, replacement
2188 logical :: found_expansion
2189 integer :: repl_len
2190
2191 work_line = input_line
2192 expanded_line = ''
2193 pos = 1
2194 out_pos = 1
2195
2196 do while (pos <= len_trim(work_line))
2197 if (work_line(pos:pos) == '!' .and. pos <= len_trim(work_line)) then
2198 ! Skip if this is $! (special variable for last background PID)
2199 if (pos > 1 .and. work_line(pos-1:pos-1) == '$') then
2200 ! This is $!, not a history expansion - copy the ! as-is
2201 expanded_line(out_pos:out_pos) = '!'
2202 out_pos = out_pos + 1
2203 pos = pos + 1
2204 else
2205 ! Found potential history expansion
2206 expansion_start = pos
2207 expansion_end = find_history_expansion_end(work_line, pos)
2208
2209 if (expansion_end > expansion_start) then
2210 expansion = work_line(expansion_start:expansion_end)
2211 call process_history_expansion(expansion, replacement, found_expansion)
2212
2213 if (found_expansion) then
2214 repl_len = len_trim(replacement)
2215 if (out_pos + repl_len - 1 <= len(expanded_line)) then
2216 expanded_line(out_pos:out_pos+repl_len-1) = trim(replacement)
2217 out_pos = out_pos + repl_len
2218 end if
2219 pos = expansion_end + 1
2220 else
2221 expanded_line(out_pos:out_pos) = '!'
2222 out_pos = out_pos + 1
2223 pos = pos + 1
2224 end if
2225 else
2226 expanded_line(out_pos:out_pos) = '!'
2227 out_pos = out_pos + 1
2228 pos = pos + 1
2229 end if
2230 end if
2231 else
2232 expanded_line(out_pos:out_pos) = work_line(pos:pos)
2233 out_pos = out_pos + 1
2234 pos = pos + 1
2235 end if
2236 end do
2237 end function
2238
2239 function find_history_expansion_end(line, start_pos) result(end_pos)
2240 character(len=*), intent(in) :: line
2241 integer, intent(in) :: start_pos
2242 integer :: end_pos
2243
2244 integer :: pos
2245 character :: ch
2246
2247 pos = start_pos + 1 ! Skip the '!'
2248 end_pos = start_pos
2249
2250 if (pos > len_trim(line)) return
2251
2252 ch = line(pos:pos)
2253
2254 if (ch == '!') then
2255 ! !! expansion
2256 end_pos = pos
2257 else if (ch >= '0' .and. ch <= '9') then
2258 ! !n expansion (number)
2259 do while (pos <= len_trim(line) .and. line(pos:pos) >= '0' .and. line(pos:pos) <= '9')
2260 end_pos = pos
2261 pos = pos + 1
2262 end do
2263 else if (ch == '-') then
2264 ! !-n expansion (negative number)
2265 pos = pos + 1
2266 if (pos <= len_trim(line) .and. line(pos:pos) >= '0' .and. line(pos:pos) <= '9') then
2267 do while (pos <= len_trim(line) .and. line(pos:pos) >= '0' .and. line(pos:pos) <= '9')
2268 end_pos = pos
2269 pos = pos + 1
2270 end do
2271 end if
2272 else if ((ch >= 'a' .and. ch <= 'z') .or. (ch >= 'A' .and. ch <= 'Z') .or. ch == '_') then
2273 ! !string expansion
2274 do while (pos <= len_trim(line) .and. &
2275 ((line(pos:pos) >= 'a' .and. line(pos:pos) <= 'z') .or. &
2276 (line(pos:pos) >= 'A' .and. line(pos:pos) <= 'Z') .or. &
2277 (line(pos:pos) >= '0' .and. line(pos:pos) <= '9') .or. &
2278 line(pos:pos) == '_' .or. line(pos:pos) == '-'))
2279 end_pos = pos
2280 pos = pos + 1
2281 end do
2282 end if
2283 end function
2284
2285 subroutine process_history_expansion(expansion, replacement, found)
2286 character(len=*), intent(in) :: expansion
2287 character(len=*), intent(out) :: replacement
2288 logical, intent(out) :: found
2289
2290 character(len=256) :: search_pattern
2291 integer :: history_num, i, search_len
2292
2293 replacement = ''
2294 found = .false.
2295
2296 if (len_trim(expansion) < 2) return
2297
2298 select case (expansion(2:2))
2299 case ('!')
2300 ! !! - last command
2301 if (command_history%count > 0) then
2302 replacement = command_history%lines(command_history%count)
2303 found = .true.
2304 end if
2305
2306 case ('0':'9')
2307 ! !n - command number n
2308 read(expansion(2:), *, iostat=i) history_num
2309 if (i == 0 .and. history_num >= 1 .and. history_num <= command_history%count) then
2310 replacement = command_history%lines(history_num)
2311 found = .true.
2312 end if
2313
2314 case ('-')
2315 ! !-n - n commands back
2316 if (len_trim(expansion) > 2) then
2317 read(expansion(3:), *, iostat=i) history_num
2318 if (i == 0 .and. history_num > 0) then
2319 history_num = command_history%count - history_num + 1
2320 if (history_num >= 1 .and. history_num <= command_history%count) then
2321 replacement = command_history%lines(history_num)
2322 found = .true.
2323 end if
2324 end if
2325 end if
2326
2327 case default
2328 ! !string - last command starting with string
2329 search_pattern = expansion(2:)
2330 search_len = len_trim(search_pattern)
2331
2332 if (search_len > 0) then
2333 ! Search backwards through history
2334 do i = command_history%count, 1, -1
2335 if (len_trim(command_history%lines(i)) >= search_len) then
2336 if (command_history%lines(i)(1:search_len) == search_pattern) then
2337 replacement = command_history%lines(i)
2338 found = .true.
2339 exit
2340 end if
2341 end if
2342 end do
2343 end if
2344 end select
2345 end subroutine
2346
2347 function needs_history_expansion(line) result(needs_expansion)
2348 character(len=*), intent(in) :: line
2349 logical :: needs_expansion
2350
2351 integer :: pos, old_pos
2352
2353 needs_expansion = .false.
2354 pos = index(line, '!')
2355
2356 do while (pos > 0 .and. pos <= len_trim(line))
2357 ! Check if this ! is the start of a history expansion
2358 ! Skip if it's part of $! (special variable for last background PID)
2359 if (pos > 1 .and. line(pos-1:pos-1) == '$') then
2360 ! This is $!, not a history expansion
2361 else if (pos == 1 .or. line(pos-1:pos-1) == ' ' .or. line(pos-1:pos-1) == char(9)) then
2362 ! Check what follows the ! (if there is something after it)
2363 if (pos < len_trim(line)) then
2364 if (line(pos+1:pos+1) == '!' .or. &
2365 (line(pos+1:pos+1) >= '0' .and. line(pos+1:pos+1) <= '9') .or. &
2366 line(pos+1:pos+1) == '-' .or. &
2367 (line(pos+1:pos+1) >= 'a' .and. line(pos+1:pos+1) <= 'z') .or. &
2368 (line(pos+1:pos+1) >= 'A' .and. line(pos+1:pos+1) <= 'Z')) then
2369 needs_expansion = .true.
2370 return
2371 end if
2372 end if
2373 end if
2374
2375 ! Look for next !
2376 old_pos = pos
2377 pos = index(line(pos+1:), '!')
2378 if (pos > 0) pos = pos + old_pos
2379 end do
2380 end function
2381
2382 ! Editing mode control functions
2383 subroutine set_editing_mode(input_state, mode)
2384 type(input_state_t), intent(inout) :: input_state
2385 integer, intent(in) :: mode
2386
2387 if (mode == EDITING_MODE_EMACS .or. mode == EDITING_MODE_VI) then
2388 input_state%editing_mode = mode
2389 if (mode == EDITING_MODE_VI) then
2390 input_state%vi_mode = VI_MODE_INSERT
2391 end if
2392 end if
2393 end subroutine
2394
2395 subroutine handle_vi_mode_switch(input_state, key)
2396 type(input_state_t), intent(inout) :: input_state
2397 integer, intent(in) :: key
2398
2399 if (input_state%editing_mode /= EDITING_MODE_VI) return
2400
2401 select case (input_state%vi_mode)
2402 case (VI_MODE_INSERT)
2403 if (key == KEY_ESC) then
2404 input_state%vi_mode = VI_MODE_COMMAND
2405 ! Move cursor back one position in command mode
2406 if (input_state%cursor_pos > 0) then
2407 input_state%cursor_pos = input_state%cursor_pos - 1
2408 end if
2409 input_state%dirty = .true.
2410 end if
2411
2412 case (VI_MODE_COMMAND)
2413 select case (key)
2414 case (ichar('i'))
2415 ! Insert mode
2416 input_state%vi_mode = VI_MODE_INSERT
2417 case (ichar('a'))
2418 ! Append mode
2419 input_state%vi_mode = VI_MODE_INSERT
2420 if (input_state%cursor_pos < input_state%length) then
2421 input_state%cursor_pos = input_state%cursor_pos + 1
2422 end if
2423 case (ichar('I'))
2424 ! Insert at beginning
2425 input_state%vi_mode = VI_MODE_INSERT
2426 input_state%cursor_pos = 0
2427 case (ichar('A'))
2428 ! Append at end
2429 input_state%vi_mode = VI_MODE_INSERT
2430 input_state%cursor_pos = input_state%length
2431 case (ichar('o'))
2432 ! Open new line below (simplified)
2433 input_state%vi_mode = VI_MODE_INSERT
2434 input_state%cursor_pos = input_state%length
2435 case (ichar('O'))
2436 ! Open new line above (simplified)
2437 input_state%vi_mode = VI_MODE_INSERT
2438 input_state%cursor_pos = 0
2439 end select
2440 input_state%dirty = .true.
2441 end select
2442 end subroutine
2443
2444 subroutine handle_vi_command_mode(input_state, key)
2445 type(input_state_t), intent(inout) :: input_state
2446 integer, intent(in) :: key
2447 character :: key_char
2448 integer :: repeat_count, i
2449
2450 if (input_state%editing_mode /= EDITING_MODE_VI .or. input_state%vi_mode /= VI_MODE_COMMAND) return
2451
2452 key_char = char(key)
2453
2454 ! Handle pending two-character commands first
2455 #ifdef USE_C_STRINGS
2456 if (len_trim(input_state%vi_command_buffer) > 0) then
2457 select case (input_state%vi_command_buffer(1:1))
2458 #elif defined(USE_MEMORY_POOL)
2459 if (len_trim(input_state%vi_command_buffer_ref%data) > 0) then
2460 select case (input_state%vi_command_buffer_ref%data(1:1))
2461 #else
2462 if (len_trim(input_state%vi_command_buffer) > 0) then
2463 select case (input_state%vi_command_buffer(1:1))
2464 #endif
2465 case ('m')
2466 ! Setting a mark
2467 call handle_vi_mark_set(input_state, key_char)
2468 return
2469 case ("'")
2470 ! Jumping to a mark
2471 call handle_vi_mark_jump(input_state, key_char)
2472 return
2473 case ('d')
2474 ! Delete with motion
2475 call handle_vi_delete_with_motion(input_state, key_char)
2476 return
2477 case ('y')
2478 ! Yank with motion
2479 call handle_vi_yank_with_motion(input_state, key_char)
2480 return
2481 case ('c')
2482 ! Change with motion
2483 call handle_vi_change_with_motion(input_state, key_char)
2484 return
2485 case ('r')
2486 ! Replace character
2487 call handle_vi_replace_char(input_state, key_char)
2488 return
2489 end select
2490 end if
2491
2492 ! Handle repeat counts (1-9)
2493 if (key >= ichar('1') .and. key <= ichar('9') .and. .not. input_state%vi_repeat_pending) then
2494 input_state%vi_repeat_pending = .true.
2495 input_state%vi_command_count = key - ichar('0')
2496 return
2497 else if (key >= ichar('0') .and. key <= ichar('9') .and. input_state%vi_repeat_pending) then
2498 input_state%vi_command_count = input_state%vi_command_count * 10 + (key - ichar('0'))
2499 return
2500 end if
2501
2502 ! Get repeat count (default to 1)
2503 if (input_state%vi_repeat_pending) then
2504 repeat_count = input_state%vi_command_count
2505 input_state%vi_repeat_pending = .false.
2506 input_state%vi_command_count = 0
2507 else
2508 repeat_count = 1
2509 end if
2510
2511 select case (key)
2512 ! Navigation (with repeat)
2513 case (ichar('h'))
2514 ! Move left
2515 do i = 1, repeat_count
2516 if (input_state%cursor_pos > 0) then
2517 input_state%cursor_pos = input_state%cursor_pos - 1
2518 end if
2519 end do
2520 input_state%dirty = .true.
2521 case (ichar('l'))
2522 ! Move right
2523 do i = 1, repeat_count
2524 if (input_state%cursor_pos < input_state%length - 1) then
2525 input_state%cursor_pos = input_state%cursor_pos + 1
2526 end if
2527 end do
2528 input_state%dirty = .true.
2529 case (ichar('j'))
2530 ! Move down (history down)
2531 do i = 1, repeat_count
2532 call handle_history_down(input_state)
2533 end do
2534 case (ichar('k'))
2535 ! Move up (history up)
2536 do i = 1, repeat_count
2537 call handle_history_up(input_state)
2538 end do
2539 case (ichar('0'))
2540 ! Beginning of line (no repeat)
2541 input_state%cursor_pos = 0
2542 input_state%dirty = .true.
2543 case (ichar('$'))
2544 ! End of line (no repeat)
2545 input_state%cursor_pos = input_state%length
2546 input_state%dirty = .true.
2547 case (ichar('w'))
2548 ! Next word
2549 do i = 1, repeat_count
2550 call move_to_next_word(input_state)
2551 end do
2552 case (ichar('b'))
2553 ! Previous word
2554 do i = 1, repeat_count
2555 call move_to_previous_word(input_state)
2556 end do
2557 case (ichar('e'))
2558 ! End of current word
2559 do i = 1, repeat_count
2560 call move_to_word_end(input_state)
2561 end do
2562
2563 ! Deletion (with repeat)
2564 case (ichar('x'))
2565 ! Delete character at cursor
2566 do i = 1, repeat_count
2567 call delete_char_at_cursor(input_state)
2568 end do
2569 case (ichar('X'))
2570 ! Delete character before cursor
2571 do i = 1, repeat_count
2572 if (input_state%cursor_pos > 0) then
2573 input_state%cursor_pos = input_state%cursor_pos - 1
2574 call delete_char_at_cursor(input_state)
2575 end if
2576 end do
2577 case (ichar('d'))
2578 ! Delete with motion - set up for next character
2579 #ifdef USE_C_STRINGS
2580 input_state%vi_command_buffer = 'd'
2581 #elif defined(USE_MEMORY_POOL)
2582 input_state%vi_command_buffer_ref%data = 'd'
2583 #else
2584 input_state%vi_command_buffer = 'd'
2585 #endif
2586 input_state%vi_command_count = repeat_count
2587
2588 ! Change (with repeat)
2589 case (ichar('c'))
2590 ! Change with motion - set up for next character
2591 #ifdef USE_C_STRINGS
2592 input_state%vi_command_buffer = 'c'
2593 #elif defined(USE_MEMORY_POOL)
2594 input_state%vi_command_buffer_ref%data = 'c'
2595 #else
2596 input_state%vi_command_buffer = 'c'
2597 #endif
2598 input_state%vi_command_count = repeat_count
2599 case (ichar('C'))
2600 ! Change to end of line
2601 call handle_vi_change_to_eol(input_state)
2602
2603 ! Undo
2604 case (ichar('u'))
2605 ! Undo (simplified)
2606 call state_buffer_restore(input_state)
2607 #ifdef USE_C_STRINGS
2608 input_state%length = c_string_length(input_state%original_buffer_c)
2609 #elif defined(USE_MEMORY_POOL)
2610 input_state%length = len_trim(input_state%original_buffer_ref%data)
2611 #else
2612 input_state%length = len_trim(input_state%original_buffer)
2613 #endif
2614 input_state%cursor_pos = min(input_state%cursor_pos, input_state%length)
2615 input_state%dirty = .true.
2616
2617 ! Yank and Put (vi-style copy/paste)
2618 case (ichar('y'))
2619 ! Yank with motion - set up for next character
2620 #ifdef USE_C_STRINGS
2621 input_state%vi_command_buffer = 'y'
2622 #elif defined(USE_MEMORY_POOL)
2623 input_state%vi_command_buffer_ref%data = 'y'
2624 #else
2625 input_state%vi_command_buffer = 'y'
2626 #endif
2627 input_state%vi_command_count = repeat_count
2628 case (ichar('p'))
2629 ! Put (paste) after cursor
2630 do i = 1, repeat_count
2631 call handle_vi_put(input_state, .false.)
2632 end do
2633 case (ichar('P'))
2634 ! Put (paste) before cursor
2635 do i = 1, repeat_count
2636 call handle_vi_put(input_state, .true.)
2637 end do
2638
2639 ! Replace
2640 case (ichar('r'))
2641 ! Replace character - wait for next character
2642 #ifdef USE_C_STRINGS
2643 input_state%vi_command_buffer = 'r'
2644 #elif defined(USE_MEMORY_POOL)
2645 input_state%vi_command_buffer_ref%data = 'r'
2646 #else
2647 input_state%vi_command_buffer = 'r'
2648 #endif
2649 input_state%vi_command_count = repeat_count
2650 case (ichar('R'))
2651 ! Replace mode - enter insert mode with replace behavior
2652 input_state%vi_mode = VI_MODE_INSERT
2653 ! TODO: Add replace mode flag for overwrite behavior
2654
2655 ! Marks
2656 case (ichar('m'))
2657 ! Set mark - next character will be the mark name
2658 #ifdef USE_C_STRINGS
2659 input_state%vi_command_buffer = 'm'
2660 #elif defined(USE_MEMORY_POOL)
2661 input_state%vi_command_buffer_ref%data = 'm'
2662 #else
2663 input_state%vi_command_buffer = 'm'
2664 #endif
2665 input_state%vi_command_count = 1
2666 case (ichar("'"))
2667 ! Jump to mark - next character will be the mark name
2668 #ifdef USE_C_STRINGS
2669 input_state%vi_command_buffer = "'"
2670 #elif defined(USE_MEMORY_POOL)
2671 input_state%vi_command_buffer_ref%data = "'"
2672 #else
2673 input_state%vi_command_buffer = "'"
2674 #endif
2675 input_state%vi_command_count = 1
2676
2677 ! Vi search
2678 case (ichar('/'))
2679 ! Forward search
2680 call handle_vi_search_start(input_state, .true.)
2681 case (ichar('?'))
2682 ! Backward search
2683 call handle_vi_search_start(input_state, .false.)
2684 case (ichar('n'))
2685 ! Next search match
2686 call handle_vi_search_next(input_state, .true.)
2687 case (ichar('N'))
2688 ! Previous search match
2689 call handle_vi_search_next(input_state, .false.)
2690
2691 ! Mode switches (with proper cursor positioning)
2692 case (ichar('i'))
2693 ! Insert at cursor
2694 input_state%vi_mode = VI_MODE_INSERT
2695 case (ichar('a'))
2696 ! Insert after cursor
2697 if (input_state%cursor_pos < input_state%length) then
2698 input_state%cursor_pos = input_state%cursor_pos + 1
2699 end if
2700 input_state%vi_mode = VI_MODE_INSERT
2701 case (ichar('I'))
2702 ! Insert at beginning of line
2703 input_state%cursor_pos = 0
2704 input_state%vi_mode = VI_MODE_INSERT
2705 case (ichar('A'))
2706 ! Insert at end of line
2707 input_state%cursor_pos = input_state%length
2708 input_state%vi_mode = VI_MODE_INSERT
2709 case (ichar('o'))
2710 ! Open line below (simplified - just go to end)
2711 input_state%cursor_pos = input_state%length
2712 input_state%vi_mode = VI_MODE_INSERT
2713 case (ichar('O'))
2714 ! Open line above (simplified - just go to beginning)
2715 input_state%cursor_pos = 0
2716 input_state%vi_mode = VI_MODE_INSERT
2717 end select
2718 end subroutine
2719
2720 ! Motion-based delete command
2721 subroutine handle_vi_delete_with_motion(input_state, motion)
2722 type(input_state_t), intent(inout) :: input_state
2723 character, intent(in) :: motion
2724 integer :: start_pos, end_pos, delete_len, i, repeat_count
2725
2726 repeat_count = max(1, input_state%vi_command_count)
2727
2728 select case (motion)
2729 case ('d')
2730 ! dd - delete entire line
2731 call state_buffer_get(input_state, input_state%vi_yank_buffer)
2732 input_state%vi_yank_buffer = input_state%vi_yank_buffer(:input_state%length)
2733 input_state%vi_yank_length = input_state%length
2734 call state_buffer_clear(input_state)
2735 input_state%length = 0
2736 input_state%cursor_pos = 0
2737 input_state%dirty = .true.
2738
2739 case ('w')
2740 ! dw - delete to next word
2741 do i = 1, repeat_count
2742 start_pos = input_state%cursor_pos + 1
2743 call move_to_next_word(input_state)
2744 end_pos = input_state%cursor_pos + 1
2745 delete_len = end_pos - start_pos
2746 if (delete_len > 0) then
2747 call yank_range(input_state, start_pos, end_pos)
2748 call delete_range(input_state, start_pos, end_pos)
2749 end if
2750 end do
2751
2752 case ('$')
2753 ! d$ - delete to end of line
2754 start_pos = input_state%cursor_pos + 1
2755 end_pos = input_state%length + 1
2756 call yank_range(input_state, start_pos, end_pos)
2757 call delete_range(input_state, start_pos, end_pos)
2758
2759 case ('0')
2760 ! d0 - delete to beginning of line
2761 start_pos = 1
2762 end_pos = input_state%cursor_pos + 1
2763 call yank_range(input_state, start_pos, end_pos)
2764 call delete_range(input_state, start_pos, end_pos)
2765
2766 case ('b')
2767 ! db - delete to previous word
2768 do i = 1, repeat_count
2769 end_pos = input_state%cursor_pos + 1
2770 call move_to_previous_word(input_state)
2771 start_pos = input_state%cursor_pos + 1
2772 call yank_range(input_state, start_pos, end_pos)
2773 call delete_range(input_state, start_pos, end_pos)
2774 end do
2775
2776 case ('e')
2777 ! de - delete to end of word
2778 do i = 1, repeat_count
2779 start_pos = input_state%cursor_pos + 1
2780 call move_to_word_end(input_state)
2781 end_pos = input_state%cursor_pos + 2
2782 call yank_range(input_state, start_pos, end_pos)
2783 call delete_range(input_state, start_pos, end_pos)
2784 end do
2785 end select
2786
2787 ! Clear command buffer
2788 #ifdef USE_C_STRINGS
2789 input_state%vi_command_buffer = ''
2790 #elif defined(USE_MEMORY_POOL)
2791 input_state%vi_command_buffer_ref%data = ''
2792 #else
2793 input_state%vi_command_buffer = ''
2794 #endif
2795 input_state%vi_command_count = 0
2796 end subroutine
2797
2798 ! Motion-based yank command
2799 subroutine handle_vi_yank_with_motion(input_state, motion)
2800 type(input_state_t), intent(inout) :: input_state
2801 character, intent(in) :: motion
2802 integer :: start_pos, end_pos, saved_cursor, repeat_count, i
2803
2804 repeat_count = max(1, input_state%vi_command_count)
2805 saved_cursor = input_state%cursor_pos
2806
2807 select case (motion)
2808 case ('y')
2809 ! yy - yank entire line
2810 call state_buffer_get(input_state, input_state%vi_yank_buffer)
2811 input_state%vi_yank_buffer = input_state%vi_yank_buffer(:input_state%length)
2812 input_state%vi_yank_length = input_state%length
2813
2814 case ('w')
2815 ! yw - yank to next word
2816 start_pos = input_state%cursor_pos + 1
2817 do i = 1, repeat_count
2818 call move_to_next_word(input_state)
2819 end do
2820 end_pos = input_state%cursor_pos + 1
2821 call yank_range(input_state, start_pos, end_pos)
2822 input_state%cursor_pos = saved_cursor
2823
2824 case ('$')
2825 ! y$ - yank to end of line
2826 start_pos = input_state%cursor_pos + 1
2827 end_pos = input_state%length + 1
2828 call yank_range(input_state, start_pos, end_pos)
2829
2830 case ('0')
2831 ! y0 - yank to beginning of line
2832 start_pos = 1
2833 end_pos = input_state%cursor_pos + 1
2834 call yank_range(input_state, start_pos, end_pos)
2835
2836 case ('b')
2837 ! yb - yank to previous word
2838 end_pos = input_state%cursor_pos + 1
2839 do i = 1, repeat_count
2840 call move_to_previous_word(input_state)
2841 end do
2842 start_pos = input_state%cursor_pos + 1
2843 call yank_range(input_state, start_pos, end_pos)
2844 input_state%cursor_pos = saved_cursor
2845
2846 case ('e')
2847 ! ye - yank to end of word
2848 start_pos = input_state%cursor_pos + 1
2849 do i = 1, repeat_count
2850 call move_to_word_end(input_state)
2851 end do
2852 end_pos = input_state%cursor_pos + 2
2853 call yank_range(input_state, start_pos, end_pos)
2854 input_state%cursor_pos = saved_cursor
2855 end select
2856
2857 ! Clear command buffer
2858 #ifdef USE_C_STRINGS
2859 input_state%vi_command_buffer = ''
2860 #elif defined(USE_MEMORY_POOL)
2861 input_state%vi_command_buffer_ref%data = ''
2862 #else
2863 input_state%vi_command_buffer = ''
2864 #endif
2865 input_state%vi_command_count = 0
2866 end subroutine
2867
2868 ! Motion-based change command
2869 subroutine handle_vi_change_with_motion(input_state, motion)
2870 type(input_state_t), intent(inout) :: input_state
2871 character, intent(in) :: motion
2872 integer :: start_pos, end_pos, saved_cursor
2873
2874 if (motion == 'c') then
2875 ! cc - change entire line
2876 call state_buffer_get(input_state, input_state%vi_yank_buffer)
2877 input_state%vi_yank_buffer = input_state%vi_yank_buffer(:input_state%length)
2878 input_state%vi_yank_length = input_state%length
2879 call state_buffer_clear(input_state)
2880 input_state%length = 0
2881 input_state%cursor_pos = 0
2882 else if (motion == 'w') then
2883 ! Vi quirk: 'cw' behaves like 'ce' (change to end of word, not to next word)
2884 start_pos = input_state%cursor_pos + 1
2885 saved_cursor = input_state%cursor_pos
2886 call move_to_word_end(input_state)
2887 end_pos = input_state%cursor_pos + 2
2888 call yank_range(input_state, start_pos, end_pos)
2889 call delete_range(input_state, start_pos, end_pos)
2890 input_state%cursor_pos = saved_cursor
2891 else
2892 ! For other motions, use standard delete + insert
2893 call handle_vi_delete_with_motion(input_state, motion)
2894 end if
2895
2896 input_state%vi_mode = VI_MODE_INSERT
2897 end subroutine
2898
2899 ! Change to end of line
2900 subroutine handle_vi_change_to_eol(input_state)
2901 type(input_state_t), intent(inout) :: input_state
2902 integer :: start_pos, end_pos
2903
2904 start_pos = input_state%cursor_pos + 1
2905 end_pos = input_state%length + 1
2906 call yank_range(input_state, start_pos, end_pos)
2907 call delete_range(input_state, start_pos, end_pos)
2908 input_state%vi_mode = VI_MODE_INSERT
2909 end subroutine
2910
2911 ! Replace single character
2912 subroutine handle_vi_replace_char(input_state, replace_char)
2913 type(input_state_t), intent(inout) :: input_state
2914 character, intent(in) :: replace_char
2915 integer :: i, repeat_count
2916
2917 repeat_count = max(1, input_state%vi_command_count)
2918
2919 ! Replace up to repeat_count characters
2920 do i = 1, repeat_count
2921 if (input_state%cursor_pos + i - 1 < input_state%length) then
2922 call state_buffer_set_char(input_state, input_state%cursor_pos+i, replace_char)
2923 input_state%dirty = .true.
2924 end if
2925 end do
2926
2927 ! Clear command buffer
2928 #ifdef USE_C_STRINGS
2929 input_state%vi_command_buffer = ''
2930 #elif defined(USE_MEMORY_POOL)
2931 input_state%vi_command_buffer_ref%data = ''
2932 #else
2933 input_state%vi_command_buffer = ''
2934 #endif
2935 input_state%vi_command_count = 0
2936 end subroutine
2937
2938 ! Helper: Yank a range of characters
2939 subroutine yank_range(input_state, start_pos, end_pos)
2940 type(input_state_t), intent(inout) :: input_state
2941 integer, intent(in) :: start_pos, end_pos
2942 integer :: yank_len
2943 character(len=MAX_LINE_LEN) :: temp_buf
2944
2945 yank_len = max(0, min(end_pos - start_pos, MAX_LINE_LEN))
2946 if (yank_len > 0 .and. start_pos >= 1 .and. start_pos <= input_state%length) then
2947 ! Extract buffer to temp, then substring
2948 call state_buffer_get(input_state, temp_buf)
2949 input_state%vi_yank_buffer = temp_buf(start_pos:start_pos+yank_len-1)
2950 input_state%vi_yank_length = yank_len
2951 end if
2952 end subroutine
2953
2954 ! Helper: Delete a range of characters
2955 subroutine delete_range(input_state, start_pos, end_pos)
2956 type(input_state_t), intent(inout) :: input_state
2957 integer, intent(in) :: start_pos, end_pos
2958 integer :: delete_len, i
2959
2960 delete_len = end_pos - start_pos
2961 if (delete_len <= 0) return
2962
2963 ! Shift remaining characters left
2964 do i = start_pos, input_state%length - delete_len
2965 if (end_pos + i - start_pos <= input_state%length) then
2966 call state_buffer_set_char(input_state, i, state_buffer_get_char(input_state, end_pos+i-start_pos))
2967 end if
2968 end do
2969
2970 input_state%length = input_state%length - delete_len
2971 input_state%cursor_pos = max(0, min(start_pos - 1, input_state%length))
2972 input_state%dirty = .true.
2973 end subroutine
2974
2975 ! Move to end of current word
2976 subroutine move_to_word_end(input_state)
2977 type(input_state_t), intent(inout) :: input_state
2978 integer :: pos
2979
2980 pos = input_state%cursor_pos + 1
2981
2982 ! If on whitespace, skip to next word
2983 do while (pos <= input_state%length .and. state_buffer_get_char(input_state, pos) == ' ')
2984 pos = pos + 1
2985 end do
2986
2987 ! Find end of word (pos will be one past the last character)
2988 do while (pos <= input_state%length .and. state_buffer_get_char(input_state, pos) /= ' ')
2989 pos = pos + 1
2990 end do
2991
2992 ! cursor_pos is 0-indexed, pos is 1-indexed buffer position
2993 ! After loop, pos is at space after word, so pos-1 is last char buffer position
2994 ! To get cursor at last char: cursor_pos + 1 = pos - 1, so cursor_pos = pos - 2
2995 input_state%cursor_pos = max(0, min(pos - 2, input_state%length - 1))
2996 input_state%dirty = .true.
2997 end subroutine
2998
2999 subroutine move_to_next_word(input_state)
3000 type(input_state_t), intent(inout) :: input_state
3001 integer :: pos
3002 integer :: old_cursor_pos
3003
3004 ! Plain word-motion with active selection: clear, then proceed from
3005 ! current cursor. No snap — word motion runs through the old cursor
3006 ! anyway (#25, #26).
3007 if (input_state%selection_active .and. .not. module_extending_selection) then
3008 call collapse_selection(input_state)
3009 input_state%dirty = .true.
3010 end if
3011
3012 old_cursor_pos = input_state%cursor_pos
3013
3014 pos = input_state%cursor_pos + 1
3015
3016 ! Vi mode vs Emacs mode have different word movement behavior
3017 if (input_state%editing_mode == EDITING_MODE_VI) then
3018 ! Vi mode 'w': move to START of next word
3019 ! 1. Skip remaining non-space chars of current word
3020 do while (pos <= input_state%length .and. state_buffer_get_char(input_state, pos) /= ' ')
3021 pos = pos + 1
3022 end do
3023 ! 2. Skip spaces
3024 do while (pos <= input_state%length .and. state_buffer_get_char(input_state, pos) == ' ')
3025 pos = pos + 1
3026 end do
3027 ! 3. Now at START of next word (or end of line)
3028 input_state%cursor_pos = min(pos - 1, input_state%length)
3029 else
3030 ! Emacs mode (Alt+f): Skip spaces first, then move to END of word
3031 ! Skip any leading spaces
3032 do while (pos <= input_state%length .and. state_buffer_get_char(input_state, pos) == ' ')
3033 pos = pos + 1
3034 end do
3035 ! Skip word characters (stop at end of word)
3036 do while (pos <= input_state%length .and. state_buffer_get_char(input_state, pos) /= ' ')
3037 pos = pos + 1
3038 end do
3039 input_state%cursor_pos = min(pos - 1, input_state%length)
3040 end if
3041
3042 input_state%dirty = .true.
3043
3044 if (module_extending_selection) then
3045 call update_selection_on_shift_motion(input_state, old_cursor_pos)
3046 end if
3047 end subroutine
3048
3049 subroutine move_to_previous_word(input_state)
3050 type(input_state_t), intent(inout) :: input_state
3051 integer :: pos
3052 integer :: old_cursor_pos
3053
3054 ! Plain word-motion with active selection: clear, then proceed from
3055 ! current cursor (#25, #26).
3056 if (input_state%selection_active .and. .not. module_extending_selection) then
3057 call collapse_selection(input_state)
3058 input_state%dirty = .true.
3059 end if
3060
3061 old_cursor_pos = input_state%cursor_pos
3062
3063 if (input_state%cursor_pos <= 0) then
3064 if (module_extending_selection) then
3065 call update_selection_on_shift_motion(input_state, old_cursor_pos)
3066 end if
3067 return
3068 end if
3069
3070 pos = input_state%cursor_pos - 1
3071
3072 ! Skip spaces
3073 do while (pos > 0 .and. state_buffer_get_char(input_state, pos) == ' ')
3074 pos = pos - 1
3075 end do
3076
3077 ! Find beginning of word
3078 do while (pos > 0 .and. state_buffer_get_char(input_state, pos) /= ' ')
3079 pos = pos - 1
3080 end do
3081
3082 ! pos is now at a space (or 0 if at beginning)
3083 ! cursor_pos represents position between characters,
3084 ! so space position is correct (cursor will be after space, before first char of word)
3085 input_state%cursor_pos = pos
3086 input_state%dirty = .true.
3087
3088 if (module_extending_selection) then
3089 call update_selection_on_shift_motion(input_state, old_cursor_pos)
3090 end if
3091 end subroutine
3092
3093 subroutine delete_char_at_cursor(input_state)
3094 type(input_state_t), intent(inout) :: input_state
3095 integer :: i
3096
3097 if (input_state%cursor_pos >= input_state%length) return
3098
3099 ! Shift characters left
3100 do i = input_state%cursor_pos + 1, input_state%length - 1
3101 call state_buffer_set_char(input_state, i, state_buffer_get_char(input_state, i+1))
3102 end do
3103
3104 input_state%length = input_state%length - 1
3105 call state_buffer_set_char(input_state, input_state%length+1, ' ')
3106 input_state%dirty = .true.
3107 end subroutine
3108
3109 function get_editing_mode_name(input_state) result(mode_name)
3110 type(input_state_t), intent(in) :: input_state
3111 character(len=16) :: mode_name
3112
3113 select case (input_state%editing_mode)
3114 case (EDITING_MODE_EMACS)
3115 mode_name = 'emacs'
3116 case (EDITING_MODE_VI)
3117 if (input_state%vi_mode == VI_MODE_INSERT) then
3118 mode_name = 'vi-insert'
3119 else
3120 mode_name = 'vi-command'
3121 end if
3122 case default
3123 mode_name = 'unknown'
3124 end select
3125 end function
3126
3127 ! Basic tab completion - simplified implementation
3128 subroutine tab_complete(partial_input, completions, num_completions)
3129 character(len=*), intent(in) :: partial_input
3130 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS) ! Max 50 completions
3131 integer, intent(out) :: num_completions
3132
3133 character(len=MAX_LINE_LEN) :: last_word
3134 integer :: last_space_pos, i
3135
3136 num_completions = 0
3137
3138 ! Find the last word to complete
3139 last_space_pos = 0
3140 do i = len_trim(partial_input), 1, -1
3141 if (partial_input(i:i) == ' ') then
3142 last_space_pos = i
3143 exit
3144 end if
3145 end do
3146
3147 if (last_space_pos == 0) then
3148 last_word = trim(partial_input)
3149 else
3150 last_word = trim(partial_input(last_space_pos+1:))
3151 end if
3152
3153 ! If it's the first word, complete commands
3154 if (last_space_pos == 0) then
3155 call complete_commands(last_word, completions, num_completions)
3156 else
3157 ! Otherwise, complete files/directories
3158 call complete_files(last_word, completions, num_completions)
3159 end if
3160 end subroutine
3161
3162 ! Enhanced tab completion with programmable completion system integration
3163 subroutine enhanced_tab_complete(partial_input, completions, num_completions, shell, input_len)
3164 character(len=*), intent(in) :: partial_input
3165 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
3166 integer, intent(out) :: num_completions
3167 type(shell_state_t), intent(inout), optional :: shell
3168 integer, intent(in), optional :: input_len
3169
3170 character(len=MAX_LINE_LEN) :: last_word, prefix_part, command_name
3171 character(len=256) :: temp_completions(MAX_COMPLETIONS) ! Must match completion module's expectation
3172 integer :: last_space_pos, i, first_space_pos, temp_count, actual_len
3173 logical :: is_command, used_programmable_completion
3174 type(completion_spec_t) :: spec
3175
3176 ! Use provided length if given, otherwise use len_trim
3177 if (present(input_len)) then
3178 actual_len = input_len
3179 else
3180 actual_len = len_trim(partial_input)
3181 end if
3182
3183 num_completions = 0
3184 used_programmable_completion = .false.
3185
3186 ! Find the last word to complete (respect quotes)
3187 last_space_pos = 0
3188 block
3189 logical :: in_sq, in_dq
3190 in_sq = .false.
3191 in_dq = .false.
3192 do i = 1, actual_len
3193 if (partial_input(i:i) == "'" .and. .not. in_dq) then
3194 in_sq = .not. in_sq
3195 else if (partial_input(i:i) == '"' .and. .not. in_sq) then
3196 in_dq = .not. in_dq
3197 else if (partial_input(i:i) == ' ' .and. .not. in_sq .and. .not. in_dq) then
3198 last_space_pos = i
3199 end if
3200 end do
3201 end block
3202
3203 if (last_space_pos == 0) then
3204 last_word = trim(partial_input)
3205 prefix_part = ''
3206 is_command = .true.
3207 command_name = ''
3208 else
3209 last_word = trim(partial_input(last_space_pos+1:))
3210 prefix_part = partial_input(:last_space_pos)
3211 is_command = .false.
3212
3213 ! Extract command name (first word)
3214 first_space_pos = index(partial_input, ' ')
3215 if (first_space_pos > 0) then
3216 command_name = partial_input(:first_space_pos-1)
3217 else
3218 command_name = trim(partial_input)
3219 end if
3220 end if
3221
3222 ! Try programmable completion first (if shell state available and not completing command)
3223 if (.not. is_command .and. present(shell)) then
3224 spec = get_completion_spec(trim(command_name))
3225 if (spec%is_active) then
3226 ! Use our programmable completion system!
3227 call generate_completions(trim(command_name), trim(last_word), temp_completions, temp_count, shell)
3228 if (temp_count > 0) then
3229 ! Copy completions (convert from 256 to MAX_LINE_LEN)
3230 do i = 1, min(temp_count, MAX_LOCAL_COMPLETIONS)
3231 completions(i) = trim(temp_completions(i))
3232 end do
3233 num_completions = min(temp_count, MAX_LOCAL_COMPLETIONS)
3234 used_programmable_completion = .true.
3235 end if
3236 end if
3237 end if
3238
3239 ! Fall back to default completion if programmable completion didn't produce results
3240 if (.not. used_programmable_completion) then
3241 if (is_command) then
3242 ! Check if this looks like a directory path for cd-less navigation
3243 if (looks_like_directory_path(last_word)) then
3244 ! Complete as files/directories for path-like input
3245 if (has_glob_chars(last_word)) then
3246 call expand_glob_for_completion(last_word, completions, num_completions)
3247 else
3248 call complete_files_enhanced(last_word, completions, num_completions)
3249 end if
3250 ! Only filter to directories when pattern is empty (path ends with /)
3251 ! i.e., cd-less navigation. When there's a filename pattern (./bin/fort),
3252 ! keep all matches so executables can be completed.
3253 if (len_trim(last_word) > 0 .and. &
3254 last_word(len_trim(last_word):len_trim(last_word)) == '/') then
3255 call filter_directories_only(completions, num_completions)
3256 end if
3257 else
3258 ! Complete commands (builtins + PATH executables)
3259 call complete_commands_enhanced(last_word, completions, num_completions)
3260 end if
3261
3262 ! Add prefix back to completions
3263 do i = 1, num_completions
3264 completions(i) = trim(completions(i))
3265 end do
3266 else
3267 ! Check if completing a variable name ($VAR)
3268 if (len_trim(last_word) > 1 .and. last_word(1:1) == '$') then
3269 ! Variable completion — match against shell variables
3270 call complete_variable_names(last_word, completions, num_completions)
3271 else if (has_glob_chars(last_word)) then
3272 ! Expand glob pattern instead of regular file completion
3273 call expand_glob_for_completion(last_word, completions, num_completions)
3274 else
3275 ! Complete files and directories normally
3276 call complete_files_enhanced(last_word, completions, num_completions)
3277 end if
3278
3279 ! Filter completions based on command type
3280 ! cd, pushd, popd should only show directories
3281 if (trim(command_name) == 'cd' .or. trim(command_name) == 'pushd' .or. &
3282 trim(command_name) == 'popd') then
3283 call filter_directories_only(completions, num_completions)
3284 end if
3285
3286 ! Don't add prefix to completions - they are for display only
3287 ! The prefix will be added when constructing the completed line
3288 end if
3289 end if
3290 end subroutine
3291
3292 ! Filter completions to only keep directories (entries ending with /)
3293 subroutine filter_directories_only(completions, num_completions)
3294 character(len=MAX_LINE_LEN), intent(inout) :: completions(MAX_LOCAL_COMPLETIONS)
3295 integer, intent(inout) :: num_completions
3296
3297 character(len=MAX_LINE_LEN) :: temp_completions(MAX_LOCAL_COMPLETIONS) ! Local temp storage
3298 integer :: i, new_count, original_count
3299
3300 original_count = num_completions
3301 new_count = 0
3302 do i = 1, num_completions
3303 ! Keep only entries that end with / (directories)
3304 if (len_trim(completions(i)) > 0) then
3305 if (completions(i)(len_trim(completions(i)):len_trim(completions(i))) == '/') then
3306 new_count = new_count + 1
3307 temp_completions(new_count) = completions(i)
3308 end if
3309 end if
3310 end do
3311
3312 ! Copy filtered results back
3313 do i = 1, new_count
3314 completions(i) = temp_completions(i)
3315 end do
3316 num_completions = new_count
3317 end subroutine
3318
3319 ! Check if a string contains glob characters
3320 function has_glob_chars(str) result(has_globs)
3321 character(len=*), intent(in) :: str
3322 logical :: has_globs
3323
3324 has_globs = (index(str, '*') > 0 .or. &
3325 index(str, '?') > 0 .or. &
3326 index(str, '[') > 0)
3327 end function has_glob_chars
3328
3329 ! Check if a string looks like a directory path (for cd-less navigation)
3330 function looks_like_directory_path(str) result(looks_like_path)
3331 character(len=*), intent(in) :: str
3332 logical :: looks_like_path
3333 character(len=:), allocatable :: trimmed
3334
3335 trimmed = trim(str)
3336 if (len(trimmed) == 0) then
3337 looks_like_path = .false.
3338 return
3339 end if
3340
3341 ! Check for path indicators:
3342 ! - Starts with / (absolute path)
3343 ! - Starts with ~ (home directory)
3344 ! - Starts with . (current/parent directory)
3345 ! - Contains / anywhere (path separator)
3346 looks_like_path = (trimmed(1:1) == '/' .or. &
3347 trimmed(1:1) == '~' .or. &
3348 trimmed(1:1) == '.' .or. &
3349 index(trimmed, '/') > 0)
3350 end function looks_like_directory_path
3351
3352 ! Expand glob pattern for tab completion using real filesystem
3353 subroutine expand_glob_for_completion(pattern, completions, num_completions)
3354 character(len=*), intent(in) :: pattern
3355 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
3356 integer, intent(out) :: num_completions
3357
3358 character(len=MAX_LINE_LEN) :: dir_path, file_pattern
3359 character(len=1024) :: ls_command
3360 character(len=:), allocatable :: ls_output_alloc
3361 character(len=8192) :: ls_output ! Large buffer for ls output (8KB)
3362 character(len=MAX_LINE_LEN), allocatable :: entries(:) ! Now allocatable to avoid stack overflow
3363 integer :: num_entries, i, last_slash_pos
3364 character(len=MAX_LINE_LEN) :: full_path
3365 logical :: is_dir
3366
3367 num_completions = 0
3368
3369 ! Extract directory path and filename pattern (same logic as complete_files_enhanced)
3370 last_slash_pos = 0
3371 do i = len_trim(pattern), 1, -1
3372 if (pattern(i:i) == '/') then
3373 last_slash_pos = i
3374 exit
3375 end if
3376 end do
3377
3378 if (last_slash_pos > 0) then
3379 dir_path = pattern(:last_slash_pos-1)
3380 file_pattern = pattern(last_slash_pos+1:)
3381 if (len_trim(dir_path) == 0) dir_path = '/'
3382 else
3383 dir_path = '.'
3384 file_pattern = trim(pattern)
3385 end if
3386
3387 ! Use ls command to get directory listing (same as scan_directory)
3388 ls_command = 'ls -1a "' // trim(dir_path) // '" 2>/dev/null'
3389 ls_output_alloc = execute_and_capture(ls_command)
3390
3391 ! Copy to fixed buffer (use larger buffer to avoid truncation)
3392 if (allocated(ls_output_alloc)) then
3393 ls_output = ls_output_alloc(:min(len(ls_output), len(ls_output_alloc)))
3394 else
3395 ls_output = ''
3396 end if
3397
3398 ! Parse ls output into individual entries
3399 call parse_ls_output(ls_output, entries, num_entries)
3400
3401 ! Match entries against glob pattern
3402 do i = 1, num_entries
3403 if (num_completions >= MAX_LOCAL_COMPLETIONS) exit
3404
3405 ! Skip . and ..
3406 if (trim(entries(i)) == '.' .or. trim(entries(i)) == '..') cycle
3407
3408 ! Use pattern_matches from glob module to match against pattern
3409 if (pattern_matches(file_pattern, trim(entries(i)))) then
3410 ! Build full path
3411 if (trim(dir_path) == '.') then
3412 full_path = trim(entries(i))
3413 else
3414 full_path = trim(dir_path) // '/' // trim(entries(i))
3415 end if
3416
3417 ! Check if it's a directory and add trailing slash
3418 is_dir = is_directory(full_path)
3419 num_completions = num_completions + 1
3420 if (is_dir) then
3421 completions(num_completions) = trim(full_path) // '/'
3422 else
3423 completions(num_completions) = trim(full_path)
3424 end if
3425 end if
3426 end do
3427
3428 ! Clean up allocatable array
3429 if (allocated(entries)) deallocate(entries)
3430 end subroutine expand_glob_for_completion
3431
3432 subroutine complete_commands(prefix, completions, num_completions)
3433 character(len=*), intent(in) :: prefix
3434 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
3435 integer, intent(out) :: num_completions
3436
3437 character(len=50), parameter :: builtin_commands(19) = [ &
3438 'cd ', 'echo ', 'exit ', 'export ', &
3439 'pwd ', 'jobs ', 'fg ', 'bg ', &
3440 'history ', 'source ', 'test ', 'if ', &
3441 'kill ', 'wait ', 'trap ', 'config ', &
3442 'alias ', 'unalias ', 'help ' &
3443 ]
3444 integer :: i, prefix_len
3445
3446 num_completions = 0
3447 prefix_len = len_trim(prefix)
3448
3449 ! Complete builtin commands
3450 do i = 1, size(builtin_commands)
3451 if (prefix_len == 0 .or. &
3452 index(trim(builtin_commands(i)), prefix(1:prefix_len)) == 1) then
3453 num_completions = num_completions + 1
3454 if (num_completions <= MAX_LOCAL_COMPLETIONS) then
3455 completions(num_completions) = trim(builtin_commands(i))
3456 end if
3457 end if
3458 end do
3459
3460 ! TODO: Add external command completion from PATH
3461 end subroutine
3462
3463 subroutine complete_files(prefix, completions, num_completions)
3464 character(len=*), intent(in) :: prefix
3465 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
3466 integer, intent(out) :: num_completions
3467
3468 character(len=MAX_LINE_LEN) :: dir_path, file_pattern
3469 integer :: last_slash_pos, i
3470
3471 num_completions = 0
3472
3473 ! Extract directory path and filename pattern
3474 last_slash_pos = 0
3475 do i = len_trim(prefix), 1, -1
3476 if (prefix(i:i) == '/') then
3477 last_slash_pos = i
3478 exit
3479 end if
3480 end do
3481
3482 if (last_slash_pos > 0) then
3483 dir_path = prefix(:last_slash_pos-1)
3484 file_pattern = prefix(last_slash_pos+1:)
3485 if (len_trim(dir_path) == 0) dir_path = '/'
3486 else
3487 dir_path = '.'
3488 file_pattern = trim(prefix)
3489 end if
3490
3491 ! Don't add ./ and ../ automatically - they're not based on user input
3492 ! Let scan_directory find all matches naturally
3493
3494 ! Add some common file extensions for demonstration
3495 if (len_trim(file_pattern) == 0) then
3496 if (num_completions < 47) then
3497 completions(num_completions + 1) = 'Makefile'
3498 completions(num_completions + 2) = 'README'
3499 completions(num_completions + 3) = 'LICENSE'
3500 num_completions = num_completions + 3
3501 end if
3502 end if
3503 end subroutine
3504
3505 ! Enhanced command completion with PATH executable scanning
3506 subroutine complete_variable_names(prefix_with_dollar, completions, num_completions)
3507 character(len=*), intent(in) :: prefix_with_dollar
3508 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
3509 integer, intent(out) :: num_completions
3510
3511 character(len=256) :: var_prefix
3512 character(len=4096) :: env_output
3513 character(len=:), allocatable :: env_alloc
3514 character(len=MAX_LINE_LEN), allocatable :: entries(:)
3515 integer :: num_entries, i, score
3516
3517 num_completions = 0
3518 ! Strip the $ from the prefix
3519 var_prefix = prefix_with_dollar(2:)
3520
3521 ! Get variable names from environment (exported + inherited)
3522 env_alloc = execute_and_capture('env 2>/dev/null | cut -d= -f1 | tr ' // "'" // char(92) // 'n' // "' ' '")
3523 env_output = env_alloc(:min(len(env_output), len(env_alloc)))
3524 if (allocated(env_alloc)) deallocate(env_alloc)
3525
3526 ! Parse into entries
3527 call parse_ls_output(env_output, entries, num_entries)
3528
3529 ! Match against prefix
3530 do i = 1, num_entries
3531 if (num_completions >= MAX_LOCAL_COMPLETIONS) exit
3532 if (len_trim(entries(i)) == 0) cycle
3533
3534 score = fuzzy_match_score(trim(var_prefix), trim(entries(i)))
3535 if (score >= 0) then
3536 num_completions = num_completions + 1
3537 completions(num_completions) = '$' // trim(entries(i))
3538 end if
3539 end do
3540 end subroutine
3541
3542 subroutine complete_commands_enhanced(prefix, completions, num_completions)
3543 character(len=*), intent(in) :: prefix
3544 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
3545 integer, intent(out) :: num_completions
3546
3547 character(len=50), parameter :: builtin_commands(20) = [ &
3548 'cd ', 'echo ', 'exit ', 'export ', &
3549 'pwd ', 'jobs ', 'fg ', 'bg ', &
3550 'history ', 'source ', 'test ', 'if ', &
3551 'kill ', 'wait ', 'trap ', 'config ', &
3552 'alias ', 'unalias ', 'help ', 'rawtest ' &
3553 ]
3554 ! Use allocatable array to avoid static storage
3555 type(scored_completion_t), allocatable :: scored(:)
3556 integer :: i, num_scored, score
3557
3558 ! Allocate scored array
3559 allocate(scored(100)) ! This should be enough for builtins
3560 num_completions = 0
3561 num_scored = 0
3562
3563 ! Score builtin commands using fuzzy matching
3564 do i = 1, size(builtin_commands)
3565 score = fuzzy_match_score(prefix, trim(builtin_commands(i)))
3566 if (score >= 0) then ! Negative score = no match
3567 num_scored = num_scored + 1
3568 if (num_scored <= 100) then
3569 scored(num_scored)%text = trim(builtin_commands(i))
3570 scored(num_scored)%score = score
3571 end if
3572 end if
3573 end do
3574
3575 ! Add common system commands
3576 call add_system_commands_fuzzy(prefix, scored, num_scored)
3577
3578 ! Sort by score
3579 if (num_scored > 0) then
3580 call sort_completions_by_score(scored, num_scored)
3581 end if
3582
3583 ! Copy top matches to output (limit to 50)
3584 num_completions = min(num_scored, 50)
3585 do i = 1, num_completions
3586 completions(i) = scored(i)%text
3587 end do
3588
3589 ! Clean up allocatable array
3590 if (allocated(scored)) deallocate(scored)
3591 end subroutine
3592
3593 subroutine add_system_commands(prefix, completions, num_completions)
3594 character(len=*), intent(in) :: prefix
3595 character(len=MAX_LINE_LEN), intent(inout) :: completions(MAX_LOCAL_COMPLETIONS)
3596 integer, intent(inout) :: num_completions
3597
3598 character(len=50), parameter :: common_commands(15) = [ &
3599 'ls ', 'cat ', 'grep ', 'find ', &
3600 'sort ', 'head ', 'tail ', 'wc ', &
3601 'cp ', 'mv ', 'rm ', 'mkdir ', &
3602 'rmdir ', 'chmod ', 'which ' &
3603 ]
3604 integer :: i, prefix_len
3605
3606 prefix_len = len_trim(prefix)
3607
3608 do i = 1, size(common_commands)
3609 if (num_completions >= MAX_LOCAL_COMPLETIONS) exit
3610 if (prefix_len == 0 .or. &
3611 index(trim(common_commands(i)), prefix(1:prefix_len)) == 1) then
3612 num_completions = num_completions + 1
3613 completions(num_completions) = trim(common_commands(i))
3614 end if
3615 end do
3616 end subroutine
3617
3618 ! Fuzzy version of add_system_commands
3619 subroutine add_system_commands_fuzzy(prefix, scored, num_scored)
3620 character(len=*), intent(in) :: prefix
3621 type(scored_completion_t), intent(inout) :: scored(:)
3622 integer, intent(inout) :: num_scored
3623
3624 character(len=50), parameter :: common_commands(15) = [ &
3625 'ls ', 'cat ', 'grep ', 'find ', &
3626 'sort ', 'head ', 'tail ', 'wc ', &
3627 'cp ', 'mv ', 'rm ', 'mkdir ', &
3628 'rmdir ', 'chmod ', 'which ' &
3629 ]
3630 integer :: i, score
3631
3632 do i = 1, size(common_commands)
3633 if (num_scored >= size(scored)) exit
3634 score = fuzzy_match_score(prefix, trim(common_commands(i)))
3635 if (score >= 0) then ! Negative score = no match
3636 num_scored = num_scored + 1
3637 scored(num_scored)%text = trim(common_commands(i))
3638 scored(num_scored)%score = score
3639 end if
3640 end do
3641 end subroutine
3642
3643 ! Enhanced file completion with real filesystem access
3644 subroutine complete_files_enhanced(prefix, completions, num_completions)
3645 character(len=*), intent(in) :: prefix
3646 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
3647 integer, intent(out) :: num_completions
3648
3649 character(len=MAX_LINE_LEN) :: dir_path, file_pattern, clean_prefix
3650 character(len=:), allocatable :: debug_mode
3651 integer :: last_slash_pos, i, cp_len
3652 logical :: debug_enabled
3653
3654 ! Check if debug mode is enabled
3655 debug_mode = get_environment_var('FORTSH_DEBUG_COMPLETION')
3656 debug_enabled = (allocated(debug_mode) .and. trim(debug_mode) == '1')
3657
3658 num_completions = 0
3659
3660 ! Strip leading/trailing quotes from prefix for filesystem access
3661 clean_prefix = trim(prefix)
3662 cp_len = len_trim(clean_prefix)
3663 if (cp_len >= 2) then
3664 if ((clean_prefix(1:1) == "'" .and. clean_prefix(cp_len:cp_len) == "'") .or. &
3665 (clean_prefix(1:1) == '"' .and. clean_prefix(cp_len:cp_len) == '"')) then
3666 clean_prefix = clean_prefix(2:cp_len-1)
3667 else if (clean_prefix(1:1) == "'" .or. clean_prefix(1:1) == '"') then
3668 ! Unclosed quote (user still typing) — strip leading quote only
3669 clean_prefix = clean_prefix(2:cp_len)
3670 end if
3671 end if
3672
3673 ! Extract directory path and filename pattern
3674 last_slash_pos = 0
3675 last_slash_pos = 0
3676 do i = len_trim(clean_prefix), 1, -1
3677 if (clean_prefix(i:i) == '/') then
3678 last_slash_pos = i
3679 exit
3680 end if
3681 end do
3682
3683 if (last_slash_pos > 0) then
3684 dir_path = clean_prefix(:last_slash_pos-1)
3685 file_pattern = clean_prefix(last_slash_pos+1:)
3686 if (len_trim(dir_path) == 0) dir_path = '/'
3687 else
3688 dir_path = '.'
3689 file_pattern = trim(clean_prefix)
3690 end if
3691
3692 ! Preserve explicit "./" prefix: when user typed "./something", dir_path
3693 ! is "." but completions should include "./" to match what was typed.
3694 ! Pass "./" as dir_path so scan_directory builds paths with "./" prefix.
3695 if (len_trim(clean_prefix) >= 2 .and. clean_prefix(1:2) == './') then
3696 if (trim(dir_path) == '.') dir_path = './'
3697 end if
3698
3699 ! scan_directory handles all matches including dotfiles when pattern is empty
3700 call scan_directory(dir_path, file_pattern, completions, num_completions)
3701 end subroutine
3702
3703 ! Scan directory for matching files and directories (with fuzzy matching)
3704 subroutine scan_directory(dir_path, pattern, completions, num_completions)
3705 character(len=*), intent(in) :: dir_path, pattern
3706 character(len=MAX_LINE_LEN), intent(inout) :: completions(MAX_LOCAL_COMPLETIONS)
3707 integer, intent(inout) :: num_completions
3708
3709 character(len=1024) :: ls_command, expanded_dir ! Large enough for command
3710 character(len=:), allocatable :: ls_output_alloc ! From execute_and_capture
3711 character(len=8192) :: ls_output ! 8KB buffer for large directories (with grep filter)
3712 character(len=MAX_LINE_LEN), allocatable :: entries(:) ! Now allocatable to avoid stack overflow
3713 character(len=MAX_LINE_LEN) :: full_path
3714 character(len=:), allocatable :: home_dir, debug_mode
3715 ! Use allocatable array to avoid static storage
3716 type(scored_completion_t), allocatable :: scored(:)
3717 integer :: num_entries, i, pattern_len, num_scored, score, j
3718 logical :: is_dir, debug_enabled
3719
3720 ! Check if debug mode is enabled
3721 debug_mode = get_environment_var('FORTSH_DEBUG_COMPLETION')
3722 debug_enabled = (allocated(debug_mode) .and. trim(debug_mode) == '1')
3723
3724 ! Allocate scored array
3725 allocate(scored(MAX_SCORED_ITEMS))
3726
3727 pattern_len = len_trim(pattern)
3728
3729 ! Expand tilde if present (shell doesn't expand ~ inside quotes)
3730 expanded_dir = dir_path
3731 if (len_trim(dir_path) > 0 .and. dir_path(1:1) == '~') then
3732 home_dir = get_environment_var('HOME')
3733 if (allocated(home_dir) .and. len(home_dir) > 0) then
3734 if (len_trim(dir_path) == 1) then
3735 ! Just ~
3736 expanded_dir = home_dir
3737 else if (dir_path(2:2) == '/') then
3738 ! ~/something
3739 expanded_dir = trim(home_dir) // dir_path(2:)
3740 else
3741 ! ~user (not supported for now, just use as-is)
3742 expanded_dir = dir_path
3743 end if
3744 end if
3745 end if
3746
3747 ! Use ls command with -F flag to mark directories with / (avoids calling test -d for each file)
3748 ! Trailing / on directory forces ls to list contents (not the symlink itself on macOS)
3749 ! When pattern is non-empty, filter with grep to avoid buffer overflow on large directories
3750 ! Use tr to convert newlines to spaces for easier parsing
3751 ! Filter with grep when pattern exists to limit output size
3752 ! No tr needed — execute_and_capture_tabs converts newlines to tabs
3753 if (pattern_len > 0) then
3754 ls_command = 'ls -1aF "' // trim(expanded_dir) // '/" 2>/dev/null | grep -i "^' // &
3755 trim(pattern) // '"'
3756 else
3757 ls_command = 'ls -1aF "' // trim(expanded_dir) // '/" 2>/dev/null'
3758 end if
3759
3760 ! Debug output
3761 if (debug_enabled) then
3762 end if
3763
3764 ! Get output from command — use tab-delimited capture to preserve spaces in filenames
3765 ls_output_alloc = execute_and_capture_tabs(ls_command)
3766
3767 ! Copy to fixed buffer (avoids flang-new issues with allocatable strings)
3768 ls_output = ls_output_alloc(:min(len(ls_output), len(ls_output_alloc)))
3769
3770 ! Clean up allocatable
3771 if (allocated(ls_output_alloc)) deallocate(ls_output_alloc)
3772
3773 ! Parse ls output — tab-delimited to preserve spaces in filenames
3774 call parse_ls_output(ls_output, entries, num_entries, use_tab_delim=.true.)
3775
3776 ! Debug output
3777 if (debug_enabled) then
3778 end if
3779
3780 ! Score entries using fuzzy matching
3781 num_scored = 0
3782 do i = 1, num_entries
3783 if (num_scored >= MAX_SCORED_ITEMS) exit
3784
3785 ! Skip . and .. unless explicitly requested (ls -F adds / to these too)
3786 if (trim(entries(i)) == './' .or. trim(entries(i)) == '../' .or. &
3787 trim(entries(i)) == '.' .or. trim(entries(i)) == '..') then
3788 if (pattern_len == 0 .or. (pattern_len > 0 .and. pattern(1:1) /= '.')) then
3789 cycle
3790 end if
3791 end if
3792
3793 ! Check if entry is a directory (ls -F adds / to directories)
3794 is_dir = .false.
3795 if (len_trim(entries(i)) > 0) then
3796 if (entries(i)(len_trim(entries(i)):len_trim(entries(i))) == '/') then
3797 is_dir = .true.
3798 ! Remove the trailing / for matching
3799 full_path = entries(i)(:len_trim(entries(i))-1)
3800 else
3801 ! Remove executable markers (*) and other ls -F markers (@, =, |, %)
3802 if (index('*@=|%', entries(i)(len_trim(entries(i)):len_trim(entries(i)))) > 0) then
3803 full_path = entries(i)(:len_trim(entries(i))-1)
3804 else
3805 full_path = trim(entries(i))
3806 end if
3807 end if
3808 else
3809 full_path = trim(entries(i))
3810 end if
3811
3812 ! Calculate fuzzy match score (without the ls -F marker)
3813 score = fuzzy_match_score(pattern, trim(full_path))
3814 if (score >= 0) then ! Negative score = no match
3815 ! Build full path for display (use original dir_path to preserve ~ in display)
3816 if (trim(dir_path) == '.') then
3817 full_path = trim(full_path)
3818 else if (trim(dir_path) == './') then
3819 ! Explicit ./ prefix — preserve it without adding extra slash
3820 full_path = './' // trim(full_path)
3821 else if (trim(dir_path) == '/') then
3822 ! Root directory - don't add extra slash
3823 full_path = '/' // trim(full_path)
3824 else
3825 full_path = trim(dir_path) // '/' // trim(full_path)
3826 end if
3827
3828 num_scored = num_scored + 1
3829 if (is_dir) then
3830 scored(num_scored)%text = trim(full_path) // '/'
3831 else
3832 scored(num_scored)%text = trim(full_path)
3833 end if
3834 scored(num_scored)%score = score
3835
3836 ! Bonus for directories (make them appear first in same score bracket)
3837 if (is_dir) then
3838 scored(num_scored)%score = scored(num_scored)%score + 5
3839 end if
3840 end if
3841 end do
3842
3843 ! Sort by score
3844 if (num_scored > 0) then
3845 call sort_completions_by_score(scored, num_scored)
3846 end if
3847
3848 ! Copy to output (add to existing completions, limit to MAX_LOCAL_COMPLETIONS)
3849 do j = 1, num_scored
3850 if (num_completions >= MAX_LOCAL_COMPLETIONS) exit
3851 num_completions = num_completions + 1
3852 completions(num_completions) = scored(j)%text
3853 end do
3854
3855 ! Debug output
3856 if (debug_enabled) then
3857 end if
3858
3859 ! Clean up allocatable arrays
3860 if (allocated(scored)) deallocate(scored)
3861 if (allocated(entries)) deallocate(entries)
3862 end subroutine
3863
3864 ! Check if a path is a directory
3865 function is_directory(path) result(is_dir)
3866 character(len=*), intent(in) :: path
3867 logical :: is_dir
3868 character(len=MAX_LINE_LEN) :: test_command, output
3869
3870 ! Use test command to check if path is a directory
3871 test_command = 'test -d "' // trim(path) // '" && echo "yes" || echo "no"'
3872 output = execute_and_capture(test_command)
3873 is_dir = (index(output, 'yes') > 0)
3874 end function
3875
3876 ! Parse ls output into individual entries
3877 subroutine parse_ls_output(output, entries, num_entries, use_tab_delim)
3878 character(len=*), intent(in) :: output
3879 character(len=MAX_LINE_LEN), allocatable, intent(out) :: entries(:)
3880 integer, intent(out) :: num_entries
3881 logical, intent(in), optional :: use_tab_delim
3882
3883 integer :: pos, start, output_len, count_pass
3884 logical :: tab_mode
3885 character :: delim
3886
3887 tab_mode = .false.
3888 if (present(use_tab_delim)) tab_mode = use_tab_delim
3889 delim = merge(char(9), ' ', tab_mode)
3890
3891 output_len = len_trim(output)
3892
3893 ! First pass: count entries
3894 num_entries = 0
3895 pos = 1
3896 do while (pos <= output_len)
3897 ! Skip delimiter characters
3898 do while (pos <= output_len .and. (output(pos:pos) == delim .or. &
3899 (.not. tab_mode .and. output(pos:pos) == char(9))))
3900 pos = pos + 1
3901 end do
3902
3903 if (pos > output_len) exit
3904
3905 start = pos
3906
3907 ! Find end of entry
3908 do while (pos <= output_len .and. output(pos:pos) /= delim)
3909 pos = pos + 1
3910 end do
3911
3912 if (pos > start) then
3913 num_entries = num_entries + 1
3914 end if
3915
3916 pos = pos + 1
3917 end do
3918
3919 ! Allocate array based on actual count
3920 if (num_entries > 0) then
3921 allocate(entries(num_entries))
3922
3923 ! Second pass: fill entries
3924 count_pass = 0
3925 pos = 1
3926 do while (pos <= output_len .and. count_pass < num_entries)
3927 ! Skip delimiter characters
3928 do while (pos <= output_len .and. (output(pos:pos) == delim .or. &
3929 (.not. tab_mode .and. output(pos:pos) == char(9))))
3930 pos = pos + 1
3931 end do
3932
3933 if (pos > output_len) exit
3934
3935 start = pos
3936
3937 ! Find end of entry
3938 do while (pos <= output_len .and. output(pos:pos) /= delim)
3939 pos = pos + 1
3940 end do
3941
3942 if (pos > start) then
3943 count_pass = count_pass + 1
3944 entries(count_pass) = output(start:pos-1)
3945 end if
3946
3947 pos = pos + 1
3948 end do
3949 else
3950 ! No entries - allocate empty array
3951 allocate(entries(0))
3952 end if
3953 end subroutine
3954
3955 subroutine show_completions(completions, num_completions)
3956 character(len=MAX_LINE_LEN), intent(in) :: completions(MAX_LOCAL_COMPLETIONS)
3957 integer, intent(in) :: num_completions
3958 integer :: i, j, max_len, col_width, num_cols, items_in_row
3959 integer :: term_width, status
3960 character(len=10) :: cols_env
3961
3962 if (num_completions > 1) then
3963 write(output_unit, '(a)') ''
3964
3965 ! Find maximum length of completions
3966 max_len = 0
3967 do i = 1, num_completions
3968 max_len = max(max_len, len_trim(completions(i)))
3969 end do
3970
3971 ! Column width = max length + 2 spaces padding
3972 col_width = max_len + 2
3973
3974 ! Get terminal width (default to 80 if not available)
3975 call get_environment_variable("COLUMNS", cols_env, status=status)
3976 if (status == 0 .and. len_trim(cols_env) > 0) then
3977 read(cols_env, *, iostat=status) term_width
3978 if (status /= 0) term_width = 80
3979 else
3980 term_width = 80
3981 end if
3982
3983 ! Calculate number of columns that fit
3984 num_cols = max(1, term_width / col_width)
3985
3986 ! Print items in rows, aligned to columns
3987 do i = 1, num_completions
3988 ! Print item padded to column width
3989 write(output_unit, '(a)', advance='no') trim(completions(i))
3990
3991 ! Calculate position in current row
3992 items_in_row = mod(i - 1, num_cols) + 1
3993
3994 ! Add padding unless it's the last item in the row or the last item overall
3995 if (items_in_row < num_cols .and. i < num_completions) then
3996 ! Pad to column width
3997 do j = len_trim(completions(i)) + 1, col_width
3998 write(output_unit, '(a)', advance='no') ' '
3999 end do
4000 else
4001 ! End of row - print newline
4002 write(output_unit, '(a)') ''
4003 end if
4004 end do
4005
4006 ! Ensure we end with a blank line if last row wasn't complete
4007 if (mod(num_completions, num_cols) /= 0) then
4008 write(output_unit, '(a)') ''
4009 end if
4010 end if
4011 end subroutine
4012
4013 ! Find common prefix among completions
4014 function get_common_prefix(completions, num_completions) result(prefix)
4015 character(len=MAX_LINE_LEN), intent(in) :: completions(MAX_LOCAL_COMPLETIONS)
4016 integer, intent(in) :: num_completions
4017 character(len=MAX_LINE_LEN) :: prefix
4018
4019 integer :: i, j, min_len, common_len
4020 logical :: matches
4021
4022 prefix = ''
4023 if (num_completions == 0) return
4024
4025 if (num_completions == 1) then
4026 prefix = trim(completions(1))
4027 return
4028 end if
4029
4030 ! Find minimum length
4031 min_len = len_trim(completions(1))
4032 do i = 2, num_completions
4033 min_len = min(min_len, len_trim(completions(i)))
4034 end do
4035
4036 ! Find common prefix length
4037 common_len = 0
4038 do j = 1, min_len
4039 matches = .true.
4040 do i = 2, num_completions
4041 if (completions(1)(j:j) /= completions(i)(j:j)) then
4042 matches = .false.
4043 exit
4044 end if
4045 end do
4046
4047 if (matches) then
4048 common_len = j
4049 else
4050 exit
4051 end if
4052 end do
4053
4054 if (common_len > 0) then
4055 prefix = completions(1)(:common_len)
4056 end if
4057 end function
4058
4059 ! Enhanced tab completion that handles partial completion
4060 subroutine smart_tab_complete(partial_input, completions, num_completions, completed_line, completed, input_len)
4061 character(len=*), intent(in) :: partial_input
4062 character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
4063 integer, intent(out) :: num_completions
4064 character(len=*), intent(out) :: completed_line
4065 logical, intent(out) :: completed
4066 integer, intent(in), optional :: input_len
4067
4068 character(len=MAX_LINE_LEN) :: common_prefix, prefix_part, last_word
4069 character(len=4096) :: expanded_matches
4070 integer :: last_space_pos, i, pos, j, actual_len
4071 logical :: is_glob_pattern
4072
4073 ! Use provided length if given, otherwise use len_trim
4074 if (present(input_len)) then
4075 actual_len = input_len
4076 else
4077 actual_len = len_trim(partial_input)
4078 end if
4079
4080 completed = .false.
4081 completed_line = partial_input
4082
4083 ! Find the prefix (command and any earlier arguments)
4084 ! Respect quotes: spaces inside quotes don't count as word boundaries
4085 last_space_pos = 0
4086 block
4087 logical :: in_single_quote, in_double_quote
4088 in_single_quote = .false.
4089 in_double_quote = .false.
4090 do i = 1, actual_len
4091 if (partial_input(i:i) == "'" .and. .not. in_double_quote) then
4092 in_single_quote = .not. in_single_quote
4093 else if (partial_input(i:i) == '"' .and. .not. in_single_quote) then
4094 in_double_quote = .not. in_double_quote
4095 else if (partial_input(i:i) == ' ' .and. .not. in_single_quote .and. .not. in_double_quote) then
4096 last_space_pos = i
4097 end if
4098 end do
4099 end block
4100
4101 if (last_space_pos > 0) then
4102 prefix_part = partial_input(:last_space_pos)
4103 last_word = partial_input(last_space_pos+1:)
4104 else
4105 prefix_part = ''
4106 last_word = trim(partial_input)
4107 end if
4108
4109 ! Check if we're completing a glob pattern
4110 is_glob_pattern = has_glob_chars(last_word)
4111
4112 ! Pass the actual length to preserve trailing spaces
4113 call enhanced_tab_complete(partial_input, completions, num_completions, input_len=actual_len)
4114
4115 if (num_completions == 0) then
4116 ! No completions found
4117 return
4118 else if (num_completions == 1) then
4119 ! Single completion - reconstruct with proper quoting
4120 block
4121 character(len=1) :: quote_char
4122 integer :: lw_len
4123
4124 lw_len = len_trim(last_word)
4125 quote_char = ' '
4126
4127 ! Check if last_word starts with a quote
4128 if (lw_len > 0 .and. (last_word(1:1) == "'" .or. last_word(1:1) == '"')) then
4129 quote_char = last_word(1:1)
4130 end if
4131
4132 ! Completions already include the full path from scan_directory.
4133 ! Just wrap in quotes if the original word was quoted.
4134 if (quote_char /= ' ') then
4135 if (last_space_pos > 0) then
4136 completed_line = prefix_part(:last_space_pos) // quote_char // &
4137 trim(completions(1)) // quote_char
4138 else
4139 completed_line = quote_char // trim(completions(1)) // quote_char
4140 end if
4141 else if (last_space_pos > 0) then
4142 completed_line = prefix_part(:last_space_pos) // trim(completions(1))
4143 else
4144 completed_line = trim(completions(1))
4145 end if
4146 end block
4147 completed = .true.
4148 else
4149 ! Multiple completions
4150 if (is_glob_pattern) then
4151 ! For glob patterns: expand all matches into command line (like bash)
4152 ! Build space-separated list of all matches
4153 expanded_matches = ''
4154 pos = 1
4155
4156 do j = 1, num_completions
4157 if (j > 1) then
4158 ! Add space separator
4159 expanded_matches(pos:pos) = ' '
4160 pos = pos + 1
4161 end if
4162
4163 ! Add this match
4164 expanded_matches(pos:pos+len_trim(completions(j))-1) = trim(completions(j))
4165 pos = pos + len_trim(completions(j))
4166 end do
4167
4168 ! Replace glob pattern with expanded matches
4169 if (last_space_pos > 0) then
4170 completed_line = prefix_part(:last_space_pos) // expanded_matches(:pos-1)
4171 else
4172 completed_line = expanded_matches(:pos-1)
4173 end if
4174 completed = .true.
4175 else
4176 ! For regular completion: try common prefix
4177 common_prefix = get_common_prefix(completions, num_completions)
4178
4179 if (len_trim(common_prefix) > len_trim(last_word)) then
4180 ! We have a common prefix that extends what user typed - use it
4181 if (last_space_pos > 0) then
4182 completed_line = prefix_part(:last_space_pos) // trim(common_prefix)
4183 else
4184 completed_line = trim(common_prefix)
4185 end if
4186 completed = .true.
4187 else
4188 ! No useful common prefix - we'll show the completions list instead
4189 ! Keep completed = .false. but don't treat as "no completions"
4190 ! The caller will see num_completions > 0 and should show them
4191 completed = .false.
4192 end if
4193 end if
4194 end if
4195 end subroutine
4196
4197 ! Wrapper to work around potential flang-new bug with repeated function calls
4198 subroutine insert_char_wrapper(input_state, ch)
4199 type(input_state_t), intent(inout) :: input_state
4200 character, intent(in) :: ch
4201 call insert_char_impl(input_state, ch)
4202 end subroutine
4203
4204 ! Insert a complete multi-byte UTF-8 character
4205 ! Handles cursor tracking correctly for wide characters
4206 subroutine insert_utf8_char(input_state, utf8_bytes, num_bytes, visual_width)
4207 use iso_fortran_env, only: output_unit, error_unit
4208 type(input_state_t), intent(inout) :: input_state
4209 character(len=*), intent(in) :: utf8_bytes
4210 integer, intent(in) :: num_bytes, visual_width
4211 integer :: i, j, term_cols
4212 logical :: debug_utf8
4213 integer :: debug_stat
4214
4215 ! Check if UTF-8 debug mode is enabled
4216 call get_environment_variable('FORTSH_DEBUG_UTF8', status=debug_stat)
4217 debug_utf8 = (debug_stat == 0)
4218
4219 ! Check if we have room
4220 if (input_state%length + num_bytes > MAX_LINE_LEN - 1) return
4221
4222 ! Exit history mode if needed
4223 if (input_state%in_history) then
4224 input_state%in_history = .false.
4225 input_state%history_pos = 0
4226 end if
4227
4228 ! Reset completion state
4229 input_state%completions_shown = .false.
4230
4231 ! Insert all bytes at cursor position
4232 if (input_state%cursor_pos >= input_state%length) then
4233 ! Append at end
4234
4235 ! Debug: show state before insertion
4236 if (debug_utf8) then
4237 write(error_unit, '(a,i0,a,i0,a,i0)') '[INSERT_UTF8] BEFORE: cursor_pos=', &
4238 input_state%cursor_pos, ' length=', input_state%length, ' screen_col=', module_cursor_screen_col
4239 end if
4240
4241 do i = 1, num_bytes
4242 call state_buffer_set_char(input_state, input_state%length + i, utf8_bytes(i:i))
4243 ! Output byte to terminal
4244 write(output_unit, '(a)', advance='no') utf8_bytes(i:i)
4245 end do
4246 flush(output_unit)
4247
4248 input_state%length = input_state%length + num_bytes
4249 input_state%cursor_pos = input_state%cursor_pos + num_bytes
4250
4251 ! Update screen cursor position by VISUAL width, not byte count!
4252 call get_terminal_size_from_env(term_cols)
4253 module_cursor_screen_col = module_cursor_screen_col + visual_width
4254
4255 ! Debug: show state after insertion
4256 if (debug_utf8) then
4257 write(error_unit, '(a,i0,a,i0,a,i0,a,i0)') '[INSERT_UTF8] AFTER: cursor_pos=', &
4258 input_state%cursor_pos, ' length=', input_state%length, ' screen_col=', module_cursor_screen_col, &
4259 ' visual_width=', visual_width
4260 end if
4261
4262 ! Handle line wrapping
4263 if (module_cursor_screen_col >= term_cols) then
4264 write(output_unit, '(a)', advance='no') char(13) // char(10)
4265 flush(output_unit)
4266 module_cursor_screen_col = 0
4267 module_cursor_screen_row = module_cursor_screen_row + 1
4268 else
4269 input_state%dirty = .true.
4270 end if
4271 else
4272 ! Insert in middle - shift characters right
4273 do j = input_state%length, input_state%cursor_pos + 1, -1
4274 call state_buffer_set_char(input_state, j + num_bytes, state_buffer_get_char(input_state, j))
4275 end do
4276
4277 ! Insert new bytes
4278 do i = 1, num_bytes
4279 call state_buffer_set_char(input_state, input_state%cursor_pos + i, utf8_bytes(i:i))
4280 end do
4281
4282 input_state%length = input_state%length + num_bytes
4283 input_state%cursor_pos = input_state%cursor_pos + num_bytes
4284 input_state%dirty = .true.
4285 end if
4286
4287 ! Update autosuggestion
4288 call update_autosuggestion(input_state)
4289 end subroutine insert_utf8_char
4290
4291 ! Helper functions for enhanced readline
4292 subroutine insert_char_impl(input_state, ch)
4293 type(input_state_t), intent(inout) :: input_state
4294 character, intent(in) :: ch
4295 integer :: term_cols
4296 character(len=:), allocatable :: temp_buffer ! Heap allocation to avoid stack overflow
4297
4298 ! Allocate temp buffer on heap
4299 allocate(character(len=MAX_LINE_LEN) :: temp_buffer)
4300
4301 ! Check if we have room for one more character
4302 ! CRITICAL: Must be >= MAX_LINE_LEN - 1 to prevent writing to position MAX_LINE_LEN + 1
4303 ! during middle insertions which shift characters right
4304 if (input_state%length >= MAX_LINE_LEN - 1) then
4305 if (allocated(temp_buffer)) deallocate(temp_buffer)
4306 return
4307 end if
4308
4309 ! If we're browsing history, exit history mode when typing
4310 if (input_state%in_history) then
4311 input_state%in_history = .false.
4312 input_state%history_pos = 0
4313 end if
4314
4315 ! Reset completion state when buffer changes
4316 input_state%completions_shown = .false.
4317
4318 ! Check for abbreviation expansion BEFORE inserting space
4319 if (ch == ' ') then
4320 call try_expand_abbreviation_at_cursor(input_state)
4321 end if
4322
4323 ! If cursor is at end, simple append
4324 if (input_state%cursor_pos >= input_state%length) then
4325 input_state%length = input_state%length + 1
4326 call state_buffer_set_char(input_state, input_state%length, ch)
4327 input_state%cursor_pos = input_state%length
4328
4329 ! Output character directly to screen (avoid full redraw)
4330 write(output_unit, '(a)', advance='no') ch
4331 flush(output_unit)
4332
4333 ! Update screen cursor position tracking
4334 call get_terminal_size_from_env(term_cols)
4335 module_cursor_screen_col = module_cursor_screen_col + 1
4336
4337 ! Handle line wrapping - if we just filled the last column, wrap to next line
4338 if (module_cursor_screen_col >= term_cols) then
4339 ! Explicitly move cursor to next line (terminal won't auto-wrap cursor until next char)
4340 write(output_unit, '(a)', advance='no') char(13) // char(10) ! CR+LF
4341 flush(output_unit)
4342 module_cursor_screen_col = 0
4343 module_cursor_screen_row = module_cursor_screen_row + 1
4344 ! Don't trigger redraw - character already on screen, cursor already positioned correctly
4345 ! Redraw would move cursor back up to row 0, causing snap-back
4346 else
4347 ! Only trigger syntax highlighting when NOT wrapping
4348 ! This gives immediate feedback (e.g., "exit" turning green)
4349 input_state%dirty = .true.
4350 end if
4351 else
4352 ! Insert in middle - use temp to avoid substring overlap issues
4353 ! Initialize temp with current buffer
4354 call state_buffer_get(input_state, temp_buffer)
4355
4356 ! Shift part after cursor one position right in temp
4357 if (input_state%cursor_pos < input_state%length) then
4358 temp_buffer(input_state%cursor_pos+2:input_state%length+1) = &
4359 temp_buffer(input_state%cursor_pos+1:input_state%length)
4360 end if
4361
4362 ! Insert new character at cursor+1
4363 temp_buffer(input_state%cursor_pos+1:input_state%cursor_pos+1) = ch
4364
4365 ! Copy result back to buffer
4366 call state_buffer_set(input_state, temp_buffer)
4367 input_state%length = input_state%length + 1
4368 input_state%cursor_pos = input_state%cursor_pos + 1
4369
4370 ! Middle insertion requires full redraw
4371 input_state%dirty = .true.
4372 end if
4373
4374 ! Deallocate heap-allocated temp buffer
4375 if (allocated(temp_buffer)) deallocate(temp_buffer)
4376
4377 ! Update autosuggestion after inserting character
4378 call update_autosuggestion(input_state)
4379
4380 ! If autosuggestion was generated, we need to redraw to show it
4381 if (input_state%cursor_pos == input_state%length .and. input_state%suggestion_length > 0) then
4382 input_state%dirty = .true.
4383 end if
4384 end subroutine
4385
4386 ! Determine how many bytes to delete for a UTF-8 character
4387 ! Returns the number of bytes to delete (1-4)
4388 ! Looks at the byte immediately before cursor and walks backward to find the start
4389 function utf8_char_bytes_before_cursor(input_state) result(num_bytes)
4390 use iso_fortran_env, only: error_unit
4391 type(input_state_t), intent(in) :: input_state
4392 integer :: num_bytes
4393 integer :: pos, byte_val, start_pos
4394 character :: ch
4395 logical :: debug_utf8
4396
4397 ! Check if debug mode is enabled
4398 call get_environment_variable('FORTSH_DEBUG_UTF8', status=byte_val)
4399 debug_utf8 = (byte_val == 0)
4400
4401 if (input_state%cursor_pos <= 0) then
4402 num_bytes = 0
4403 return
4404 end if
4405
4406 start_pos = input_state%cursor_pos
4407
4408 ! Start at the byte immediately before cursor
4409 pos = input_state%cursor_pos
4410 ch = state_buffer_get_char(input_state, pos)
4411 byte_val = iand(iachar(ch), 255)
4412
4413 if (debug_utf8) then
4414 write(error_unit, '(a,i0,a,z2.2)') '[UTF8 DEBUG] cursor_pos=', input_state%cursor_pos, ' byte=0x', byte_val
4415 end if
4416
4417 ! If it's a continuation byte (10xx xxxx), walk backward to find lead byte
4418 if (iand(byte_val, 192) == 128) then
4419 ! Continuation byte - count how many bytes back to the lead byte
4420 num_bytes = 1
4421 pos = pos - 1
4422
4423 ! Walk backward through continuation bytes (max 3 more)
4424 do while (pos > 0 .and. num_bytes < 4)
4425 ch = state_buffer_get_char(input_state, pos)
4426 byte_val = iand(iachar(ch), 255)
4427
4428 if (iand(byte_val, 192) == 128) then
4429 ! Still a continuation byte
4430 if (debug_utf8) then
4431 write(error_unit, '(a,i0,a,z2.2)') '[UTF8 DEBUG] pos=', pos, ' continuation byte=0x', byte_val
4432 end if
4433 num_bytes = num_bytes + 1
4434 pos = pos - 1
4435 else
4436 ! Found the lead byte (not a continuation byte)
4437 if (debug_utf8) then
4438 write(error_unit, '(a,i0,a,z2.2)') '[UTF8 DEBUG] pos=', pos, ' lead byte=0x', byte_val
4439 end if
4440 num_bytes = num_bytes + 1
4441 exit
4442 end if
4443 end do
4444
4445 if (debug_utf8) then
4446 write(error_unit, '(a,i0,a,i0,a,i0)') '[UTF8 DEBUG] Moving back ', num_bytes, &
4447 ' bytes from ', start_pos, ' to ', start_pos - num_bytes
4448 end if
4449 else
4450 ! Not a continuation byte - single byte character (ASCII or orphaned byte)
4451 num_bytes = 1
4452 if (debug_utf8) then
4453 write(error_unit, '(a)') '[UTF8 DEBUG] Single byte character'
4454 end if
4455 end if
4456 end function utf8_char_bytes_before_cursor
4457
4458 ! Determine how many bytes make up the UTF-8 character at the cursor
4459 ! Returns the number of bytes (1-4) for moving right
4460 function utf8_char_bytes_at_cursor(input_state) result(num_bytes)
4461 type(input_state_t), intent(in) :: input_state
4462 integer :: num_bytes
4463 integer :: byte_val
4464 character :: ch
4465
4466 if (input_state%cursor_pos >= input_state%length) then
4467 num_bytes = 0
4468 return
4469 end if
4470
4471 ! Get the byte at cursor position
4472 ch = state_buffer_get_char(input_state, input_state%cursor_pos + 1)
4473 byte_val = iand(iachar(ch), 255)
4474
4475 ! Determine character length based on lead byte
4476 if (byte_val < 128) then
4477 ! ASCII character (0x00-0x7F): 1 byte
4478 num_bytes = 1
4479 else if (iand(byte_val, 224) == 192) then
4480 ! 2-byte UTF-8 (0xC0-0xDF)
4481 num_bytes = 2
4482 else if (iand(byte_val, 240) == 224) then
4483 ! 3-byte UTF-8 (0xE0-0xEF)
4484 num_bytes = 3
4485 else if (iand(byte_val, 248) == 240) then
4486 ! 4-byte UTF-8 (0xF0-0xF7)
4487 num_bytes = 4
4488 else
4489 ! Invalid or continuation byte - treat as single byte
4490 num_bytes = 1
4491 end if
4492 end function utf8_char_bytes_at_cursor
4493
4494 subroutine handle_backspace(input_state)
4495 type(input_state_t), intent(inout) :: input_state
4496 integer :: i
4497 integer :: bytes_to_delete, delete_count
4498
4499 ! Defensive checks for buffer corruption
4500 if (input_state%cursor_pos <= 0) return
4501 if (input_state%length <= 0) return
4502 if (input_state%cursor_pos > input_state%length) then
4503 ! Cursor beyond buffer - fix it
4504 input_state%cursor_pos = input_state%length
4505 end if
4506 if (input_state%length > MAX_LINE_LEN) then
4507 ! Buffer overflow detected - reset to safe state
4508 input_state%length = 0
4509 input_state%cursor_pos = 0
4510 input_state%dirty = .true.
4511 return
4512 end if
4513
4514 ! If we're browsing history, exit history mode when editing
4515 if (input_state%in_history) then
4516 input_state%in_history = .false.
4517 input_state%history_pos = 0
4518 end if
4519
4520 ! Reset completion state when buffer changes
4521 input_state%completions_shown = .false.
4522
4523 ! Determine how many bytes to delete (1 for ASCII, 2-4 for UTF-8)
4524 bytes_to_delete = utf8_char_bytes_before_cursor(input_state)
4525 if (bytes_to_delete <= 0) return
4526
4527 ! If cursor is at end, simple deletion
4528 if (input_state%cursor_pos >= input_state%length) then
4529 ! Delete UTF-8 character (1-4 bytes) from buffer
4530 input_state%length = input_state%length - bytes_to_delete
4531 input_state%cursor_pos = input_state%cursor_pos - bytes_to_delete
4532
4533 ! Clear the deleted bytes
4534 do delete_count = 1, bytes_to_delete
4535 call state_buffer_set_char(input_state, input_state%length + delete_count, ' ')
4536 end do
4537
4538 ! Don't manually move cursor - let redraw handle it
4539 ! This avoids conflicts between cursor_move() escape sequences and redraw escape sequences
4540 ! Just trigger redraw which will position everything correctly
4541 input_state%dirty = .true.
4542 else
4543 ! Delete in middle - shift characters left by bytes_to_delete positions
4544 do i = input_state%cursor_pos - bytes_to_delete + 1, input_state%length - bytes_to_delete
4545 call state_buffer_set_char(input_state, i, state_buffer_get_char(input_state, i + bytes_to_delete))
4546 end do
4547 input_state%cursor_pos = input_state%cursor_pos - bytes_to_delete
4548 input_state%length = input_state%length - bytes_to_delete
4549
4550 ! Clear the bytes at the end
4551 do delete_count = 1, bytes_to_delete
4552 call state_buffer_set_char(input_state, input_state%length + delete_count, ' ')
4553 end do
4554
4555 ! Middle deletion requires full redraw
4556 input_state%dirty = .true.
4557 end if
4558
4559 ! Update autosuggestion after deleting character
4560 call update_autosuggestion(input_state)
4561 end subroutine
4562
4563 ! Delete character at cursor position (forward delete — Delete key / Ctrl+D)
4564 subroutine handle_forward_delete_char(input_state)
4565 type(input_state_t), intent(inout) :: input_state
4566 integer :: i, bytes_to_delete, delete_count
4567
4568 ! Nothing to delete if cursor is at end or buffer is empty
4569 if (input_state%cursor_pos >= input_state%length) return
4570 if (input_state%length <= 0) return
4571
4572 ! Exit history mode on edit
4573 if (input_state%in_history) then
4574 input_state%in_history = .false.
4575 input_state%history_pos = 0
4576 end if
4577 input_state%completions_shown = .false.
4578
4579 ! Determine how many bytes the character at cursor occupies (UTF-8: 1-4)
4580 bytes_to_delete = utf8_char_bytes_at_cursor(input_state)
4581 if (bytes_to_delete <= 0) bytes_to_delete = 1
4582
4583 ! Shift characters left to fill the gap
4584 do i = input_state%cursor_pos + 1, input_state%length - bytes_to_delete
4585 call state_buffer_set_char(input_state, i, state_buffer_get_char(input_state, i + bytes_to_delete))
4586 end do
4587 input_state%length = input_state%length - bytes_to_delete
4588
4589 ! Clear trailing bytes
4590 do delete_count = 1, bytes_to_delete
4591 call state_buffer_set_char(input_state, input_state%length + delete_count, ' ')
4592 end do
4593
4594 input_state%dirty = .true.
4595 call update_autosuggestion(input_state)
4596 end subroutine
4597
4598 ! Separate tab completion handler to work around macOS ARM64 crash
4599 ! This modifies the SAVE'd input_state directly without problematic returns
4600 subroutine handle_tab_key_separate(input_state)
4601 type(input_state_t), intent(inout) :: input_state
4602 integer :: tab_num_completions, i, j, last_space_pos, copy_len
4603 logical :: tab_completed, tab_made_progress, tab_buffer_changed
4604 character(len=MAX_LINE_LEN) :: tab_completions(MAX_LOCAL_COMPLETIONS)
4605 character(len=MAX_LINE_LEN) :: tab_partial_input
4606 character(len=MAX_LINE_LEN) :: tab_completed_line
4607 character(len=MAX_LINE_LEN) :: tab_saved_input
4608 character(len=MAX_MENU_ITEM_LEN) :: temp_buffer
4609
4610 ! Exit history mode if we're browsing
4611 if (input_state%in_history) then
4612 input_state%in_history = .false.
4613 input_state%history_pos = 0
4614 end if
4615
4616 ! Clear any existing autosuggestion — tab completion replaces it
4617 input_state%suggestion = ''
4618 input_state%suggestion_length = 0
4619
4620 ! Don't complete empty buffer - just ring bell
4621 if (input_state%length == 0) then
4622 write(output_unit, '(a)', advance='no') char(7)
4623 flush(output_unit)
4624 return
4625 end if
4626
4627 ! Get the current buffer content
4628 call state_buffer_get(input_state, tab_partial_input)
4629 tab_partial_input = tab_partial_input(:input_state%length)
4630 tab_saved_input = tab_partial_input
4631
4632 ! Check if buffer has changed since we last showed completions
4633 ! IMPORTANT: Compare actual length (NOT trimmed!) to handle trailing spaces correctly
4634 tab_buffer_changed = .not. state_buffer_equals_last_completion(input_state)
4635
4636 ! Attempt smart completion (pass input_state%length to preserve trailing spaces)
4637 call smart_tab_complete(tab_partial_input, tab_completions, &
4638 tab_num_completions, tab_completed_line, tab_completed, input_state%length)
4639
4640 if (tab_num_completions == 0) then
4641 ! No completions found - ring bell
4642 write(output_unit, '(a)', advance='no') char(7)
4643 flush(output_unit)
4644 else if (tab_completed) then
4645 ! We have a completed line - update buffer
4646 ! For glob patterns, always consider it as progress (inline expansion happened)
4647 ! For regular completion, check if line got longer
4648 tab_made_progress = (len_trim(tab_completed_line) > len_trim(tab_saved_input)) .or. &
4649 has_glob_chars(tab_partial_input)
4650
4651 call state_buffer_set(input_state, tab_completed_line)
4652 input_state%length = len_trim(tab_completed_line)
4653 input_state%cursor_pos = input_state%length
4654 input_state%dirty = .true.
4655
4656 ! Recompute autosuggestion for the completed buffer — without this,
4657 ! the stale suggestion from before tab (e.g. "tsh" for "fort" → "fortsh")
4658 ! persists and renders as ghost text after the completed word.
4659 call update_autosuggestion(input_state)
4660
4661 if (tab_num_completions > 1) then
4662 if (tab_made_progress) then
4663 input_state%completions_shown = .false.
4664 else
4665 if (.not. input_state%completions_shown .or. tab_buffer_changed) then
4666 ! First tab - store completions and draw grid menu
4667 input_state%menu_total_items = tab_num_completions
4668 input_state%menu_num_items = min(tab_num_completions, MAX_MENU_ITEMS)
4669 do i = 1, input_state%menu_num_items
4670 ! Copy via temp buffer to avoid flang-new bugs with allocatables
4671 temp_buffer = ' '
4672 copy_len = min(MAX_MENU_ITEM_LEN, len_trim(tab_completions(i)))
4673 do j = 1, copy_len
4674 temp_buffer(j:j) = tab_completions(i)(j:j)
4675 end do
4676 input_state%menu_items(i) = temp_buffer
4677 end do
4678 input_state%menu_selection = 1
4679 write(output_unit, '()') ! Blank line before menu
4680 call draw_completion_menu(input_state, .true.)
4681 call state_last_completion_buffer_set_from_buffer(input_state)
4682 input_state%completions_shown = .true.
4683 ! Don't set dirty - menu is already drawn, no need to redraw command line
4684 else
4685 ! Second tab - enter menu selection mode
4686 ! Activate menu mode (items already stored and displayed)
4687 write(error_unit, '(a)') '[ENTERING_MENU_SELECT_MODE]'
4688 input_state%in_menu_select = .true.
4689
4690 ! Clear autosuggestion when entering menu mode
4691 input_state%suggestion = ''
4692 input_state%suggestion_length = 0
4693
4694 ! Store menu prefix (use actual length, NOT trimmed!)
4695 last_space_pos = 0
4696 do i = input_state%length, 1, -1
4697 if (tab_partial_input(i:i) == ' ') then
4698 last_space_pos = i
4699 exit
4700 end if
4701 end do
4702
4703 if (last_space_pos > 0) then
4704 #ifdef __APPLE__
4705 ! Copy character by character to avoid substring on allocatable (flang-new bug)
4706 ! __APPLE__ implies USE_C_STRINGS, so use allocatable directly
4707 input_state%menu_prefix = ''
4708 do j = 1, last_space_pos
4709 input_state%menu_prefix(j:j) = tab_partial_input(j:j)
4710 end do
4711 #else
4712 ! Linux: Direct substring operation works fine
4713 #ifdef USE_MEMORY_POOL
4714 input_state%menu_prefix_ref%data = tab_partial_input(:last_space_pos)
4715 #else
4716 input_state%menu_prefix = tab_partial_input(:last_space_pos)
4717 #endif
4718 #endif
4719 input_state%menu_prefix_len = last_space_pos
4720 else
4721 #ifdef USE_C_STRINGS
4722 input_state%menu_prefix = ''
4723 #elif defined(USE_MEMORY_POOL)
4724 input_state%menu_prefix_ref%data = ''
4725 #else
4726 input_state%menu_prefix = ''
4727 #endif
4728 input_state%menu_prefix_len = 0
4729 end if
4730
4731 ! Advance selection to second item to show we've entered menu mode
4732 ! Update in place without reprinting the whole menu
4733 if (input_state%menu_num_items > 1) then
4734 input_state%menu_selection = 2 ! Change from 1 to 2
4735 call update_menu_selection(input_state, 1) ! Update display (old was 1, new is 2)
4736 end if
4737 flush(output_unit)
4738 end if
4739 end if
4740 end if
4741 else
4742 ! We have completions but no single completion to apply
4743 ! Show the available options
4744 if (.not. input_state%completions_shown .or. tab_buffer_changed) then
4745 ! First tab - store completions and draw grid menu
4746 input_state%menu_total_items = tab_num_completions
4747 input_state%menu_num_items = min(tab_num_completions, MAX_MENU_ITEMS)
4748 do i = 1, input_state%menu_num_items
4749 ! Copy via temp buffer to avoid flang-new bugs with allocatables
4750 temp_buffer = ' '
4751 copy_len = min(MAX_MENU_ITEM_LEN, len_trim(tab_completions(i)))
4752 do j = 1, copy_len
4753 temp_buffer(j:j) = tab_completions(i)(j:j)
4754 end do
4755 input_state%menu_items(i) = temp_buffer
4756 end do
4757 input_state%menu_selection = 1
4758 write(output_unit, '()') ! Blank line before menu
4759 call draw_completion_menu(input_state, .true.)
4760 call state_last_completion_buffer_set_from_buffer(input_state)
4761 input_state%completions_shown = .true.
4762 ! Don't set dirty - command line is already displayed above menu
4763 else
4764 ! Second tab - enter menu selection mode
4765 ! Activate menu mode (items already stored and displayed)
4766 input_state%in_menu_select = .true.
4767
4768 ! Clear autosuggestion when entering menu mode
4769 input_state%suggestion = ''
4770 input_state%suggestion_length = 0
4771
4772 ! Store menu prefix (use actual length, NOT trimmed!)
4773 last_space_pos = 0
4774 do i = input_state%length, 1, -1
4775 if (tab_partial_input(i:i) == ' ') then
4776 last_space_pos = i
4777 exit
4778 end if
4779 end do
4780
4781 if (last_space_pos > 0) then
4782 #ifdef __APPLE__
4783 ! Copy character by character to avoid substring on allocatable (flang-new bug)
4784 ! __APPLE__ implies USE_C_STRINGS, so use allocatable directly
4785 input_state%menu_prefix = ''
4786 do i = 1, last_space_pos
4787 input_state%menu_prefix(i:i) = tab_partial_input(i:i)
4788 end do
4789 #else
4790 ! Linux: Direct substring operation works fine
4791 #ifdef USE_MEMORY_POOL
4792 input_state%menu_prefix_ref%data = tab_partial_input(:last_space_pos)
4793 #else
4794 input_state%menu_prefix = tab_partial_input(:last_space_pos)
4795 #endif
4796 #endif
4797 input_state%menu_prefix_len = last_space_pos
4798 else
4799 #ifdef USE_C_STRINGS
4800 input_state%menu_prefix = ''
4801 #elif defined(USE_MEMORY_POOL)
4802 input_state%menu_prefix_ref%data = ''
4803 #else
4804 input_state%menu_prefix = ''
4805 #endif
4806 input_state%menu_prefix_len = 0
4807 end if
4808
4809 ! Advance selection to second item to show we've entered menu mode
4810 ! Update in place without reprinting the whole menu
4811 if (input_state%menu_num_items > 1) then
4812 input_state%menu_selection = 2 ! Change from 1 to 2
4813 call update_menu_selection(input_state, 1) ! Update display (old was 1, new is 2)
4814 ! Update command line preview with selected item
4815 call update_live_preview(input_state)
4816 end if
4817 flush(output_unit)
4818 end if
4819 end if
4820 end subroutine handle_tab_key_separate
4821
4822 subroutine handle_tab_completion(input_state)
4823 type(input_state_t), intent(inout) :: input_state
4824 character(len=MAX_LINE_LEN) :: partial_input
4825 character(len=MAX_LINE_LEN) :: completions(MAX_LOCAL_COMPLETIONS)
4826 character(len=MAX_LINE_LEN) :: completed_line
4827 character(len=MAX_LINE_LEN) :: saved_input
4828 character(len=MAX_MENU_ITEM_LEN) :: temp_buffer
4829 integer :: num_completions, i
4830 logical :: completed, made_progress, buffer_changed
4831
4832 ! Exit history mode if we're browsing
4833 if (input_state%in_history) then
4834 input_state%in_history = .false.
4835 input_state%history_pos = 0
4836 end if
4837
4838 ! Get the current buffer content
4839 call state_buffer_get(input_state, partial_input)
4840 partial_input = partial_input(:input_state%length)
4841 saved_input = partial_input
4842
4843 ! Check if buffer has changed since we last showed completions
4844 buffer_changed = .not. state_buffer_equals_last_completion(input_state)
4845
4846 ! Attempt smart completion
4847 call smart_tab_complete(partial_input, completions, num_completions, completed_line, completed)
4848
4849 if (num_completions == 0) then
4850 ! No completions found - ring bell (ASCII 7)
4851 write(output_unit, '(a)', advance='no') char(7) ! Bell for audio feedback
4852 flush(output_unit)
4853 else if (completed) then
4854 ! We have a completed line - update buffer
4855 ! Check if we made actual progress
4856 made_progress = (len_trim(completed_line) > len_trim(saved_input))
4857
4858 ! Update the input buffer with completion
4859 call state_buffer_set(input_state, completed_line)
4860 input_state%length = len_trim(completed_line)
4861 input_state%cursor_pos = input_state%length
4862 input_state%dirty = .true.
4863
4864 ! Update autosuggestion to account for the completion
4865 ! If the completed line still matches a history entry, show the rest
4866 call update_autosuggestion(input_state)
4867
4868 if (num_completions > 1) then
4869 if (made_progress) then
4870 ! We completed to common prefix - don't show options yet
4871 ! User can press tab again to see options
4872 input_state%completions_shown = .false.
4873 else
4874 ! At common prefix already - show available options only if not already shown
4875 if (.not. input_state%completions_shown .or. buffer_changed) then
4876 ! Store completions for menu mode and draw once
4877 input_state%menu_total_items = num_completions
4878 input_state%menu_num_items = min(num_completions, MAX_MENU_ITEMS)
4879 do i = 1, input_state%menu_num_items
4880 ! Use temp_buffer and explicit copy to avoid truncation warnings
4881 temp_buffer = completions(i)(1:min(len_trim(completions(i)), MAX_MENU_ITEM_LEN))
4882 input_state%menu_items(i) = temp_buffer
4883 end do
4884 input_state%menu_selection = 1
4885 write(output_unit, '()') ! Blank line before menu
4886 call draw_completion_menu(input_state, .true.)
4887 call state_last_completion_buffer_set_from_buffer(input_state)
4888 input_state%completions_shown = .true.
4889 ! Don't set dirty - command line is already displayed above menu
4890 else
4891 ! Second tab (double-tab) at common prefix - enter menu selection mode!
4892 call enter_menu_select_mode(input_state, completions, num_completions, completed_line)
4893 end if
4894 end if
4895 else
4896 ! Single completion - reset flag
4897 input_state%completions_shown = .false.
4898 end if
4899 else
4900 ! We have completions but no single completion to apply
4901 ! Show the available options
4902 if (.not. input_state%completions_shown .or. buffer_changed) then
4903 ! First tab - store completions and draw menu
4904 input_state%menu_total_items = num_completions
4905 input_state%menu_num_items = min(num_completions, MAX_MENU_ITEMS)
4906 do i = 1, input_state%menu_num_items
4907 ! Use temp_buffer and explicit copy to avoid truncation warnings
4908 temp_buffer = completions(i)(1:min(len_trim(completions(i)), MAX_MENU_ITEM_LEN))
4909 input_state%menu_items(i) = temp_buffer
4910 end do
4911 input_state%menu_selection = 1
4912 write(output_unit, '()') ! Blank line before menu
4913 call draw_completion_menu(input_state, .true.)
4914 call state_last_completion_buffer_set_from_buffer(input_state)
4915 input_state%completions_shown = .true.
4916 ! Don't set dirty - command line is already displayed above menu
4917 else
4918 ! Second tab (double-tab) - enter menu selection mode!
4919 call enter_menu_select_mode(input_state, completions, num_completions, partial_input)
4920 end if
4921 end if
4922 end subroutine
4923
4924 ! ===========================================================================
4925 ! Menu Selection Mode (zsh/fish-style interactive completion)
4926 ! ===========================================================================
4927
4928 subroutine enter_menu_select_mode(input_state, completions, num_completions, current_input)
4929 type(input_state_t), intent(inout) :: input_state
4930 character(len=MAX_LINE_LEN), intent(in) :: completions(MAX_LOCAL_COMPLETIONS)
4931 integer, intent(in) :: num_completions
4932 character(len=*), intent(in) :: current_input
4933 character(len=MAX_MENU_ITEM_LEN) :: temp_buffer
4934 integer :: i, last_space_pos
4935
4936 ! Store menu items
4937 input_state%in_menu_select = .true.
4938 input_state%menu_total_items = num_completions
4939 input_state%menu_num_items = min(num_completions, MAX_MENU_ITEMS)
4940
4941 ! Clear autosuggestion when entering menu mode
4942 input_state%suggestion = ''
4943 input_state%suggestion_length = 0
4944
4945 do i = 1, input_state%menu_num_items
4946 ! Use temp_buffer and explicit copy to avoid truncation warnings
4947 temp_buffer = completions(i)(1:min(len_trim(completions(i)), MAX_MENU_ITEM_LEN))
4948 input_state%menu_items(i) = temp_buffer
4949 end do
4950
4951 ! Find the prefix (everything before the last word being completed)
4952 last_space_pos = 0
4953 do i = len_trim(current_input), 1, -1
4954 if (current_input(i:i) == ' ') then
4955 last_space_pos = i
4956 exit
4957 end if
4958 end do
4959
4960 if (last_space_pos > 0) then
4961 ! Copy character by character to avoid substring on allocatable
4962 #ifdef USE_C_STRINGS
4963 input_state%menu_prefix = ''
4964 #elif defined(USE_MEMORY_POOL)
4965 input_state%menu_prefix_ref%data = ''
4966 #else
4967 input_state%menu_prefix = ''
4968 #endif
4969 do i = 1, last_space_pos
4970 #ifdef USE_C_STRINGS
4971 input_state%menu_prefix(i:i) = current_input(i:i)
4972 #elif defined(USE_MEMORY_POOL)
4973 input_state%menu_prefix_ref%data(i:i) = current_input(i:i)
4974 #else
4975 input_state%menu_prefix(i:i) = current_input(i:i)
4976 #endif
4977 end do
4978 input_state%menu_prefix_len = last_space_pos ! Store length WITH the space
4979 else
4980 #ifdef USE_C_STRINGS
4981 input_state%menu_prefix = ''
4982 #elif defined(USE_MEMORY_POOL)
4983 input_state%menu_prefix_ref%data = ''
4984 #else
4985 input_state%menu_prefix = ''
4986 #endif
4987 input_state%menu_prefix_len = 0
4988 end if
4989
4990 ! Advance selection to second item to make it clear we've entered menu mode
4991 ! This provides visual feedback that menu selection is now active
4992 ! Update in place without reprinting the whole menu
4993 if (input_state%menu_num_items > 1) then
4994 input_state%menu_selection = 2 ! Change from 1 to 2
4995 call update_menu_selection(input_state, 1) ! Update display (old was 1, new is 2)
4996 ! Show initial preview
4997 write(error_unit, '(a)') '[CALLING_PREVIEW_FROM_ENTER_MENU]'
4998 call update_live_preview(input_state)
4999 end if
5000 flush(output_unit)
5001 end subroutine
5002
5003 subroutine draw_completion_menu(input_state, initial_draw)
5004 type(input_state_t), intent(inout) :: input_state ! inout to cache layout
5005 logical, intent(in) :: initial_draw
5006 integer :: i, j, cols_per_item, items_per_row, col, item_idx
5007 integer :: term_rows, term_cols, item_len
5008 character(len=MAX_MENU_ITEM_LEN) :: current_item
5009 character(len=1) :: ch
5010 logical :: success
5011
5012 if (.false.) print *, initial_draw ! Silence unused warning
5013
5014 ! Get terminal size
5015 success = get_terminal_size(term_rows, term_cols)
5016 if (.not. success .or. term_cols <= 0) then
5017 term_cols = 80
5018 end if
5019
5020 ! Calculate layout (ALWAYS recalculate to ensure correctness)
5021 ! Note: Caller is responsible for outputting initial newline before calling with initial_draw=true
5022 cols_per_item = 0
5023 do i = 1, input_state%menu_num_items
5024 current_item = input_state%menu_items(i)
5025 item_len = len_trim(current_item)
5026 cols_per_item = max(cols_per_item, item_len)
5027 end do
5028 cols_per_item = cols_per_item + 2 ! Add spacing
5029 items_per_row = max(1, term_cols / cols_per_item)
5030
5031 ! Cache the layout (always update cache for use by update_live_preview and navigation)
5032 input_state%menu_cols_per_item = cols_per_item
5033 input_state%menu_items_per_row = items_per_row
5034 input_state%menu_num_rows = (input_state%menu_num_items + items_per_row - 1) / items_per_row
5035
5036 ! Draw menu items
5037 item_idx = 1
5038 do while (item_idx <= input_state%menu_num_items)
5039 ! Draw one row
5040 do col = 1, items_per_row
5041 if (item_idx > input_state%menu_num_items) exit
5042
5043 ! Copy item to local variable to avoid substring operations on array element
5044 current_item = input_state%menu_items(item_idx)
5045 item_len = len_trim(current_item)
5046
5047 ! Highlight selected item with reverse video
5048 if (item_idx == input_state%menu_selection) then
5049 write(output_unit, '(a)', advance='no') char(27) // '[7m' ! Reverse video
5050 end if
5051
5052 ! Write menu item character by character from local variable
5053 do j = 1, item_len
5054 ch = current_item(j:j)
5055 write(output_unit, '(a)', advance='no') ch
5056 end do
5057
5058 if (item_idx == input_state%menu_selection) then
5059 write(output_unit, '(a)', advance='no') char(27) // '[0m' ! Reset
5060 end if
5061
5062 ! Pad to column width for alignment (except last column in row)
5063 if (col < items_per_row .and. item_idx < input_state%menu_num_items) then
5064 ! Pad with spaces to reach full column width
5065 do j = item_len + 1, cols_per_item
5066 write(output_unit, '(a)', advance='no') ' '
5067 end do
5068 end if
5069
5070 item_idx = item_idx + 1
5071 end do
5072 write(output_unit, '()') ! New line after each row
5073 end do
5074
5075 ! Show "more items" indicator if there are truncated completions
5076 if (input_state%menu_total_items > input_state%menu_num_items) then
5077 write(output_unit, '(a,i15,a)') &
5078 ' ... ', input_state%menu_total_items - input_state%menu_num_items, ' more items available'
5079 end if
5080
5081 ! Mark that we need to redraw the command line
5082 flush(output_unit)
5083 end subroutine
5084
5085 subroutine handle_menu_navigation(input_state, key, done)
5086 type(input_state_t), intent(inout) :: input_state
5087 integer, intent(in) :: key
5088 logical, intent(inout) :: done
5089 integer :: old_selection, new_selection
5090 integer :: items_per_row
5091 integer :: current_row, current_col, target_row
5092
5093 if (.false.) print *, done ! Silence unused warning (set by caller)
5094
5095 if (.not. input_state%in_menu_select) return
5096
5097 old_selection = input_state%menu_selection
5098
5099 select case (key)
5100 case (KEY_UP, KEY_DOWN)
5101 ! 2D navigation: move up/down by one row in the grid
5102 ! Use cached layout from input_state (avoids repeated array iterations)
5103 items_per_row = input_state%menu_items_per_row
5104
5105 ! Calculate current position in grid (1-indexed)
5106 current_row = (input_state%menu_selection - 1) / items_per_row + 1
5107 current_col = mod(input_state%menu_selection - 1, items_per_row) + 1
5108
5109 if (key == KEY_UP) then
5110 ! Move up one row
5111 target_row = current_row - 1
5112 if (target_row < 1) then
5113 ! Wrap to bottom row, same column
5114 target_row = (input_state%menu_num_items - 1) / items_per_row + 1
5115 end if
5116 else ! KEY_DOWN
5117 ! Move down one row
5118 target_row = current_row + 1
5119 if ((target_row - 1) * items_per_row + current_col > input_state%menu_num_items) then
5120 ! Wrap to top row, same column
5121 target_row = 1
5122 end if
5123 end if
5124
5125 ! Calculate new selection
5126 new_selection = (target_row - 1) * items_per_row + current_col
5127 ! Clamp to valid range
5128 if (new_selection < 1) new_selection = 1
5129 if (new_selection > input_state%menu_num_items) then
5130 ! If target position doesn't exist (incomplete last row), go to last item
5131 new_selection = input_state%menu_num_items
5132 end if
5133 input_state%menu_selection = new_selection
5134
5135 case (KEY_LEFT)
5136 ! Move left one item (same row)
5137 input_state%menu_selection = input_state%menu_selection - 1
5138 if (input_state%menu_selection < 1) then
5139 input_state%menu_selection = input_state%menu_num_items ! Wrap to end
5140 end if
5141
5142 case (KEY_RIGHT)
5143 ! Move right one item (same row)
5144 input_state%menu_selection = input_state%menu_selection + 1
5145 if (input_state%menu_selection > input_state%menu_num_items) then
5146 input_state%menu_selection = 1 ! Wrap to beginning
5147 end if
5148
5149 case (KEY_TAB)
5150 ! Tab continues to cycle sequentially through all items
5151 input_state%menu_selection = input_state%menu_selection + 1
5152 if (input_state%menu_selection > input_state%menu_num_items) then
5153 input_state%menu_selection = 1
5154 end if
5155
5156 case (10, 13) ! Enter (LF or CR)
5157 ! Accept selection - insert into command line and continue editing
5158 call accept_menu_selection(input_state)
5159 ! Don't set done = .true. - let user continue editing
5160 return
5161
5162 case (KEY_ESC)
5163 ! Cancel menu mode
5164 call exit_menu_select_mode(input_state)
5165 return
5166
5167 case default
5168 ! Any other key exits menu mode and processes normally
5169 call exit_menu_select_mode(input_state)
5170 return
5171 end select
5172
5173 ! Update menu highlighting if selection changed (in-place update)
5174 if (old_selection /= input_state%menu_selection) then
5175 call update_menu_selection(input_state, old_selection)
5176 ! Update command line preview with selected item
5177 call update_live_preview(input_state)
5178 end if
5179 end subroutine
5180
5181 subroutine accept_menu_selection(input_state)
5182 type(input_state_t), intent(inout) :: input_state
5183 character(len=MAX_LINE_LEN) :: completed_line
5184 character(len=MAX_MENU_ITEM_LEN) :: current_item
5185 character(len=1) :: ch
5186 integer :: i, j, item_len, completed_len
5187
5188 ! Build completed command character by character (copy to local vars first)
5189 completed_line = ''
5190 completed_len = 0
5191
5192 if (input_state%menu_prefix_len > 0) then
5193 ! Copy directly from menu_prefix character-by-character (avoid temp assignment)
5194 ! CRITICAL: Don't use intermediate variable - flang-new bug causes corruption
5195 do i = 1, input_state%menu_prefix_len
5196 #ifdef USE_C_STRINGS
5197 ch = input_state%menu_prefix(i:i)
5198 #elif defined(USE_MEMORY_POOL)
5199 ch = input_state%menu_prefix_ref%data(i:i)
5200 #else
5201 ch = input_state%menu_prefix(i:i)
5202 #endif
5203 completed_len = completed_len + 1
5204 completed_line(completed_len:completed_len) = ch
5205 end do
5206 end if
5207
5208 current_item = input_state%menu_items(input_state%menu_selection)
5209 item_len = len_trim(current_item)
5210 do j = 1, item_len
5211 ch = current_item(j:j)
5212 completed_len = completed_len + 1
5213 completed_line(completed_len:completed_len) = ch
5214 end do
5215
5216 ! Exit menu mode FIRST (clears menu from screen and positions cursor at start of command line)
5217 call exit_menu_select_mode(input_state)
5218
5219 ! Update buffer after menu is cleared
5220 call state_buffer_set(input_state, completed_line)
5221 input_state%length = completed_len
5222 input_state%cursor_pos = completed_len ! Cursor at end
5223
5224 ! CRITICAL: Set flag to skip upward cursor movement on redraw
5225 ! We're already on the first line after exit_menu_select_mode,
5226 ! so the redraw shouldn't try to move up based on cursor position
5227 input_state%skip_cursor_up_on_redraw = .true.
5228
5229 ! Mark dirty to trigger redraw
5230 input_state%dirty = .true.
5231
5232 ! Update autosuggestion for future use
5233 call update_autosuggestion(input_state)
5234 end subroutine
5235
5236 subroutine exit_menu_select_mode(input_state)
5237 type(input_state_t), intent(inout) :: input_state
5238 integer :: i, num_rows, term_rows, term_cols, cols_per_item, items_per_row, extra_lines
5239 logical :: success
5240
5241 ! Clear the menu from screen before exiting
5242 if (input_state%menu_num_items > 0) then
5243 ! Calculate how many rows the menu uses
5244 success = get_terminal_size(term_rows, term_cols)
5245 if (.not. success .or. term_cols <= 0) then
5246 term_cols = 80
5247 end if
5248
5249 ! Calculate layout to determine number of rows used
5250 cols_per_item = 0
5251 do i = 1, input_state%menu_num_items
5252 cols_per_item = max(cols_per_item, len_trim(input_state%menu_items(i)))
5253 end do
5254 cols_per_item = cols_per_item + 2
5255
5256 items_per_row = max(1, term_cols / cols_per_item)
5257 num_rows = (input_state%menu_num_items + items_per_row - 1) / items_per_row
5258
5259 ! Account for "more items" indicator line if present
5260 extra_lines = 0
5261 if (input_state%menu_total_items > input_state%menu_num_items) then
5262 extra_lines = 1
5263 end if
5264
5265 ! Move cursor up to where the command line was (before the blank line and menu)
5266 ! Cursor is currently on empty line after the menu rows + extra lines
5267 ! Layout: [cmd][blank][row1]...[rowN][extra?][cursor here]
5268 ! Move up: num_rows (menu content) + extra_lines (more items) + 1 (blank line) + 1 (to command line)
5269 do i = 1, num_rows + extra_lines + 2
5270 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
5271 end do
5272
5273 ! Now at command line - clear from next line down to remove menu
5274 write(output_unit, '(a)', advance='no') char(13) ! Carriage return (start of command line)
5275 write(output_unit, '(a)', advance='no') char(27) // '[K' ! Clear current line (remove old command)
5276 write(output_unit, '(a)', advance='no') char(27) // '[B' ! Move down to first menu line
5277 write(output_unit, '(a)', advance='no') char(27) // '[J' ! Clear from cursor down (all menu)
5278 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Move back up to command line
5279 write(output_unit, '(a)', advance='no') char(13) ! Back to start of command line
5280 end if
5281
5282 input_state%in_menu_select = .false.
5283 input_state%menu_num_items = 0
5284 input_state%menu_total_items = 0
5285 input_state%menu_selection = 1
5286 input_state%menu_prefix_len = 0
5287 input_state%completions_shown = .false.
5288 input_state%dirty = .true.
5289 end subroutine
5290
5291 subroutine update_menu_selection(input_state, old_selection)
5292 type(input_state_t), intent(inout) :: input_state ! inout to pass to draw function
5293 integer, intent(in) :: old_selection
5294 integer :: i, num_menu_rows, extra_lines, total_lines
5295
5296 if (.false.) print *, old_selection ! Silence unused warning
5297
5298 ! Use cached layout from input_state (avoids repeated array iterations)
5299 num_menu_rows = input_state%menu_num_rows
5300
5301 ! Account for "more items" indicator line if present
5302 extra_lines = 0
5303 if (input_state%menu_total_items > input_state%menu_num_items) then
5304 extra_lines = 1
5305 end if
5306 total_lines = num_menu_rows + extra_lines
5307
5308 ! Move cursor up to the blank line before menu
5309 ! Cursor is currently on new line after last menu row (each row ends with newline)
5310 ! Menu layout: [blank line] [row 1] [row 2] ... [row N] [more items?] [cursor on new line]
5311 ! So we move up: num_menu_rows + extra_lines + 1 to get to blank line
5312 do i = 1, total_lines + 1
5313 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
5314 end do
5315 write(output_unit, '(a)', advance='no') char(13) ! Carriage return
5316
5317 ! Clear the menu area including blank line (clear all lines including extra and blank)
5318 do i = 1, total_lines + 1
5319 write(output_unit, '(a)', advance='no') char(27) // '[K' ! Clear line
5320 if (i < total_lines + 1) then
5321 write(output_unit, '()') ! Move to next line
5322 end if
5323 end do
5324
5325 ! Move back up to start (the blank line before menu)
5326 if (total_lines > 0) then
5327 do i = 1, total_lines
5328 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
5329 end do
5330 write(output_unit, '(a)', advance='no') char(13) ! Carriage return
5331 else
5332 write(output_unit, '(a)', advance='no') char(13) ! Carriage return
5333 end if
5334
5335 ! We're now positioned at the START of the blank line
5336 ! Output a blank line (to match initial draw spacing)
5337 write(output_unit, '()') ! Blank line before menu
5338
5339 ! Redraw the menu with the new selection highlighted
5340 call draw_completion_menu(input_state, .false.)
5341
5342 flush(output_unit)
5343 end subroutine
5344
5345 subroutine update_live_preview(input_state)
5346 type(input_state_t), intent(in) :: input_state
5347 integer :: i, j, num_menu_rows, extra_lines
5348 integer :: prompt_len, highlighted_len, item_len, preview_len
5349 character(len=MAX_LINE_LEN) :: preview_line, current_prefix
5350 character(len=MAX_MENU_ITEM_LEN) :: current_item
5351 character(len=MAX_HIGHLIGHT_LEN) :: highlighted_preview ! Fixed-length to avoid flang-new bugs
5352 character(len=1) :: ch
5353
5354 ! Initialize buffer
5355 highlighted_preview = ' '
5356 highlighted_len = 0
5357 preview_line = ''
5358
5359 ! Use cached menu layout (avoids repeated array iterations and len_trim calls)
5360 num_menu_rows = input_state%menu_num_rows
5361
5362 ! Account for "more items" indicator line if present
5363 extra_lines = 0
5364 if (input_state%menu_total_items > input_state%menu_num_items) then
5365 extra_lines = 1
5366 end if
5367
5368 ! Save current cursor position (after menu) - ESC[s
5369 write(output_unit, '(a)', advance='no') char(27) // '[s'
5370
5371 ! Build preview line character by character (copy to local vars first)
5372 preview_len = 0
5373 if (input_state%menu_prefix_len > 0) then
5374 ! IMPORTANT: Copy allocatable menu_prefix character-by-character to avoid flang-new bug
5375 ! Direct assignment creates a temporary that gets corrupted
5376 current_prefix = '' ! Initialize
5377 do i = 1, input_state%menu_prefix_len
5378 #ifdef USE_C_STRINGS
5379 current_prefix(i:i) = input_state%menu_prefix(i:i)
5380 #elif defined(USE_MEMORY_POOL)
5381 current_prefix(i:i) = input_state%menu_prefix_ref%data(i:i)
5382 #else
5383 current_prefix(i:i) = input_state%menu_prefix(i:i)
5384 #endif
5385 end do
5386
5387 ! Now copy to preview_line
5388 do i = 1, input_state%menu_prefix_len
5389 ch = current_prefix(i:i)
5390 preview_len = preview_len + 1
5391 preview_line(preview_len:preview_len) = ch
5392 end do
5393 end if
5394 current_item = input_state%menu_items(input_state%menu_selection)
5395 item_len = len_trim(current_item)
5396 do j = 1, item_len
5397 ch = current_item(j:j)
5398 preview_len = preview_len + 1
5399 preview_line(preview_len:preview_len) = ch
5400 end do
5401
5402 ! Move cursor up past menu to command line
5403 ! We need to go up: num_menu_rows + extra_lines (menu content) + 1 (blank line before menu) + 1 (to command line)
5404 do i = 1, num_menu_rows + extra_lines + 2
5405 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
5406 end do
5407
5408 ! Move to start of line
5409 write(output_unit, '(a)', advance='no') char(13) ! CR
5410
5411 ! Clear the entire line
5412 write(output_unit, '(a)', advance='no') char(27) // '[K' ! Clear from cursor to end of line
5413
5414 ! Apply syntax highlighting to preview (use preview_len we calculated)
5415 call highlight_command_line(preview_line, highlighted_preview, highlighted_len, preview_len)
5416
5417 ! Redraw prompt character by character (copy to local var first)
5418 ! IMPORTANT: Copy allocatable menu_prompt character-by-character to avoid flang-new bug
5419 current_prefix = ''
5420 prompt_len = len_trim(input_state%menu_prompt)
5421 if (prompt_len > 0) then
5422 do i = 1, prompt_len
5423 current_prefix(i:i) = input_state%menu_prompt(i:i)
5424 end do
5425 do i = 1, prompt_len
5426 ch = current_prefix(i:i)
5427 write(output_unit, '(a)', advance='no') ch
5428 end do
5429 end if
5430
5431 ! Write space after prompt (to match the original spacing)
5432 write(output_unit, '(a)', advance='no') ' '
5433
5434 ! Redraw highlighted preview character by character (already local var)
5435 if (highlighted_len > 0 .and. highlighted_len <= MAX_HIGHLIGHT_LEN) then
5436 do i = 1, highlighted_len
5437 ch = highlighted_preview(i:i)
5438 write(output_unit, '(a)', advance='no') ch
5439 end do
5440 end if
5441
5442 ! Restore cursor position (back to after menu) - ESC[u
5443 write(output_unit, '(a)', advance='no') char(27) // '[u'
5444
5445 flush(output_unit)
5446 ! highlighted_preview is now fixed-length, no deallocation needed
5447 end subroutine
5448
5449 ! ===========================================================================
5450 ! Process Kill Mode (Ctrl-X quick process termination)
5451 ! ===========================================================================
5452
5453 subroutine enter_process_kill_mode(input_state)
5454 type(input_state_t), intent(inout) :: input_state
5455 character(len=MAX_LINE_LEN) :: processes(MAX_MENU_ITEMS)
5456 integer :: pids(MAX_MENU_ITEMS)
5457 integer :: num_processes, i
5458
5459 ! Get process list
5460 call get_process_list(processes, pids, num_processes)
5461
5462 if (num_processes == 0) then
5463 write(output_unit, '(a)') ''
5464 write(output_unit, '(a)') 'No processes found.'
5465 return
5466 end if
5467
5468 ! Clear the current line
5469 write(output_unit, '(a)', advance='no') char(13) ! CR
5470 write(output_unit, '(a)', advance='no') char(27) // '[K' ! Clear line
5471
5472 ! Enter process kill mode
5473 input_state%in_process_kill_mode = .true.
5474 input_state%in_menu_select = .true. ! Reuse menu selection infrastructure
5475 input_state%menu_num_items = num_processes
5476
5477 ! Store process info in menu items (format: "PID: process_name")
5478 do i = 1, num_processes
5479 write(input_state%menu_items(i), '(i8,a,a)') pids(i), ': ', trim(processes(i))
5480 end do
5481
5482 ! Store PIDs for later use (we'll extract from menu_items when needed)
5483 input_state%menu_selection = 1
5484
5485 ! Draw the process menu
5486 write(output_unit, '(a)') 'Select process to signal (arrow keys to navigate, Enter to select, ESC to cancel):'
5487 call draw_completion_menu(input_state, .true.)
5488 end subroutine
5489
5490 subroutine get_process_list(processes, pids, num_processes)
5491 character(len=MAX_LINE_LEN), intent(out) :: processes(MAX_MENU_ITEMS)
5492 integer, intent(out) :: pids(MAX_MENU_ITEMS)
5493 integer, intent(out) :: num_processes
5494
5495 integer :: unit, iostat, pid
5496 character(len=512) :: line, cmd_name, username
5497 integer :: stat
5498
5499 num_processes = 0
5500
5501 ! Get current username for filtering
5502 call get_environment_variable('USER', username, status=stat)
5503 if (stat /= 0) then
5504 username = '' ! Fall back to showing all processes if USER not set
5505 end if
5506
5507 ! Use ps command to get process list for current user
5508 ! -u USER: processes for current user only
5509 ! -o pid,comm: output PID and command name
5510 ! --no-headers: no headers
5511 open(newunit=unit, file='/tmp/fortsh_procs.tmp', status='replace', &
5512 action='write', iostat=iostat)
5513 if (iostat /= 0) return
5514 close(unit)
5515
5516 ! Execute ps and capture output - filter to current user
5517 #ifdef __APPLE__
5518 ! macOS uses BSD ps which doesn't support --no-headers
5519 if (len_trim(username) > 0) then
5520 call execute_command_line('ps -u ' // trim(username) // &
5521 ' -o pid= -o comm= > /tmp/fortsh_procs.tmp 2>/dev/null', &
5522 exitstat=iostat)
5523 else
5524 call execute_command_line('ps -ax -o pid= -o comm= > /tmp/fortsh_procs.tmp 2>/dev/null', &
5525 exitstat=iostat)
5526 end if
5527 #else
5528 ! Linux uses GNU ps with --no-headers
5529 if (len_trim(username) > 0) then
5530 call execute_command_line('ps -u ' // trim(username) // &
5531 ' -o pid,comm --no-headers > /tmp/fortsh_procs.tmp 2>/dev/null', &
5532 exitstat=iostat)
5533 else
5534 call execute_command_line('ps -eo pid,comm --no-headers > /tmp/fortsh_procs.tmp 2>/dev/null', &
5535 exitstat=iostat)
5536 end if
5537 #endif
5538
5539 if (iostat == 0) then
5540 ! Read the process list
5541 open(newunit=unit, file='/tmp/fortsh_procs.tmp', status='old', &
5542 action='read', iostat=iostat)
5543
5544 if (iostat == 0) then
5545 ! Skip header if BSD-style (first line contains PID)
5546 read(unit, '(a)', iostat=iostat) line
5547 if (iostat == 0 .and. index(line, 'PID') > 0) then
5548 ! This was a header, skip it
5549 else if (iostat == 0) then
5550 ! Not a header, process it
5551 read(line, *, iostat=iostat) pid, cmd_name
5552 if (iostat == 0 .and. num_processes < MAX_MENU_ITEMS) then
5553 num_processes = num_processes + 1
5554 pids(num_processes) = pid
5555 processes(num_processes) = trim(cmd_name)
5556 end if
5557 end if
5558
5559 ! Read remaining lines
5560 do while (iostat == 0 .and. num_processes < MAX_MENU_ITEMS)
5561 read(unit, '(a)', iostat=iostat) line
5562 if (iostat == 0 .and. len_trim(line) > 0) then
5563 ! Parse PID and command
5564 read(line, *, iostat=iostat) pid, cmd_name
5565 if (iostat == 0) then
5566 num_processes = num_processes + 1
5567 pids(num_processes) = pid
5568 processes(num_processes) = trim(cmd_name)
5569 end if
5570 end if
5571 end do
5572
5573 close(unit)
5574 end if
5575 end if
5576
5577 ! Clean up temp file
5578 call execute_command_line('rm -f /tmp/fortsh_procs.tmp 2>/dev/null')
5579 end subroutine
5580
5581 subroutine handle_process_selection(input_state)
5582 type(input_state_t), intent(inout) :: input_state
5583 character(len=256) :: pid_str
5584 integer :: colon_pos, iostat
5585
5586 ! Extract PID from selected menu item (format: "PID: process_name")
5587 colon_pos = index(input_state%menu_items(input_state%menu_selection), ':')
5588 if (colon_pos > 0) then
5589 pid_str = input_state%menu_items(input_state%menu_selection)(:colon_pos-1)
5590 read(pid_str, *, iostat=iostat) input_state%selected_pid
5591
5592 if (iostat == 0) then
5593 ! Store process name
5594 input_state%selected_process_name = &
5595 input_state%menu_items(input_state%menu_selection)(colon_pos+2:)
5596
5597 ! Clear menu and enter signal input mode
5598 call exit_menu_select_mode(input_state)
5599
5600 ! Enter signal input mode - like reverse-i-search
5601 input_state%in_process_kill_mode = .true.
5602 input_state%in_signal_input = .true.
5603
5604 ! Clear the buffer for signal input
5605 call state_buffer_clear(input_state)
5606 input_state%length = 0
5607 input_state%cursor_pos = 0
5608
5609 ! Clear dirty flag set by exit_menu_select_mode
5610 ! We handle our own display, don't want normal redraw
5611 input_state%dirty = .false.
5612
5613 ! Display the signal prompt (like reverse-i-search display)
5614 call update_signal_display(input_state)
5615 end if
5616 end if
5617 end subroutine
5618
5619 subroutine update_signal_display(input_state)
5620 type(input_state_t), intent(in) :: input_state
5621 character(len=512) :: signal_prompt
5622 character(len=MAX_LINE_LEN) :: temp_buf ! For buffer extraction
5623
5624 ! Build signal prompt: (signal: PID 1234 firefox):
5625 write(signal_prompt, '(a,i15,a,a,a)') '(signal: PID ', input_state%selected_pid, ' ', &
5626 trim(input_state%selected_process_name), '): '
5627
5628 ! Clear line and redraw with signal prompt
5629 write(output_unit, '(a)', advance='no') char(13) // ESC_CLEAR_LINE
5630 write(output_unit, '(a)', advance='no') trim(signal_prompt)
5631 if (input_state%length > 0) then
5632 call state_buffer_get(input_state, temp_buf)
5633 write(output_unit, '(a)', advance='no') temp_buf(:input_state%length)
5634 end if
5635 flush(output_unit)
5636 end subroutine
5637
5638 subroutine handle_signal_input(input_state, ch)
5639 type(input_state_t), intent(inout) :: input_state
5640 character(len=1), intent(in) :: ch
5641
5642 ! Add character to buffer directly (like search mode does)
5643 ! Don't use insert_char() to avoid setting dirty flag
5644 if (input_state%length < MAX_LINE_LEN) then
5645 input_state%length = input_state%length + 1
5646 call state_buffer_set_char(input_state, input_state%length, ch)
5647 input_state%cursor_pos = input_state%length
5648 end if
5649
5650 ! Update the signal display (inline prompt like reverse-i-search)
5651 call update_signal_display(input_state)
5652 end subroutine
5653
5654 subroutine send_signal_to_process(input_state)
5655 type(input_state_t), intent(inout) :: input_state
5656 integer :: signal_num, iostat, result
5657 character(len=MAX_LINE_LEN) :: signal_str
5658 interface
5659 function c_kill(pid, sig) bind(C, name="kill")
5660 use iso_c_binding
5661 integer(c_int), value :: pid, sig
5662 integer(c_int) :: c_kill
5663 end function c_kill
5664 end interface
5665
5666 ! Parse signal from buffer (can be number or SIG<name>)
5667 call state_buffer_get(input_state, signal_str)
5668 signal_str = signal_str(:input_state%length)
5669
5670 ! Try to parse as number first
5671 read(signal_str, *, iostat=iostat) signal_num
5672
5673 if (iostat /= 0) then
5674 ! Try to parse as signal name
5675 call parse_signal_name(signal_str, signal_num)
5676 end if
5677
5678 if (signal_num > 0) then
5679 ! Send the signal
5680 result = c_kill(input_state%selected_pid, signal_num)
5681
5682 if (result == 0) then
5683 ! Success - green
5684 write(output_unit, '(a)', advance='no') char(27) // '[1;32m' ! Bold green
5685 write(output_unit, '(a)', advance='no') ' ✓ '
5686 write(output_unit, '(a)', advance='no') char(27) // '[0m'
5687 write(output_unit, '(a,i15,a,i15)') 'Sent signal ', signal_num, &
5688 ' to PID ', input_state%selected_pid
5689 else
5690 ! Failure - red
5691 write(output_unit, '(a)', advance='no') char(27) // '[1;31m' ! Bold red
5692 write(output_unit, '(a)', advance='no') ' ✗ '
5693 write(output_unit, '(a)', advance='no') char(27) // '[0m'
5694 write(output_unit, '(a,i15,a,i0)') 'Failed to send signal ', signal_num, &
5695 ' to PID ', input_state%selected_pid
5696 write(output_unit, '(a)', advance='no') char(27) // '[33m' ! Yellow
5697 write(output_unit, '(a)') ' (permission denied or process not found)'
5698 write(output_unit, '(a)', advance='no') char(27) // '[0m'
5699 end if
5700 else
5701 ! Invalid signal - red
5702 write(output_unit, '(a)', advance='no') char(27) // '[1;31m' ! Bold red
5703 write(output_unit, '(a)', advance='no') ' ✗ '
5704 write(output_unit, '(a)', advance='no') char(27) // '[0m'
5705 write(output_unit, '(a)', advance='no') 'Invalid signal: '
5706 write(output_unit, '(a)', advance='no') char(27) // '[33m' ! Yellow
5707 write(output_unit, '(a)', advance='no') trim(signal_str)
5708 write(output_unit, '(a)', advance='no') char(27) // '[0m'
5709 write(output_unit, '(a)') ' (use number or SIGTERM, SIGKILL, etc.)'
5710 end if
5711
5712 ! Don't set dirty - we're exiting readline, caller will handle prompt
5713 ! Cleanup is done in Enter key handler
5714 end subroutine
5715
5716 subroutine parse_signal_name(name, signal_num)
5717 character(len=*), intent(in) :: name
5718 integer, intent(out) :: signal_num
5719 character(len=32) :: upper_name
5720 integer :: i
5721
5722 ! Convert to uppercase
5723 upper_name = name
5724 do i = 1, len_trim(upper_name)
5725 if (upper_name(i:i) >= 'a' .and. upper_name(i:i) <= 'z') then
5726 upper_name(i:i) = char(iachar(upper_name(i:i)) - 32)
5727 end if
5728 end do
5729
5730 ! Remove SIG prefix if present
5731 if (upper_name(1:3) == 'SIG') then
5732 upper_name = upper_name(4:)
5733 end if
5734
5735 ! Map common signal names to numbers
5736 select case(trim(upper_name))
5737 case('HUP', 'SIGHUP')
5738 signal_num = 1
5739 case('INT', 'SIGINT')
5740 signal_num = 2
5741 case('QUIT', 'SIGQUIT')
5742 signal_num = 3
5743 case('ILL', 'SIGILL')
5744 signal_num = 4
5745 case('TRAP', 'SIGTRAP')
5746 signal_num = 5
5747 case('ABRT', 'SIGABRT')
5748 signal_num = 6
5749 case('BUS', 'SIGBUS')
5750 signal_num = 7
5751 case('FPE', 'SIGFPE')
5752 signal_num = 8
5753 case('KILL', 'SIGKILL')
5754 signal_num = 9
5755 case('USR1', 'SIGUSR1')
5756 signal_num = 10
5757 case('SEGV', 'SIGSEGV')
5758 signal_num = 11
5759 case('USR2', 'SIGUSR2')
5760 signal_num = 12
5761 case('PIPE', 'SIGPIPE')
5762 signal_num = 13
5763 case('ALRM', 'SIGALRM')
5764 signal_num = 14
5765 case('TERM', 'SIGTERM')
5766 signal_num = 15
5767 case('STKFLT', 'SIGSTKFLT')
5768 signal_num = 16
5769 case('CHLD', 'SIGCHLD')
5770 signal_num = 17
5771 case('CONT', 'SIGCONT')
5772 signal_num = 18
5773 case('STOP', 'SIGSTOP')
5774 signal_num = 19
5775 case('TSTP', 'SIGTSTP')
5776 signal_num = 20
5777 case('TTIN', 'SIGTTIN')
5778 signal_num = 21
5779 case('TTOU', 'SIGTTOU')
5780 signal_num = 22
5781 case default
5782 signal_num = -1 ! Invalid signal
5783 end select
5784 end subroutine
5785
5786 subroutine handle_escape_sequence(input_state, done, prompt)
5787 type(input_state_t), intent(inout) :: input_state
5788 logical, intent(inout) :: done
5789 character(len=*), intent(in) :: prompt
5790 character :: ch1, ch2
5791 logical :: success
5792
5793
5794 ! Check if we're in menu select mode - route arrow keys to menu navigation
5795 if (input_state%in_menu_select) then
5796 ! Try to read the next character to see if it's an arrow key
5797 success = read_single_char(ch1)
5798 if (.not. success) then
5799 ! Just ESC by itself - exit menu
5800 call handle_menu_navigation(input_state, KEY_ESC, done)
5801 return
5802 end if
5803
5804 if (ch1 == '[') then
5805 ! ANSI escape sequence
5806 success = read_single_char(ch2)
5807 if (.not. success) return
5808
5809 select case(ch2)
5810 case('A') ! Up arrow
5811 call handle_menu_navigation(input_state, KEY_UP, done)
5812 case('B') ! Down arrow
5813 call handle_menu_navigation(input_state, KEY_DOWN, done)
5814 case('C') ! Right arrow
5815 call handle_menu_navigation(input_state, KEY_RIGHT, done)
5816 case('D') ! Left arrow
5817 call handle_menu_navigation(input_state, KEY_LEFT, done)
5818 case default
5819 ! Unknown escape sequence in menu mode
5820 continue
5821 end select
5822 end if
5823 return
5824 end if
5825
5826 ! Check if we're in Vi insert mode - ESC switches to command mode
5827 if (input_state%editing_mode == EDITING_MODE_VI .and. &
5828 input_state%vi_mode == VI_MODE_INSERT) then
5829 call handle_vi_mode_switch(input_state, KEY_ESC)
5830 return
5831 end if
5832
5833 ! Try to read the next character
5834 success = read_single_char(ch1)
5835 if (.not. success) then
5836 ! Bare ESC with no follow-up — accept search result for editing
5837 if (input_state%in_search) then
5838 call accept_search_for_editing(input_state)
5839 end if
5840 return
5841 end if
5842
5843 if (ch1 == '[') then
5844 ! ANSI escape sequence
5845 success = read_single_char(ch2)
5846 if (.not. success) then
5847 return
5848 end if
5849
5850 select case(ch2)
5851 case('A') ! Up arrow
5852 ! In search mode, cancel search and restore buffer
5853 if (input_state%in_search) then
5854 call cancel_search(input_state)
5855 else
5856 call handle_history_up(input_state)
5857 end if
5858 case('B') ! Down arrow
5859 ! In search mode, cancel search and restore buffer
5860 if (input_state%in_search) then
5861 call cancel_search(input_state)
5862 else
5863 call handle_history_down(input_state)
5864 end if
5865 case('C') ! Right arrow
5866 ! In search mode, accept search and allow editing
5867 if (input_state%in_search) then
5868 call accept_search_for_editing(input_state)
5869 else
5870 if (input_state%in_prefix_search) call cancel_prefix_search(input_state)
5871 call handle_cursor_right(input_state)
5872 end if
5873 case('D') ! Left arrow
5874 ! In search mode, accept search and allow editing
5875 if (input_state%in_search) then
5876 call accept_search_for_editing(input_state)
5877 else
5878 if (input_state%in_prefix_search) call cancel_prefix_search(input_state)
5879 call handle_cursor_left(input_state)
5880 end if
5881 case('2')
5882 ! Could be bracketed paste (ESC[200~ or ESC[201~) or extended escape
5883 if (input_state%in_prefix_search) call cancel_prefix_search(input_state)
5884 call handle_paste_or_extended(input_state, done)
5885 case('1', '3', '4', '5', '6')
5886 ! Extended escape sequence (e.g., Ctrl+Arrow = ESC[1;5C) or simple (ESC[3~)
5887 if (input_state%in_prefix_search) call cancel_prefix_search(input_state)
5888 call handle_extended_escape_sequence(input_state, done, ch2)
5889 case default
5890 ! Unknown escape sequence - ignore it
5891 continue
5892 end select
5893 else
5894 ! Not '[', so it's an Alt+key combination (ESC followed by character)
5895 if (input_state%in_prefix_search) call cancel_prefix_search(input_state)
5896 ! In search mode, only Alt+Backspace is meaningful — everything else is no-op
5897 if (input_state%in_search) then
5898 if (ch1 == char(127)) then
5899 call search_kill_word(input_state, prompt)
5900 end if
5901 return
5902 end if
5903
5904 select case(ch1)
5905 case('.')
5906 ! Alt+. - Insert last argument from previous command
5907 call handle_yank_last_arg(input_state)
5908 case('b')
5909 ! Alt+b - Move backward one word
5910 call move_to_previous_word(input_state)
5911 case('B')
5912 ! Alt+Shift+b - Extend selection one word back (shift phase, Sprint 1)
5913 ! ESC-uppercase is xterm's encoding for Alt+Shift+letter. Routes through
5914 ! the shift-extending path so move_to_previous_word grows the selection.
5915 module_extending_selection = .true.
5916 call move_to_previous_word(input_state)
5917 module_extending_selection = .false.
5918 case('d')
5919 ! Alt+d - Delete forward one word (emacs standard)
5920 call handle_kill_word_forward(input_state)
5921 case('f')
5922 ! Alt+f - Move forward one word
5923 call move_to_next_word(input_state)
5924 case('F')
5925 ! Alt+Shift+f - Extend selection one word forward (shift phase, Sprint 1)
5926 module_extending_selection = .true.
5927 call move_to_next_word(input_state)
5928 module_extending_selection = .false.
5929 case('j')
5930 ! Alt+j - Jump to directory with fzf
5931 call launch_fzf_directory_browser(input_state)
5932 case('g')
5933 ! Alt+g - Git browser with fzf
5934 call launch_fzf_git_browser(input_state)
5935 case('u')
5936 ! Alt+u - Uppercase word (from cursor to end of word)
5937 call handle_uppercase_word(input_state)
5938 case('l')
5939 ! Alt+l - Lowercase word (from cursor to end of word)
5940 call handle_lowercase_word(input_state)
5941 case('c')
5942 ! Alt+c - Capitalize word (uppercase first char, lowercase rest)
5943 call handle_capitalize_word(input_state)
5944 case('w')
5945 ! Alt+w - Accept one word from autosuggestion
5946 if (input_state%cursor_pos == input_state%length .and. &
5947 input_state%suggestion_length > 0) then
5948 call accept_autosuggestion_word(input_state)
5949 end if
5950 case(char(127))
5951 ! Alt+Backspace - Delete word backward (same as Ctrl+W)
5952 call handle_kill_word(input_state)
5953 case(char(27))
5954 ! Alt+ESC sequence — could be Alt+Delete (ESC ESC [ 3 ~)
5955 block
5956 character :: ach1, ach2, ach3
5957 logical :: asuc
5958 asuc = read_single_char(ach1)
5959 if (asuc .and. ach1 == '[') then
5960 asuc = read_single_char(ach2)
5961 if (asuc .and. ach2 == '3') then
5962 asuc = read_single_char(ach3)
5963 if (asuc .and. ach3 == '~') then
5964 ! Alt+Delete — kill word forward
5965 call handle_kill_word_forward(input_state)
5966 end if
5967 end if
5968 end if
5969 end block
5970 case default
5971 ! Unknown Alt+key combination
5972 continue
5973 end select
5974 end if
5975 end subroutine
5976
5977 ! Handle bracketed paste or extended escape sequences starting with '2'
5978 subroutine handle_paste_or_extended(input_state, done)
5979 type(input_state_t), intent(inout) :: input_state
5980 logical, intent(inout) :: done
5981 character :: ch1, ch2, ch3
5982 logical :: success
5983 character(len=MAX_LINE_LEN) :: paste_buffer
5984 integer :: paste_len, i
5985 character :: ch_paste
5986
5987 if (.false.) print *, done ! Silence unused warning
5988
5989 ! After ESC[2, check next chars for:
5990 ! - 00~ = paste start (ESC[200~)
5991 ! - 01~ = paste end (ESC[201~)
5992 ! - or it's an extended sequence like ESC[2;...
5993
5994 success = read_single_char(ch1)
5995 if (.not. success) return
5996
5997 if (ch1 == '0') then
5998 ! Could be 200~ or 201~
5999 success = read_single_char(ch2)
6000 if (.not. success) return
6001
6002 if (ch2 == '0') then
6003 ! Check for ~ to confirm ESC[200~
6004 success = read_single_char(ch3)
6005 if (.not. success) return
6006
6007 if (ch3 == '~') then
6008 ! PASTE START MARKER DETECTED!
6009 ! Buffer all text until we see ESC[201~
6010
6011 ! Debug output if FORTSH_DEBUG_PASTE is set
6012 block
6013 use iso_fortran_env, only: error_unit
6014 character(len=16) :: debug_paste
6015 integer :: stat
6016 call get_environment_variable('FORTSH_DEBUG_PASTE', debug_paste, status=stat)
6017 if (stat == 0 .and. len_trim(debug_paste) > 0) then
6018 write(error_unit, '(A)') '[DEBUG: PASTE START detected (ESC[200~)]'
6019 end if
6020 end block
6021
6022 paste_len = 0
6023 paste_buffer = ''
6024
6025 ! Read characters until we find ESC[201~
6026 do while (paste_len < MAX_LINE_LEN - 1)
6027 success = read_single_char(ch_paste)
6028 if (.not. success) exit
6029
6030 ! Check if this is the start of the end marker
6031 if (ch_paste == char(27)) then ! ESC
6032 ! Peek ahead for [201~
6033 success = read_single_char(ch1)
6034 if (.not. success) exit
6035 if (ch1 == '[') then
6036 success = read_single_char(ch1)
6037 if (.not. success) exit
6038 if (ch1 == '2') then
6039 success = read_single_char(ch1)
6040 if (.not. success) exit
6041 if (ch1 == '0') then
6042 success = read_single_char(ch1)
6043 if (.not. success) exit
6044 if (ch1 == '1') then
6045 success = read_single_char(ch1)
6046 if (.not. success) exit
6047 if (ch1 == '~') then
6048 ! PASTE END MARKER FOUND!
6049
6050 ! Debug output if FORTSH_DEBUG_PASTE is set
6051 block
6052 use iso_fortran_env, only: error_unit
6053 character(len=16) :: debug_paste
6054 integer :: stat
6055 call get_environment_variable('FORTSH_DEBUG_PASTE', debug_paste, status=stat)
6056 if (stat == 0 .and. len_trim(debug_paste) > 0) then
6057 write(error_unit, '(A,I0,A)') '[DEBUG: PASTE END detected (ESC[201~), buffered ', paste_len, ' chars]'
6058 end if
6059 end block
6060
6061 ! Insert buffered text at cursor position
6062 do i = 1, paste_len
6063 call insert_char_wrapper(input_state, paste_buffer(i:i))
6064 end do
6065 input_state%dirty = .true.
6066 return
6067 end if
6068 end if
6069 end if
6070 end if
6071 end if
6072 ! Not end marker, add ESC and what we read to buffer
6073 paste_len = paste_len + 1
6074 paste_buffer(paste_len:paste_len) = char(27)
6075 if (paste_len < MAX_LINE_LEN) then
6076 paste_len = paste_len + 1
6077 paste_buffer(paste_len:paste_len) = ch1
6078 end if
6079 else
6080 ! Regular character, add to paste buffer
6081 paste_len = paste_len + 1
6082 paste_buffer(paste_len:paste_len) = ch_paste
6083 end if
6084 end do
6085 end if
6086 else if (ch2 == '1') then
6087 ! ESC[201~ - paste end without start (shouldn't happen, ignore)
6088 success = read_single_char(ch3)
6089 return
6090 end if
6091 end if
6092
6093 ! Not a paste marker, could be extended escape (rare for '2')
6094 ! Just ignore it for now
6095 end subroutine
6096
6097 ! Handle extended escape sequences like ESC[1;5C (Ctrl+Right Arrow)
6098 subroutine handle_extended_escape_sequence(input_state, done, initial_digit)
6099 type(input_state_t), intent(inout) :: input_state
6100 logical, intent(inout) :: done
6101 character, intent(in) :: initial_digit
6102 character :: ch, modifier, terminator
6103 logical :: success
6104 integer :: count
6105
6106 ! Extended sequences have format: ESC[1;5C
6107 ! We've already read '1' (or similar), now read rest of sequence
6108 ! Format: [digit];[modifier][letter]
6109
6110 ! Read until we find a semicolon or letter
6111 count = 0
6112 do while (count < 10) ! Safety limit
6113 success = read_single_char(ch)
6114 if (.not. success) return
6115
6116 if (ch == ';') then
6117 ! Found semicolon, next char is the modifier
6118 success = read_single_char(modifier)
6119 if (.not. success) return
6120
6121 ! Read the terminating letter
6122 success = read_single_char(terminator)
6123 if (.not. success) return
6124
6125 ! In search mode, consume the sequence but don't act on it
6126 if (input_state%in_search) then
6127 return
6128 end if
6129
6130 ! Check for Ctrl+Right arrow (modifier=5, terminator=C)
6131 if (modifier == '5' .and. terminator == 'C') then
6132 ! Ctrl+Right arrow - accept one word from autosuggestion
6133 if (input_state%cursor_pos == input_state%length .and. &
6134 input_state%suggestion_length > 0) then
6135 call accept_autosuggestion_word(input_state)
6136 end if
6137 ! ============================================================
6138 ! Shift-phase selection extension (modifiers 2 and 6)
6139 ! Sprint 1: state only; Sprint 2 adds the visible highlight.
6140 ! ============================================================
6141 ! Shift+Left — extend selection one char back
6142 else if (modifier == '2' .and. terminator == 'D') then
6143 module_extending_selection = .true.
6144 call handle_cursor_left(input_state)
6145 module_extending_selection = .false.
6146 ! Shift+Right — extend selection one char forward
6147 else if (modifier == '2' .and. terminator == 'C') then
6148 module_extending_selection = .true.
6149 call handle_cursor_right(input_state)
6150 module_extending_selection = .false.
6151 ! Shift+Up — treat as Shift+Home on single-line prompt (#25)
6152 else if (modifier == '2' .and. terminator == 'A') then
6153 module_extending_selection = .true.
6154 call handle_home(input_state)
6155 module_extending_selection = .false.
6156 ! Shift+Down — treat as Shift+End on single-line prompt (#25)
6157 else if (modifier == '2' .and. terminator == 'B') then
6158 module_extending_selection = .true.
6159 call handle_end(input_state)
6160 module_extending_selection = .false.
6161 ! Shift+Home — extend selection to start of line
6162 else if (modifier == '2' .and. terminator == 'H') then
6163 module_extending_selection = .true.
6164 call handle_home(input_state)
6165 module_extending_selection = .false.
6166 ! Shift+End — extend selection to end of line
6167 else if (modifier == '2' .and. terminator == 'F') then
6168 module_extending_selection = .true.
6169 call handle_end(input_state)
6170 module_extending_selection = .false.
6171 ! Ctrl+Shift+Left — extend selection by one word back
6172 else if (modifier == '6' .and. terminator == 'D') then
6173 module_extending_selection = .true.
6174 call move_to_previous_word(input_state)
6175 module_extending_selection = .false.
6176 ! Ctrl+Shift+Right — extend selection by one word forward
6177 else if (modifier == '6' .and. terminator == 'C') then
6178 module_extending_selection = .true.
6179 call move_to_next_word(input_state)
6180 module_extending_selection = .false.
6181 ! Check for Alt+Left/Right for word movement (modifier=3)
6182 else if (modifier == '3' .and. terminator == 'D') then
6183 ! Alt+Left - Move cursor backward one word (standard behavior)
6184 call move_to_previous_word(input_state)
6185 else if (modifier == '3' .and. terminator == 'C') then
6186 ! Alt+Right - Move cursor forward one word (standard behavior)
6187 call move_to_next_word(input_state)
6188 ! Check for Alt+Shift+Up arrow (modifier=4, terminator=A)
6189 else if (modifier == '4' .and. terminator == 'A') then
6190 ! Alt+Shift+Up - Go to parent directory (cd ..)
6191 call handle_alt_up(input_state, done)
6192 ! Check for Alt+Shift+Left arrow (modifier=4, terminator=D)
6193 else if (modifier == '4' .and. terminator == 'D') then
6194 ! Alt+Shift+Left - Go to previous directory (prevd)
6195 call handle_alt_left(input_state, done)
6196 ! Check for Alt+Shift+Right arrow (modifier=4, terminator=C)
6197 else if (modifier == '4' .and. terminator == 'C') then
6198 ! Alt+Shift+Right - Go to next directory (nextd)
6199 call handle_alt_right(input_state, done)
6200 ! Alt+Delete: modifier=3, initial_digit=3, terminator=~
6201 else if (modifier == '3' .and. terminator == '~' .and. initial_digit == '3') then
6202 call handle_kill_word_forward(input_state)
6203 ! Ctrl+Delete: modifier=5, initial_digit=3, terminator=~
6204 else if (modifier == '5' .and. terminator == '~' .and. initial_digit == '3') then
6205 call handle_kill_word_forward(input_state)
6206 end if
6207 ! For other extended sequences, we just consume them
6208 return
6209 else if (ch == '~') then
6210 ! Tilde-terminated sequence: ESC[3~ (delete), ESC[1~ (home), ESC[4~ (end), etc.
6211 if (.not. input_state%in_search) then
6212 select case(initial_digit)
6213 case('3') ! Delete key — forward delete character
6214 call handle_forward_delete_char(input_state)
6215 case('1') ! Home key
6216 call handle_home(input_state)
6217 case('4') ! End key
6218 call handle_end(input_state)
6219 case default
6220 continue ! Page up/down — no action
6221 end select
6222 end if
6223 return
6224 else if ((ch >= 'A' .and. ch <= 'Z') .or. (ch >= 'a' .and. ch <= 'z')) then
6225 ! Found letter terminator without semicolon, done
6226 return
6227 end if
6228
6229 count = count + 1
6230 end do
6231 end subroutine
6232
6233 subroutine handle_cursor_left(input_state)
6234 use iso_fortran_env, only: error_unit
6235 type(input_state_t), intent(inout) :: input_state
6236 integer :: old_row, old_col, new_row, new_col, term_cols
6237 integer :: bytes_to_move
6238 integer :: old_cursor_pos
6239 logical :: debug_utf8
6240 integer :: debug_stat
6241
6242 ! Shift-phase: plain Left with an active selection snaps cursor to the
6243 ! LEFT edge and clears selection, without further motion (#25, #26).
6244 ! Char-motion uses the snap-to-edge convention (matches VS Code/TextEdit).
6245 if (input_state%selection_active .and. .not. module_extending_selection) then
6246 input_state%cursor_pos = min(input_state%selection_anchor, input_state%cursor_pos)
6247 call collapse_selection(input_state)
6248 input_state%dirty = .true.
6249 return
6250 end if
6251
6252 ! Capture pre-motion cursor so shift-extending can anchor the selection.
6253 old_cursor_pos = input_state%cursor_pos
6254
6255 ! Check if UTF-8 debug mode is enabled
6256 call get_environment_variable('FORTSH_DEBUG_UTF8', status=debug_stat)
6257 debug_utf8 = (debug_stat == 0)
6258
6259 if (input_state%cursor_pos > 0) then
6260 ! Get terminal size
6261 call get_terminal_size_from_env(term_cols)
6262
6263 ! Use the tracked cursor position as the starting point
6264 ! This is more accurate than recalculating, especially after direct character output
6265 old_row = module_cursor_screen_row
6266 old_col = module_cursor_screen_col
6267
6268 if (debug_utf8) then
6269 write(error_unit, '(a,i0,a,i0,a,i0)') '[CURSOR_LEFT] BEFORE: cursor_pos=', &
6270 input_state%cursor_pos, ' old_row=', old_row, ' old_col=', old_col
6271 end if
6272
6273 ! Determine how many bytes to move left (1-4 for complete UTF-8 character)
6274 bytes_to_move = utf8_char_bytes_before_cursor(input_state)
6275 if (bytes_to_move <= 0) bytes_to_move = 1
6276
6277 if (debug_utf8) then
6278 write(error_unit, '(a,i0)') '[CURSOR_LEFT] bytes_to_move=', bytes_to_move
6279 end if
6280
6281 ! Move cursor left in buffer by complete UTF-8 character
6282 input_state%cursor_pos = input_state%cursor_pos - bytes_to_move
6283
6284 ! Calculate new cursor position
6285 call cursor_get_row_col(input_state%menu_prompt, input_state%cursor_pos, term_cols, new_row, new_col)
6286
6287 if (debug_utf8) then
6288 write(error_unit, '(a,i0,a,i0,a,i0)') '[CURSOR_LEFT] AFTER: cursor_pos=', &
6289 input_state%cursor_pos, ' new_row=', new_row, ' new_col=', new_col
6290 end if
6291
6292 ! Move cursor on screen (handles line wrapping)
6293 call cursor_move(old_row, old_col, new_row, new_col)
6294
6295 ! Update module cursor tracking
6296 module_cursor_screen_row = new_row
6297 module_cursor_screen_col = new_col
6298 end if
6299
6300 ! Shift-phase: if this call is extending a selection, update it now.
6301 if (module_extending_selection) then
6302 call update_selection_on_shift_motion(input_state, old_cursor_pos)
6303 end if
6304 end subroutine
6305
6306 subroutine handle_cursor_right(input_state)
6307 type(input_state_t), intent(inout) :: input_state
6308 integer :: old_row, old_col, new_row, new_col, term_cols
6309 integer :: bytes_to_move
6310 integer :: old_cursor_pos
6311
6312 ! Shift-phase: plain Right with an active selection snaps cursor to the
6313 ! RIGHT edge and clears selection, without further motion (#25, #26).
6314 if (input_state%selection_active .and. .not. module_extending_selection) then
6315 input_state%cursor_pos = max(input_state%selection_anchor, input_state%cursor_pos)
6316 if (input_state%cursor_pos > input_state%length) then
6317 input_state%cursor_pos = input_state%length
6318 end if
6319 call collapse_selection(input_state)
6320 input_state%dirty = .true.
6321 return
6322 end if
6323
6324 old_cursor_pos = input_state%cursor_pos
6325
6326 if (input_state%cursor_pos < input_state%length) then
6327 ! Get terminal size
6328 call get_terminal_size_from_env(term_cols)
6329
6330 ! Use the tracked cursor position as the starting point
6331 ! This is more accurate than recalculating, especially after direct character output
6332 old_row = module_cursor_screen_row
6333 old_col = module_cursor_screen_col
6334
6335 ! Determine how many bytes to move right (1-4 for complete UTF-8 character)
6336 bytes_to_move = utf8_char_bytes_at_cursor(input_state)
6337 if (bytes_to_move <= 0) bytes_to_move = 1
6338
6339 ! Move cursor right in buffer by complete UTF-8 character
6340 input_state%cursor_pos = input_state%cursor_pos + bytes_to_move
6341
6342 ! Don't go past end of buffer
6343 if (input_state%cursor_pos > input_state%length) then
6344 input_state%cursor_pos = input_state%length
6345 end if
6346
6347 ! Calculate new cursor position
6348 call cursor_get_row_col(input_state%menu_prompt, input_state%cursor_pos, term_cols, new_row, new_col)
6349
6350 ! Move cursor on screen (handles line wrapping)
6351 call cursor_move(old_row, old_col, new_row, new_col)
6352
6353 ! Update module cursor tracking
6354 module_cursor_screen_row = new_row
6355 module_cursor_screen_col = new_col
6356 else if (input_state%cursor_pos == input_state%length .and. input_state%suggestion_length > 0 &
6357 .and. .not. module_extending_selection) then
6358 ! At end of line with suggestion - accept it (but not during shift-extension —
6359 ! Shift+Right at the end of the line should not eat an autosuggestion).
6360 call accept_autosuggestion(input_state)
6361 end if
6362
6363 ! Shift-phase: if this call is extending a selection, update it now.
6364 if (module_extending_selection) then
6365 call update_selection_on_shift_motion(input_state, old_cursor_pos)
6366 end if
6367 end subroutine
6368
6369 subroutine handle_history_up(input_state)
6370 type(input_state_t), intent(inout) :: input_state
6371 character(len=MAX_LINE_LEN) :: history_line
6372 logical :: found
6373
6374 ! History navigation replaces the buffer wholesale — selection byte
6375 ! offsets from the old buffer would point into stale data (#27).
6376 if (input_state%selection_active) call collapse_selection(input_state)
6377
6378 ! If there's text on the line and we're not yet in any history mode,
6379 ! enter prefix search mode (fish-style)
6380 if (.not. input_state%in_history .and. .not. input_state%in_prefix_search &
6381 .and. input_state%length > 0) then
6382 call state_buffer_save(input_state)
6383 ! Freeze the prefix
6384 input_state%prefix_search_len = input_state%length
6385 input_state%prefix_search_text = ''
6386 call state_buffer_get(input_state, input_state%prefix_search_text)
6387 input_state%in_prefix_search = .true.
6388 input_state%prefix_search_idx = 0 ! 0 = at present
6389 ! Clear shadow text — prefix search replaces it
6390 input_state%suggestion_length = 0
6391 input_state%suggestion = ''
6392 end if
6393
6394 ! Prefix search: find previous match
6395 if (input_state%in_prefix_search) then
6396 call prefix_search_move(input_state, -1)
6397 return
6398 end if
6399
6400 ! Standard history navigation (empty line)
6401 if (.not. input_state%in_history) then
6402 call state_buffer_save(input_state)
6403 input_state%history_pos = command_history%count + 1
6404 input_state%in_history = .true.
6405 end if
6406
6407 if (input_state%history_pos > 1) then
6408 input_state%history_pos = input_state%history_pos - 1
6409 call get_history_line(input_state%history_pos, history_line, found)
6410 if (found) then
6411 call state_buffer_set(input_state, history_line)
6412 input_state%length = len_trim(history_line)
6413 input_state%cursor_pos = input_state%length
6414 input_state%dirty = .true.
6415 end if
6416 end if
6417 end subroutine
6418
6419 subroutine handle_history_down(input_state)
6420 type(input_state_t), intent(inout) :: input_state
6421 character(len=MAX_LINE_LEN) :: history_line
6422 logical :: found
6423
6424 ! Buffer replacement — clear any stale selection (#27).
6425 if (input_state%selection_active) call collapse_selection(input_state)
6426
6427 ! Prefix search: find next match or return to present
6428 if (input_state%in_prefix_search) then
6429 call prefix_search_move(input_state, +1)
6430 return
6431 end if
6432
6433 ! Only navigate down if we're currently in history
6434 if (.not. input_state%in_history) return
6435
6436 ! Move down in history
6437 if (input_state%history_pos < command_history%count) then
6438 input_state%history_pos = input_state%history_pos + 1
6439 call get_history_line(input_state%history_pos, history_line, found)
6440
6441 if (found) then
6442 call state_buffer_set(input_state, history_line)
6443 input_state%length = len_trim(history_line)
6444 input_state%cursor_pos = input_state%length
6445 input_state%dirty = .true.
6446 end if
6447 else if (input_state%history_pos <= command_history%count) then
6448 ! Reached the end of history, restore original input
6449 call state_buffer_restore(input_state)
6450 #ifdef USE_C_STRINGS
6451 input_state%length = c_string_length(input_state%original_buffer_c)
6452 #elif defined(USE_MEMORY_POOL)
6453 input_state%length = len_trim(input_state%original_buffer_ref%data)
6454 #else
6455 input_state%length = len_trim(input_state%original_buffer)
6456 #endif
6457 input_state%cursor_pos = input_state%length
6458 input_state%history_pos = command_history%count + 1
6459 input_state%in_history = .false.
6460 input_state%dirty = .true.
6461 end if
6462 end subroutine
6463
6464 ! --------------------------------------------------------------------------
6465 ! Prefix history search: find next/previous history entry matching prefix.
6466 ! direction: -1 = backward (older), +1 = forward (newer)
6467 ! --------------------------------------------------------------------------
6468 subroutine prefix_search_move(input_state, direction)
6469 type(input_state_t), intent(inout) :: input_state
6470 integer, intent(in) :: direction
6471
6472 character(len=MAX_LINE_LEN) :: history_line
6473 integer :: i, start_idx, hist_len, j
6474 logical :: matches, found
6475
6476 if (command_history%count == 0) return
6477
6478 ! Search backward (older entries)
6479 if (direction < 0) then
6480 ! Determine starting point
6481 if (input_state%prefix_search_idx == 0) then
6482 ! At present — start from most recent
6483 start_idx = command_history%count
6484 else
6485 start_idx = input_state%prefix_search_idx - 1
6486 end if
6487
6488 do i = start_idx, 1, -1
6489 call get_history_line(i, history_line, found)
6490 if (.not. found) cycle
6491 hist_len = len_trim(history_line)
6492 if (hist_len <= input_state%prefix_search_len) cycle
6493
6494 ! Check prefix match character-by-character
6495 matches = .true.
6496 do j = 1, input_state%prefix_search_len
6497 if (history_line(j:j) /= input_state%prefix_search_text(j:j)) then
6498 matches = .false.
6499 exit
6500 end if
6501 end do
6502
6503 if (matches) then
6504 input_state%prefix_search_idx = i
6505 call state_buffer_set(input_state, history_line)
6506 input_state%length = hist_len
6507 input_state%cursor_pos = input_state%length
6508 input_state%suggestion_length = 0
6509 input_state%suggestion = ''
6510 input_state%dirty = .true.
6511 return
6512 end if
6513 end do
6514 ! No match found — flash reverse video to indicate no match
6515 input_state%prefix_search_flash = .true.
6516 input_state%dirty = .true.
6517
6518 else
6519 ! Search forward (newer entries)
6520 if (input_state%prefix_search_idx == 0) return ! Already at present
6521
6522 start_idx = input_state%prefix_search_idx + 1
6523
6524 do i = start_idx, command_history%count
6525 call get_history_line(i, history_line, found)
6526 if (.not. found) cycle
6527 hist_len = len_trim(history_line)
6528 if (hist_len <= input_state%prefix_search_len) cycle
6529
6530 matches = .true.
6531 do j = 1, input_state%prefix_search_len
6532 if (history_line(j:j) /= input_state%prefix_search_text(j:j)) then
6533 matches = .false.
6534 exit
6535 end if
6536 end do
6537
6538 if (matches) then
6539 input_state%prefix_search_idx = i
6540 call state_buffer_set(input_state, history_line)
6541 input_state%length = hist_len
6542 input_state%cursor_pos = input_state%length
6543 input_state%suggestion_length = 0
6544 input_state%suggestion = ''
6545 input_state%dirty = .true.
6546 return
6547 end if
6548 end do
6549
6550 ! No more forward matches — return to present (original text)
6551 call state_buffer_restore(input_state)
6552 input_state%length = input_state%prefix_search_len
6553 input_state%cursor_pos = input_state%length
6554 input_state%prefix_search_idx = 0
6555 input_state%dirty = .true.
6556 call update_autosuggestion(input_state)
6557 end if
6558 end subroutine
6559
6560 ! Cancel prefix search and accept current buffer content
6561 subroutine cancel_prefix_search(input_state)
6562 type(input_state_t), intent(inout) :: input_state
6563 input_state%in_prefix_search = .false.
6564 input_state%prefix_search_len = 0
6565 input_state%prefix_search_idx = 0
6566 input_state%prefix_search_flash = .false.
6567 end subroutine
6568
6569 ! Calculate display width of UTF-8 character
6570 ! Returns 1 for ASCII, 2 for wide chars (emoji, CJK), 0 for combining
6571 function utf8_char_width(byte1) result(width)
6572 character(len=1), intent(in) :: byte1
6573 integer :: width
6574 integer :: code
6575
6576 code = iachar(byte1)
6577
6578 ! ASCII characters (0-127) have width 1
6579 if (code < 128) then
6580 width = 1
6581 return
6582 end if
6583
6584 ! UTF-8 multi-byte character
6585 ! Simple heuristic: assume wide (emoji, CJK)
6586 ! Could be improved with full Unicode width tables
6587 if (code >= 192) then ! Start of 2, 3, or 4 byte sequence
6588 width = 2 ! Assume wide
6589 else
6590 width = 1 ! Continuation byte or other
6591 end if
6592 end function utf8_char_width
6593
6594 ! Calculate visual length of string (excluding ANSI escape codes)
6595 ! Handles CSI (ESC[...m), OSC (ESC]...BEL), multi-line prompts, and UTF-8 wide chars
6596 function visual_length(str) result(vlen)
6597 character(len=*), intent(in) :: str
6598 integer :: vlen
6599 integer :: i, slen
6600 integer :: state
6601 integer :: terminator_code
6602 integer :: last_newline_pos
6603
6604 ! State machine for parsing escape sequences
6605 integer, parameter :: STATE_NORMAL = 0
6606 integer, parameter :: STATE_ESC = 1
6607 integer, parameter :: STATE_CSI = 2
6608 integer, parameter :: STATE_OSC = 3
6609
6610 vlen = 0
6611 last_newline_pos = 0
6612 slen = len_trim(str)
6613 ! Stop at first null byte (buffer padding)
6614 ! len_trim doesn't strip nulls, so we must scan for them
6615 block
6616 integer :: null_scan
6617 do null_scan = 1, slen
6618 if (iachar(str(null_scan:null_scan)) == 0) then
6619 slen = null_scan - 1
6620 exit
6621 end if
6622 end do
6623 end block
6624 state = STATE_NORMAL
6625
6626 i = 1
6627 do while (i <= slen)
6628 select case (state)
6629 case (STATE_NORMAL)
6630 if (str(i:i) == char(27)) then ! ESC
6631 state = STATE_ESC
6632 i = i + 1
6633 else if (str(i:i) == char(0)) then ! NUL
6634 ! Null byte from buffer padding - skip
6635 i = i + 1
6636 else if (str(i:i) == char(13)) then ! CR
6637 ! Carriage return - doesn't add to visual length
6638 i = i + 1
6639 else if (str(i:i) == char(10)) then ! LF
6640 ! Newline resets visual position (for multi-line prompts)
6641 vlen = 0
6642 last_newline_pos = i
6643 i = i + 1
6644 else
6645 ! Regular character - count it (account for wide UTF-8 chars)
6646 vlen = vlen + utf8_char_width(str(i:i))
6647 i = i + 1
6648 end if
6649
6650 case (STATE_ESC)
6651 if (str(i:i) == '[') then
6652 ! CSI sequence: ESC[...[@-~]
6653 state = STATE_CSI
6654 i = i + 1
6655 else if (str(i:i) == ']') then
6656 ! OSC sequence: ESC]...BEL or ESC]...ESC\
6657 state = STATE_OSC
6658 i = i + 1
6659 else
6660 ! Other escape sequence (e.g., ESC c for reset)
6661 ! Skip this character and return to normal
6662 state = STATE_NORMAL
6663 i = i + 1
6664 end if
6665
6666 case (STATE_CSI)
6667 ! CSI sequences end with character in range [@-~] (64-126)
6668 terminator_code = iachar(str(i:i))
6669 if (terminator_code >= 64 .and. terminator_code <= 126) then
6670 ! Found terminator (includes letters, @, and punctuation)
6671 state = STATE_NORMAL
6672 end if
6673 i = i + 1
6674
6675 case (STATE_OSC)
6676 ! OSC sequences end with BEL (07) or ST (ESC\)
6677 if (str(i:i) == char(7)) then ! BEL
6678 state = STATE_NORMAL
6679 i = i + 1
6680 else if (i < slen .and. str(i:i) == char(27) .and. str(i+1:i+1) == '\') then
6681 ! ST = ESC\
6682 state = STATE_NORMAL
6683 i = i + 2
6684 else
6685 i = i + 1
6686 end if
6687 end select
6688 end do
6689
6690 ! Debug: log visual_length result for multi-line prompts
6691 if (last_newline_pos > 0 .and. slen > 10) then
6692 end if
6693 end function
6694
6695 ! ===========================================================================
6696 ! Fuzzy Matching Functions
6697 ! ===========================================================================
6698
6699 ! Calculate fuzzy match score (higher = better match)
6700 ! Returns -1 if no match (pattern chars not found in order)
6701 ! Returns 0+ for matches with bonus points for:
6702 ! - Consecutive character matches
6703 ! - Matches at word boundaries
6704 ! - Matches at start of string
6705 function fuzzy_match_score(pattern, candidate) result(score)
6706 character(len=*), intent(in) :: pattern, candidate
6707 integer :: score
6708
6709 integer :: pattern_len, candidate_len
6710 integer :: pattern_idx, candidate_idx
6711 integer :: match_positions(MAX_LINE_LEN)
6712 integer :: num_matches, i
6713 integer :: consecutive_bonus, boundary_bonus
6714 logical :: case_match, is_prefix_match
6715 character :: pattern_char, candidate_char
6716
6717 ! Initialize match_positions to avoid uninitialized warning
6718 match_positions = 0
6719
6720 pattern_len = len_trim(pattern)
6721 candidate_len = len_trim(candidate)
6722
6723 ! Empty pattern matches everything with base score
6724 if (pattern_len == 0) then
6725 score = 100
6726 return
6727 end if
6728
6729 ! Pattern longer than candidate = no match
6730 if (pattern_len > candidate_len) then
6731 score = -1
6732 return
6733 end if
6734
6735 ! Require prefix match unless fuzzy-complete is enabled.
6736 ! With fuzzy off (default): behaves like bash/zsh — only prefix matches.
6737 ! With fuzzy on (set -o fuzzy-complete): short patterns still require
6738 ! prefix, longer patterns allow fuzzy subsequence matching.
6739 if (.not. global_fuzzy_complete .or. pattern_len <= 3) then
6740 is_prefix_match = .true.
6741 do i = 1, pattern_len
6742 if (to_lowercase(pattern(i:i)) /= to_lowercase(candidate(i:i))) then
6743 is_prefix_match = .false.
6744 exit
6745 end if
6746 end do
6747 if (.not. is_prefix_match) then
6748 score = -1
6749 return
6750 end if
6751 end if
6752
6753 ! Find all pattern characters in order
6754 pattern_idx = 1
6755 num_matches = 0
6756
6757 do candidate_idx = 1, candidate_len
6758 if (pattern_idx > pattern_len) exit
6759
6760 pattern_char = pattern(pattern_idx:pattern_idx)
6761 candidate_char = candidate(candidate_idx:candidate_idx)
6762
6763 ! Case-insensitive comparison
6764 if (to_lowercase(pattern_char) == to_lowercase(candidate_char)) then
6765 num_matches = num_matches + 1
6766 match_positions(num_matches) = candidate_idx
6767 pattern_idx = pattern_idx + 1
6768 end if
6769 end do
6770
6771 ! Not all pattern characters found = no match
6772 if (pattern_idx <= pattern_len) then
6773 score = -1
6774 return
6775 end if
6776
6777 ! Base score: 100 points for matching
6778 score = 100
6779
6780 ! Bonus for matching at start
6781 if (match_positions(1) == 1) then
6782 score = score + 50
6783 end if
6784
6785 ! Bonus for consecutive matches
6786 consecutive_bonus = 0
6787 do i = 2, num_matches
6788 if (match_positions(i) == match_positions(i-1) + 1) then
6789 consecutive_bonus = consecutive_bonus + 10
6790 end if
6791 end do
6792 score = score + consecutive_bonus
6793
6794 ! Bonus for matches at word boundaries (after space, -, _, /)
6795 boundary_bonus = 0
6796 do i = 1, num_matches
6797 if (match_positions(i) > 1) then
6798 candidate_char = candidate(match_positions(i)-1:match_positions(i)-1)
6799 if (candidate_char == ' ' .or. candidate_char == '-' .or. &
6800 candidate_char == '_' .or. candidate_char == '/') then
6801 boundary_bonus = boundary_bonus + 15
6802 end if
6803 end if
6804 end do
6805 score = score + boundary_bonus
6806
6807 ! Bonus for case-sensitive match
6808 case_match = .true.
6809 do i = 1, num_matches
6810 pattern_char = pattern(i:i)
6811 candidate_char = candidate(match_positions(i):match_positions(i))
6812 if (pattern_char /= candidate_char) then
6813 case_match = .false.
6814 exit
6815 end if
6816 end do
6817 if (case_match) then
6818 score = score + 20
6819 end if
6820
6821 ! Penalty for longer candidates (prefer shorter matches)
6822 score = score - (candidate_len - pattern_len)
6823
6824 ! Penalty for gaps between matches
6825 do i = 2, num_matches
6826 score = score - (match_positions(i) - match_positions(i-1) - 1)
6827 end do
6828 end function
6829
6830 ! Helper: convert character to lowercase
6831 function to_lowercase(c) result(lower)
6832 character, intent(in) :: c
6833 character :: lower
6834 integer :: ascii_val
6835
6836 ascii_val = ichar(c)
6837 if (ascii_val >= ichar('A') .and. ascii_val <= ichar('Z')) then
6838 lower = char(ascii_val + 32)
6839 else
6840 lower = c
6841 end if
6842 end function
6843
6844 ! Sort completions by fuzzy match score (bubble sort - good enough for small arrays)
6845 subroutine sort_completions_by_score(scored_completions, count)
6846 type(scored_completion_t), intent(inout) :: scored_completions(:)
6847 integer, intent(in) :: count
6848
6849 type(scored_completion_t) :: temp
6850 integer :: i, j
6851 logical :: swapped
6852
6853 ! Bubble sort (descending order - highest scores first)
6854 do i = 1, count - 1
6855 swapped = .false.
6856 do j = 1, count - i
6857 if (scored_completions(j)%score < scored_completions(j+1)%score) then
6858 temp = scored_completions(j)
6859 scored_completions(j) = scored_completions(j+1)
6860 scored_completions(j+1) = temp
6861 swapped = .true.
6862 end if
6863 end do
6864 if (.not. swapped) exit
6865 end do
6866 end subroutine
6867
6868 subroutine redraw_line(prompt, input_state)
6869 character(len=*), intent(in) :: prompt
6870 type(input_state_t), intent(in) :: input_state
6871 character(len=:), allocatable :: highlighted ! Heap allocation to avoid stack overflow
6872 integer :: highlighted_len
6873 integer :: term_rows, term_cols
6874 integer :: prompt_visual_len, current_line
6875 integer :: cursor_visual_pos
6876 integer :: i, k, suggestion_display_len, available_space
6877 logical :: success
6878 character(len=MAX_LINE_LEN) :: temp_buf ! For buffer extraction
6879
6880 ! Allocate highlight buffer on heap (too large for stack)
6881 ! Do NOT use 'highlighted = ...' — deferred-length allocatable assignment
6882 ! reallocates to match RHS length, causing heap corruption downstream
6883 allocate(character(len=MAX_HIGHLIGHT_LEN) :: highlighted)
6884 highlighted_len = 0
6885
6886 ! Get terminal size
6887 success = get_terminal_size(term_rows, term_cols)
6888 if (.not. success .or. term_cols <= 0) then
6889 term_cols = 80 ! Fallback
6890 end if
6891
6892 ! Additional safety check
6893 if (term_cols < 20) then
6894 term_cols = 80 ! Ensure reasonable minimum
6895 end if
6896
6897 ! Calculate visual length of prompt (excluding ANSI codes)
6898 prompt_visual_len = visual_length(prompt)
6899
6900 ! Safety check for prompt length
6901 if (prompt_visual_len < 0) then
6902 prompt_visual_len = 0
6903 end if
6904
6905 ! Calculate current cursor position in visual characters (add 1 for space after prompt)
6906 cursor_visual_pos = prompt_visual_len + 1 + input_state%cursor_pos
6907
6908 ! Calculate which line the cursor is currently on (0-indexed)
6909 ! Extra safety: ensure term_cols is positive before division
6910 if (term_cols > 0) then
6911 current_line = cursor_visual_pos / term_cols
6912 else
6913 current_line = 0
6914 end if
6915
6916 ! Safety check: limit current_line to reasonable value
6917 if (current_line < 0) current_line = 0
6918 if (current_line > 100) current_line = 0 ! Probably an error
6919
6920 ! Move cursor up to the first line (where prompt starts)
6921 ! IMPORTANT: Only move up if we're not already at top (avoid negative positioning)
6922 if (current_line > 0) then
6923 do i = 1, current_line
6924 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
6925 end do
6926 end if
6927
6928 ! Move to beginning of current line
6929 write(output_unit, '(a)', advance='no') ESC_MOVE_BOL
6930
6931 ! Clear from cursor to end of screen (clears all wrapped lines)
6932 write(output_unit, '(a)', advance='no') char(27) // '[J'
6933
6934 ! Redraw prompt and full buffer with syntax highlighting
6935 write(output_unit, '(a)', advance='no') prompt
6936 write(output_unit, '(a)', advance='no') ' ' ! Space after prompt
6937 if (input_state%length > 0) then
6938 ! Extract buffer for highlighting
6939 call state_buffer_get(input_state, temp_buf)
6940 call highlight_command_line(temp_buf(:input_state%length), highlighted, highlighted_len)
6941 if (highlighted_len > 0 .and. highlighted_len <= MAX_HIGHLIGHT_LEN) then
6942 write(output_unit, '(a)', advance='no') highlighted(1:highlighted_len)
6943 end if
6944 end if
6945
6946 ! Display autosuggestion if cursor is at end
6947 ! IMPORTANT: Truncate suggestion to prevent wrapping beyond terminal width
6948 if (input_state%suggestion_length > 0 .and. input_state%cursor_pos == input_state%length) then
6949 ! Calculate available space on current line (add 1 for space after prompt)
6950 available_space = term_cols - mod(prompt_visual_len + 1 + input_state%length, term_cols)
6951
6952 ! Safety check: ensure available_space is positive
6953 if (available_space < 0) available_space = 0
6954
6955 ! Ensure we have enough space (need at least 2 chars: 1 for suggestion + 1 for cursor)
6956 if (available_space > 2) then
6957 ! Truncate suggestion if it would overflow the line
6958 suggestion_display_len = min(input_state%suggestion_length, available_space - 1)
6959
6960 ! Additional safety check
6961 if (suggestion_display_len < 0) suggestion_display_len = 0
6962 if (suggestion_display_len > MAX_LINE_LEN) suggestion_display_len = 0
6963
6964 if (suggestion_display_len > 0) then
6965 ! Use bright black (gray) color for suggestions - ANSI code 90
6966 ! This is more visible than dim mode and better supported across terminals
6967 write(output_unit, '(a)', advance='no') char(27) // '[90m'
6968
6969 ! Display suggestion character-by-character (avoid substring)
6970 do k = 1, suggestion_display_len
6971 write(output_unit, '(a)', advance='no') input_state%suggestion(k:k)
6972 end do
6973
6974 write(output_unit, '(a)', advance='no') char(27) // '[0m' ! Reset
6975
6976 ! Move cursor back using simple cursor-left commands
6977 do k = 1, suggestion_display_len
6978 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
6979 end do
6980 end if
6981 end if
6982 end if
6983
6984 ! Position cursor correctly (if not at end of input)
6985 if (input_state%cursor_pos < input_state%length) then
6986 ! Cursor not at end - move back to correct position
6987 do i = 1, input_state%length - input_state%cursor_pos
6988 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
6989 end do
6990 end if
6991
6992 flush(output_unit)
6993
6994 ! Deallocate heap-allocated buffer
6995 if (allocated(highlighted)) deallocate(highlighted)
6996 end subroutine
6997
6998 ! Partial redraw - only from cursor to end (reduces flashing)
6999 subroutine redraw_from_cursor(input_state)
7000 use syntax_highlight, only: highlight_command_line
7001 type(input_state_t), intent(in) :: input_state
7002 character(len=:), allocatable :: highlighted ! Heap allocation to avoid stack overflow
7003 integer :: i, cursor_col, highlighted_len
7004 character(len=MAX_LINE_LEN) :: temp_buf ! For buffer extraction
7005
7006 ! Allocate highlight buffer on heap (too large for stack)
7007 ! Do NOT use 'highlighted = ...' — deferred-length allocatable assignment
7008 ! reallocates to match RHS length, causing heap corruption downstream
7009 allocate(character(len=MAX_HIGHLIGHT_LEN) :: highlighted)
7010 highlighted_len = 0
7011
7012 if (input_state%length == 0) return
7013
7014 ! Save current cursor column (we're already at the right position)
7015 cursor_col = input_state%cursor_pos
7016
7017 ! Move to just before cursor position (account for prompt already displayed)
7018 ! We need to move back to start of buffer to redraw with highlighting
7019 if (cursor_col > 0) then
7020 do i = 1, cursor_col
7021 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
7022 end do
7023 end if
7024
7025 ! Clear from here to end of line
7026 write(output_unit, '(a)', advance='no') char(27) // '[K'
7027
7028 ! Redraw buffer with highlighting
7029 call state_buffer_get(input_state, temp_buf)
7030 call highlight_command_line(temp_buf(:input_state%length), highlighted, highlighted_len)
7031 if (highlighted_len > 0 .and. highlighted_len <= MAX_HIGHLIGHT_LEN) then
7032 write(output_unit, '(a)', advance='no') highlighted(1:highlighted_len)
7033 end if
7034
7035 ! Move cursor back to correct position
7036 do i = input_state%length, cursor_col + 1, -1
7037 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
7038 end do
7039
7040 flush(output_unit)
7041
7042 ! Deallocate heap-allocated buffer
7043 if (allocated(highlighted)) deallocate(highlighted)
7044 end subroutine
7045
7046 ! Helper to convert integer to string
7047 function int_to_str(n) result(str)
7048 integer, intent(in) :: n
7049 character(len=20) :: str
7050 write(str, '(i15)') n
7051 end function
7052
7053 ! Advanced line editing functions for Phase 5
7054 subroutine handle_home(input_state)
7055 type(input_state_t), intent(inout) :: input_state
7056 integer :: old_cursor_pos
7057
7058 ! Plain motion with active selection: clear selection, then proceed with
7059 ! normal motion. Home/End don't snap — they always go to 0/length — so a
7060 ! simple clear is correct (#25, #26).
7061 if (input_state%selection_active .and. .not. module_extending_selection) then
7062 call collapse_selection(input_state)
7063 input_state%dirty = .true.
7064 end if
7065
7066 old_cursor_pos = input_state%cursor_pos
7067
7068 ! Move cursor to beginning of line
7069 if (input_state%cursor_pos > 0) then
7070 input_state%cursor_pos = 0
7071 ! Mark dirty to trigger full redraw with correct cursor position
7072 input_state%dirty = .true.
7073 end if
7074
7075 if (module_extending_selection) then
7076 call update_selection_on_shift_motion(input_state, old_cursor_pos)
7077 end if
7078 end subroutine
7079
7080 subroutine handle_end(input_state)
7081 type(input_state_t), intent(inout) :: input_state
7082 integer :: old_cursor_pos
7083
7084 if (input_state%selection_active .and. .not. module_extending_selection) then
7085 call collapse_selection(input_state)
7086 input_state%dirty = .true.
7087 end if
7088
7089 old_cursor_pos = input_state%cursor_pos
7090
7091 ! Move cursor to end of line
7092 if (input_state%cursor_pos < input_state%length) then
7093 input_state%cursor_pos = input_state%length
7094 ! Mark dirty to trigger full redraw with correct cursor position
7095 input_state%dirty = .true.
7096 end if
7097
7098 if (module_extending_selection) then
7099 call update_selection_on_shift_motion(input_state, old_cursor_pos)
7100 end if
7101 end subroutine
7102
7103 subroutine handle_kill_to_end(input_state)
7104 type(input_state_t), intent(inout) :: input_state
7105 character(len=MAX_LINE_LEN) :: temp_buf
7106
7107 ! Save text from cursor to end of line in kill buffer
7108 if (input_state%cursor_pos < input_state%length) then
7109 ! Extract substring and save to kill buffer
7110 call state_buffer_get(input_state, temp_buf)
7111 call state_kill_buffer_set(input_state, temp_buf(input_state%cursor_pos+1:input_state%length))
7112 input_state%kill_length = input_state%length - input_state%cursor_pos
7113
7114 ! Clear from cursor to end of line
7115 input_state%length = input_state%cursor_pos
7116 input_state%dirty = .true.
7117
7118 ! Update autosuggestion after killing to end
7119 call update_autosuggestion(input_state)
7120 else
7121 ! Nothing to kill
7122 input_state%kill_length = 0
7123 end if
7124 end subroutine
7125
7126 subroutine handle_kill_line(input_state)
7127 use iso_fortran_env, only: output_unit
7128 type(input_state_t), intent(inout) :: input_state
7129 character(len=MAX_LINE_LEN) :: temp_buf
7130 integer :: current_row, current_col, i
7131
7132 ! Save entire line in kill buffer
7133 if (input_state%length > 0) then
7134 ! Copy buffer to kill buffer via temp
7135 call state_buffer_get(input_state, temp_buf)
7136 call state_kill_buffer_set(input_state, temp_buf(:input_state%length))
7137 input_state%kill_length = input_state%length
7138
7139 ! IMPORTANT: Move cursor to start of prompt BEFORE clearing buffer
7140 ! Otherwise redraw won't know where we are
7141 ! Use actual screen cursor position, not calculated from buffer
7142 current_row = module_cursor_screen_row
7143 current_col = module_cursor_screen_col
7144
7145 ! Move up to first line if needed
7146 if (current_row > 0) then
7147 do i = 1, current_row
7148 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
7149 end do
7150 end if
7151
7152 ! Move to column 0
7153 write(output_unit, '(a)', advance='no') char(13) ! CR
7154 flush(output_unit)
7155
7156 ! Update cursor tracking - we're now at start of first line
7157 module_cursor_screen_row = 0
7158 module_cursor_screen_col = 0
7159
7160 ! Now clear the line
7161 call state_buffer_clear(input_state)
7162 input_state%length = 0
7163 input_state%cursor_pos = 0
7164
7165 ! Clear any autosuggestion
7166 input_state%suggestion = ''
7167 input_state%suggestion_length = 0
7168
7169 input_state%dirty = .true.
7170 else
7171 input_state%kill_length = 0
7172 end if
7173 end subroutine
7174
7175 subroutine handle_kill_word(input_state)
7176 type(input_state_t), intent(inout) :: input_state
7177 integer :: word_start, i
7178 character(len=MAX_LINE_LEN) :: temp_buf
7179
7180 if (input_state%cursor_pos == 0) then
7181 input_state%kill_length = 0
7182 return
7183 end if
7184
7185 ! Find start of current word (skip trailing spaces first)
7186 word_start = input_state%cursor_pos
7187
7188 ! Skip any trailing whitespace
7189 do while (word_start > 0 .and. state_buffer_get_char(input_state, word_start) == ' ')
7190 word_start = word_start - 1
7191 end do
7192
7193 ! Find beginning of word (non-space characters)
7194 do while (word_start > 0 .and. state_buffer_get_char(input_state, word_start) /= ' ')
7195 word_start = word_start - 1
7196 end do
7197
7198 ! word_start is now at space before word, or 0 if at beginning
7199 if (word_start < input_state%cursor_pos) then
7200 ! Save killed text
7201 call state_buffer_get(input_state, temp_buf)
7202 call state_kill_buffer_set(input_state, temp_buf(word_start+1:input_state%cursor_pos))
7203 input_state%kill_length = input_state%cursor_pos - word_start
7204
7205 ! Shift remaining text left
7206 do i = word_start + 1, input_state%length - input_state%cursor_pos + word_start
7207 if (input_state%cursor_pos + i - word_start <= input_state%length) then
7208 call state_buffer_set_char(input_state, i, state_buffer_get_char(input_state, input_state%cursor_pos + i - word_start))
7209 else
7210 call state_buffer_set_char(input_state, i, ' ')
7211 end if
7212 end do
7213
7214 ! Update length and cursor position
7215 input_state%length = input_state%length - (input_state%cursor_pos - word_start)
7216 input_state%cursor_pos = word_start
7217 input_state%dirty = .true.
7218
7219 ! Update autosuggestion after killing word
7220 call update_autosuggestion(input_state)
7221 else
7222 input_state%kill_length = 0
7223 end if
7224 end subroutine
7225
7226 ! Alt+d — kill word forward (delete from cursor to end of next word)
7227 subroutine handle_kill_word_forward(input_state)
7228 type(input_state_t), intent(inout) :: input_state
7229 integer :: word_end, i, chars_to_delete
7230
7231 if (input_state%cursor_pos >= input_state%length) return
7232
7233 word_end = input_state%cursor_pos + 1
7234
7235 ! Skip whitespace first
7236 do while (word_end <= input_state%length .and. state_buffer_get_char(input_state, word_end) == ' ')
7237 word_end = word_end + 1
7238 end do
7239
7240 ! Skip word characters
7241 do while (word_end <= input_state%length .and. state_buffer_get_char(input_state, word_end) /= ' ')
7242 word_end = word_end + 1
7243 end do
7244
7245 chars_to_delete = word_end - input_state%cursor_pos - 1
7246 if (chars_to_delete <= 0) return
7247
7248 ! Shift remaining text left
7249 do i = input_state%cursor_pos + 1, input_state%length - chars_to_delete
7250 call state_buffer_set_char(input_state, i, state_buffer_get_char(input_state, i + chars_to_delete))
7251 end do
7252 do i = input_state%length - chars_to_delete + 1, input_state%length
7253 call state_buffer_set_char(input_state, i, ' ')
7254 end do
7255
7256 input_state%length = input_state%length - chars_to_delete
7257 input_state%dirty = .true.
7258 call update_autosuggestion(input_state)
7259 end subroutine
7260
7261 subroutine handle_yank(input_state)
7262 type(input_state_t), intent(inout) :: input_state
7263 integer :: i, insert_len
7264
7265 if (input_state%kill_length == 0) return
7266
7267 insert_len = min(input_state%kill_length, MAX_LINE_LEN - input_state%length)
7268 if (insert_len == 0) return
7269
7270 ! Shift existing text right to make room
7271 do i = input_state%length, input_state%cursor_pos + 1, -1
7272 if (i + insert_len <= MAX_LINE_LEN) then
7273 call state_buffer_set_char(input_state, i + insert_len, state_buffer_get_char(input_state, i))
7274 end if
7275 end do
7276
7277 ! Insert killed text at cursor position
7278 do i = 1, insert_len
7279 #ifdef USE_C_STRINGS
7280 call state_buffer_set_char(input_state, input_state%cursor_pos + i, c_string_get_char(input_state%kill_buffer_c, i))
7281 #else
7282 #ifdef USE_MEMORY_POOL
7283 call state_buffer_set_char(input_state, input_state%cursor_pos + i, input_state%kill_buffer_ref%data(i:i))
7284 #else
7285 call state_buffer_set_char(input_state, input_state%cursor_pos + i, input_state%kill_buffer(i:i))
7286 #endif
7287 #endif
7288 end do
7289
7290 ! Update length and cursor position
7291 input_state%length = input_state%length + insert_len
7292 input_state%cursor_pos = input_state%cursor_pos + insert_len
7293 input_state%dirty = .true.
7294 end subroutine
7295
7296 subroutine handle_clear_screen(input_state, prompt)
7297 type(input_state_t), intent(inout) :: input_state
7298 character(len=*), intent(in) :: prompt
7299 character(len=4096) :: highlighted ! Fixed-length to avoid flang-new allocatable bugs
7300 integer :: i, term_rows, term_cols, available_space, suggestion_display_len, highlighted_len
7301 logical :: success
7302 character(len=MAX_LINE_LEN) :: temp_buf ! For buffer extraction
7303
7304 highlighted = ' '
7305 highlighted_len = 0
7306
7307 ! Clear screen and move cursor to home position (0,0)
7308 write(output_unit, '(a)', advance='no') char(27) // '[2J' // char(27) // '[H'
7309
7310 ! Since we're now at home position, just redraw everything from scratch
7311 ! No need to calculate cursor movement - we know we're at top left
7312
7313 ! Draw prompt
7314 write(output_unit, '(a)', advance='no') prompt
7315 write(output_unit, '(a)', advance='no') ' ' ! Space after prompt
7316
7317 ! Draw the current buffer with syntax highlighting
7318 if (input_state%length > 0) then
7319 call state_buffer_get(input_state, temp_buf)
7320 call highlight_command_line(temp_buf(:input_state%length), highlighted, highlighted_len, input_state%length)
7321 if (highlighted_len > 0 .and. highlighted_len <= len(highlighted)) then
7322 write(output_unit, '(a)', advance='no') highlighted(1:highlighted_len)
7323 end if
7324 end if
7325
7326 ! Position cursor correctly
7327 if (input_state%cursor_pos < input_state%length) then
7328 ! Need to move cursor back from end of line
7329 do i = 1, input_state%length - input_state%cursor_pos
7330 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
7331 end do
7332 end if
7333
7334 ! Handle autosuggestion if cursor is at end
7335 if (input_state%suggestion_length > 0 .and. input_state%cursor_pos == input_state%length) then
7336 ! Get terminal width for suggestion truncation
7337 success = get_terminal_size(term_rows, term_cols)
7338 if (.not. success .or. term_cols <= 0) then
7339 term_cols = 80
7340 end if
7341
7342 ! Calculate available space (add 1 for space after prompt)
7343 available_space = term_cols - mod(visual_length(prompt) + 1 + input_state%length, term_cols)
7344
7345 if (available_space > 2) then
7346 suggestion_display_len = min(input_state%suggestion_length, available_space - 1)
7347
7348 if (suggestion_display_len > 0) then
7349 ! Use bright black (gray) color for suggestions - ANSI code 90
7350 write(output_unit, '(a)', advance='no') char(27) // '[90m'
7351
7352 ! Display suggestion character-by-character (avoid substring)
7353 do i = 1, suggestion_display_len
7354 write(output_unit, '(a)', advance='no') input_state%suggestion(i:i)
7355 end do
7356
7357 write(output_unit, '(a)', advance='no') char(27) // '[0m'
7358
7359 ! Move cursor back using simple cursor-left commands
7360 do i = 1, suggestion_display_len
7361 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
7362 end do
7363 end if
7364 end if
7365 end if
7366
7367 flush(output_unit)
7368 input_state%dirty = .false.
7369
7370 ! Update cursor tracking after clearing screen and redrawing
7371 call get_terminal_size_from_env(term_cols)
7372 call cursor_get_row_col(prompt, input_state%cursor_pos, term_cols, &
7373 module_cursor_screen_row, module_cursor_screen_col)
7374 end subroutine
7375
7376 ! Transpose characters (Ctrl+t) - swap char at cursor with previous char
7377 subroutine handle_transpose_chars(input_state)
7378 type(input_state_t), intent(inout) :: input_state
7379 character :: temp
7380
7381 ! Need at least 2 characters
7382 if (input_state%length < 2) return
7383
7384 ! If at end of line, transpose last two chars
7385 if (input_state%cursor_pos >= input_state%length) then
7386 if (input_state%length >= 2) then
7387 temp = state_buffer_get_char(input_state, input_state%length)
7388 call state_buffer_set_char(input_state, input_state%length, state_buffer_get_char(input_state, input_state%length-1))
7389 call state_buffer_set_char(input_state, input_state%length-1, temp)
7390 input_state%dirty = .true.
7391 end if
7392 ! If at beginning, do nothing
7393 else if (input_state%cursor_pos == 0) then
7394 return
7395 ! Normal case: swap char at cursor with previous char, move cursor forward
7396 else
7397 temp = state_buffer_get_char(input_state, input_state%cursor_pos+1)
7398 call state_buffer_set_char(input_state, input_state%cursor_pos+1, state_buffer_get_char(input_state, input_state%cursor_pos))
7399 call state_buffer_set_char(input_state, input_state%cursor_pos, temp)
7400 input_state%cursor_pos = input_state%cursor_pos + 1
7401 input_state%dirty = .true.
7402 end if
7403 end subroutine
7404
7405 ! Yank last argument from previous command (Alt+.)
7406 subroutine handle_yank_last_arg(input_state)
7407 type(input_state_t), intent(inout) :: input_state
7408 character(len=MAX_LINE_LEN) :: last_cmd, last_arg
7409 integer :: i, arg_start, arg_end
7410 logical :: in_arg
7411
7412 ! Get last command from history
7413 if (command_history%count == 0) return
7414
7415 last_cmd = command_history%lines(command_history%count)
7416
7417 ! Find last argument (last non-space word)
7418 arg_end = 0
7419 arg_start = 0
7420 in_arg = .false.
7421
7422 ! Scan backwards to find last argument
7423 do i = len_trim(last_cmd), 1, -1
7424 if (last_cmd(i:i) /= ' ' .and. last_cmd(i:i) /= char(9)) then
7425 if (.not. in_arg) then
7426 arg_end = i
7427 in_arg = .true.
7428 end if
7429 else if (in_arg) then
7430 arg_start = i + 1
7431 exit
7432 end if
7433 end do
7434
7435 ! If we found an arg but arg_start is still 0, it starts at position 1
7436 if (in_arg .and. arg_start == 0) arg_start = 1
7437
7438 if (arg_start > 0 .and. arg_end >= arg_start) then
7439 last_arg = last_cmd(arg_start:arg_end)
7440
7441 ! Insert the last argument at cursor position
7442 call insert_string_at_cursor(input_state, trim(last_arg))
7443 end if
7444 end subroutine
7445
7446 ! Delete word forward (Alt+d)
7447 subroutine handle_delete_word_forward(input_state)
7448 type(input_state_t), intent(inout) :: input_state
7449 integer :: word_end, i
7450 character(len=MAX_LINE_LEN) :: temp_buf
7451
7452 if (input_state%cursor_pos >= input_state%length) return
7453
7454 word_end = input_state%cursor_pos + 1
7455
7456 ! Skip any leading whitespace
7457 do while (word_end <= input_state%length .and. &
7458 state_buffer_get_char(input_state, word_end) == ' ')
7459 word_end = word_end + 1
7460 end do
7461
7462 ! Find end of word (non-space characters)
7463 do while (word_end <= input_state%length .and. &
7464 state_buffer_get_char(input_state, word_end) /= ' ')
7465 word_end = word_end + 1
7466 end do
7467
7468 if (word_end > input_state%cursor_pos + 1) then
7469 ! Save deleted text to kill buffer
7470 call state_buffer_get(input_state, temp_buf)
7471 call state_kill_buffer_set(input_state, temp_buf(input_state%cursor_pos+1:word_end-1))
7472 input_state%kill_length = word_end - input_state%cursor_pos - 1
7473
7474 ! Shift remaining text left
7475 do i = input_state%cursor_pos + 1, input_state%length - (word_end - input_state%cursor_pos - 1)
7476 if (word_end + i - input_state%cursor_pos - 1 <= input_state%length) then
7477 call state_buffer_set_char(input_state, i, state_buffer_get_char(input_state, word_end + i - input_state%cursor_pos - 1))
7478 else
7479 call state_buffer_set_char(input_state, i, ' ')
7480 end if
7481 end do
7482
7483 ! Update length
7484 input_state%length = input_state%length - (word_end - input_state%cursor_pos - 1)
7485 input_state%dirty = .true.
7486 end if
7487 end subroutine
7488
7489 ! Uppercase word (Alt+u) - convert from cursor to end of word to uppercase
7490 subroutine handle_uppercase_word(input_state)
7491 type(input_state_t), intent(inout) :: input_state
7492 integer :: pos
7493 character :: ch
7494
7495 if (input_state%cursor_pos >= input_state%length) return
7496
7497 pos = input_state%cursor_pos + 1
7498
7499 ! Skip any leading whitespace
7500 do while (pos <= input_state%length .and. &
7501 state_buffer_get_char(input_state, pos) == ' ')
7502 pos = pos + 1
7503 end do
7504
7505 ! Uppercase characters until end of word
7506 do while (pos <= input_state%length .and. &
7507 state_buffer_get_char(input_state, pos) /= ' ')
7508 ch = state_buffer_get_char(input_state, pos)
7509 if (ch >= 'a' .and. ch <= 'z') then
7510 call state_buffer_set_char(input_state, pos, char(ichar(ch) - 32))
7511 end if
7512 pos = pos + 1
7513 end do
7514
7515 ! Move cursor to end of word
7516 input_state%cursor_pos = pos - 1
7517 input_state%dirty = .true.
7518 end subroutine
7519
7520 ! Lowercase word (Alt+l) - convert from cursor to end of word to lowercase
7521 subroutine handle_lowercase_word(input_state)
7522 type(input_state_t), intent(inout) :: input_state
7523 integer :: pos
7524 character :: ch
7525
7526 if (input_state%cursor_pos >= input_state%length) return
7527
7528 pos = input_state%cursor_pos + 1
7529
7530 ! Skip any leading whitespace
7531 do while (pos <= input_state%length .and. &
7532 state_buffer_get_char(input_state, pos) == ' ')
7533 pos = pos + 1
7534 end do
7535
7536 ! Lowercase characters until end of word
7537 do while (pos <= input_state%length .and. &
7538 state_buffer_get_char(input_state, pos) /= ' ')
7539 ch = state_buffer_get_char(input_state, pos)
7540 if (ch >= 'A' .and. ch <= 'Z') then
7541 call state_buffer_set_char(input_state, pos, char(ichar(ch) + 32))
7542 end if
7543 pos = pos + 1
7544 end do
7545
7546 ! Move cursor to end of word
7547 input_state%cursor_pos = pos - 1
7548 input_state%dirty = .true.
7549 end subroutine
7550
7551 ! Capitalize word (Alt+c) - uppercase first char, lowercase rest
7552 subroutine handle_capitalize_word(input_state)
7553 type(input_state_t), intent(inout) :: input_state
7554 integer :: pos
7555 character :: ch
7556 logical :: first_char
7557
7558 if (input_state%cursor_pos >= input_state%length) return
7559
7560 pos = input_state%cursor_pos + 1
7561
7562 ! Skip any leading whitespace
7563 do while (pos <= input_state%length .and. &
7564 state_buffer_get_char(input_state, pos) == ' ')
7565 pos = pos + 1
7566 end do
7567
7568 first_char = .true.
7569
7570 ! Capitalize first character, lowercase rest until end of word
7571 do while (pos <= input_state%length .and. &
7572 state_buffer_get_char(input_state, pos) /= ' ')
7573 ch = state_buffer_get_char(input_state, pos)
7574
7575 if (first_char) then
7576 ! Uppercase first character
7577 if (ch >= 'a' .and. ch <= 'z') then
7578 call state_buffer_set_char(input_state, pos, char(ichar(ch) - 32))
7579 end if
7580 first_char = .false.
7581 else
7582 ! Lowercase remaining characters
7583 if (ch >= 'A' .and. ch <= 'Z') then
7584 call state_buffer_set_char(input_state, pos, char(ichar(ch) + 32))
7585 end if
7586 end if
7587
7588 pos = pos + 1
7589 end do
7590
7591 ! Move cursor to end of word
7592 input_state%cursor_pos = pos - 1
7593 input_state%dirty = .true.
7594 end subroutine
7595
7596 ! Alt+Up: Replace line with "cd .." and execute (Fish-style parent directory navigation)
7597 subroutine handle_alt_up(input_state, done)
7598 type(input_state_t), intent(inout) :: input_state
7599 logical, intent(inout) :: done
7600 character(len=5) :: cmd
7601
7602 ! Buffer replacement — clear any stale selection (#27).
7603 if (input_state%selection_active) call collapse_selection(input_state)
7604
7605 cmd = 'cd ..'
7606
7607 ! Clear current buffer and insert "cd .."
7608 call state_buffer_set(input_state, cmd)
7609 input_state%length = 5
7610 input_state%cursor_pos = 5
7611
7612 ! Clear suggestion since we're replacing the line
7613 input_state%suggestion = ''
7614 input_state%suggestion_length = 0
7615
7616 ! Don't set dirty - we don't want to redraw, just execute silently (Fish behavior)
7617 ! input_state%dirty = .true.
7618
7619 ! Print newline before execution (like pressing Enter)
7620 write(output_unit, '()')
7621
7622 ! Auto-execute the command (Fish behavior)
7623 done = .true.
7624 end subroutine
7625
7626 ! Alt+Left: Replace line with "prevd" and execute (Fish-style previous directory)
7627 subroutine handle_alt_left(input_state, done)
7628 type(input_state_t), intent(inout) :: input_state
7629 logical, intent(inout) :: done
7630 character(len=5) :: cmd
7631
7632 if (input_state%selection_active) call collapse_selection(input_state)
7633
7634 cmd = 'prevd'
7635
7636 ! Clear current buffer and insert "prevd"
7637 call state_buffer_set(input_state, cmd)
7638 input_state%length = 5
7639 input_state%cursor_pos = 5
7640
7641 ! Clear suggestion since we're replacing the line
7642 input_state%suggestion = ''
7643 input_state%suggestion_length = 0
7644
7645 ! Don't set dirty - we don't want to redraw, just execute silently (Fish behavior)
7646 ! input_state%dirty = .true.
7647
7648 ! Print newline before execution (like pressing Enter)
7649 write(output_unit, '()')
7650
7651 ! Auto-execute the command (Fish behavior)
7652 done = .true.
7653 end subroutine
7654
7655 ! Alt+Right: Replace line with "nextd" and execute (Fish-style next directory)
7656 subroutine handle_alt_right(input_state, done)
7657 type(input_state_t), intent(inout) :: input_state
7658 logical, intent(inout) :: done
7659 character(len=5) :: cmd
7660
7661 if (input_state%selection_active) call collapse_selection(input_state)
7662
7663 cmd = 'nextd'
7664
7665 ! Clear current buffer and insert "nextd"
7666 call state_buffer_set(input_state, cmd)
7667 input_state%length = 5
7668 input_state%cursor_pos = 5
7669
7670 ! Clear suggestion since we're replacing the line
7671 input_state%suggestion = ''
7672 input_state%suggestion_length = 0
7673
7674 ! Don't set dirty - we don't want to redraw, just execute silently (Fish behavior)
7675 ! input_state%dirty = .true.
7676
7677 ! Print newline before execution (like pressing Enter)
7678 write(output_unit, '()')
7679
7680 ! Auto-execute the command (Fish behavior)
7681 done = .true.
7682 end subroutine
7683
7684 ! Helper: Insert string at cursor position
7685 subroutine insert_string_at_cursor(input_state, str)
7686 type(input_state_t), intent(inout) :: input_state
7687 character(len=*), intent(in) :: str
7688 integer :: i, str_len, insert_len
7689
7690 str_len = len_trim(str)
7691 if (str_len == 0) return
7692
7693 insert_len = min(str_len, MAX_LINE_LEN - input_state%length)
7694 if (insert_len == 0) return
7695
7696 ! Shift existing text right to make room
7697 do i = input_state%length, input_state%cursor_pos + 1, -1
7698 if (i + insert_len <= MAX_LINE_LEN) then
7699 call state_buffer_set_char(input_state, i + insert_len, state_buffer_get_char(input_state, i))
7700 end if
7701 end do
7702
7703 ! Insert string at cursor position
7704 do i = 1, insert_len
7705 call state_buffer_set_char(input_state, input_state%cursor_pos + i, str(i:i))
7706 end do
7707
7708 ! Update length and cursor position
7709 input_state%length = input_state%length + insert_len
7710 input_state%cursor_pos = input_state%cursor_pos + insert_len
7711 input_state%dirty = .true.
7712 end subroutine
7713
7714 ! Cursor flash effect for visual feedback
7715 subroutine cursor_flash_effect()
7716 integer :: i, j
7717 integer, parameter :: FLASH_COUNT = 3
7718 integer, parameter :: DELAY_ITERATIONS = 50000
7719
7720 ! Flash cursor multiple times with visible delay
7721 do i = 1, FLASH_COUNT
7722 ! Hide cursor
7723 write(output_unit, '(a)', advance='no') ESC_HIDE_CURSOR
7724 flush(output_unit)
7725
7726 ! Small delay using busy-wait
7727 do j = 1, DELAY_ITERATIONS
7728 ! Busy wait
7729 end do
7730
7731 ! Show cursor
7732 write(output_unit, '(a)', advance='no') ESC_SHOW_CURSOR
7733 flush(output_unit)
7734
7735 ! Small delay using busy-wait
7736 do j = 1, DELAY_ITERATIONS
7737 ! Busy wait
7738 end do
7739 end do
7740 end subroutine
7741
7742 ! Reverse-i-search implementation
7743 subroutine handle_isearch(input_state, prompt, forward)
7744 type(input_state_t), intent(inout) :: input_state
7745 character(len=*), intent(in) :: prompt
7746 logical, intent(in) :: forward
7747
7748 ! Save current buffer if entering search for first time
7749 if (.not. input_state%in_search) then
7750 call state_buffer_save(input_state)
7751 input_state%in_search = .true.
7752 input_state%search_forward = forward
7753 call clear_search_string(input_state)
7754 input_state%search_length = 0
7755 input_state%search_match_index = 0
7756 else
7757 ! Ctrl+R/Ctrl+S pressed again - find next match
7758 ! Allow switching direction mid-search
7759 input_state%search_forward = forward
7760 call search_next_match(input_state)
7761 end if
7762
7763 ! Display search prompt
7764 call update_search_display(input_state, prompt)
7765 end subroutine
7766
7767 subroutine search_next_match(input_state)
7768 type(input_state_t), intent(inout) :: input_state
7769 integer :: i
7770 character(len=MAX_LINE_LEN) :: search_str
7771
7772 if (input_state%search_length == 0) return
7773
7774 call get_search_string(input_state, search_str, input_state%search_length)
7775
7776 if (input_state%search_forward) then
7777 ! Forward search - search from current match towards newer history
7778 do i = input_state%search_match_index + 1, command_history%count
7779 if (index(command_history%lines(i), trim(search_str)) > 0) then
7780 input_state%search_match_index = i
7781 call state_buffer_set(input_state, command_history%lines(i))
7782 input_state%length = len_trim(command_history%lines(i))
7783 input_state%cursor_pos = input_state%length
7784 return
7785 end if
7786 end do
7787
7788 ! Wrap around to beginning if no match found
7789 if (input_state%search_match_index > 0) then
7790 do i = 1, input_state%search_match_index - 1
7791 if (index(command_history%lines(i), trim(search_str)) > 0) then
7792 input_state%search_match_index = i
7793 call state_buffer_set(input_state, command_history%lines(i))
7794 input_state%length = len_trim(command_history%lines(i))
7795 input_state%cursor_pos = input_state%length
7796 return
7797 end if
7798 end do
7799 end if
7800 else
7801 ! Reverse search - search from current match towards older history
7802 do i = input_state%search_match_index - 1, 1, -1
7803 if (index(command_history%lines(i), trim(search_str)) > 0) then
7804 input_state%search_match_index = i
7805 call state_buffer_set(input_state, command_history%lines(i))
7806 input_state%length = len_trim(command_history%lines(i))
7807 input_state%cursor_pos = input_state%length
7808 return
7809 end if
7810 end do
7811
7812 ! Wrap around to end if no match found
7813 if (input_state%search_match_index > 0) then
7814 do i = command_history%count, input_state%search_match_index + 1, -1
7815 if (index(command_history%lines(i), trim(search_str)) > 0) then
7816 input_state%search_match_index = i
7817 call state_buffer_set(input_state, command_history%lines(i))
7818 input_state%length = len_trim(command_history%lines(i))
7819 input_state%cursor_pos = input_state%length
7820 return
7821 end if
7822 end do
7823 end if
7824 end if
7825 end subroutine
7826
7827 subroutine search_add_char(input_state, ch, prompt)
7828 type(input_state_t), intent(inout) :: input_state
7829 character, intent(in) :: ch
7830 character(len=*), intent(in) :: prompt
7831 integer :: i
7832 character(len=MAX_LINE_LEN) :: search_str
7833
7834 ! Add character to search string
7835 if (input_state%search_length < MAX_LINE_LEN) then
7836 input_state%search_length = input_state%search_length + 1
7837 call set_search_char(input_state, input_state%search_length, ch)
7838 call get_search_string(input_state, search_str, input_state%search_length)
7839
7840 ! Search through history in the appropriate direction
7841 if (input_state%search_forward) then
7842 ! Forward search - from beginning to end
7843 do i = 1, command_history%count
7844 if (index(command_history%lines(i), trim(search_str)) > 0) then
7845 input_state%search_match_index = i
7846 call state_buffer_set(input_state, command_history%lines(i))
7847 input_state%length = len_trim(command_history%lines(i))
7848 input_state%cursor_pos = input_state%length
7849 exit
7850 end if
7851 end do
7852 else
7853 ! Reverse search - from end to beginning
7854 do i = command_history%count, 1, -1
7855 if (index(command_history%lines(i), trim(search_str)) > 0) then
7856 input_state%search_match_index = i
7857 call state_buffer_set(input_state, command_history%lines(i))
7858 input_state%length = len_trim(command_history%lines(i))
7859 input_state%cursor_pos = input_state%length
7860 exit
7861 end if
7862 end do
7863 end if
7864
7865 call update_search_display(input_state, prompt)
7866 end if
7867 end subroutine
7868
7869 subroutine search_backspace(input_state, prompt)
7870 type(input_state_t), intent(inout) :: input_state
7871 character(len=*), intent(in) :: prompt
7872 integer :: i
7873 character(len=MAX_LINE_LEN) :: search_str
7874
7875 if (input_state%search_length > 0) then
7876 input_state%search_length = input_state%search_length - 1
7877
7878 if (input_state%search_length > 0) then
7879 ! Search again with shorter string
7880 call get_search_string(input_state, search_str, input_state%search_length)
7881
7882 if (input_state%search_forward) then
7883 ! Forward search
7884 do i = 1, command_history%count
7885 if (index(command_history%lines(i), trim(search_str)) > 0) then
7886 input_state%search_match_index = i
7887 call state_buffer_set(input_state, command_history%lines(i))
7888 input_state%length = len_trim(command_history%lines(i))
7889 input_state%cursor_pos = input_state%length
7890 exit
7891 end if
7892 end do
7893 else
7894 ! Reverse search
7895 do i = command_history%count, 1, -1
7896 if (index(command_history%lines(i), trim(search_str)) > 0) then
7897 input_state%search_match_index = i
7898 call state_buffer_set(input_state, command_history%lines(i))
7899 input_state%length = len_trim(command_history%lines(i))
7900 input_state%cursor_pos = input_state%length
7901 exit
7902 end if
7903 end do
7904 end if
7905 else
7906 ! Empty search - restore original buffer on prompt line
7907 call state_buffer_restore(input_state)
7908 #ifdef USE_C_STRINGS
7909 input_state%length = c_string_length(input_state%original_buffer_c)
7910 #elif defined(USE_MEMORY_POOL)
7911 input_state%length = len_trim(input_state%original_buffer_ref%data)
7912 #else
7913 input_state%length = len_trim(input_state%original_buffer)
7914 #endif
7915 input_state%cursor_pos = input_state%length
7916 input_state%search_match_index = 0
7917 end if
7918
7919 call update_search_display(input_state, prompt)
7920 end if
7921 end subroutine
7922
7923 ! Clear the entire search query (Ctrl-U in search mode)
7924 subroutine search_clear_query(input_state, prompt)
7925 type(input_state_t), intent(inout) :: input_state
7926 character(len=*), intent(in) :: prompt
7927
7928 if (input_state%search_length == 0) return
7929
7930 call clear_search_string(input_state)
7931 input_state%search_length = 0
7932
7933 ! Restore original buffer
7934 call state_buffer_restore(input_state)
7935 #ifdef USE_C_STRINGS
7936 input_state%length = c_string_length(input_state%original_buffer_c)
7937 #elif defined(USE_MEMORY_POOL)
7938 input_state%length = len_trim(input_state%original_buffer_ref%data)
7939 #else
7940 input_state%length = len_trim(input_state%original_buffer)
7941 #endif
7942 input_state%cursor_pos = input_state%length
7943 input_state%search_match_index = 0
7944
7945 call update_search_display(input_state, prompt)
7946 end subroutine search_clear_query
7947
7948 ! Delete last word from search query (Ctrl-W / Alt-Backspace in search mode)
7949 subroutine search_kill_word(input_state, prompt)
7950 type(input_state_t), intent(inout) :: input_state
7951 character(len=*), intent(in) :: prompt
7952 character(len=MAX_LINE_LEN) :: search_str
7953 integer :: i, new_len
7954
7955 if (input_state%search_length == 0) return
7956
7957 call get_search_string(input_state, search_str, input_state%search_length)
7958
7959 ! Skip trailing spaces
7960 new_len = input_state%search_length
7961 do while (new_len > 0 .and. search_str(new_len:new_len) == ' ')
7962 new_len = new_len - 1
7963 end do
7964 ! Skip back to previous space or beginning
7965 do while (new_len > 0 .and. search_str(new_len:new_len) /= ' ')
7966 new_len = new_len - 1
7967 end do
7968
7969 input_state%search_length = new_len
7970
7971 if (new_len > 0) then
7972 ! Re-search with shorter query
7973 call get_search_string(input_state, search_str, new_len)
7974 input_state%search_match_index = 0
7975 if (input_state%search_forward) then
7976 do i = 1, command_history%count
7977 if (index(command_history%lines(i), trim(search_str(:new_len))) > 0) then
7978 input_state%search_match_index = i
7979 call state_buffer_set(input_state, command_history%lines(i))
7980 input_state%length = len_trim(command_history%lines(i))
7981 input_state%cursor_pos = input_state%length
7982 exit
7983 end if
7984 end do
7985 else
7986 do i = command_history%count, 1, -1
7987 if (index(command_history%lines(i), trim(search_str(:new_len))) > 0) then
7988 input_state%search_match_index = i
7989 call state_buffer_set(input_state, command_history%lines(i))
7990 input_state%length = len_trim(command_history%lines(i))
7991 input_state%cursor_pos = input_state%length
7992 exit
7993 end if
7994 end do
7995 end if
7996 else
7997 ! Empty query - restore original buffer
7998 call state_buffer_restore(input_state)
7999 #ifdef USE_C_STRINGS
8000 input_state%length = c_string_length(input_state%original_buffer_c)
8001 #elif defined(USE_MEMORY_POOL)
8002 input_state%length = len_trim(input_state%original_buffer_ref%data)
8003 #else
8004 input_state%length = len_trim(input_state%original_buffer)
8005 #endif
8006 input_state%cursor_pos = input_state%length
8007 input_state%search_match_index = 0
8008 end if
8009
8010 call update_search_display(input_state, prompt)
8011 end subroutine search_kill_word
8012
8013 ! Clean up the status line below the prompt when exiting search mode
8014 subroutine cleanup_search_status_line()
8015 ! Move up from status line to prompt line, clear everything below
8016 if (module_search_status_shown) then
8017 write(output_unit, '(a)', advance='no') char(27) // '[A' ! cursor up
8018 end if
8019 write(output_unit, '(a)', advance='no') char(13) ! BOL
8020 write(output_unit, '(a)', advance='no') char(27) // '[J' ! clear from cursor down
8021 module_search_status_shown = .false.
8022 flush(output_unit)
8023 end subroutine cleanup_search_status_line
8024
8025 subroutine cancel_search(input_state)
8026 type(input_state_t), intent(inout) :: input_state
8027
8028 ! Restore original buffer
8029 call state_buffer_restore(input_state)
8030 #ifdef USE_C_STRINGS
8031 input_state%length = c_string_length(input_state%original_buffer_c)
8032 #elif defined(USE_MEMORY_POOL)
8033 input_state%length = len_trim(input_state%original_buffer_ref%data)
8034 #else
8035 input_state%length = len_trim(input_state%original_buffer)
8036 #endif
8037 input_state%cursor_pos = input_state%length
8038 input_state%in_search = .false.
8039 call clear_search_string(input_state)
8040 input_state%search_length = 0
8041 input_state%search_match_index = 0
8042
8043 call cleanup_search_status_line()
8044 input_state%dirty = .true.
8045 end subroutine
8046
8047 subroutine accept_search(input_state, prompt)
8048 type(input_state_t), intent(inout) :: input_state
8049 character(len=*), intent(in) :: prompt
8050 character(len=MAX_LINE_LEN) :: temp_buf
8051 character(len=4096) :: highlighted
8052 integer :: highlighted_len, pv_len, term_rows, term_cols
8053 character(len=8) :: col_str
8054 logical :: success
8055
8056 ! Keep the current buffer (matched command)
8057 input_state%in_search = .false.
8058 call clear_search_string(input_state)
8059 input_state%search_length = 0
8060 input_state%search_match_index = 0
8061
8062 ! Clear status line and rewrite command text without redrawing prompt
8063 if (module_search_status_shown) then
8064 write(output_unit, '(a)', advance='no') char(27) // '[A' ! cursor up from status line
8065 end if
8066
8067 ! Position cursor after prompt, clear to end of screen
8068 pv_len = visual_length(prompt)
8069 if (pv_len < 0) pv_len = 0
8070 write(col_str, '(i0)') pv_len + 2
8071 write(output_unit, '(a)', advance='no') char(27) // '[' // trim(col_str) // 'G'
8072 write(output_unit, '(a)', advance='no') char(27) // '[J'
8073
8074 ! Write syntax-highlighted command text
8075 if (input_state%length > 0) then
8076 call state_buffer_get(input_state, temp_buf)
8077 call highlight_command_line(temp_buf(:input_state%length), &
8078 highlighted, highlighted_len, &
8079 input_state%length)
8080 if (highlighted_len > 0 .and. highlighted_len <= len(highlighted)) then
8081 write(output_unit, '(a)', advance='no') highlighted(:highlighted_len)
8082 else
8083 write(output_unit, '(a)', advance='no') temp_buf(:input_state%length)
8084 end if
8085 end if
8086
8087 module_search_status_shown = .false.
8088 flush(output_unit)
8089
8090 ! Update cursor screen position tracking so subsequent redraws work correctly
8091 success = get_terminal_size(term_rows, term_cols)
8092 if (.not. success .or. term_cols <= 0) term_cols = 80
8093 call cursor_get_row_col(prompt, input_state%cursor_pos, term_cols, &
8094 module_cursor_screen_row, module_cursor_screen_col)
8095 end subroutine
8096
8097 subroutine accept_search_for_editing(input_state)
8098 ! Accept the search result and prepare for normal editing
8099 ! Called when arrow keys are pressed during Ctrl+R search
8100 type(input_state_t), intent(inout) :: input_state
8101
8102 ! Keep the current buffer (matched command)
8103 input_state%in_search = .false.
8104 call clear_search_string(input_state)
8105 input_state%search_length = 0
8106 input_state%search_match_index = 0
8107
8108 ! Clean up status line, mark for normal redraw
8109 call cleanup_search_status_line()
8110 input_state%dirty = .true.
8111 end subroutine
8112
8113 subroutine update_search_display(input_state, prompt)
8114 type(input_state_t), intent(in) :: input_state
8115 character(len=*), intent(in) :: prompt
8116 character(len=MAX_LINE_LEN) :: temp_buf, search_str
8117 character(len=4096) :: highlighted
8118 integer :: highlighted_len, pv_len
8119 character(len=16) :: direction_label
8120 character(len=8) :: col_str
8121
8122 ! 1. If status line already shown, cursor is on status line — move up first
8123 if (module_search_status_shown) then
8124 write(output_unit, '(a)', advance='no') char(27) // '[A' ! cursor up to prompt line
8125 end if
8126
8127 ! 2. Position cursor right after the prompt (don't rewrite the prompt)
8128 ! Use cursor horizontal absolute ESC[{col}G to jump to the command area
8129 pv_len = visual_length(prompt)
8130 if (pv_len < 0) pv_len = 0
8131 write(col_str, '(i0)') pv_len + 2 ! +1 for space, +1 for 1-based column
8132 write(output_unit, '(a)', advance='no') char(27) // '[' // trim(col_str) // 'G'
8133
8134 ! 3. Clear from cursor to end of screen (clears old command text + old status line)
8135 write(output_unit, '(a)', advance='no') char(27) // '[J'
8136
8137 ! 4. Write matched command text with syntax highlighting
8138 if (input_state%length > 0) then
8139 call state_buffer_get(input_state, temp_buf)
8140 call highlight_command_line(temp_buf(:input_state%length), &
8141 highlighted, highlighted_len, &
8142 input_state%length)
8143 if (highlighted_len > 0 .and. highlighted_len <= len(highlighted)) then
8144 write(output_unit, '(a)', advance='no') highlighted(:highlighted_len)
8145 else
8146 write(output_unit, '(a)', advance='no') temp_buf(:input_state%length)
8147 end if
8148 end if
8149
8150 ! 5. Move to status line below
8151 write(output_unit, '(a)', advance='no') char(10) // char(13) ! newline + BOL
8152
8153 ! 6. Render search status line
8154 if (input_state%search_forward) then
8155 direction_label = 'fwd-search: '
8156 else
8157 direction_label = 'bck-search: '
8158 end if
8159 write(output_unit, '(a)', advance='no') trim(direction_label)
8160 if (input_state%search_length > 0) then
8161 call get_search_string(input_state, search_str, input_state%search_length)
8162 write(output_unit, '(a)', advance='no') search_str(:input_state%search_length)
8163 end if
8164 ! Cursor naturally sits at end of query text on the status line
8165 module_search_status_shown = .true.
8166
8167 flush(output_unit)
8168 end subroutine
8169
8170 ! ============================================================================
8171 ! Advanced Vi Mode Features
8172 ! ============================================================================
8173
8174 ! Vi-style yank (copy)
8175 subroutine handle_vi_yank(input_state)
8176 type(input_state_t), intent(inout) :: input_state
8177
8178 ! Simplified: yank entire line (yy behavior)
8179 if (input_state%length > 0) then
8180 call state_buffer_get(input_state, input_state%vi_yank_buffer)
8181 input_state%vi_yank_buffer = input_state%vi_yank_buffer(:input_state%length)
8182 input_state%vi_yank_length = input_state%length
8183 else
8184 #ifdef USE_C_STRINGS
8185 input_state%vi_yank_buffer = ''
8186 #elif defined(USE_MEMORY_POOL)
8187 input_state%vi_yank_buffer_ref%data = ''
8188 #else
8189 input_state%vi_yank_buffer = ''
8190 #endif
8191 input_state%vi_yank_length = 0
8192 end if
8193 end subroutine
8194
8195 ! Vi-style put (paste)
8196 subroutine handle_vi_put(input_state, before_cursor)
8197 type(input_state_t), intent(inout) :: input_state
8198 logical, intent(in) :: before_cursor
8199 integer :: i, insert_len, insert_pos
8200
8201 if (input_state%vi_yank_length == 0) return
8202
8203 insert_len = min(input_state%vi_yank_length, MAX_LINE_LEN - input_state%length)
8204 if (insert_len == 0) return
8205
8206 ! Determine insertion position
8207 if (before_cursor) then
8208 insert_pos = input_state%cursor_pos
8209 else
8210 ! After cursor
8211 insert_pos = min(input_state%cursor_pos + 1, input_state%length)
8212 end if
8213
8214 ! Insert yanked text at insertion position
8215 #ifdef USE_C_STRINGS
8216 ! Use C string API for insertion
8217 if (.not. c_string_insert(input_state%buffer_c, insert_pos + 1, &
8218 input_state%vi_yank_buffer(:insert_len))) then
8219 ! Insertion failed, silently ignore
8220 return
8221 end if
8222 #else
8223 ! Shift existing text right to make room
8224 do i = input_state%length, insert_pos + 1, -1
8225 if (i + insert_len <= MAX_LINE_LEN) then
8226 input_state%buffer(i + insert_len:i + insert_len) = input_state%buffer(i:i)
8227 end if
8228 end do
8229
8230 ! Insert yanked text at insertion position
8231 do i = 1, insert_len
8232 input_state%buffer(insert_pos + i:insert_pos + i) = input_state%vi_yank_buffer(i:i)
8233 end do
8234 #endif
8235
8236 ! Update length and cursor position
8237 input_state%length = input_state%length + insert_len
8238 input_state%cursor_pos = insert_pos + insert_len - 1
8239 input_state%dirty = .true.
8240 end subroutine
8241
8242 ! Set a vi mark
8243 subroutine handle_vi_mark_set(input_state, mark_char)
8244 type(input_state_t), intent(inout) :: input_state
8245 character, intent(in) :: mark_char
8246 integer :: mark_index
8247
8248 ! Convert character to mark index (a-z = 1-26)
8249 if (mark_char >= 'a' .and. mark_char <= 'z') then
8250 mark_index = iachar(mark_char) - iachar('a') + 1
8251 input_state%vi_marks(mark_index) = input_state%cursor_pos
8252 end if
8253
8254 ! Clear command buffer
8255 #ifdef USE_C_STRINGS
8256 input_state%vi_command_buffer = ''
8257 #elif defined(USE_MEMORY_POOL)
8258 input_state%vi_command_buffer_ref%data = ''
8259 #else
8260 input_state%vi_command_buffer = ''
8261 #endif
8262 input_state%vi_command_count = 0
8263 end subroutine
8264
8265 ! Jump to a vi mark
8266 subroutine handle_vi_mark_jump(input_state, mark_char)
8267 type(input_state_t), intent(inout) :: input_state
8268 character, intent(in) :: mark_char
8269 integer :: mark_index, mark_pos
8270
8271 ! Convert character to mark index (a-z = 1-26)
8272 if (mark_char >= 'a' .and. mark_char <= 'z') then
8273 mark_index = iachar(mark_char) - iachar('a') + 1
8274 mark_pos = input_state%vi_marks(mark_index)
8275
8276 ! Jump to mark if it's set (non-zero) and valid
8277 if (mark_pos > 0 .and. mark_pos <= input_state%length) then
8278 input_state%cursor_pos = mark_pos
8279 input_state%dirty = .true.
8280 end if
8281 end if
8282
8283 ! Clear command buffer
8284 #ifdef USE_C_STRINGS
8285 input_state%vi_command_buffer = ''
8286 #elif defined(USE_MEMORY_POOL)
8287 input_state%vi_command_buffer_ref%data = ''
8288 #else
8289 input_state%vi_command_buffer = ''
8290 #endif
8291 input_state%vi_command_count = 0
8292 end subroutine
8293
8294 ! Start vi-style search (/ or ?)
8295 subroutine handle_vi_search_start(input_state, forward)
8296 type(input_state_t), intent(inout) :: input_state
8297 logical, intent(in) :: forward
8298
8299 ! Enter vi search mode
8300 input_state%vi_in_vi_search = .true.
8301 input_state%vi_search_forward = forward
8302 #ifdef USE_C_STRINGS
8303 input_state%vi_search_pattern = ''
8304 #elif defined(USE_MEMORY_POOL)
8305 input_state%vi_search_pattern_ref%data = ''
8306 #else
8307 input_state%vi_search_pattern = ''
8308 #endif
8309 input_state%vi_search_length = 0
8310
8311 ! Visual feedback: show search prompt
8312 write(output_unit, '()') ! New line
8313 if (forward) then
8314 write(output_unit, '(a)', advance='no') '/'
8315 else
8316 write(output_unit, '(a)', advance='no') '?'
8317 end if
8318 flush(output_unit)
8319 end subroutine
8320
8321 ! Find next/previous search match in vi mode
8322 subroutine handle_vi_search_next(input_state, forward)
8323 type(input_state_t), intent(inout) :: input_state
8324 logical, intent(in) :: forward
8325 integer :: i, match_pos
8326 logical :: found
8327 character(len=MAX_LINE_LEN) :: temp_buf
8328
8329 if (input_state%vi_search_length == 0) return
8330
8331 found = .false.
8332
8333 ! Determine search direction based on original direction and forward flag
8334 if (input_state%vi_search_forward .eqv. forward) then
8335 ! Search in same direction as original
8336 if (input_state%vi_search_forward) then
8337 ! Search forward from current position
8338 call state_buffer_get(input_state, temp_buf)
8339 match_pos = index(temp_buf(input_state%cursor_pos+2:input_state%length), &
8340 input_state%vi_search_pattern(:input_state%vi_search_length))
8341 if (match_pos > 0) then
8342 input_state%cursor_pos = input_state%cursor_pos + 1 + match_pos
8343 found = .true.
8344 end if
8345 else
8346 ! Search backward from current position
8347 ! Simplified: search from beginning to current position
8348 call state_buffer_get(input_state, temp_buf)
8349 do i = input_state%cursor_pos - 1, 1, -1
8350 match_pos = index(temp_buf(i:input_state%cursor_pos-1), &
8351 input_state%vi_search_pattern(:input_state%vi_search_length))
8352 if (match_pos > 0) then
8353 input_state%cursor_pos = i + match_pos - 1
8354 found = .true.
8355 exit
8356 end if
8357 end do
8358 end if
8359 else
8360 ! Search in opposite direction
8361 if (input_state%vi_search_forward) then
8362 ! Original was forward, now search backward
8363 call state_buffer_get(input_state, temp_buf)
8364 do i = input_state%cursor_pos - 1, 1, -1
8365 match_pos = index(temp_buf(i:input_state%cursor_pos-1), &
8366 input_state%vi_search_pattern(:input_state%vi_search_length))
8367 if (match_pos > 0) then
8368 input_state%cursor_pos = i + match_pos - 1
8369 found = .true.
8370 exit
8371 end if
8372 end do
8373 else
8374 ! Original was backward, now search forward
8375 call state_buffer_get(input_state, temp_buf)
8376 match_pos = index(temp_buf(input_state%cursor_pos+2:input_state%length), &
8377 input_state%vi_search_pattern(:input_state%vi_search_length))
8378 if (match_pos > 0) then
8379 input_state%cursor_pos = input_state%cursor_pos + 1 + match_pos
8380 found = .true.
8381 end if
8382 end if
8383 end if
8384
8385 if (found) then
8386 input_state%dirty = .true.
8387 end if
8388 end subroutine
8389
8390 ! ============================================================================
8391 ! Abbreviation Expansion (Fish-style)
8392 ! ============================================================================
8393
8394 ! Try to expand an abbreviation at cursor position (called when space is typed)
8395 subroutine try_expand_abbreviation_at_cursor(input_state)
8396 type(input_state_t), intent(inout) :: input_state
8397 character(len=:), allocatable :: word_before_cursor ! Heap allocation to avoid stack overflow
8398 character(len=:), allocatable :: expanded_form
8399 integer :: word_start, word_end, i, expanded_len
8400 character(len=MAX_LINE_LEN) :: temp_buf
8401
8402
8403 ! Allocate buffer on heap
8404 allocate(character(len=MAX_LINE_LEN) :: word_before_cursor)
8405
8406 ! Extract word before cursor
8407 word_end = input_state%cursor_pos
8408 word_start = word_end
8409
8410 ! Find start of word (go backwards until space or beginning)
8411 do while (word_start > 0)
8412 if (state_buffer_get_char(input_state, word_start) == ' ') then
8413 word_start = word_start + 1
8414 exit
8415 end if
8416 word_start = word_start - 1
8417 end do
8418
8419 if (word_start == 0) word_start = 1
8420
8421 ! Extract the word
8422 if (word_end > word_start) then
8423 call state_buffer_get(input_state, temp_buf)
8424 word_before_cursor = temp_buf(word_start:word_end)
8425 else
8426 if (allocated(word_before_cursor)) deallocate(word_before_cursor)
8427 return ! No word to expand
8428 end if
8429
8430 ! Check if it's an abbreviation
8431 expanded_form = try_expand_abbreviation(trim(word_before_cursor))
8432 if (len(expanded_form) == 0) then
8433 if (allocated(word_before_cursor)) deallocate(word_before_cursor)
8434 return ! Not an abbreviation
8435 end if
8436
8437 ! Replace the word with expanded form
8438 expanded_len = len(expanded_form)
8439
8440 ! First, remove the original word by shifting left
8441 do i = word_end + 1, input_state%length
8442 call state_buffer_set_char(input_state, word_start + i - word_end - 1, state_buffer_get_char(input_state, i))
8443 end do
8444 input_state%length = input_state%length - (word_end - word_start + 1)
8445 input_state%cursor_pos = word_start - 1
8446
8447 ! Then insert the expanded form
8448 ! Make room for expanded text
8449 do i = input_state%length, input_state%cursor_pos + 1, -1
8450 if (i + expanded_len <= MAX_LINE_LEN) then
8451 call state_buffer_set_char(input_state, i + expanded_len, state_buffer_get_char(input_state, i))
8452 end if
8453 end do
8454
8455 ! Insert expanded text
8456 do i = 1, expanded_len
8457 if (input_state%cursor_pos + i <= MAX_LINE_LEN) then
8458 call state_buffer_set_char(input_state, input_state%cursor_pos + i, expanded_form(i:i))
8459 end if
8460 end do
8461
8462 input_state%length = input_state%length + expanded_len
8463 input_state%cursor_pos = input_state%cursor_pos + expanded_len
8464 input_state%dirty = .true.
8465
8466 ! Deallocate heap buffer
8467 if (allocated(word_before_cursor)) deallocate(word_before_cursor)
8468 end subroutine try_expand_abbreviation_at_cursor
8469
8470 ! ============================================================================
8471 ! Autosuggestion Support (Fish-style)
8472 ! ============================================================================
8473
8474 ! Update autosuggestion based on current input
8475 ! Try to suggest path completion (fish-style lookahead)
8476 subroutine try_path_suggestion(current_input, input_state)
8477 character(len=*), intent(in) :: current_input
8478 type(input_state_t), intent(inout) :: input_state
8479 character(len=MAX_LINE_LEN) :: last_word
8480 character(len=MAX_LINE_LEN) :: completions(MAX_LOCAL_COMPLETIONS)
8481 integer :: num_completions, last_space_pos, i, input_len, last_word_len
8482 type(suggestion_result_t) :: path_result
8483
8484 ! Clear any existing suggestion
8485 input_state%suggestion = ''
8486 input_state%suggestion_length = 0
8487
8488 input_len = len_trim(current_input)
8489 if (input_len == 0) return
8490
8491 ! Find the last word (what user is currently typing)
8492 last_space_pos = 0
8493 do i = input_len, 1, -1
8494 if (current_input(i:i) == ' ') then
8495 last_space_pos = i
8496 exit
8497 end if
8498 end do
8499
8500 if (last_space_pos > 0) then
8501 last_word = trim(current_input(last_space_pos+1:))
8502 else
8503 last_word = trim(current_input)
8504 end if
8505
8506 last_word_len = len_trim(last_word)
8507 if (last_word_len == 0) return
8508
8509 ! Get filesystem completions for the last word
8510 call complete_files_enhanced(last_word(1:last_word_len), completions, num_completions)
8511
8512 ! Delegate suggestion selection to the suggestions module
8513 path_result = compute_path_suggestion(last_word, last_word_len, completions, num_completions)
8514
8515 if (path_result%source /= SUGGEST_NONE) then
8516 ! Copy result into input_state character-by-character for flang-new safety
8517 input_state%suggestion = ''
8518 do i = 1, path_result%length
8519 input_state%suggestion(i:i) = path_result%text(i:i)
8520 end do
8521 input_state%suggestion_length = path_result%length
8522 end if
8523 end subroutine try_path_suggestion
8524
8525 subroutine update_autosuggestion(input_state)
8526 type(input_state_t), intent(inout) :: input_state
8527 integer :: j
8528 ! CRITICAL: Use fixed-length (NOT deferred-length) for flang-new compatibility
8529 character(len=MAX_LINE_LEN), allocatable :: current_input
8530 type(suggestion_result_t) :: hist_result
8531
8532 ! Disable autosuggestion in test mode - prevents output pollution
8533 if (.not. test_mode_initialized) call init_test_mode()
8534 if (test_mode_enabled) then
8535 input_state%suggestion = ''
8536 input_state%suggestion_length = 0
8537 return
8538 end if
8539
8540 ! Allocate buffer on heap
8541 allocate(current_input)
8542 current_input = ''
8543
8544 ! Defensive check: ensure length and cursor_pos are valid
8545 if (input_state%length < 0 .or. input_state%length > MAX_LINE_LEN) then
8546 input_state%length = 0
8547 input_state%cursor_pos = 0
8548 input_state%suggestion = ''
8549 input_state%suggestion_length = 0
8550 if (allocated(current_input)) deallocate(current_input)
8551 return
8552 end if
8553
8554 ! Clear suggestion if buffer is empty or in special modes
8555 if (input_state%length == 0 .or. input_state%in_search .or. input_state%in_history &
8556 .or. input_state%in_prefix_search) then
8557 input_state%suggestion = ''
8558 input_state%suggestion_length = 0
8559 if (allocated(current_input)) deallocate(current_input)
8560 return
8561 end if
8562
8563 ! Get current input - copy character-by-character (avoid substring on allocatable)
8564 current_input = ''
8565 do j = 1, input_state%length
8566 current_input(j:j) = state_buffer_get_char(input_state, j)
8567 end do
8568
8569 ! Priority 1: history-based suggestion (fish-style: history first)
8570 if (command_history%count > 0 .and. allocated(command_history%lines)) then
8571 hist_result = compute_history_suggestion( &
8572 current_input, input_state%length, &
8573 command_history%lines, command_history%count)
8574
8575 if (hist_result%source /= SUGGEST_NONE) then
8576 input_state%suggestion = ''
8577 do j = 1, hist_result%length
8578 input_state%suggestion(j:j) = hist_result%text(j:j)
8579 end do
8580 input_state%suggestion_length = hist_result%length
8581 if (allocated(current_input)) deallocate(current_input)
8582 return
8583 end if
8584 end if
8585
8586 ! Priority 2: path-based suggestion (fallback when no history match)
8587 call try_path_suggestion(current_input(1:input_state%length), input_state)
8588
8589 if (allocated(current_input)) deallocate(current_input)
8590 end subroutine
8591
8592 ! Accept the current autosuggestion
8593 subroutine accept_autosuggestion(input_state)
8594 type(input_state_t), intent(inout) :: input_state
8595 integer :: j, new_length
8596
8597 if (input_state%suggestion_length == 0) return
8598
8599 ! Buffer is about to be extended — any lingering selection is stale (#27).
8600 if (input_state%selection_active) call collapse_selection(input_state)
8601
8602 ! Safety check: ensure we won't overflow
8603 new_length = input_state%length + input_state%suggestion_length
8604 if (new_length > MAX_LINE_LEN) then
8605 input_state%suggestion_length = MAX_LINE_LEN - input_state%length
8606 if (input_state%suggestion_length < 0) input_state%suggestion_length = 0
8607 new_length = input_state%length + input_state%suggestion_length
8608 end if
8609
8610 ! Append suggestion to buffer using character-by-character assignment
8611 do j = 1, input_state%suggestion_length
8612 call state_buffer_set_char(input_state, input_state%length + j, input_state%suggestion(j:j))
8613 end do
8614
8615 input_state%length = new_length
8616 input_state%cursor_pos = input_state%length
8617 input_state%suggestion = ''
8618 input_state%suggestion_length = 0
8619 input_state%dirty = .true.
8620 end subroutine
8621
8622 ! Accept one word from the autosuggestion (for partial acceptance)
8623 subroutine accept_autosuggestion_word(input_state)
8624 type(input_state_t), intent(inout) :: input_state
8625 integer :: i, word_end
8626
8627 if (input_state%suggestion_length == 0) return
8628
8629 ! Buffer is about to be extended — any lingering selection is stale (#27).
8630 if (input_state%selection_active) call collapse_selection(input_state)
8631
8632 ! Find the end of the first word in the suggestion
8633 word_end = 0
8634 do i = 1, input_state%suggestion_length
8635 if (input_state%suggestion(i:i) == ' ' .or. input_state%suggestion(i:i) == '/') then
8636 word_end = i
8637 exit
8638 end if
8639 end do
8640
8641 if (word_end == 0) then
8642 ! No space found, accept entire suggestion
8643 call accept_autosuggestion(input_state)
8644 return
8645 end if
8646
8647 ! Safety check: ensure we won't overflow
8648 if (input_state%length + word_end > MAX_LINE_LEN) then
8649 word_end = MAX_LINE_LEN - input_state%length
8650 if (word_end <= 0) return
8651 end if
8652
8653 ! Append first word to buffer using accessor (handles memory pool + C strings)
8654 do i = 1, word_end
8655 call state_buffer_set_char(input_state, input_state%length + i, input_state%suggestion(i:i))
8656 end do
8657
8658 input_state%length = input_state%length + word_end
8659 input_state%cursor_pos = input_state%length
8660 input_state%dirty = .true.
8661
8662 ! Update suggestion to remove accepted part
8663 call update_autosuggestion(input_state)
8664 end subroutine
8665
8666 ! ===========================================================================
8667 ! Helper function for execute_command_line in raw mode (flang-new workaround)
8668 ! ===========================================================================
8669 subroutine safe_execute_command(command, exitstat)
8670 character(len=*), intent(in) :: command
8671 integer, intent(out), optional :: exitstat
8672 type(termios_t) :: temp_termios
8673 logical :: success
8674 integer(c_int) :: c_exit_code
8675 type(c_funptr) :: old_sigchld_handler
8676
8677 ! Flush all I/O before system() call
8678 flush(output_unit)
8679 flush(0) ! stdin
8680
8681 ! CRITICAL: Must restore terminal to cooked mode before fork/exec
8682 if (module_termios_saved) then
8683 success = restore_terminal(module_original_termios)
8684 end if
8685
8686 ! CRITICAL FIX: Temporarily restore SIGCHLD to default handler
8687 ! The shell's SIGCHLD handler causes auto-reaping of child processes,
8688 ! which makes system()'s wait() fail with ECHILD (errno 10)
8689 old_sigchld_handler = c_signal(SIGCHLD, SIG_DFL)
8690
8691 ! Use C system() instead of execute_command_line (flang-new workaround)
8692 c_exit_code = readline_c_system(trim(command) // c_null_char)
8693
8694 ! Restore the original SIGCHLD handler
8695 old_sigchld_handler = c_signal(SIGCHLD, old_sigchld_handler)
8696
8697 ! Re-enable raw mode for continued readline operation
8698 if (module_termios_saved) then
8699 success = enable_raw_mode(temp_termios)
8700 if (success) then
8701 ! Update saved state with new termios
8702 module_original_termios = temp_termios
8703 end if
8704 end if
8705
8706 ! Convert C exit code to Fortran exitstat
8707 ! system() returns: (exit_status << 8) | signal_number
8708 ! Extract just the exit status
8709 if (present(exitstat)) then
8710 if (c_exit_code == -1) then
8711 exitstat = -1 ! Fork/exec failed
8712 else
8713 exitstat = ishft(c_exit_code, -8) ! Shift right 8 bits
8714 end if
8715 end if
8716 end subroutine
8717
8718 ! ===========================================================================
8719 ! FZF Integration (Ctrl-F fuzzy file finder)
8720 ! ===========================================================================
8721
8722 subroutine launch_fzf_file_browser(input_state, prompt)
8723 type(input_state_t), intent(inout) :: input_state
8724 character(len=*), intent(in) :: prompt
8725 character(len=1024) :: fzf_cmd
8726 character(len=512) :: preview_cmd
8727 integer :: unit, iostat, exit_status
8728 logical :: file_exists
8729 character(len=256) :: bat_path
8730 ! Variables for block construct workaround (flang-new compatibility)
8731 character(len=1024) :: line, combined_selection
8732 logical :: first_line
8733 integer :: i, moves
8734 character(len=MAX_LINE_LEN) :: temp_buf
8735
8736
8737 ! Check if fzf is installed
8738 call safe_execute_command('command -v fzf >/dev/null 2>&1', exitstat=exit_status)
8739 if (exit_status /= 0) then
8740 write(output_unit, '()')
8741 write(output_unit, '(a)') 'Error: fzf is not installed. Please install fzf first.'
8742 write(output_unit, '(a)') ' Ubuntu/Debian: sudo apt install fzf'
8743 write(output_unit, '(a)') ' macOS: brew install fzf'
8744 write(output_unit, '(a)') ' Arch: sudo pacman -S fzf'
8745 input_state%dirty = .true.
8746 return
8747 end if
8748
8749 ! Check if bat is available for syntax highlighting
8750 call safe_execute_command('command -v bat >/dev/null 2>&1', exitstat=exit_status)
8751 if (exit_status == 0) then
8752 bat_path = 'bat'
8753 else
8754 ! Try batcat (Debian/Ubuntu package name)
8755 call safe_execute_command('command -v batcat >/dev/null 2>&1', exitstat=exit_status)
8756 if (exit_status == 0) then
8757 bat_path = 'batcat'
8758 else
8759 bat_path = '' ! Will use cat fallback
8760 end if
8761 end if
8762
8763 ! Build preview command
8764 if (len_trim(bat_path) > 0) then
8765 write(preview_cmd, '(a)') trim(bat_path) // &
8766 ' --color=always --style=numbers,changes --line-range=:500 {}'
8767 else
8768 preview_cmd = 'head -n 500 {}'
8769 end if
8770
8771 ! Build fzf command with options (including multi-select)
8772 write(fzf_cmd, '(a)') 'fzf --multi --height=40% --reverse --border ' // &
8773 '--preview=''' // trim(preview_cmd) // ''' ' // &
8774 '--preview-window=right:60%:wrap ' // &
8775 '--bind=''ctrl-/:toggle-preview'' ' // &
8776 '--header=''TAB: Multi-select | Ctrl-/: Toggle Preview | ESC: Cancel'' ' // &
8777 '> /tmp/fortsh_fzf_selection.tmp 2>/dev/null'
8778
8779 ! Clear screen and show fzf
8780 write(output_unit, '(a)', advance='no') char(27) // '[2J' ! Clear screen
8781 write(output_unit, '(a)', advance='no') char(27) // '[H' ! Move cursor home
8782 flush(output_unit)
8783
8784 ! Execute fzf
8785 call safe_execute_command(trim(fzf_cmd), exitstat=exit_status)
8786
8787 ! Read selection(s) if fzf exited successfully (supports multi-select)
8788 if (exit_status == 0) then
8789 inquire(file='/tmp/fortsh_fzf_selection.tmp', exist=file_exists)
8790 if (file_exists) then
8791 open(newunit=unit, file='/tmp/fortsh_fzf_selection.tmp', &
8792 status='old', action='read', iostat=iostat)
8793 if (iostat == 0) then
8794 ! WORKAROUND: Removed block construct for flang-new compatibility
8795 ! Variables moved to subroutine level
8796 first_line = .true.
8797 combined_selection = ''
8798
8799 ! Read all lines (one per selected file)
8800 do
8801 read(unit, '(a)', iostat=iostat) line
8802 if (iostat /= 0) exit
8803
8804 if (len_trim(line) > 0) then
8805 if (first_line) then
8806 combined_selection = trim(line)
8807 first_line = .false.
8808 else
8809 ! Add space between multiple selections
8810 combined_selection = trim(combined_selection) // ' ' // trim(line)
8811 end if
8812 end if
8813 end do
8814 close(unit)
8815
8816 ! Insert combined selections at cursor position
8817 if (len_trim(combined_selection) > 0) then
8818 call insert_string_at_cursor(input_state, trim(combined_selection))
8819 end if
8820 end if
8821 ! Clean up temp file
8822 call safe_execute_command('rm -f /tmp/fortsh_fzf_selection.tmp 2>/dev/null')
8823 end if
8824 end if
8825
8826 ! Restore terminal and redraw prompt
8827 write(output_unit, '(a)', advance='no') char(27) // '[2J' ! Clear screen
8828 write(output_unit, '(a)', advance='no') char(27) // '[H' ! Move cursor home
8829 write(output_unit, '(a)', advance='no') trim(prompt)
8830
8831 ! Redraw current line
8832 if (input_state%length > 0) then
8833 call state_buffer_get(input_state, temp_buf)
8834 write(output_unit, '(a)', advance='no') temp_buf(:input_state%length)
8835 ! Move cursor to correct position (if not at end)
8836 if (input_state%cursor_pos < input_state%length) then
8837 ! Move cursor back from end to cursor position using ANSI escape codes
8838 ! WORKAROUND: Removed block construct for flang-new compatibility
8839 ! Variables moved to subroutine level
8840 moves = input_state%length - input_state%cursor_pos
8841 do i = 1, moves
8842 write(output_unit, '(a)', advance='no') char(27) // '[D' ! Cursor left
8843 end do
8844 end if
8845 end if
8846 flush(output_unit)
8847
8848 input_state%dirty = .true.
8849 end subroutine
8850
8851 subroutine launch_fzf_history_browser(input_state, prompt)
8852 type(input_state_t), intent(inout) :: input_state
8853 character(len=*), intent(in) :: prompt
8854 character(len=1024) :: fzf_cmd, selected_cmd, history_file
8855 integer :: unit, iostat, exit_status
8856 logical :: file_exists
8857 character(len=MAX_LINE_LEN) :: temp_buf
8858
8859 ! Check if fzf is installed
8860 call safe_execute_command('command -v fzf >/dev/null 2>&1', exitstat=exit_status)
8861 if (exit_status /= 0) then
8862 write(output_unit, '()')
8863 write(output_unit, '(a)') 'Error: fzf is not installed. Please install fzf first.'
8864 input_state%dirty = .true.
8865 return
8866 end if
8867
8868 ! Get history file path
8869 call get_environment_variable('HOME', history_file)
8870 history_file = trim(history_file) // '/.fortsh_history'
8871
8872 ! Check if history file exists
8873 inquire(file=trim(history_file), exist=file_exists)
8874 if (.not. file_exists) then
8875 write(output_unit, '()')
8876 write(output_unit, '(a)') 'No history file found.'
8877 input_state%dirty = .true.
8878 return
8879 end if
8880
8881 ! Build fzf command for history
8882 ! tac reverses the file so recent commands appear first
8883 ! Use exact match for consistency
8884 write(fzf_cmd, '(a)') 'tac ' // trim(history_file) // ' | ' // &
8885 'fzf --height=40% --reverse --border ' // &
8886 '--no-sort ' // &
8887 '--tiebreak=index ' // &
8888 '--header=''Ctrl-H: History Browser | Select: Replace Line | ESC: Cancel'' ' // &
8889 '> /tmp/fortsh_fzf_history.tmp 2>/dev/null'
8890
8891 ! Clear screen and show fzf
8892 write(output_unit, '(a)', advance='no') char(27) // '[2J' ! Clear screen
8893 write(output_unit, '(a)', advance='no') char(27) // '[H' ! Move cursor home
8894 flush(output_unit)
8895
8896 ! Execute fzf
8897 call safe_execute_command(trim(fzf_cmd), exitstat=exit_status)
8898
8899 ! Read selection if fzf exited successfully
8900 if (exit_status == 0) then
8901 inquire(file='/tmp/fortsh_fzf_history.tmp', exist=file_exists)
8902 if (file_exists) then
8903 open(newunit=unit, file='/tmp/fortsh_fzf_history.tmp', &
8904 status='old', action='read', iostat=iostat)
8905 if (iostat == 0) then
8906 read(unit, '(a)', iostat=iostat) selected_cmd
8907 close(unit)
8908
8909 if (iostat == 0 .and. len_trim(selected_cmd) > 0) then
8910 ! Replace entire line with selected command
8911 call state_buffer_set(input_state, trim(selected_cmd))
8912 input_state%length = len_trim(selected_cmd)
8913 input_state%cursor_pos = input_state%length
8914 end if
8915 end if
8916 ! Clean up temp file
8917 call safe_execute_command('rm -f /tmp/fortsh_fzf_history.tmp 2>/dev/null')
8918 end if
8919 end if
8920
8921 ! Restore terminal and redraw prompt
8922 write(output_unit, '(a)', advance='no') char(27) // '[2J' ! Clear screen
8923 write(output_unit, '(a)', advance='no') char(27) // '[H' ! Move cursor home
8924 write(output_unit, '(a)', advance='no') trim(prompt)
8925
8926 ! Redraw current line
8927 if (input_state%length > 0) then
8928 call state_buffer_get(input_state, temp_buf)
8929 write(output_unit, '(a)', advance='no') temp_buf(:input_state%length)
8930 end if
8931 flush(output_unit)
8932
8933 input_state%dirty = .true.
8934 end subroutine
8935
8936 subroutine launch_fzf_directory_browser(input_state)
8937 type(input_state_t), intent(inout) :: input_state
8938 character(len=1024) :: fzf_cmd, selected_dir
8939 integer :: unit, iostat, exit_status
8940 logical :: file_exists
8941 character(len=MAX_LINE_LEN) :: temp_buf
8942
8943 ! Check if fzf is installed
8944 call safe_execute_command('command -v fzf >/dev/null 2>&1', exitstat=exit_status)
8945 if (exit_status /= 0) then
8946 write(output_unit, '()')
8947 write(output_unit, '(a)') 'Error: fzf is not installed.'
8948 input_state%dirty = .true.
8949 return
8950 end if
8951
8952 ! Build fzf command for directories only
8953 ! Use find to list directories, fd if available (faster)
8954 write(fzf_cmd, '(a)') '(command -v fd >/dev/null 2>&1 && ' // &
8955 'fd --type d --hidden --exclude .git || ' // &
8956 'find . -type d -not -path ''*/\.git/*'' 2>/dev/null) | ' // &
8957 'fzf --height=40% --reverse --border ' // &
8958 '--preview=''ls -lah {}'' ' // &
8959 '--preview-window=right:60%:wrap ' // &
8960 '--header=''Alt-J: Jump to Directory | Select: CD into dir | ESC: Cancel'' ' // &
8961 '> /tmp/fortsh_fzf_dir.tmp 2>/dev/null'
8962
8963 ! Clear screen and show fzf
8964 write(output_unit, '(a)', advance='no') char(27) // '[2J'
8965 write(output_unit, '(a)', advance='no') char(27) // '[H'
8966 flush(output_unit)
8967
8968 ! Execute fzf
8969 call safe_execute_command(trim(fzf_cmd), exitstat=exit_status)
8970
8971 ! Read selection and cd into it
8972 if (exit_status == 0) then
8973 inquire(file='/tmp/fortsh_fzf_dir.tmp', exist=file_exists)
8974 if (file_exists) then
8975 open(newunit=unit, file='/tmp/fortsh_fzf_dir.tmp', &
8976 status='old', action='read', iostat=iostat)
8977 if (iostat == 0) then
8978 read(unit, '(a)', iostat=iostat) selected_dir
8979 close(unit)
8980
8981 if (iostat == 0 .and. len_trim(selected_dir) > 0) then
8982 ! Replace line with cd command
8983 call state_buffer_set(input_state, 'cd ' // trim(selected_dir))
8984 #ifdef USE_C_STRINGS
8985 input_state%length = len(trim('cd ' // trim(selected_dir)))
8986 #else
8987 #ifdef USE_MEMORY_POOL
8988 input_state%length = len_trim(input_state%buffer_ref%data)
8989 #else
8990 input_state%length = len_trim(input_state%buffer)
8991 #endif
8992 #endif
8993 input_state%cursor_pos = input_state%length
8994 end if
8995 end if
8996 call safe_execute_command('rm -f /tmp/fortsh_fzf_dir.tmp 2>/dev/null')
8997 end if
8998 end if
8999
9000 ! Restore terminal
9001 write(output_unit, '(a)', advance='no') char(27) // '[2J'
9002 write(output_unit, '(a)', advance='no') char(27) // '[H'
9003 write(output_unit, '(a)', advance='no') trim(input_state%menu_prompt)
9004 if (input_state%length > 0) then
9005 call state_buffer_get(input_state, temp_buf)
9006 write(output_unit, '(a)', advance='no') temp_buf(:input_state%length)
9007 end if
9008 flush(output_unit)
9009
9010 input_state%dirty = .true.
9011 end subroutine
9012
9013 subroutine launch_fzf_git_browser(input_state)
9014 type(input_state_t), intent(inout) :: input_state
9015 character(len=1024) :: fzf_cmd, selected_item, git_cmd
9016 character(len=512) :: preview_cmd
9017 character(len=MAX_LINE_LEN) :: temp_buf
9018 integer :: unit, iostat, exit_status
9019 logical :: file_exists, in_git_repo
9020 ! Variables for block construct workaround (flang-new compatibility)
9021 integer :: i, moves
9022
9023 ! Check if in git repo
9024 call safe_execute_command('git rev-parse --git-dir >/dev/null 2>&1', exitstat=exit_status)
9025 in_git_repo = (exit_status == 0)
9026
9027 if (.not. in_git_repo) then
9028 write(output_unit, '()')
9029 write(output_unit, '(a)') 'Not in a git repository.'
9030 input_state%dirty = .true.
9031 return
9032 end if
9033
9034 ! Check if fzf is installed
9035 call safe_execute_command('command -v fzf >/dev/null 2>&1', exitstat=exit_status)
9036 if (exit_status /= 0) then
9037 write(output_unit, '()')
9038 write(output_unit, '(a)') 'Error: fzf is not installed.'
9039 input_state%dirty = .true.
9040 return
9041 end if
9042
9043 ! Build git file browser (changed/staged files + branches)
9044 ! Show modified files and branches
9045 write(git_cmd, '(a)') '{ echo "=== Changed Files ==="; ' // &
9046 'git status --short; ' // &
9047 'echo ""; echo "=== Branches ==="; ' // &
9048 'git branch --all; }'
9049
9050 write(preview_cmd, '(a)') 'if [[ {} == *"==="* ]]; then echo "Select an item below"; ' // &
9051 'elif git show {} >/dev/null 2>&1; then git show --stat {}; ' // &
9052 'else git diff {}; fi'
9053
9054 write(fzf_cmd, '(a)') trim(git_cmd) // ' | ' // &
9055 'fzf --height=40% --reverse --border --ansi ' // &
9056 '--preview=''' // trim(preview_cmd) // ''' ' // &
9057 '--preview-window=right:60%:wrap ' // &
9058 '--header=''Alt-G: Git Browser | Select file or branch | ESC: Cancel'' ' // &
9059 '> /tmp/fortsh_fzf_git.tmp 2>/dev/null'
9060
9061 ! Clear screen and show fzf
9062 write(output_unit, '(a)', advance='no') char(27) // '[2J'
9063 write(output_unit, '(a)', advance='no') char(27) // '[H'
9064 flush(output_unit)
9065
9066 ! Execute fzf
9067 call safe_execute_command(trim(fzf_cmd), exitstat=exit_status)
9068
9069 ! Read selection
9070 if (exit_status == 0) then
9071 inquire(file='/tmp/fortsh_fzf_git.tmp', exist=file_exists)
9072 if (file_exists) then
9073 open(newunit=unit, file='/tmp/fortsh_fzf_git.tmp', &
9074 status='old', action='read', iostat=iostat)
9075 if (iostat == 0) then
9076 read(unit, '(a)', iostat=iostat) selected_item
9077 close(unit)
9078
9079 if (iostat == 0 .and. len_trim(selected_item) > 0) then
9080 ! Insert selected item at cursor
9081 call insert_string_at_cursor(input_state, trim(selected_item))
9082 end if
9083 end if
9084 call safe_execute_command('rm -f /tmp/fortsh_fzf_git.tmp 2>/dev/null')
9085 end if
9086 end if
9087
9088 ! Restore terminal
9089 write(output_unit, '(a)', advance='no') char(27) // '[2J'
9090 write(output_unit, '(a)', advance='no') char(27) // '[H'
9091 write(output_unit, '(a)', advance='no') trim(input_state%menu_prompt)
9092 if (input_state%length > 0) then
9093 call state_buffer_get(input_state, temp_buf)
9094 write(output_unit, '(a)', advance='no') temp_buf(:input_state%length)
9095 ! Move cursor to correct position
9096 if (input_state%cursor_pos < input_state%length) then
9097 ! WORKAROUND: Removed block construct for flang-new compatibility
9098 ! Variables moved to subroutine level
9099 moves = input_state%length - input_state%cursor_pos
9100 do i = 1, moves
9101 write(output_unit, '(a)', advance='no') char(27) // '[D'
9102 end do
9103 end if
9104 end if
9105 flush(output_unit)
9106
9107 input_state%dirty = .true.
9108 end subroutine
9109
9110 ! ===========================================================================
9111 ! Cursor Position Helpers for Multi-Line Support
9112 ! ===========================================================================
9113
9114 ! Get terminal columns from environment variable
9115 subroutine get_terminal_size_from_env(term_cols)
9116 integer, intent(out) :: term_cols
9117 character(len=16) :: cols_str
9118 integer :: stat, iostat_val
9119
9120 call get_environment_variable('COLUMNS', cols_str, status=stat)
9121 if (stat == 0 .and. len_trim(cols_str) > 0) then
9122 read(cols_str, *, iostat=iostat_val) term_cols
9123 if (iostat_val /= 0 .or. term_cols <= 0) then
9124 term_cols = 80 ! Fallback
9125 end if
9126 else
9127 term_cols = 80 ! Fallback
9128 end if
9129 end subroutine
9130
9131 ! Calculate cursor row and column given prompt and cursor position
9132 ! Returns (row, col) where row 0 = first line, col 0 = first column
9133 subroutine cursor_get_row_col(prompt, cursor_pos, term_cols, cursor_row, cursor_col)
9134 use iso_fortran_env, only: output_unit, error_unit
9135 character(len=*), intent(in) :: prompt
9136 integer, intent(in) :: cursor_pos, term_cols
9137 integer, intent(out) :: cursor_row, cursor_col
9138 integer :: prompt_visual_len, total_pos, visual_width
9139 integer :: i, byte_val
9140 character :: ch
9141 logical :: debug_utf8
9142
9143 ! Check if debug mode is enabled
9144 call get_environment_variable('FORTSH_DEBUG_UTF8', status=byte_val)
9145 debug_utf8 = (byte_val == 0)
9146
9147 if (term_cols <= 0) then
9148 cursor_row = 0
9149 cursor_col = 0
9150 return
9151 end if
9152
9153 ! Calculate visual length of prompt (excluding ANSI codes)
9154 prompt_visual_len = visual_length(prompt)
9155 if (prompt_visual_len < 0) prompt_visual_len = 0
9156
9157 ! Calculate visual width of buffer from position 1 to cursor_pos
9158 ! This accounts for multi-byte UTF-8 characters and their display width
9159 visual_width = 0
9160 i = 1
9161 do while (i <= cursor_pos)
9162 ch = state_buffer_get_char(module_input_state, i)
9163 byte_val = iand(iachar(ch), 255)
9164
9165 if (debug_utf8) then
9166 write(error_unit, '(a,i0,a,z2.2,a,i0)') '[VISUAL] pos=', i, ' byte=0x', byte_val, ' visual_width=', visual_width
9167 end if
9168
9169 ! Check if this is a UTF-8 lead byte
9170 if (byte_val < 128) then
9171 ! ASCII - 1 byte, 1 column
9172 visual_width = visual_width + 1
9173 i = i + 1
9174 else if (iand(byte_val, 224) == 192) then
9175 ! 2-byte UTF-8 - usually 1 column, but could be 2
9176 visual_width = visual_width + utf8_char_width(ch)
9177 i = i + 2
9178 else if (iand(byte_val, 240) == 224) then
9179 ! 3-byte UTF-8 (CJK) - 2 columns
9180 visual_width = visual_width + 2
9181 i = i + 3
9182 else if (iand(byte_val, 248) == 240) then
9183 ! 4-byte UTF-8 (emoji) - 2 columns
9184 visual_width = visual_width + 2
9185 i = i + 4
9186 else
9187 ! Continuation byte or invalid - skip
9188 i = i + 1
9189 end if
9190 end do
9191
9192 if (debug_utf8) then
9193 write(error_unit, '(a,i0,a,i0,a,i0,a,i0)') '[VISUAL] cursor_pos=', cursor_pos, &
9194 ' prompt_len=', prompt_visual_len, ' visual_width=', visual_width, &
9195 ' total=', prompt_visual_len + 1 + visual_width
9196 end if
9197
9198 ! Total position = prompt + space + visual width of buffer content
9199 total_pos = prompt_visual_len + 1 + visual_width
9200
9201 ! Calculate row and column (0-based)
9202 cursor_row = total_pos / term_cols
9203 cursor_col = mod(total_pos, term_cols)
9204 end subroutine
9205
9206 ! Move cursor from old position to new position, handling line wrapping
9207 subroutine cursor_move(old_row, old_col, new_row, new_col)
9208 use iso_fortran_env, only: output_unit, error_unit
9209 integer, intent(in) :: old_row, old_col, new_row, new_col
9210 integer :: row_diff, col_diff, i
9211 logical :: debug_utf8
9212 integer :: stat
9213
9214 ! Check if debug mode is enabled
9215 call get_environment_variable('FORTSH_DEBUG_UTF8', status=stat)
9216 debug_utf8 = (stat == 0)
9217
9218 if (debug_utf8) then
9219 write(error_unit, '(a,i0,a,i0,a,i0,a,i0)') '[CURSOR_MOVE] from (', old_row, ',', old_col, ') to (', new_row, ',', new_col, ')'
9220 end if
9221
9222 row_diff = new_row - old_row
9223
9224 ! Move up/down first
9225 if (row_diff > 0) then
9226 ! Move down
9227 do i = 1, row_diff
9228 write(output_unit, '(a)', advance='no') char(27) // '[B' ! ESC[B = down
9229 end do
9230 else if (row_diff < 0) then
9231 ! Move up
9232 do i = 1, abs(row_diff)
9233 write(output_unit, '(a)', advance='no') char(27) // '[A' ! ESC[A = up
9234 end do
9235 end if
9236
9237 ! Then move left/right to correct column
9238 col_diff = new_col - old_col
9239
9240 if (debug_utf8) then
9241 write(error_unit, '(a,i0)') '[CURSOR_MOVE] col_diff=', col_diff
9242 end if
9243
9244 if (col_diff > 0) then
9245 ! Move right
9246 do i = 1, col_diff
9247 write(output_unit, '(a)', advance='no') char(27) // '[C' ! ESC[C = right
9248 end do
9249 else if (col_diff < 0) then
9250 ! Move left
9251 if (debug_utf8) then
9252 write(error_unit, '(a,i0,a)') '[CURSOR_MOVE] Moving left ', abs(col_diff), ' columns'
9253 end if
9254 do i = 1, abs(col_diff)
9255 write(output_unit, '(a)', advance='no') char(27) // '[D' ! ESC[D = left
9256 end do
9257 end if
9258
9259 flush(output_unit)
9260 end subroutine
9261
9262 ! Restore terminal from raw mode — called by REPL after all continuation prompts
9263 subroutine restore_readline_terminal()
9264 if (module_termios_saved) then
9265 if (.not. restore_terminal(module_original_termios)) then
9266 end if
9267 module_termios_saved = .false. ! Next readline call will re-save and re-enable
9268 end if
9269 end subroutine
9270
9271 end module readline