8989 allocate_prompt_scene_rows ,
9090)
9191from pythinker_code .ui .shell .prompting .lifecycle import PromptLifecycle
92+ from pythinker_code .ui .shell .prompting .state import (
93+ BufferObserved ,
94+ Invalidate ,
95+ ModalAttached ,
96+ ModalDetached ,
97+ ModalState ,
98+ ModeChanged ,
99+ PromptEffect ,
100+ PromptEvent ,
101+ PromptMode ,
102+ PromptPhase ,
103+ PromptState ,
104+ RestoreDocument ,
105+ RunningDelegateAttached ,
106+ RunningDelegateDetached ,
107+ RunningPromptDelegate ,
108+ SelectCompleter ,
109+ SetEraseWhenDone ,
110+ ShortcutHelpToggled ,
111+ SuspendDocument ,
112+ TurnCleared ,
113+ TurnStarting ,
114+ transition ,
115+ )
92116from pythinker_code .ui .shell .spacing import (
93117 PREAMBLE_EARLIER_OUTPUT_HIDDEN_HINT ,
94118 ensure_prompt_newline ,
@@ -1859,17 +1883,6 @@ def _load_history_entries(history_file: Path) -> list[_HistoryEntry]:
18591883 return entries
18601884
18611885
1862- class PromptMode (Enum ):
1863- AGENT = "agent"
1864- SHELL = "shell"
1865-
1866- def toggle (self ) -> PromptMode :
1867- return PromptMode .SHELL if self == PromptMode .AGENT else PromptMode .AGENT
1868-
1869- def __str__ (self ) -> str :
1870- return self .value
1871-
1872-
18731886class PromptUIState (Enum ):
18741887 NORMAL_INPUT = "normal_input"
18751888 MODAL_HIDDEN_INPUT = "modal_hidden_input"
@@ -2155,26 +2168,6 @@ class _ToastEntry:
21552168 """Optional prompt_toolkit style for the rendered line; "" uses the default toast style."""
21562169
21572170
2158- class RunningPromptDelegate (Protocol ):
2159- """Protocol for components that can take over the bottom prompt area."""
2160-
2161- modal_priority : int
2162-
2163- def render_running_prompt_body (self , columns : int ) -> AnyFormattedText : ...
2164-
2165- def running_prompt_placeholder (self ) -> AnyFormattedText | None : ...
2166-
2167- def running_prompt_allows_text_input (self ) -> bool : ...
2168-
2169- def running_prompt_hides_input_buffer (self ) -> bool : ...
2170-
2171- def running_prompt_accepts_submission (self ) -> bool : ...
2172-
2173- def should_handle_running_prompt_key (self , key : str ) -> bool : ...
2174-
2175- def handle_running_prompt_key (self , key : str , event : KeyPressEvent ) -> None : ...
2176-
2177-
21782171@dataclass (frozen = True , slots = True )
21792172class BgTaskCounts :
21802173 bash : int = 0
@@ -2392,6 +2385,7 @@ def __init__(
23922385 self ._slash_menu_control : SlashCommandMenuControl | None = None
23932386 self ._last_ui_state : PromptUIState = PromptUIState .NORMAL_INPUT
23942387 self ._suspended_buffer_document : Document | None = None
2388+ self ._prompt_state = PromptState (mode = self ._mode )
23952389 clipboard_available = is_clipboard_available ()
23962390 media_clipboard_available = is_media_clipboard_available ()
23972391 self ._tips = _build_toolbar_tips (clipboard_available or media_clipboard_available )
@@ -2522,21 +2516,16 @@ def _(event: KeyPressEvent) -> None:
25222516 if event .current_buffer .text .strip ():
25232517 event .current_buffer .insert_text ("?" )
25242518 return
2525- self ._shortcut_help_open = not self ._shortcut_help_open
2526- event .app .invalidate ()
2519+ self .toggle_shortcut_help ()
25272520
25282521 @_kb .add ("c-x" , eager = True )
25292522 def _ (event : KeyPressEvent ) -> None :
25302523 if self ._active_prompt_delegate () is not None :
25312524 return
2532- self ._mode = self . _mode . toggle ()
2525+ self .toggle_mode ()
25332526 from pythinker_code .telemetry import track
25342527
25352528 track ("shortcut_mode_switch" , to_mode = self ._mode .value )
2536- # Apply mode-specific settings
2537- self ._apply_mode (event )
2538- # Redraw UI
2539- event .app .invalidate ()
25402529
25412530 @_kb .add ("s-tab" , eager = True )
25422531 def _ (event : KeyPressEvent ) -> None :
@@ -2726,8 +2715,7 @@ def _(event: KeyPressEvent) -> None:
27262715 filter = Condition (lambda : self ._shortcut_help_open ),
27272716 )
27282717 def _ (event : KeyPressEvent ) -> None :
2729- self ._shortcut_help_open = False
2730- event .app .invalidate ()
2718+ self .close_shortcut_help ()
27312719
27322720 @_kb .add (
27332721 "1" ,
@@ -2861,6 +2849,7 @@ def _capture_prompt_frame(app: Application[str]) -> None:
28612849 def _ (buffer : Buffer ) -> None :
28622850 self ._last_input_activity_time = time .monotonic ()
28632851 self ._input_activity_event .set ()
2852+ self ._dispatch (BufferObserved (buffer .document ))
28642853 if buffer .complete_while_typing () and not self ._suppress_auto_completion :
28652854 buffer .start_completion ()
28662855
@@ -3344,33 +3333,7 @@ def invalidate(self) -> None:
33443333 app .invalidate ()
33453334
33463335 def _sync_prompt_ui_state (self ) -> None :
3347- new_state = self ._active_ui_state ()
3348- old_state = getattr (self , "_last_ui_state" , PromptUIState .NORMAL_INPUT )
3349- buffer = self ._session .default_buffer
3350-
3351- if (
3352- old_state != PromptUIState .MODAL_HIDDEN_INPUT
3353- and new_state == PromptUIState .MODAL_HIDDEN_INPUT
3354- ):
3355- if self ._suspended_buffer_document is None and buffer .text :
3356- self ._suspended_buffer_document = buffer .document
3357- buffer .set_document (Document (), bypass_readonly = True )
3358- elif (
3359- old_state == PromptUIState .MODAL_HIDDEN_INPUT
3360- and new_state != PromptUIState .MODAL_HIDDEN_INPUT
3361- and self ._suspended_buffer_document is not None
3362- ):
3363- if not buffer .text :
3364- buffer .set_document (self ._suspended_buffer_document , bypass_readonly = True )
3365- else :
3366- # Buffer was externally modified (e.g. approval inline feedback).
3367- # Don't overwrite the new content, but log that the old input is lost.
3368- logger .debug (
3369- "Dropping suspended buffer document because buffer was modified externally"
3370- )
3371- self ._suspended_buffer_document = None
3372-
3373- self ._last_ui_state = new_state
3336+ self ._last_ui_state = self ._active_ui_state ()
33743337
33753338 def _render_agent_prompt_message (self ) -> FormattedText :
33763339 frame = self ._prompt_frame_for_render ()
@@ -4187,6 +4150,102 @@ async def wait_for_input_activity(self) -> None:
41874150 await self ._input_activity_event .wait ()
41884151 self ._input_activity_event .clear ()
41894152
4153+ def _prompt_reducer_state (self ) -> PromptState :
4154+ """Return the authoritative reducer state, bootstrapping legacy sessions once.
4155+
4156+ A few integrations historically populated these attributes on partially
4157+ constructed sessions. Bootstrap those sessions at this boundary without
4158+ rebuilding reducer-owned state from mutable facade projections on every
4159+ dispatch.
4160+ """
4161+ existing = getattr (self , "_prompt_state" , None )
4162+ if isinstance (existing , PromptState ):
4163+ return existing
4164+
4165+ running = getattr (self , "_running_prompt_delegate" , None )
4166+ turn_starting = getattr (self , "_turn_starting" , False )
4167+ phase = (
4168+ PromptPhase .RUNNING
4169+ if running is not None
4170+ else PromptPhase .TURN_STARTING
4171+ if turn_starting
4172+ else PromptPhase .IDLE
4173+ )
4174+ return PromptState (
4175+ mode = getattr (self , "_mode" , PromptMode .AGENT ),
4176+ phase = phase ,
4177+ running_delegate = running ,
4178+ modal_stack = tuple (
4179+ ModalState (
4180+ delegate = delegate ,
4181+ priority = delegate .modal_priority ,
4182+ hides_input = delegate .running_prompt_hides_input_buffer (),
4183+ )
4184+ for delegate in getattr (self , "_modal_delegates" , ())
4185+ ),
4186+ suspended_document = getattr (self , "_suspended_buffer_document" , None ),
4187+ shortcut_help_open = getattr (self , "_shortcut_help_open" , False ),
4188+ running_previous_mode = getattr (
4189+ self ,
4190+ "_running_prompt_previous_mode" ,
4191+ None ,
4192+ ),
4193+ )
4194+
4195+ def _apply_reducer_state (self , state : PromptState ) -> None :
4196+ self ._prompt_state = state
4197+ self ._mode = state .mode
4198+ self ._turn_starting = state .phase is PromptPhase .TURN_STARTING
4199+ self ._running_prompt_delegate = state .running_delegate
4200+ self ._running_prompt_previous_mode = state .running_previous_mode
4201+ self ._modal_delegates = [modal .delegate for modal in state .modal_stack ]
4202+ self ._suspended_buffer_document = state .suspended_document
4203+ self ._shortcut_help_open = state .shortcut_help_open
4204+
4205+ def _apply_prompt_effect (self , effect : PromptEffect ) -> None :
4206+ session = getattr (self , "_session" , None )
4207+ buffer = getattr (session , "default_buffer" , None )
4208+ if isinstance (effect , SelectCompleter ):
4209+ if buffer is not None :
4210+ attribute = (
4211+ "_shell_mode_completer"
4212+ if effect .mode is PromptMode .SHELL
4213+ else "_agent_mode_completer"
4214+ )
4215+ completer = getattr (self , attribute , None )
4216+ if completer is not None :
4217+ buffer .completer = completer
4218+ elif isinstance (effect , SetEraseWhenDone ):
4219+ app = getattr (session , "app" , None )
4220+ if app is not None :
4221+ app .erase_when_done = effect .erase_when_done
4222+ elif isinstance (effect , SuspendDocument ):
4223+ if buffer is not None and buffer .text :
4224+ buffer .set_document (Document (), bypass_readonly = True )
4225+ elif isinstance (effect , RestoreDocument ) and buffer is not None and not buffer .text :
4226+ buffer .set_document (effect .document , bypass_readonly = True )
4227+
4228+ def _dispatch (self , event : PromptEvent , * , invalidate_noop : bool = False ) -> None :
4229+ result = transition (self ._prompt_reducer_state (), event )
4230+ self ._apply_reducer_state (result .state )
4231+ should_invalidate = invalidate_noop
4232+ for effect in result .effects :
4233+ if isinstance (effect , Invalidate ):
4234+ should_invalidate = True
4235+ else :
4236+ self ._apply_prompt_effect (effect )
4237+ if should_invalidate :
4238+ self .invalidate ()
4239+
4240+ def toggle_mode (self ) -> None :
4241+ self ._dispatch (ModeChanged (self ._prompt_reducer_state ().mode .toggle ()))
4242+
4243+ def toggle_shortcut_help (self ) -> None :
4244+ self ._dispatch (ShortcutHelpToggled ())
4245+
4246+ def close_shortcut_help (self ) -> None :
4247+ self ._dispatch (ShortcutHelpToggled (open = False ))
4248+
41904249 def mark_turn_starting (self ) -> None :
41914250 """Collapse the input card immediately, before the delegate attaches.
41924251
@@ -4196,11 +4255,7 @@ def mark_turn_starting(self) -> None:
41964255 the stream). Superseded by the delegate once :meth:`attach_running_prompt`
41974256 runs; cleared there and on detach.
41984257 """
4199- # Idempotent: a repeat call (e.g. two dispatches before an attach) must
4200- # not cost an extra repaint.
4201- if not self ._turn_starting :
4202- self ._turn_starting = True
4203- self .invalidate ()
4258+ self ._dispatch (TurnStarting ())
42044259
42054260 def clear_turn_starting (self ) -> None :
42064261 """Drop the pre-attach turn-starting hint without an attach/detach.
@@ -4210,52 +4265,30 @@ def clear_turn_starting(self) -> None:
42104265 path that occurred before the running-prompt delegate ever attached —
42114266 without reaching into the private ``_turn_starting`` attribute.
42124267 """
4213- self ._turn_starting = False
4214- self .invalidate ()
4268+ self ._dispatch (TurnCleared (), invalidate_noop = not hasattr (self , "_session" ))
42154269
42164270 def attach_running_prompt (self , delegate : RunningPromptDelegate ) -> None :
4217- current = getattr (self , "_running_prompt_delegate" , None )
4218- if current is delegate :
4219- return
4220- if current is None :
4221- self ._running_prompt_previous_mode = self ._mode
4222- self ._running_prompt_delegate = delegate
4223- # The delegate is the source of truth now; drop the pre-attach hint.
4224- self ._turn_starting = False
4225- self ._mode = PromptMode .AGENT
4226- self ._apply_mode ()
4227- self .invalidate ()
4271+ self ._dispatch (RunningDelegateAttached (delegate ))
42284272
42294273 def detach_running_prompt (self , delegate : RunningPromptDelegate ) -> None :
4230- if getattr (self , "_running_prompt_delegate" , None ) is not delegate :
4231- return
4232- previous_mode = getattr (self , "_running_prompt_previous_mode" , None )
4233- self ._running_prompt_delegate = None
4234- self ._running_prompt_previous_mode = None
4235- self ._turn_starting = False
4236- if previous_mode is not None :
4237- self ._mode = previous_mode
4238- self ._apply_mode ()
4239- self .invalidate ()
4274+ self ._dispatch (RunningDelegateDetached (delegate ))
42404275
42414276 def attach_modal (self , delegate : RunningPromptDelegate ) -> None :
4242- modal_delegates : list [RunningPromptDelegate ] | None = getattr (
4243- self , "_modal_delegates" , None
4277+ buffer = getattr (getattr (self , "_session" , None ), "default_buffer" , None )
4278+ document = buffer .document if buffer is not None else Document ()
4279+ self ._dispatch (
4280+ ModalAttached (
4281+ delegate = delegate ,
4282+ priority = delegate .modal_priority ,
4283+ hides_input = delegate .running_prompt_hides_input_buffer (),
4284+ document = document ,
4285+ )
42444286 )
4245- if modal_delegates is None :
4246- modal_delegates = []
4247- self ._modal_delegates = modal_delegates
4248- if delegate in modal_delegates :
4249- return
4250- modal_delegates .append (delegate )
4251- self .invalidate ()
42524287
42534288 def detach_modal (self , delegate : RunningPromptDelegate ) -> None :
4254- modal_delegates = getattr (self , "_modal_delegates" , None )
4255- if not modal_delegates or delegate not in modal_delegates :
4256- return
4257- modal_delegates .remove (delegate )
4258- self .invalidate ()
4289+ buffer = getattr (getattr (self , "_session" , None ), "default_buffer" , None )
4290+ document = buffer .document if buffer is not None else Document ()
4291+ self ._dispatch (ModalDetached (delegate , document ))
42594292
42604293 def running_prompt_accepts_submission (self ) -> bool :
42614294 delegate = self ._active_prompt_delegate ()
0 commit comments