6Input layer for the OC Chat REPL (Phase 2 PR 2.A + 2.B).
10:class:`PromptDriver` is the single entry point for reading the next
11user line. At construction it tries to import ``prompt_toolkit``; if
12that succeeds (and ``OC_CHAT_LEGACY_PROMPT`` is not set), it builds a
13:class:`prompt_toolkit.PromptSession` with file-backed history,
14context-aware tab completion (see
15:class:`~oc_ui.terminal.completers.OCChatCompleter`), an
16``Alt+Enter`` keybinding that inserts a literal newline, a
17``bottom_toolbar`` showing live session stats, an ``rprompt`` showing
18token count, and a ``Ctrl+P`` binding that opens a fuzzy command
19palette. Otherwise it falls back to plain :func:`input` — same
20prompt format, no behaviour change.
22The driver runs **between turns only**. Rich's ``Live`` region owns
23the terminal during streaming; ``prompt_toolkit`` owns it during
24input. They never overlap, so the two libraries coexist without
25fighting over the screen.
29- :class:`PromptDriver` — wraps prompt-toolkit (or legacy
30 :func:`input`) and exposes a single ``read_line`` call.
31- :func:`build_prompt_prefix` — formats the ``[<model>] You: `` string used by
32 both paths. The session NAME is deliberately not in the typing line; it is
33 shown on the bottom status toolbar instead (:func:`_build_bottom_toolbar`).
34 Both surfaces report whichever session ``read_user_input`` is handed, so the
35 caller must pass the CURRENT session — ``ctx.session``, never a loop-local
36 bound at startup, which a slash command may since have replaced (plan 152 D7).
37- :class:`TurnStats` — thread-safe shared state used to communicate
38 token counts from the streaming thread to the rprompt.
50- ``PromptDriver.read_line`` is blocking; the caller is responsible
51 for backgrounding it if needed.
52- **Chat prompts are MULTI-LINE (multiline-prompt fix, 2026-07-18):**
53 when ``read_user_input`` is called without a ``prompt_text`` override
54 (the main chat prompt), Enter inserts a newline and submission is
55 **Ctrl+Enter** (arrives as ``c-j`` on Windows consoles — incl.
56 Windows Terminal / VS Code via ConPTY), with ``Ctrl+J`` and
57 ``Alt+Enter`` as the portable equivalents on terminals that cannot
58 distinguish Ctrl+Enter from Enter. A multi-line paste therefore lands
59 in the buffer as ONE prompt — nothing auto-submits. Up/Down move the
60 cursor within a multi-line buffer and navigate history from the
61 first/last line (prompt_toolkit ``auto_up``/``auto_down`` defaults).
62- **Menu/startup prompts stay single-line:** any call passing
63 ``prompt_text=`` keeps the classic Enter-submits behaviour, so
64 numbered menus answer on a single keypress+Enter as before.
65- ``TurnStats`` mutations are all guarded by an internal lock; reads
66 return a consistent snapshot.
67- The legacy ``input()`` fallback path remains SINGLE-line (Python's
68 ``input`` cannot compose multi-line); it is otherwise functionally
70- ``OC_CHAT_LEGACY_PROMPT`` is honoured at construction; toggling it
71 mid-session has no effect.
75- oc_diagnostics (structured debug logging via _dbg)
76- oc_ui.terminal.statusline (StatusBarState, render_status_line and token formatting for the bottom toolbar and rprompt)
77- oc_ui.terminal.turn_layout (TURN_INDENT for the prompt prefix)
78- oc_chat.core.cache_usage (lazy; cache hit ratio and prompt cache hit/miss split for turn stats)
79- oc_chat.core.commands (lazy; COMMANDS for the Ctrl+P command palette)
80- oc_chat.core.persistence (lazy; list_sessions for the Ctrl+P command palette)
81- oc_chat.core.session (lazy; ChatSession type hint for the prompt prefix and read_user_input)
82- oc_ui.terminal.completers (lazy; OCChatCompleter for tab completion)
83- prompt_toolkit (lazy; PromptSession for the interactive prompt)
84- prompt_toolkit.completion (lazy; FuzzyCompleter and WordCompleter for the Ctrl+P palette)
85- prompt_toolkit.filters (lazy; Condition gating the multi-line chat key bindings)
86- prompt_toolkit.formatted_text (lazy; FormattedText for the coloured chat prompt)
87- prompt_toolkit.history (lazy; FileHistory for persistent input history)
88- prompt_toolkit.key_binding (lazy; KeyBindings for the newline, submit, cancel and palette keys)
90from __future__
import annotations
95from pathlib
import Path
96from threading
import Lock
97from typing
import TYPE_CHECKING, Any, Callable
99from oc_diagnostics
import _dbg
116 return cache_hit_ratio_pct, usage_prompt_cache_hit_miss
122 """Thread-safe accumulator for in-flight turn token counts.
124 ``PromptDriver.read_user_input`` reads these via
125 :meth:`rprompt_text` to show a live ``→ NNN tok`` counter in the
126 right prompt. The main thread's ``run_turn`` writes them.
128 Plan 147 D1: also the sole owner of turn/session wall-clock timing
129 and :class:`~oc_ui.terminal.statusline.StatusBarState` production —
130 see :meth:`build_status`. ``clock`` is injectable so tests never
131 need ``sleep()``-based timing assertions.
133 Thread safety: all public methods acquire a :class:`Lock`.
136 def __init__(self, clock: Callable[[], float] = time.monotonic) ->
None:
157 """Call at the beginning of a chat turn (resets per-turn counters)."""
172 """Accumulate token usage from a single iteration."""
174 pt = usage.get(
"prompt_tokens", 0)
175 ct = usage.get(
"completion_tokens", 0)
178 self.
_turn_total += usage.get(
"total_tokens", pt + ct)
180 ch, cm = usage_prompt_cache_hit_miss(usage)
185 """Increment the per-turn iteration counter."""
190 """Increment the per-turn tool-call counter by *n*."""
195 tool_calls: int = 0, session_cache_pct: float |
None =
None,
196 session_cache_saved: int = 0) ->
None:
197 """Call at the end of a turn; sets the session total and marks idle.
199 Freezes :attr:`_last_turn_elapsed_ms` from this instance's OWN
200 ``_turn_start`` (set in :meth:`start_turn`) — self-contained and
201 independent of any duration a caller may separately compute for
202 ``show_turn_summary`` (the D1 summary-ordering pin: that value is
203 never derived from ``finish_turn`` state, nor vice versa).
217 """Reset the session-elapsed clock when the active session identity changes.
219 A no-op when *session_id* matches the last-bound id — called
220 uniformly from every :func:`~oc_chat.core.commands.refresh_presenter_bindings`
221 call site (including the harmless every-turn rebind), so it must
222 stay idempotent rather than resetting unconditionally.
230 self, *, model: str, session_name: str, effort: str, session_tools: int,
231 session_grant:
"str | None" =
None,
232 session_cost_usd:
"float | None" =
None,
233 unpriced_samples: int = 0,
234 checkpoint_auto: bool =
False,
236 """Produce a :class:`~oc_ui.terminal.statusline.StatusBarState` snapshot.
238 *model* / *session_name* / *effort* / *session_tools* are read by
239 the caller from the live ``ChatSession`` at call time (late-bound —
240 never captured) since :class:`TurnStats` itself only tracks
241 token/timing aggregates, not session identity fields. *session_grant*
242 (plan 235) is read the same way from the process grant state, and
243 defaults to ``None`` so every pre-235 caller is unchanged.
245 *session_cost_usd* / *unpriced_samples* / *checkpoint_auto* (plan 237)
246 follow the same rule: read at CALL time from the live session's cost
247 fold and the process checkpoint latches, all defaulted so a pre-237
248 caller produces a byte-identical snapshot.
253 turn_elapsed_ms = int((now - self.
_turn_start) * 1000)
261 session_name=session_name,
266 turn_cache_hit_pct=turn_cache_pct,
268 turn_elapsed_ms=turn_elapsed_ms,
270 session_tools=session_tools,
273 session_elapsed_ms=session_elapsed_ms,
274 session_grant=session_grant,
275 session_cost_usd=session_cost_usd,
276 unpriced_samples=unpriced_samples,
277 checkpoint_auto=checkpoint_auto,
281 """Return the right-prompt string for the current state.
283 Returns ``"→ NNN tok"`` during an active turn, or
284 ``"↻ NNNk / NNNk"`` (this-turn / session) between turns.
289 line = f
"\u2192 {fmt_tokens_short(self._turn_total)} tok"
292 line += f
" · {pct:.0f}% cache"
297 return f
"\u21bb {fmt_tokens_short(tt)} / {fmt_tokens_short(st)}"
307 """Return ``(turn_total, session_total)`` atomically.
309 Kept for direct unit testing of the accumulator; ``_build_bottom_toolbar``
310 renders through :meth:`build_status` and the shared statusline
311 formatter instead (plan 147 D1/D3).
317 def toolbar_data(self) -> tuple[int, int, int, int, float | None, float | None, int, int]:
318 """Return ``(turn_total, session_total, turn_iterations, turn_tool_calls,
319 turn_cache_pct, session_cache_pct, turn_cache_saved, session_cache_saved)`` atomically.
321 Kept for direct unit testing of the accumulator; ``_build_bottom_toolbar``
322 renders through :meth:`build_status` and the shared statusline
323 formatter instead (plan 147 D1/D3).
344_PROMPT_COLOR_PT =
"fg:#ff87af"
345_PROMPT_COLOR_ANSI =
"\033[38;5;211m"
346_ANSI_RESET =
"\033[0m"
352 """Return the input prompt prefix used by the REPL.
354 Format: ``\n [<model>] You: ``
356 The session name is intentionally omitted from the typing line — it added
357 nothing there (and was truncated). The full session name is shown on the
358 bottom status toolbar instead (see :func:`_build_bottom_toolbar`).
360 Plan 218 T3: the line carries the same three-space turn indent every
361 other turn-scoped line does. The prompt echo sits between the previous
362 rule and the turn rule that the Enter keypress generates, so indenting
363 it is what makes the question read as part of the turn it opens —
364 the operator's "it would be even better if the You: line fell within
365 the turn boundary". The driver gains ONLY this indent (P4).
367 return f
"\n{TURN_INDENT}[{session.model}] You: "
378 """Block for one keypress and return it (no echo, no Enter needed).
380 Windows multi-byte function keys (arrows, F-keys) are consumed and reported
381 as the sentinel ``"\\x00"`` so the caller's valid-key filter ignores them.
387 if ch
in (
"\x00",
"\xe0"):
394 fd = sys.stdin.fileno()
395 old = termios.tcgetattr(fd)
398 return sys.stdin.read(1)
400 termios.tcsetattr(fd, termios.TCSADRAIN, old)
404 """Read a single keypress from an interactive TTY (no Enter required).
406 Returns the pressed character lower-cased, or ``""`` for Enter/Return — a
407 caller treats ``""`` as "accept the default / first option". When *valid* is
408 non-empty, keys outside it (and not Enter) are ignored and the read repeats.
409 ``Ctrl+C`` raises :class:`KeyboardInterrupt`; ``Ctrl+D`` raises
410 :class:`EOFError` — the caller's existing handlers decide what that means.
412 On a non-interactive stdin there is no raw single-key mode, so this degrades
413 to one line read: *fallback* (e.g. a ``prompt_driver.read_user_input``
414 closure) when given, else :func:`input`. The first character of the entered
415 line is used (empty line -> ``""``).
417 func =
"read_single_key"
419 sys.stdout.write(prompt)
422 if not sys.stdin.isatty():
425 sys.stdout.write(
"\n")
428 line = fallback()
if fallback
is not None else input(
"")
429 except (EOFError, KeyboardInterrupt):
434 ch = line[0].lower()
if line
else ""
435 return ch
if (
not valid
or ch
in valid)
else ""
439 if ch
in (
"\r",
"\n"):
440 sys.stdout.write(
"\n")
442 _dbg(
"OC_UI.TERMINAL", func,
"single-key: Enter (default)", 4)
445 raise KeyboardInterrupt
449 if not valid
or ch
in valid:
450 sys.stdout.write(ch +
"\n")
452 _dbg(
"OC_UI.TERMINAL", func, f
"single-key: {ch!r}", 4)
460 """Build the bottom-toolbar string shown at the prompt (idle regime, D3-a).
462 Renders through the shared :func:`~oc_ui.terminal.statusline.render_status_line`
463 formatter — the ONLY bar-string producer; :class:`RichPresenter` renders
464 the same formatter for the streaming/dispatch regimes. Format::
466 model: deepseek-v4-pro │ name: 141421 │ this turn: 1.2K · 3 iters · 2 tools · 75% cache · saved ~900 tok · 12.4s │ session: 430K · 40 tools · 68% cache · 18m 32s │ effort: high Ctrl+D exit · Ctrl+P palette
470 session_tools = session.stats.tool_call_count
if hasattr(session,
"stats")
else 0
471 if turn_stats
is not None:
472 state = turn_stats.build_status(
474 session_name=session.name,
475 effort=getattr(session,
"reasoning_effort",
"high"),
476 session_tools=session_tools,
483 session_name=session.name,
484 effort=getattr(session,
"reasoning_effort",
"high"),
485 turn_tokens=0, turn_iterations=0, turn_tools=0,
486 turn_cache_hit_pct=
None, turn_cache_saved_tokens=0, turn_elapsed_ms=0,
487 session_tokens=session.stats.total_tokens
if hasattr(session,
"stats")
else 0,
488 session_tools=session_tools,
489 session_cache_hit_pct=
None, session_cache_saved_tokens=0, session_elapsed_ms=0,
492 width = max(20, shutil.get_terminal_size((80, 24)).columns)
493 _segments, line = render_status_line(state, busy_label=
None, target_width=width)
494 hint = busy_hint_text(busy=
False)
495 if len(line) + len(hint) + 3 <= width:
496 line = f
"{line} {hint}"
503 """Read the next user line.
505 Resolves at construction whether ``prompt_toolkit`` is available.
506 If yes (and the legacy escape hatch is not set), uses a
507 ``PromptSession`` with history + completer + bottom_toolbar +
508 rprompt + Ctrl+P palette. Otherwise falls back to plain
511 Construction never raises on missing optional dep.
516 The live :class:`~oc_chat.core.commands.Context`. The
517 completer reads ``ctx.model_manager`` and the live
518 ``COMMANDS`` registry.
520 Override the default ``~/.oc_chat/history`` location. Useful
523 Shared :class:`TurnStats` for live rprompt updates. ``None``
524 disables the live token counter.
527 def __init__(self, ctx, history_path: Path |
None =
None,
528 turn_stats: TurnStats |
None =
None) ->
None:
540 if os.getenv(
"OC_CHAT_LEGACY_PROMPT"):
541 _dbg(
"OC_UI.TERMINAL", func,
"PromptDriver: legacy prompt mode", 2)
545 from prompt_toolkit
import PromptSession
546 from prompt_toolkit.filters
import Condition
547 from prompt_toolkit.history
import FileHistory
548 from prompt_toolkit.key_binding
import KeyBindings
553 if history_path
is None:
554 history_path = Path.home() /
".oc_chat" /
"history"
556 history_path.parent.mkdir(parents=
True, exist_ok=
True)
557 except OSError
as exc:
558 print(f
"[history disabled: {exc}]", file=sys.stderr)
561 bindings = KeyBindings()
574 @bindings.add("enter", filter=in_chat_prompt)
575 def _chat_newline(event):
576 event.current_buffer.insert_text(
"\n")
578 @bindings.add("c-j", filter=in_chat_prompt)
579 def _chat_submit(event):
580 event.current_buffer.validate_and_handle()
582 @bindings.add("escape", "enter", filter=in_chat_prompt)
583 def _chat_submit_alt(event):
584 event.current_buffer.validate_and_handle()
586 @bindings.add("escape", "enter", filter=~in_chat_prompt)
587 def _insert_newline(event):
590 event.current_buffer.insert_text(
"\n")
594 def _cancel_input(event):
595 """Reset the buffer, save partial input to history, and mark as cancelled."""
597 text = event.current_buffer.text
598 if text.strip()
and self.
_pt is not None and getattr(self.
_pt,
"history",
None):
599 self.
_pt.history.append_string(text)
600 event.current_buffer.reset()
605 from prompt_toolkit.completion
import FuzzyCompleter
607 FuzzyCompleter =
None
609 if FuzzyCompleter
is not None:
611 def _open_palette(event):
612 """Open a fuzzy command-palette overlay.
614 Temporarily swaps the buffer's completer for a
615 ``FuzzyCompleter``, opens the completion menu, and
616 restores the original completer immediately.
617 The completion menu captures the swapped completer
618 at open time, so it keeps using the fuzzy matcher
619 until dismissed — after which normal Tab completion
622 func =
"_open_palette"
625 palette_words: list[str] = sorted(COMMANDS.keys())
627 palette_words.extend(
628 f
"/session load {s['name']}"
629 for s
in list_sessions()
631 except Exception
as e:
633 _dbg(
"OC_UI.TERMINAL", func, f
"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
635 from prompt_toolkit.completion
import WordCompleter
636 palette_completer = FuzzyCompleter(
637 WordCompleter(palette_words, WORD=
True)
640 buf = event.app.current_buffer
641 old_completer = buf.completer
642 buf.completer = palette_completer
643 buf.start_completion(select_first=
False)
648 buf.completer = old_completer
651 kwargs: dict[str, Any] = dict(
655 key_bindings=bindings,
656 complete_while_typing=
False,
662 key_bindings=bindings,
663 complete_while_typing=
False,
665 if history_path
is not None:
666 kwargs[
"history"] = FileHistory(str(history_path))
670 kwargs[
"prompt_continuation"] =
" … "
678 kwargs[
"rprompt"] =
lambda: self.
_turn_stats.rprompt_text()
684 kwargs[
"refresh_interval"] = 1.0
687 self.
_pt = PromptSession(**kwargs)
688 except Exception
as exc:
689 print(f
"[prompt_toolkit disabled: {exc}]", file=sys.stderr)
696 """``True`` iff the modern prompt is active. Read-only."""
697 return self.
_pt is not None
699 def read_user_input(self, session: ChatSession, prompt_text: str |
None =
None) -> str |
None:
700 """Read the next user input.
702 *prompt_text* overrides the default ``[model | session] You: ``
703 prefix — useful for startup menus that need a custom prompt.
705 The main CHAT prompt (``prompt_text is None``) is MULTI-LINE:
706 Enter inserts a newline, Ctrl+Enter submits (``c-j`` on Windows
707 consoles; ``Ctrl+J`` / ``Alt+Enter`` are the portable
708 equivalents), so a multi-line paste arrives as ONE prompt.
709 Menu/startup prompts (``prompt_text`` given) stay single-line
710 with Enter submitting, as before. The returned text may contain
711 newlines; the caller strips as needed.
713 Returns ``None`` when the user cancels with ``Ctrl+C``.
714 Re-raises :class:`EOFError` so the main loop's existing handler
715 keeps working unchanged.
720 is_chat = prompt_text
is None
723 is_chat_prompt = is_chat
and not os.getenv(
"NO_COLOR")
725 if prompt_text
is not None:
730 if self.
_pt is not None:
732 from prompt_toolkit.formatted_text
import FormattedText
733 pt_prompt = FormattedText([(_PROMPT_COLOR_PT, prompt)])
738 return self.
_pt.prompt(pt_prompt, multiline=is_chat)
741 except KeyboardInterrupt:
751 display_prompt = f
"{_PROMPT_COLOR_ANSI}{prompt}{_ANSI_RESET}"
753 display_prompt = prompt
755 return input(display_prompt)
756 except KeyboardInterrupt: