fortrangoingonforty/fortsh / 81a91f7

Browse files

add shift+arrow text selection infrastructure

Introduce native text selection in the emacs-mode line editor. Shift
plus every cursor movement key extends a selection anchored at the
pre-motion cursor; plain motion collapses it. Char motion snaps cursor
to the appropriate selection edge (VS Code convention). Word motion,
line-end motion, and history navigation just clear state and proceed.

Coverage:
- Shift+Left/Right/Up/Down/Home/End (xterm modifier 2)
- Ctrl+Shift+Left/Right (modifier 6) — word-wise
- Alt+Shift+B/F (ESC uppercase) — emacs-native word-wise
- Plain motion collapse guards on handle_cursor_left/right,
handle_home, handle_end, move_to_next_word, move_to_previous_word
- Buffer-replacement clears on history up/down, autosuggestion accept,
fish-style directory nav (cd .., prevd, nextd)

Dispatch uses a module-level module_extending_selection flag that the
shift-dispatch sets before calling a base handler and clears after. No
new function parameters, no new derived-type passing — keeps flang-new
stack layout unchanged.

No visible rendering yet — that's the next step. Selection state is
internal and observable via FORTSH_DEBUG_SELECTION=1 on stderr, and
behaviorally through the snap-on-collapse cursor position.

Tests: 15 new PTY assertions in selection.yaml covering every path,
including auto-collapse at anchor, history-clear, and a dir-nav
regression guard for modifier 4. keys.py gains S-*, C-S-*, M-S-*,
M-B/F aliases so YAML specs can drive the new sequences.
Authored by espadonne
SHA
81a91f7337c448a3c210b0345af95201cb0f9c85
Parents
74d634b
Tree
067cad7

3 changed files

StatusFile+-
M src/io/readline.f90 337 7
A tests/interactive/test_specs/selection.yaml 273 0
M tests/interactive/utils/keys.py 45 0
src/io/readline.f90modified
@@ -209,6 +209,13 @@ module readline
209209
     type(string_ref) :: menu_prefix_ref
210210
     type(string_ref) :: selected_process_name_ref
211211
 #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
212219
   end type input_state_t
213220
 
214221
   type :: history_t
@@ -258,6 +265,19 @@ module readline
258265
   ! Track whether the search status line is currently displayed below the prompt
259266
   logical, save :: module_search_status_shown = .false.
260267
 
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
+
261281
 contains
262282
 
263283
   !============================================================================
@@ -592,6 +612,138 @@ contains
592612
   ! END BUFFER OPERATION WRAPPERS
593613
   !============================================================================
594614
 
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
+
595747
   ! Initialize input_state_t with allocated strings
596748
   subroutine init_input_state(state)
597749
     type(input_state_t), intent(inout) :: state
@@ -2817,6 +2969,17 @@ contains
28172969
   subroutine move_to_next_word(input_state)
28182970
     type(input_state_t), intent(inout) :: input_state
28192971
     integer :: pos
