Skip to content

Commit b8d8ec1

Browse files
committed
refactor(tui): centralize prompt state transitions
Add prompting/state.py with a pure transition(state, event) reducer over PromptState/PromptTransition (mode, phase, running delegate, modal stack, suspended document, shortcut help). CustomPromptSession routes turn, delegate, modal, mode, shortcut, and buffer mutations through one _dispatch that applies effects and invalidates once; the distributed flags become reducer-owned mirrors. PromptMode and RunningPromptDelegate move to the new module and are re-exported for compatibility.
1 parent 0376c61 commit b8d8ec1

3 files changed

Lines changed: 857 additions & 109 deletions

File tree

src/pythinker_code/ui/shell/prompt.py

Lines changed: 142 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,30 @@
8989
allocate_prompt_scene_rows,
9090
)
9191
from 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+
)
92116
from 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-
18731886
class 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)
21792172
class 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

Comments
 (0)