oc_chat
Loading...
Searching...
No Matches
input.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/input.py
4
5"""
6Input layer for the OC Chat REPL (Phase 2 PR 2.A + 2.B).
7
8Purpose
9-------
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.
21
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.
26
27Responsibilities
28----------------
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.
39
40Diagnostics
41-----------
42Domain: OC_UI.TERMINAL
43Levels:
44 L2 — lifecycle events
45 L3 — semantic details
46 L4 — deep tracing
47
48Contracts
49---------
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
69 equivalent.
70- ``OC_CHAT_LEGACY_PROMPT`` is honoured at construction; toggling it
71 mid-session has no effect.
72
73Dependencies
74------------
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)
89"""
90from __future__ import annotations
91
92import os
93import sys
94import time
95from pathlib import Path
96from threading import Lock
97from typing import TYPE_CHECKING, Any, Callable
98
99from oc_diagnostics import _dbg
100
101from oc_ui.terminal.statusline import (
102 StatusBarState,
103 busy_hint_text,
104 fmt_tokens_short,
105 render_status_line,
106)
107from oc_ui.terminal.turn_layout import TURN_INDENT
108
109if TYPE_CHECKING:
110 from oc_chat.core.session import ChatSession
111
112# Lazy import — keeps oc_ui independent of oc_chat at import time.
113# (Full DI via UIContext is deferred to Phase C.)
115 from oc_chat.core.cache_usage import cache_hit_ratio_pct, usage_prompt_cache_hit_miss
116 return cache_hit_ratio_pct, usage_prompt_cache_hit_miss
117
118
119# ── TurnStats: thread-safe shared state for rprompt ─────────────────────────
120
122 """Thread-safe accumulator for in-flight turn token counts.
123
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.
127
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.
132
133 Thread safety: all public methods acquire a :class:`Lock`.
134 """
135
136 def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
137 self._lock = Lock()
138 self._clock = clock
139 self._prompt_tokens: int = 0
140 self._completion_tokens: int = 0
141 self._turn_total: int = 0
142 self._session_total: int = 0
143 self._active: bool = False
144 self._cache_hit: int = 0
145 self._cache_miss: int = 0
146 self._turn_iterations: int = 0
147 self._turn_tool_calls: int = 0
148 self._session_cache_pct: float | None = None
150 # ── Plan 147 D1: wall-clock timing (monotonic; never sleep()-tested) ──
151 self._session_start: float = clock()
152 self._turn_start: float | None = None
154 self._last_bound_session_id: str | None = None
155
156 def start_turn(self) -> None:
157 """Call at the beginning of a chat turn (resets per-turn counters)."""
158 with self._lock:
159 self._prompt_tokens = 0
160 self._completion_tokens = 0
161 self._turn_total = 0
162 self._cache_hit = 0
163 self._cache_miss = 0
164 self._turn_iterations = 0
165 self._turn_tool_calls = 0
166 self._session_cache_pct = None
167 self._session_cache_saved = 0
168 self._active = True
169 self._turn_start = self._clock()
170
171 def add_usage(self, usage: dict) -> None:
172 """Accumulate token usage from a single iteration."""
173 with self._lock:
174 pt = usage.get("prompt_tokens", 0)
175 ct = usage.get("completion_tokens", 0)
176 self._prompt_tokens += pt
177 self._completion_tokens += ct
178 self._turn_total += usage.get("total_tokens", pt + ct)
179 _, usage_prompt_cache_hit_miss = _cache_usage_fns()
180 ch, cm = usage_prompt_cache_hit_miss(usage)
181 self._cache_hit += ch
182 self._cache_miss += cm
183
184 def add_iteration(self) -> None:
185 """Increment the per-turn iteration counter."""
186 with self._lock:
187 self._turn_iterations += 1
188
189 def add_tool_calls(self, n: int) -> None:
190 """Increment the per-turn tool-call counter by *n*."""
191 with self._lock:
192 self._turn_tool_calls += n
193
194 def finish_turn(self, session_total: int, iterations: int = 0,
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.
198
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).
204 """
205 with self._lock:
206 self._session_total = session_total
207 self._turn_iterations = iterations
208 self._turn_tool_calls = tool_calls
209 self._session_cache_pct = session_cache_pct
210 self._session_cache_saved = session_cache_saved
211 if self._turn_start is not None:
212 self._last_turn_elapsed_ms = int((self._clock() - self._turn_start) * 1000)
213 self._turn_start = None
214 self._active = False
215
216 def rebind_session(self, session_id: str) -> None:
217 """Reset the session-elapsed clock when the active session identity changes.
218
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.
223 """
224 with self._lock:
225 if session_id != self._last_bound_session_id:
226 self._session_start = self._clock()
227 self._last_bound_session_id = session_id
228
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,
235 ) -> StatusBarState:
236 """Produce a :class:`~oc_ui.terminal.statusline.StatusBarState` snapshot.
237
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.
244
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.
249 """
250 with self._lock:
251 now = self._clock()
252 if self._active and self._turn_start is not None:
253 turn_elapsed_ms = int((now - self._turn_start) * 1000)
254 else:
255 turn_elapsed_ms = self._last_turn_elapsed_ms
256 session_elapsed_ms = int((now - self._session_start) * 1000)
257 cache_hit_ratio_pct, _ = _cache_usage_fns()
258 turn_cache_pct = cache_hit_ratio_pct(self._cache_hit, self._cache_miss)
259 return StatusBarState(
260 model=model,
261 session_name=session_name,
262 effort=effort,
263 turn_tokens=self._turn_total,
264 turn_iterations=self._turn_iterations,
265 turn_tools=self._turn_tool_calls,
266 turn_cache_hit_pct=turn_cache_pct,
267 turn_cache_saved_tokens=self._cache_hit,
268 turn_elapsed_ms=turn_elapsed_ms,
269 session_tokens=self._session_total,
270 session_tools=session_tools,
271 session_cache_hit_pct=self._session_cache_pct,
272 session_cache_saved_tokens=self._session_cache_saved,
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,
278 )
279
280 def rprompt_text(self) -> str:
281 """Return the right-prompt string for the current state.
282
283 Returns ``"→ NNN tok"`` during an active turn, or
284 ``"↻ NNNk / NNNk"`` (this-turn / session) between turns.
285 """
286 with self._lock:
287 cache_hit_ratio_pct, _ = _cache_usage_fns()
288 if self._active and self._turn_total > 0:
289 line = f"\u2192 {fmt_tokens_short(self._turn_total)} tok"
290 pct = cache_hit_ratio_pct(self._cache_hit, self._cache_miss)
291 if pct is not None:
292 line += f" · {pct:.0f}% cache"
293 return line
294 if self._session_total > 0 or self._turn_total > 0:
295 st = self._session_total or 0
296 tt = self._turn_total or 0
297 return f"\u21bb {fmt_tokens_short(tt)} / {fmt_tokens_short(st)}"
298 return ""
299
300 @property
301 def active(self) -> bool:
302 with self._lock:
303 return self._active
304
305 @property
306 def totals(self) -> tuple[int, int]:
307 """Return ``(turn_total, session_total)`` atomically.
308
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).
312 """
313 with self._lock:
314 return (self._turn_total, self._session_total)
315
316 @property
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.
320
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).
324 """
325 with self._lock:
326 cache_hit_ratio_pct, _ = _cache_usage_fns()
327 turn_cache_pct = cache_hit_ratio_pct(self._cache_hit, self._cache_miss)
328 return (
329 self._turn_total,
330 self._session_total,
331 self._turn_iterations,
332 self._turn_tool_calls,
333 turn_cache_pct,
335 self._cache_hit,
337 )
338
339
340# ── Prompt colour ───────────────────────────────────────────────────────────
341# Applied only to the "[model | session] You: " prefix, not to typed text.
342# Change _PROMPT_COLOR_PT / _PROMPT_COLOR_ANSI to restyle without hunting
343# through logic.
344_PROMPT_COLOR_PT = "fg:#ff87af" # prompt_toolkit style string (pink)
345_PROMPT_COLOR_ANSI = "\033[38;5;211m" # ANSI 256-colour equivalent
346_ANSI_RESET = "\033[0m"
347
348
349# ── Prompt prefix ───────────────────────────────────────────────────────────
350
351def build_prompt_prefix(session: ChatSession) -> str:
352 """Return the input prompt prefix used by the REPL.
353
354 Format: ``\n [<model>] You: ``
355
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`).
359
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).
366 """
367 return f"\n{TURN_INDENT}[{session.model}] You: "
368
369
370# ── Single-key menu input ────────────────────────────────────────────────────
371# Used by the startup/model/confirm menus so a bare ``1``/``2``/``y`` selects
372# immediately without an Enter press. Engages only on an interactive TTY; under
373# a pipe / test capture / redirected stdin there is no raw mode, so it degrades
374# to a single line read (Enter required) — keeping tests and headless runs
375# behaviour-identical to the old line-based menus.
376
377def _getch_raw() -> str:
378 """Block for one keypress and return it (no echo, no Enter needed).
379
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.
382 """
383 if os.name == "nt":
384 import msvcrt # Windows-only; imported lazily.
385
386 ch = msvcrt.getwch()
387 if ch in ("\x00", "\xe0"): # function-key prefix — consume the scan code
388 msvcrt.getwch()
389 return "\x00"
390 return ch
391 import termios
392 import tty
393
394 fd = sys.stdin.fileno()
395 old = termios.tcgetattr(fd)
396 try:
397 tty.setcbreak(fd)
398 return sys.stdin.read(1)
399 finally:
400 termios.tcsetattr(fd, termios.TCSADRAIN, old)
401
402
403def read_single_key(prompt: str = "", *, valid: str = "", fallback=None) -> str:
404 """Read a single keypress from an interactive TTY (no Enter required).
405
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.
411
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 -> ``""``).
416 """
417 func = "read_single_key"
418 if prompt:
419 sys.stdout.write(prompt)
420 sys.stdout.flush()
421
422 if not sys.stdin.isatty():
423 # No raw mode available (pipe / pytest capture / redirect) — line read.
424 if prompt:
425 sys.stdout.write("\n")
426 sys.stdout.flush()
427 try:
428 line = fallback() if fallback is not None else input("")
429 except (EOFError, KeyboardInterrupt):
430 raise
431 if line is None:
432 return ""
433 line = line.strip()
434 ch = line[0].lower() if line else ""
435 return ch if (not valid or ch in valid) else ""
436
437 while True:
438 ch = _getch_raw()
439 if ch in ("\r", "\n"):
440 sys.stdout.write("\n")
441 sys.stdout.flush()
442 _dbg("OC_UI.TERMINAL", func, "single-key: Enter (default)", 4)
443 return ""
444 if ch == "\x03":
445 raise KeyboardInterrupt
446 if ch == "\x04":
447 raise EOFError
448 ch = ch.lower()
449 if not valid or ch in valid:
450 sys.stdout.write(ch + "\n")
451 sys.stdout.flush()
452 _dbg("OC_UI.TERMINAL", func, f"single-key: {ch!r}", 4)
453 return ch
454 # Unrecognised key — ignore and keep waiting.
455
456
457# ── Toolbar helpers ─────────────────────────────────────────────────────────
458
459def _build_bottom_toolbar(session, turn_stats: TurnStats | None) -> str:
460 """Build the bottom-toolbar string shown at the prompt (idle regime, D3-a).
461
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::
465
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
467 """
468 import shutil
469
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(
473 model=session.model,
474 session_name=session.name,
475 effort=getattr(session, "reasoning_effort", "high"),
476 session_tools=session_tools,
477 )
478 else:
479 # Degraded fallback (no TurnStats bound) — still renders through the
480 # shared formatter with the data session.stats alone can provide.
481 state = StatusBarState(
482 model=session.model,
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,
490 )
491
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}"
497 return line
498
499
500# ── PromptDriver ────────────────────────────────────────────────────────────
501
503 """Read the next user line.
504
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
509 :func:`input`.
510
511 Construction never raises on missing optional dep.
512
513 Parameters
514 ----------
515 ctx:
516 The live :class:`~oc_chat.core.commands.Context`. The
517 completer reads ``ctx.model_manager`` and the live
518 ``COMMANDS`` registry.
519 history_path:
520 Override the default ``~/.oc_chat/history`` location. Useful
521 for tests.
522 turn_stats:
523 Shared :class:`TurnStats` for live rprompt updates. ``None``
524 disables the live token counter.
525 """
526
527 def __init__(self, ctx, history_path: Path | None = None,
528 turn_stats: TurnStats | None = None) -> None:
529 func = "__init__"
530 self._ctx = ctx
531 self._turn_stats = turn_stats
532 self._last_session = None
533 self._cancelled = False
534 self._pt: Any = None # PromptSession when prompt_toolkit is available
535 # Multiline-prompt fix (2026-07-18): True only while a CHAT prompt
536 # (no prompt_text override) is being read — gates the Enter-inserts-
537 # newline / Ctrl+Enter-submits bindings so menus keep Enter-submits.
538 self._chat_multiline = False
539
540 if os.getenv("OC_CHAT_LEGACY_PROMPT"):
541 _dbg("OC_UI.TERMINAL", func, "PromptDriver: legacy prompt mode", 2)
542 return
543
544 try:
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
549 from oc_ui.terminal.completers import OCChatCompleter
550 except ImportError:
551 return
552
553 if history_path is None:
554 history_path = Path.home() / ".oc_chat" / "history"
555 try:
556 history_path.parent.mkdir(parents=True, exist_ok=True)
557 except OSError as exc:
558 print(f"[history disabled: {exc}]", file=sys.stderr)
559 history_path = None
560
561 bindings = KeyBindings()
562
563 # ── Multiline chat prompt (2026-07-18 fix) ───────────────────────
564 # Active ONLY for the main chat prompt (read_user_input without a
565 # prompt_text override). Enter inserts a newline — so a multi-line
566 # paste can never auto-submit line-by-line — and submission is
567 # Ctrl+Enter (delivered as c-j by Windows consoles, incl. Windows
568 # Terminal / VS Code via ConPTY) with Ctrl+J / Alt+Enter as the
569 # portable equivalents. Up/Down keep prompt_toolkit's auto_up /
570 # auto_down defaults: cursor movement inside a multi-line buffer,
571 # history navigation from the first/last line.
572 in_chat_prompt = Condition(lambda: self._chat_multiline)
573
574 @bindings.add("enter", filter=in_chat_prompt)
575 def _chat_newline(event):
576 event.current_buffer.insert_text("\n")
577
578 @bindings.add("c-j", filter=in_chat_prompt) # Ctrl+Enter / Ctrl+J
579 def _chat_submit(event):
580 event.current_buffer.validate_and_handle()
581
582 @bindings.add("escape", "enter", filter=in_chat_prompt) # Alt+Enter
583 def _chat_submit_alt(event):
584 event.current_buffer.validate_and_handle()
585
586 @bindings.add("escape", "enter", filter=~in_chat_prompt)
587 def _insert_newline(event):
588 # Menu/startup prompts keep the pre-fix Alt+Enter behaviour:
589 # compose a manual newline inside a single-line prompt.
590 event.current_buffer.insert_text("\n")
591
592 # ── Ctrl+C — cancel current input (does not exit app) ────────────
593 @bindings.add("c-c")
594 def _cancel_input(event):
595 """Reset the buffer, save partial input to history, and mark as cancelled."""
596 self._cancelled = True
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()
601 event.app.exit()
602
603 # ── Ctrl+P — fuzzy command palette (Phase 2.B) ──────────────────
604 try:
605 from prompt_toolkit.completion import FuzzyCompleter
606 except ImportError:
607 FuzzyCompleter = None # type: ignore[assignment]
608
609 if FuzzyCompleter is not None:
610 @bindings.add("c-p")
611 def _open_palette(event):
612 """Open a fuzzy command-palette overlay.
613
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
620 works again.
621 """
622 func = "_open_palette"
623 from oc_chat.core.commands import COMMANDS
624 from oc_chat.core.persistence import list_sessions
625 palette_words: list[str] = sorted(COMMANDS.keys())
626 try:
627 palette_words.extend(
628 f"/session load {s['name']}"
629 for s in list_sessions()
630 )
631 except Exception as e:
632 # VALIDATION_ERROR — sessions dir absent / unreadable; degrade to verb-only palette
633 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
634
635 from prompt_toolkit.completion import WordCompleter
636 palette_completer = FuzzyCompleter(
637 WordCompleter(palette_words, WORD=True)
638 )
639
640 buf = event.app.current_buffer
641 old_completer = buf.completer
642 buf.completer = palette_completer
643 buf.start_completion(select_first=False)
644 # Restore immediately — the completion popup captured the
645 # palette completer at start_completion time, so it keeps
646 # working until dismissed. Normal Tab after that uses the
647 # original OCChatCompleter again.
648 buf.completer = old_completer
649
650 if ctx is not None:
651 kwargs: dict[str, Any] = dict(
652 completer=OCChatCompleter(ctx),
653 multiline=False,
654 mouse_support=False,
655 key_bindings=bindings,
656 complete_while_typing=False, # Tab to complete; less noisy.
657 )
658 else:
659 kwargs = dict(
660 multiline=False,
661 mouse_support=False,
662 key_bindings=bindings,
663 complete_while_typing=False,
664 )
665 if history_path is not None:
666 kwargs["history"] = FileHistory(str(history_path))
667
668 # Continuation prefix for the wrapped lines of a multi-line chat
669 # prompt (plain text — NO_COLOR-safe; unused by single-line menus).
670 kwargs["prompt_continuation"] = " … "
671
672 # ── bottom_toolbar & rprompt (Phase 2.B) ─────────────────────────
673 kwargs["bottom_toolbar"] = lambda: _build_bottom_toolbar(
674 self._last_session, self._turn_stats
675 ) if self._last_session is not None else ""
676
677 if self._turn_stats is not None:
678 kwargs["rprompt"] = lambda: self._turn_stats.rprompt_text()
679
680 # Plan 147 D3-a: without a periodic refresh the toolbar lambda only
681 # re-evaluates on a keypress, so the idle session timer would appear
682 # frozen between turns. 1.0s matches the status bar's coarsest tier
683 # (whole seconds once >= 60s).
684 kwargs["refresh_interval"] = 1.0
685
686 try:
687 self._pt = PromptSession(**kwargs)
688 except Exception as exc:
689 print(f"[prompt_toolkit disabled: {exc}]", file=sys.stderr)
690 self._pt = None
691
692 # ── Public API ────────────────────────────────────────────────────────
693
694 @property
695 def using_prompt_toolkit(self) -> bool:
696 """``True`` iff the modern prompt is active. Read-only."""
697 return self._pt is not None
698
699 def read_user_input(self, session: ChatSession, prompt_text: str | None = None) -> str | None:
700 """Read the next user input.
701
702 *prompt_text* overrides the default ``[model | session] You: ``
703 prefix — useful for startup menus that need a custom prompt.
704
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.
712
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.
716 """
717 self._cancelled = False
718 self._last_session = session
719
720 is_chat = prompt_text is None
721 # Colour applies only to the main chat prompt, not startup-menu overrides
722 # or NO_COLOR sessions.
723 is_chat_prompt = is_chat and not os.getenv("NO_COLOR")
724
725 if prompt_text is not None:
726 prompt = prompt_text
727 else:
728 prompt = build_prompt_prefix(session)
729
730 if self._pt is not None:
731 if is_chat_prompt:
732 from prompt_toolkit.formatted_text import FormattedText
733 pt_prompt = FormattedText([(_PROMPT_COLOR_PT, prompt)])
734 else:
735 pt_prompt = prompt
736 self._chat_multiline = is_chat
737 try:
738 return self._pt.prompt(pt_prompt, multiline=is_chat)
739 except EOFError:
740 raise
741 except KeyboardInterrupt:
742 # If our custom c-c binding fired, _cancelled is True.
743 if self._cancelled:
744 return None
745 raise
746 finally:
747 self._chat_multiline = False
748
749 # Legacy input() path
750 if is_chat_prompt:
751 display_prompt = f"{_PROMPT_COLOR_ANSI}{prompt}{_ANSI_RESET}"
752 else:
753 display_prompt = prompt
754 try:
755 return input(display_prompt)
756 except KeyboardInterrupt:
757 return None
None __init__(self, ctx, Path|None history_path=None, TurnStats|None turn_stats=None)
Definition input.py:528
str|None read_user_input(self, ChatSession session, str|None prompt_text=None)
Definition input.py:699
None finish_turn(self, int session_total, int iterations=0, int tool_calls=0, float|None session_cache_pct=None, int session_cache_saved=0)
Definition input.py:196
None add_tool_calls(self, int n)
Definition input.py:189
None add_usage(self, dict usage)
Definition input.py:171
tuple[int, int] totals(self)
Definition input.py:306
tuple[int, int, int, int, float|None, float|None, int, int] toolbar_data(self)
Definition input.py:317
None __init__(self, Callable[[], float] clock=time.monotonic)
Definition input.py:136
None rebind_session(self, str session_id)
Definition input.py:216
StatusBarState build_status(self, *, str model, str session_name, str effort, int session_tools, "str | None" session_grant=None, "float | None" session_cost_usd=None, int unpriced_samples=0, bool checkpoint_auto=False)
Definition input.py:235
str read_single_key(str prompt="", *, str valid="", fallback=None)
Definition input.py:403
str build_prompt_prefix(ChatSession session)
Definition input.py:351
str _build_bottom_toolbar(session, TurnStats|None turn_stats)
Definition input.py:459