2972
+    integer :: old_cursor_pos
2973
+
2974
+    ! Plain word-motion with active selection: clear, then proceed from
2975
+    ! current cursor. No snap — word motion runs through the old cursor
2976
+    ! anyway (#25, #26).
2977
+    if (input_state%selection_active .and. .not. module_extending_selection) then
2978
+      call collapse_selection(input_state)
2979
+      input_state%dirty = .true.
2980
+    end if
2981
+
2982
+    old_cursor_pos = input_state%cursor_pos
28202983
 
28212984
     pos = input_state%cursor_pos + 1
28222985
 
@@ -2847,13 +3010,32 @@ contains
28473010
     end if
28483011
 
28493012
     input_state%dirty = .true.
3013
+
3014
+    if (module_extending_selection) then
3015
+      call update_selection_on_shift_motion(input_state, old_cursor_pos)
3016
+    end if
28503017
   end subroutine
28513018
 
28523019
   subroutine move_to_previous_word(input_state)
28533020
     type(input_state_t), intent(inout) :: input_state
28543021
     integer :: pos
2855
-    
2856
-    if (input_state%cursor_pos <= 0) return
3022
+    integer :: old_cursor_pos
3023
+
3024
+    ! Plain word-motion with active selection: clear, then proceed from
3025
+    ! current cursor (#25, #26).
3026
+    if (input_state%selection_active .and. .not. module_extending_selection) then
3027
+      call collapse_selection(input_state)
3028
+      input_state%dirty = .true.
3029
+    end if
3030
+
3031
+    old_cursor_pos = input_state%cursor_pos
3032
+
3033
+    if (input_state%cursor_pos <= 0) then
3034
+      if (module_extending_selection) then
3035
+        call update_selection_on_shift_motion(input_state, old_cursor_pos)
3036
+      end if
3037
+      return
3038
+    end if
28573039
 
28583040
     pos = input_state%cursor_pos - 1
28593041
 
@@ -2872,6 +3054,10 @@ contains
28723054
     ! so space position is correct (cursor will be after space, before first char of word)
28733055
     input_state%cursor_pos = pos
28743056
     input_state%dirty = .true.
3057
+
3058
+    if (module_extending_selection) then
3059
+      call update_selection_on_shift_motion(input_state, old_cursor_pos)
3060
+    end if
28753061
   end subroutine
28763062
 
28773063
   subroutine delete_char_at_cursor(input_state)
@@ -5692,12 +5878,24 @@ contains
56925878
       case('b')
56935879
         ! Alt+b - Move backward one word
56945880
         call move_to_previous_word(input_state)
5881
+      case('B')
5882
+        ! Alt+Shift+b - Extend selection one word back (shift phase, Sprint 1)
5883
+        ! ESC-uppercase is xterm's encoding for Alt+Shift+letter. Routes through
5884
+        ! the shift-extending path so move_to_previous_word grows the selection.
5885
+        module_extending_selection = .true.
5886
+        call move_to_previous_word(input_state)
5887
+        module_extending_selection = .false.
56955888
       case('d')
56965889
         ! Alt+d - Delete forward one word (emacs standard)
56975890
         call handle_kill_word_forward(input_state)
56985891
       case('f')
56995892
         ! Alt+f - Move forward one word
57005893
         call move_to_next_word(input_state)
5894
+      case('F')
5895
+        ! Alt+Shift+f - Extend selection one word forward (shift phase, Sprint 1)
5896
+        module_extending_selection = .true.
5897
+        call move_to_next_word(input_state)
5898
+        module_extending_selection = .false.
57015899
       case('j')
57025900
         ! Alt+j - Jump to directory with fzf
57035901
         call launch_fzf_directory_browser(input_state)
@@ -5906,6 +6104,50 @@ contains
59066104
               input_state%suggestion_length > 0) then
59076105
             call accept_autosuggestion_word(input_state)
59086106
           end if
6107
+        ! ============================================================
6108
+        ! Shift-phase selection extension (modifiers 2 and 6)
6109
+        ! Sprint 1: state only; Sprint 2 adds the visible highlight.
6110
+        ! ============================================================
6111
+        ! Shift+Left — extend selection one char back
6112
+        else if (modifier == '2' .and. terminator == 'D') then
6113
+          module_extending_selection = .true.
6114
+          call handle_cursor_left(input_state)
6115
+          module_extending_selection = .false.
6116
+        ! Shift+Right — extend selection one char forward
6117
+        else if (modifier == '2' .and. terminator == 'C') then
6118
+          module_extending_selection = .true.
6119
+          call handle_cursor_right(input_state)
6120
+          module_extending_selection = .false.
6121
+        ! Shift+Up — treat as Shift+Home on single-line prompt (#25)
6122
+        else if (modifier == '2' .and. terminator == 'A') then
6123
+          module_extending_selection = .true.
6124
+          call handle_home(input_state)
6125
+          module_extending_selection = .false.
6126
+        ! Shift+Down — treat as Shift+End on single-line prompt (#25)
6127
+        else if (modifier == '2' .and. terminator == 'B') then
6128
+          module_extending_selection = .true.
6129
+          call handle_end(input_state)
6130
+          module_extending_selection = .false.
6131
+        ! Shift+Home — extend selection to start of line
6132
+        else if (modifier == '2' .and. terminator == 'H') then
6133
+          module_extending_selection = .true.
6134
+          call handle_home(input_state)
6135
+          module_extending_selection = .false.
6136
+        ! Shift+End — extend selection to end of line
6137
+        else if (modifier == '2' .and. terminator == 'F') then
6138
+          module_extending_selection = .true.
6139
+          call handle_end(input_state)
6140
+          module_extending_selection = .false.
6141
+        ! Ctrl+Shift+Left — extend selection by one word back
6142
+        else if (modifier == '6' .and. terminator == 'D') then
6143
+          module_extending_selection = .true.
6144
+          call move_to_previous_word(input_state)
6145
+          module_extending_selection = .false.
6146
+        ! Ctrl+Shift+Right — extend selection by one word forward
6147
+        else if (modifier == '6' .and. terminator == 'C') then
6148
+          module_extending_selection = .true.
6149
+          call move_to_next_word(input_state)
6150
+          module_extending_selection = .false.
59096151
         ! Check for Alt+Left/Right for word movement (modifier=3)
59106152
         else if (modifier == '3' .and. terminator == 'D') then
59116153
           ! Alt+Left - Move cursor backward one word (standard behavior)
@@ -5963,9 +6205,23 @@ contains
59636205
     type(input_state_t), intent(inout) :: input_state
59646206
     integer :: old_row, old_col, new_row, new_col, term_cols
59656207
     integer :: bytes_to_move
6208
+    integer :: old_cursor_pos
59666209
     logical :: debug_utf8
59676210
     integer :: debug_stat
59686211
 
6212
+    ! Shift-phase: plain Left with an active selection snaps cursor to the
6213
+    ! LEFT edge and clears selection, without further motion (#25, #26).
6214
+    ! Char-motion uses the snap-to-edge convention (matches VS Code/TextEdit).
6215
+    if (input_state%selection_active .and. .not. module_extending_selection) then
6216
+      input_state%cursor_pos = min(input_state%selection_anchor, input_state%cursor_pos)
6217
+      call collapse_selection(input_state)
6218
+      input_state%dirty = .true.
6219
+      return
6220
+    end if
6221
+
6222
+    ! Capture pre-motion cursor so shift-extending can anchor the selection.
6223
+    old_cursor_pos = input_state%cursor_pos
6224
+
59696225
     ! Check if UTF-8 debug mode is enabled
59706226
     call get_environment_variable('FORTSH_DEBUG_UTF8', status=debug_stat)
59716227
     debug_utf8 = (debug_stat == 0)
@@ -6010,12 +6266,32 @@ contains
60106266
       module_cursor_screen_row = new_row
60116267
       module_cursor_screen_col = new_col
60126268
     end if
6269
+
6270
+    ! Shift-phase: if this call is extending a selection, update it now.
6271
+    if (module_extending_selection) then
6272
+      call update_selection_on_shift_motion(input_state, old_cursor_pos)
6273
+    end if
60136274
   end subroutine
6014
-  
6275
+
60156276
   subroutine handle_cursor_right(input_state)
60166277
     type(input_state_t), intent(inout) :: input_state
60176278
     integer :: old_row, old_col, new_row, new_col, term_cols
60186279
     integer :: bytes_to_move
6280
+    integer :: old_cursor_pos
6281
+
6282
+    ! Shift-phase: plain Right with an active selection snaps cursor to the
6283
+    ! RIGHT edge and clears selection, without further motion (#25, #26).
6284
+    if (input_state%selection_active .and. .not. module_extending_selection) then
6285
+      input_state%cursor_pos = max(input_state%selection_anchor, input_state%cursor_pos)
6286
+      if (input_state%cursor_pos > input_state%length) then
6287
+        input_state%cursor_pos = input_state%length
6288
+      end if
6289
+      call collapse_selection(input_state)
6290
+      input_state%dirty = .true.
6291
+      return
6292
+    end if
6293
+
6294
+    old_cursor_pos = input_state%cursor_pos
60196295
 
60206296
     if (input_state%cursor_pos < input_state%length) then
60216297
       ! Get terminal size
@@ -6047,17 +6323,28 @@ contains
60476323
       ! Update module cursor tracking
60486324
       module_cursor_screen_row = new_row
60496325
       module_cursor_screen_col = new_col
6050
-    else if (input_state%cursor_pos == input_state%length .and. input_state%suggestion_length > 0) then
6051
-      ! At end of line with suggestion - accept it
6326
+    else if (input_state%cursor_pos == input_state%length .and. input_state%suggestion_length > 0 &
6327
+             .and. .not. module_extending_selection) then
6328
+      ! At end of line with suggestion - accept it (but not during shift-extension —
6329
+      ! Shift+Right at the end of the line should not eat an autosuggestion).
60526330
       call accept_autosuggestion(input_state)
60536331
     end if
6332
+
6333
+    ! Shift-phase: if this call is extending a selection, update it now.
6334
+    if (module_extending_selection) then
6335
+      call update_selection_on_shift_motion(input_state, old_cursor_pos)
6336
+    end if
60546337
   end subroutine
6055
-  
6338
+
60566339
   subroutine handle_history_up(input_state)
60576340
     type(input_state_t), intent(inout) :: input_state
60586341
     character(len=MAX_LINE_LEN) :: history_line
60596342
     logical :: found
60606343
 
6344
+    ! History navigation replaces the buffer wholesale — selection byte
6345
+    ! offsets from the old buffer would point into stale data (#27).
6346
+    if (input_state%selection_active) call collapse_selection(input_state)
6347
+
60616348
     ! If there's text on the line and we're not yet in any history mode,
60626349
     ! enter prefix search mode (fish-style)
60636350
     if (.not. input_state%in_history .and. .not. input_state%in_prefix_search &
@@ -6104,6 +6391,9 @@ contains
61046391
     character(len=MAX_LINE_LEN) :: history_line
61056392
     logical :: found
61066393
 
6394
+    ! Buffer replacement — clear any stale selection (#27).
6395
+    if (input_state%selection_active) call collapse_selection(input_state)
6396
+
61076397
     ! Prefix search: find next match or return to present
61086398
     if (input_state%in_prefix_search) then
61096399
       call prefix_search_move(input_state, +1)
@@ -6733,6 +7023,17 @@ contains
67337023
   ! Advanced line editing functions for Phase 5
67347024
   subroutine handle_home(input_state)
67357025
     type(input_state_t), intent(inout) :: input_state
7026
+    integer :: old_cursor_pos
7027
+
7028
+    ! Plain motion with active selection: clear selection, then proceed with
7029
+    ! normal motion. Home/End don't snap — they always go to 0/length — so a
7030
+    ! simple clear is correct (#25, #26).
7031
+    if (input_state%selection_active .and. .not. module_extending_selection) then
7032
+      call collapse_selection(input_state)
7033
+      input_state%dirty = .true.
7034
+    end if
7035
+
7036
+    old_cursor_pos = input_state%cursor_pos
67367037
 
67377038
     ! Move cursor to beginning of line
67387039
     if (input_state%cursor_pos > 0) then
@@ -6740,10 +7041,22 @@ contains
67407041
       ! Mark dirty to trigger full redraw with correct cursor position
67417042
       input_state%dirty = .true.
67427043
     end if
7044
+
7045
+    if (module_extending_selection) then
7046
+      call update_selection_on_shift_motion(input_state, old_cursor_pos)
7047
+    end if
67437048
   end subroutine
6744
-  
7049
+
67457050
   subroutine handle_end(input_state)
67467051
     type(input_state_t), intent(inout) :: input_state
7052
+    integer :: old_cursor_pos
7053
+
7054
+    if (input_state%selection_active .and. .not. module_extending_selection) then
7055
+      call collapse_selection(input_state)
7056
+      input_state%dirty = .true.
7057
+    end if
7058
+
7059
+    old_cursor_pos = input_state%cursor_pos
67477060
 
67487061
     ! Move cursor to end of line
67497062
     if (input_state%cursor_pos < input_state%length) then
@@ -6751,6 +7064,10 @@ contains
67517064
       ! Mark dirty to trigger full redraw with correct cursor position
67527065
       input_state%dirty = .true.
67537066
     end if
7067
+
7068
+    if (module_extending_selection) then
7069
+      call update_selection_on_shift_motion(input_state, old_cursor_pos)
7070
+    end if
67547071
   end subroutine
67557072
   
67567073
   subroutine handle_kill_to_end(input_state)
@@ -7252,6 +7569,9 @@ contains
72527569
     logical, intent(inout) :: done
72537570
     character(len=5) :: cmd
72547571
 
7572
+    ! Buffer replacement — clear any stale selection (#27).
7573
+    if (input_state%selection_active) call collapse_selection(input_state)
7574
+
72557575
     cmd = 'cd ..'
72567576
 
72577577
     ! Clear current buffer and insert "cd .."
@@ -7279,6 +7599,8 @@ contains
72797599
     logical, intent(inout) :: done
72807600
     character(len=5) :: cmd
72817601
 
7602
+    if (input_state%selection_active) call collapse_selection(input_state)
7603
+
72827604
     cmd = 'prevd'
72837605
 
72847606
     ! Clear current buffer and insert "prevd"
@@ -7306,6 +7628,8 @@ contains
73067628
     logical, intent(inout) :: done
73077629
     character(len=5) :: cmd
73087630
 
7631
+    if (input_state%selection_active) call collapse_selection(input_state)
7632
+
73097633
     cmd = 'nextd'
73107634
 
73117635
     ! Clear current buffer and insert "nextd"
@@ -8242,6 +8566,9 @@ contains
82428566
 
82438567
     if (input_state%suggestion_length == 0) return
82448568
 
8569
+    ! Buffer is about to be extended — any lingering selection is stale (#27).
8570
+    if (input_state%selection_active) call collapse_selection(input_state)
8571
+
82458572
     ! Safety check: ensure we won't overflow
82468573
     new_length = input_state%length + input_state%suggestion_length
82478574
     if (new_length > MAX_LINE_LEN) then
@@ -8269,6 +8596,9 @@ contains
82698596
 
82708597
     if (input_state%suggestion_length == 0) return
82718598
 
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
+
82728602
     ! Find the end of the first word in the suggestion
82738603
     word_end = 0
82748604
     do i = 1, input_state%suggestion_length
tests/interactive/test_specs/selection.yamladded
@@ -0,0 +1,273 @@
1
+# Text Selection Tests for fortsh
2
+# shift phase, Sprint 1: selection state + shift/ctrl-shift arrow dispatch.
3
+#
4
+# Sprint 1 does NOT render the selection visually (that's Sprint 2). These
5
+# tests assert BEHAVIORAL differences through subsequent text operations:
6
+#
7
+# - Plain Left/Right with an active selection SNAPS cursor to the selection
8
+#   edge (not a single-char step). A follow-up character insert lands at
9
+#   the edge position — observably different from a baseline fortsh.
10
+#
11
+# - Ctrl+Shift+Left/Right and Alt+Shift+B/F extend selection by a whole
12
+#   word. A subsequent plain motion + insert reveals where the selection
13
+#   edge was.
14
+#
15
+# Assertion strategy: build a final buffer, prepend `echo `, hit Enter, and
16
+# match on the echoed output. Echo word-splits trailing whitespace, so
17
+# expected strings are trimmed-right.
18
+
19
+metadata:
20
+  category: "Text Selection"
21
+  description: "Shift+Arrow selection state + dispatch (shift phase)"
22
+  phase: "shift/sprint-1"
23
+
24
+tests:
25
+  # =============================================================
26
+  # SHIFT+LEFT / SHIFT+RIGHT: char-wise extension + snap collapse
27
+  # =============================================================
28
+
29
+  - name: "Shift+Left extends; plain Right snaps to right edge"
30
+    # Type abcd, Shift+Left x2 (anchor=4, cursor=2), plain Right snaps
31
+    # cursor to max(4,2)=4, insert X at 4 → "abcdX".
32
+    # Baseline: Shift+Left treated as plain Left x2 → cursor=2; plain
33
+    # Right → cursor=3; X at 3 → "abcXd".
34
+    steps:
35
+      - send: "abcd"
36
+      - send_key: "S-Left"
37
+      - send_key: "S-Left"
38
+      - send_key: "Right"
39
+      - send: "X"
40
+      - send_key: "C-a"
41
+      - send: "echo "
42
+      - send_key: "Enter"
43
+    expect_output: "abcdX"
44
+    match_type: "contains"
45
+
46
+  - name: "Shift+Right extends; plain Left snaps to left edge"
47
+    # Type abcd, C-a (cursor=0), Shift+Right x2 (anchor=0, cursor=2),
48
+    # plain Left snaps to min(0,2)=0, insert X at 0 → "Xabcd".
49
+    # Baseline: Shift+Right → cursor=2; plain Left → cursor=1; X at 1 → "aXbcd".
50
+    steps:
51
+      - send: "abcd"
52
+      - send_key: "C-a"
53
+      - send_key: "S-Right"
54
+      - send_key: "S-Right"
55
+      - send_key: "Left"
56
+      - send: "X"
57
+      - send_key: "C-a"
58
+      - send: "echo "
59
+      - send_key: "Enter"
60
+    expect_output: "Xabcd"
61
+    match_type: "contains"
62
+
63
+  - name: "Shift+Right then Shift+Left auto-collapses at anchor"
64
+    # Anchor=0, cursor 0→1→0. update_selection_on_shift_motion
65
+    # auto-collapses when cursor returns to anchor. Next plain Right
66
+    # moves normally (one char), not snap.
67
+    steps:
68
+      - send: "abcd"
69
+      - send_key: "C-a"
70
+      - send_key: "S-Right"
71
+      - send_key: "S-Left"
72
+      - send_key: "Right"
73
+      - send: "X"
74
+      - send_key: "C-a"
75
+      - send: "echo "
76
+      - send_key: "Enter"
77
+    expect_output: "aXbcd"
78
+    match_type: "contains"
79
+
80
+  # =============================================================
81
+  # SHIFT+HOME / SHIFT+END: line-edge extension
82
+  # =============================================================
83
+
84
+  - name: "Shift+End selects to end; plain Left snaps to left edge"
85
+    # Type abcd, C-a (cursor=0), Shift+End (anchor=0, cursor=4), plain
86
+    # Left snaps to 0, insert X at 0 → "Xabcd".
87
+    steps:
88
+      - send: "abcd"
89
+      - send_key: "C-a"
90
+      - send_key: "S-End"
91
+      - send_key: "Left"
92
+      - send: "X"
93
+      - send_key: "C-a"
94
+      - send: "echo "
95
+      - send_key: "Enter"
96
+    expect_output: "Xabcd"
97
+    match_type: "contains"
98
+
99
+  - name: "Shift+Home selects to start; plain Right snaps to right edge"
100
+    # Type abcd (cursor=4), Shift+Home (anchor=4, cursor=0), plain Right
101
+    # snaps to max(4,0)=4, insert X at 4 → "abcdX".
102
+    steps:
103
+      - send: "abcd"
104
+      - send_key: "S-Home"
105
+      - send_key: "Right"
106
+      - send: "X"
107
+      - send_key: "C-a"
108
+      - send: "echo "
109
+      - send_key: "Enter"
110
+    expect_output: "abcdX"
111
+    match_type: "contains"
112
+
113
+  # =============================================================
114
+  # CTRL+SHIFT+LEFT / CTRL+SHIFT+RIGHT: word-wise extension
115
+  # =============================================================
116
+
117
+  - name: "Ctrl+Shift+Left word-wise selection snaps to word start"
118
+    # "aaa bbb" (cursor=7). C-S-Left → move_to_previous_word lands
119
+    # cursor at 4 (before 'b' of "bbb"), anchor=7. Plain Left snaps
120
+    # to min(7,4)=4. X at 4 → "aaa Xbbb".
121
+    steps:
122
+      - send: "aaa bbb"
123
+      - send_key: "C-S-Left"
124
+      - send_key: "Left"
125
+      - send: "X"
126
+      - send_key: "C-a"
127
+      - send: "echo "
128
+      - send_key: "Enter"
129
+    expect_output: "aaa Xbbb"
130
+    match_type: "contains"
131
+
132
+  - name: "Ctrl+Shift+Right word-wise selection snaps to word end"
133
+    # "aaa bbb ccc" (cursor=11). C-a (cursor=0). C-S-Right →
134
+    # move_to_next_word lands at 3 (end of "aaa"), anchor=0. Plain Right
135
+    # snaps to max(0,3)=3. X at 3 → "aaaX bbb ccc".
136
+    steps:
137
+      - send: "aaa bbb ccc"
138
+      - send_key: "C-a"
139
+      - send_key: "C-S-Right"
140
+      - send_key: "Right"
141
+      - send: "X"
142
+      - send_key: "C-a"
143
+      - send: "echo "
144
+      - send_key: "Enter"
145
+    expect_output: "aaaX bbb ccc"
146
+    match_type: "contains"
147
+
148
+  # =============================================================
149
+  # ALT+SHIFT+B / ALT+SHIFT+F: emacs-native word-wise selection
150
+  # (ESC-uppercase is xterm's Alt+Shift+letter encoding.)
151
+  # =============================================================
152
+
153
+  - name: "Alt+Shift+B (ESC uppercase B) extends selection one word back"
154
+    steps:
155
+      - send: "aaa bbb"
156
+      - send_key: "M-B"
157
+      - send_key: "Left"
158
+      - send: "X"
159
+      - send_key: "C-a"
160
+      - send: "echo "
161
+      - send_key: "Enter"
162
+    expect_output: "aaa Xbbb"
163
+    match_type: "contains"
164
+
165
+  - name: "Alt+Shift+F (ESC uppercase F) extends selection one word forward"
166
+    steps:
167
+      - send: "aaa bbb ccc"
168
+      - send_key: "C-a"
169
+      - send_key: "M-F"
170
+      - send_key: "Right"
171
+      - send: "X"
172
+      - send_key: "C-a"
173
+      - send: "echo "
174
+      - send_key: "Enter"
175
+    expect_output: "aaaX bbb ccc"
176
+    match_type: "contains"
177
+
178
+  # =============================================================
179
+  # REGRESSION GUARDS: plain motion with no selection still works
180
+  # =============================================================
181
+
182
+  - name: "Plain Left with no selection still moves one char"
183
+    steps:
184
+      - send: "abcd"
185
+      - send_key: "Left"
186
+      - send: "X"
187
+      - send_key: "C-a"
188
+      - send: "echo "
189
+      - send_key: "Enter"
190
+    expect_output: "abcXd"
191
+    match_type: "contains"
192
+
193
+  - name: "Plain Right with no selection still moves one char"
194
+    steps:
195
+      - send: "abcd"
196
+      - send_key: "C-a"
197
+      - send_key: "Right"
198
+      - send: "X"
199
+      - send_key: "C-a"
200
+      - send: "echo "
201
+      - send_key: "Enter"
202
+    expect_output: "aXbcd"
203
+    match_type: "contains"
204
+
205
+  - name: "Plain Alt+b word-back with no selection still moves one word"
206
+    # Baseline check: Alt+b (lowercase) still jumps to word start and
207
+    # does NOT engage selection mode.
208
+    steps:
209
+      - send: "aaa bbb"
210
+      - send_key: "M-b"
211
+      - send: "X"
212
+      - send_key: "C-a"
213
+      - send: "echo "
214
+      - send_key: "Enter"
215
+    expect_output: "aaa Xbbb"
216
+    match_type: "contains"
217
+
218
+  - name: "Plain Alt+f word-forward with no selection still moves one word"
219
+    steps:
220
+      - send: "aaa bbb ccc"
221
+      - send_key: "C-a"
222
+      - send_key: "M-f"
223
+      - send: "X"
224
+      - send_key: "C-a"
225
+      - send: "echo "
226
+      - send_key: "Enter"
227
+    expect_output: "aaaX bbb ccc"
228
+    match_type: "contains"
229
+
230
+  # =============================================================
231
+  # HISTORY NAVIGATION CLEARS SELECTION (#27)
232
+  # After Up/Down round-trip, selection must be gone — subsequent
233
+  # plain motion should NOT snap to an orphan anchor.
234
+  # =============================================================
235
+
236
+  - name: "History Up clears active selection (#27)"
237
+    # Type "abcd", S-Left x2 → anchor=4, cursor=2, active.
238
+    # Up enters prefix-search mode. No history entry matches "abcd",
239
+    # so buffer/cursor stay put — but selection must still be cleared
240
+    # per #27. Subsequent plain Right then has NO anchor to snap to,
241
+    # so it moves one char normally: cursor 2 → 3. X at 3 → "abcXd".
242
+    #
243
+    # Without #27: collapse_selection is skipped in handle_history_up,
244
+    # selection stays active (anchor=4, cursor=2). Next plain Right
245
+    # snaps to max(4,2)=4, X at 4 → "abcdX". Test would fail.
246
+    steps:
247
+      - send_line: "echo first"
248
+      - send: "abcd"
249
+      - send_key: "S-Left"
250
+      - send_key: "S-Left"
251
+      - send_key: "Up"
252
+      - send_key: "Right"
253
+      - send: "X"
254
+      - send_key: "C-a"
255
+      - send: "echo "
256
+      - send_key: "Enter"
257
+    expect_output: "abcXd"
258
+    match_type: "contains"
259
+
260
+  # =============================================================
261
+  # REGRESSION GUARD: modifier 4 (Alt+Shift+Arrow) still navigates
262
+  # directories, not selection — ensure we didn't steal its encoding.
263
+  # =============================================================
264
+
265
+  - name: "Alt+Shift+Up still triggers cd .. (dir nav binding intact)"
266
+    # Send pwd, then Alt+Shift+Up which fortsh binds to 'cd ..' and
267
+    # auto-executes, then pwd again. Second pwd should be parent of first.
268
+    steps:
269
+      - send_line: "pwd"
270
+      - send_key: "M-S-Up"
271
+      - send_line: "pwd"
272
+    expect_output: "/"
273
+    match_type: "contains"
tests/interactive/utils/keys.pymodified
@@ -10,6 +10,29 @@ ARROW_DOWN = "\x1b[B"
1010
 ARROW_RIGHT = "\x1b[C"
1111
 ARROW_LEFT = "\x1b[D"
1212
 
13
+# Shift-modified arrow / nav keys (xterm modifier 2 = Shift)
14
+# Used for native text selection (shift phase, Sprint 1+).
15
+SHIFT_ARROW_UP = "\x1b[1;2A"
16
+SHIFT_ARROW_DOWN = "\x1b[1;2B"
17
+SHIFT_ARROW_RIGHT = "\x1b[1;2C"
18
+SHIFT_ARROW_LEFT = "\x1b[1;2D"
19
+SHIFT_HOME = "\x1b[1;2H"
20
+SHIFT_END = "\x1b[1;2F"
21
+
22
+# Ctrl+Shift arrow (xterm modifier 6) — word-wise selection extension
23
+CTRL_SHIFT_ARROW_RIGHT = "\x1b[1;6C"
24
+CTRL_SHIFT_ARROW_LEFT = "\x1b[1;6D"
25
+
26
+# Alt+Shift letter (ESC + uppercase) — emacs-native word-wise selection
27
+ALT_SHIFT_B = "\x1bB"
28
+ALT_SHIFT_F = "\x1bF"
29
+
30
+# Alt+Shift arrow (xterm modifier 4) — fortsh binds these to dir history
31
+ALT_SHIFT_UP = "\x1b[1;4A"
32
+ALT_SHIFT_DOWN = "\x1b[1;4B"
33
+ALT_SHIFT_LEFT = "\x1b[1;4D"
34
+ALT_SHIFT_RIGHT = "\x1b[1;4C"
35
+
1336
 # Control keys (ASCII control characters)
1437
 CTRL_A = "\x01"  # Beginning of line
1538
 CTRL_B = "\x02"  # Back one character
@@ -91,6 +114,28 @@ KEYS = {
91114
     "Right": ARROW_RIGHT,
92115
     "Left": ARROW_LEFT,
93116
 
117
+    # Shift-modified navigation (for native text selection)
118
+    "S-Up": SHIFT_ARROW_UP,
119
+    "S-Down": SHIFT_ARROW_DOWN,
120
+    "S-Right": SHIFT_ARROW_RIGHT,
121
+    "S-Left": SHIFT_ARROW_LEFT,
122
+    "S-Home": SHIFT_HOME,
123
+    "S-End": SHIFT_END,
124
+
125
+    # Ctrl+Shift navigation (word-wise selection)
126
+    "C-S-Right": CTRL_SHIFT_ARROW_RIGHT,
127
+    "C-S-Left": CTRL_SHIFT_ARROW_LEFT,
128
+
129
+    # Alt+Shift letter (emacs word-wise selection)
130
+    "M-B": ALT_SHIFT_B,
131
+    "M-F": ALT_SHIFT_F,
132
+
133
+    # Alt+Shift arrows (fortsh directory history, NOT selection)
134
+    "M-S-Up": ALT_SHIFT_UP,
135
+    "M-S-Down": ALT_SHIFT_DOWN,
136
+    "M-S-Left": ALT_SHIFT_LEFT,
137
+    "M-S-Right": ALT_SHIFT_RIGHT,
138
+
94139
     # Control keys
95140
     "C-a": CTRL_A,
96141
     "C-b": CTRL_B,