-
-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathinput.py
More file actions
1226 lines (1059 loc) · 47.4 KB
/
Copy pathinput.py
File metadata and controls
1226 lines (1059 loc) · 47.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""prompt_toolkit-based REPL input with typing-time slash-command autosuggest.
Optional dependency: when prompt_toolkit is not installed, HAS_PROMPT_TOOLKIT
is False and callers should fall through to readline-based input.
Dependency-injected: callers register command/meta providers via setup()
before calling read_line(). This module never imports Dulus core — keeping
the dependency one-way and eliminating any circular-import risk.
"""
from __future__ import annotations
import re
import threading
import time
from pathlib import Path
from typing import Any, Callable, Optional
try:
from prompt_toolkit import PromptSession, Application
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import (
Completer, Completion, FuzzyCompleter, WordCompleter, merge_completers,
)
from prompt_toolkit.document import Document
from prompt_toolkit.completion.base import CompleteEvent
from prompt_toolkit.formatted_text import ANSI, is_formatted_text
from prompt_toolkit.history import FileHistory, InMemoryHistory
from prompt_toolkit.keys import Keys
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.styles import Style
from prompt_toolkit.enums import EditingMode
HAS_PROMPT_TOOLKIT = True
except ImportError:
HAS_PROMPT_TOOLKIT = False
try:
import paste_placeholders as _paste_ph
except ImportError:
_paste_ph: Any = None
try:
from common import C
except ImportError:
C = {"cyan": "\x1b[36m", "bold": "\x1b[1m", "reset": "\x1b[0m", "gray": "\x1b[90m", "dim": "\x1b[2m"}
# ── Injected providers ───────────────────────────────────────────────────────
# Callers (Dulus REPL) must call setup() before read_line().
_commands_provider: Optional[Callable[[], dict]] = None
_meta_provider: Optional[Callable[[], dict]] = None
_dynamic_completions_provider: Optional[Callable[[], dict]] = None
_toolbar_provider: Optional[Callable[[], str]] = None
_toolbar_status: str = "" # Background status (e.g. wake energy bar)
_active_app: Any = None # Track currently running prompt-toolkit app (Any: optional pt)
_TOOLBAR_SENTINEL = object()
def setup(
commands_provider: Callable[[], dict],
meta_provider: Callable[[], dict],
toolbar_provider: Optional[Callable[[], str]] = _TOOLBAR_SENTINEL, # type: ignore[assignment]
dynamic_completions_provider: Optional[Callable[[], dict]] = None,
) -> None:
"""Register providers for the live command registry and metadata.
`commands_provider` returns the dispatcher's COMMANDS dict.
`meta_provider` returns the _CMD_META dict (descriptions + subcommands).
`toolbar_provider` returns an ANSI toolbar string (or "" to hide).
`dynamic_completions_provider` returns a dict mapping command names to
callables: fn(partial: str) -> Iterable[str]. Used for context-aware
completions beyond static subcommand lists (e.g. /model providers).
Pass None explicitly to clear a previously-registered toolbar.
"""
global _commands_provider, _meta_provider, _dynamic_completions_provider, _toolbar_provider
_commands_provider = commands_provider
_meta_provider = meta_provider
_dynamic_completions_provider = dynamic_completions_provider
if toolbar_provider is not _TOOLBAR_SENTINEL:
_toolbar_provider = toolbar_provider # type: ignore[assignment]
# ── Completer ────────────────────────────────────────────────────────────────
if HAS_PROMPT_TOOLKIT:
class SlashCompleter(Completer): # type: ignore[no-redef]
"""Two-level completer for slash commands.
Level 1: /partial (no space) → command names.
Level 2: /cmd partial → subcommands listed in the meta dict.
Providers default to the module-level ones registered via setup(),
but can be injected via the constructor for testing.
"""
def __init__(
self,
commands_provider: Optional[Callable[[], dict]] = None,
meta_provider: Optional[Callable[[], dict]] = None,
):
self._commands_override = commands_provider
self._meta_override = meta_provider
self._cache_key: Optional[tuple] = None
self._cache_names: list[str] = []
def _get_commands(self) -> dict:
provider = self._commands_override or _commands_provider
return (provider() if provider else {}) or {}
def _get_meta(self) -> dict:
provider = self._meta_override or _meta_provider
return (provider() if provider else {}) or {}
def _get_dynamic_completions(self, cmd: str, partial: str) -> list[str]:
provider = self._commands_override or _commands_provider
if provider is not None:
# Unused override path; keep signature consistent.
pass
dyn_provider = _dynamic_completions_provider
if not dyn_provider:
return []
try:
fn = dyn_provider().get(cmd)
if fn:
return list(fn(partial))[:60]
except Exception:
# Never let a dynamic completer crash the REPL input.
pass
return []
def _live_command_names(self) -> list[str]:
keys = sorted(set(self._get_commands().keys()) | set(self._get_meta().keys()))
sig = tuple(keys)
if self._cache_key == sig:
return self._cache_names
self._cache_key = sig
self._cache_names = keys
return keys
def get_completions(self, document, complete_event): # type: ignore[override]
text = document.text_before_cursor
if not text.startswith("/"):
return
meta = self._get_meta()
if " " not in text:
word = text[1:]
for name in self._live_command_names():
if not name.startswith(word):
continue
desc, subs = meta.get(name, ("", []))
hint = ""
if subs:
head = ", ".join(subs[:3])
more = "…" if len(subs) > 3 else ""
hint = f" [{head}{more}]"
yield Completion(
"/" + name,
start_position=-len(text),
display=ANSI(f"{C['cyan']}/{name}{C['reset']}"),
display_meta=(desc + hint) if desc else hint.strip(),
)
return
head, _, tail = text.partition(" ")
cmd = head[1:]
partial = tail.rsplit(" ", 1)[-1]
# Dynamic command-specific completions (e.g. /model provider/model).
dynamic = self._get_dynamic_completions(cmd, partial)
if dynamic:
for item in dynamic:
yield Completion(
item,
start_position=-len(partial),
display_meta=f"{cmd} provider/model",
)
return
meta_entry = meta.get(cmd)
if not meta_entry:
return
subs = meta_entry[1]
if not subs:
return
for sub in subs:
if sub.startswith(partial):
yield Completion(
sub,
start_position=-len(partial),
display_meta=f"{cmd} subcommand",
)
else: # pragma: no cover — unreachable when prompt_toolkit is installed
class SlashCompleter: # type: ignore[no-redef]
def __init__(self, *_args, **_kwargs):
raise RuntimeError("prompt_toolkit is not installed")
if HAS_PROMPT_TOOLKIT:
class FileMentionCompleter(Completer): # type: ignore[no-redef]
"""Fuzzy ``@`` path completion using file_filter from kimi-cli."""
_FRAGMENT_PATTERN = re.compile(r"[^\s@]+")
_TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~"))
def __init__(self, root: Path | None = None, *, limit: int = 1000) -> None:
self._root = root or Path.cwd()
self._limit = limit
self._cache_time: float = 0.0
self._cached_paths: list[str] = []
self._fragment_hint: str | None = None
self._word_completer = WordCompleter(
self._get_paths,
WORD=False,
pattern=self._FRAGMENT_PATTERN,
)
self._fuzzy = FuzzyCompleter(
self._word_completer,
WORD=False,
pattern=r"^[^\s@]*",
)
def _get_paths(self) -> list[str]:
try:
from file_filter import list_files_git, list_files_walk, detect_git
except Exception:
return []
fragment = self._fragment_hint or ""
scope: str | None = None
if "/" in fragment:
scope = fragment.rsplit("/", 1)[0]
now = time.monotonic()
if now - self._cache_time <= 2.0:
return self._cached_paths
try:
if detect_git(self._root):
paths = list_files_git(self._root, scope)
else:
paths = list_files_walk(self._root, scope, limit=self._limit)
except Exception:
paths = []
self._cached_paths = paths or []
self._cache_time = now
return self._cached_paths
@staticmethod
def _extract_fragment(text: str) -> str | None:
index = text.rfind("@")
if index == -1:
return None
if index > 0:
prev = text[index - 1]
if prev.isalnum() or prev in FileMentionCompleter._TRIGGER_GUARDS: # type: ignore[attr-defined]
return None
fragment = text[index + 1 :]
if not fragment:
return ""
if any(ch.isspace() for ch in fragment):
return None
return fragment
def get_completions(self, document, complete_event): # type: ignore[override]
fragment = self._extract_fragment(document.text_before_cursor)
if fragment is None:
return
mention_doc = Document(text=fragment, cursor_position=len(fragment))
self._fragment_hint = fragment
try:
candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))
frag_lower = fragment.lower()
def _rank(c: Completion) -> tuple[int, ...]:
path = c.text
base = path.rstrip("/").split("/")[-1].lower()
if base.startswith(frag_lower):
return (0,)
elif frag_lower in base:
return (1,)
return (2,)
candidates.sort(key=_rank)
yield from candidates
finally:
self._fragment_hint = None
else:
class FileMentionCompleter: # type: ignore[no-redef]
def __init__(self, *_args, **_kwargs):
raise RuntimeError("prompt_toolkit is not installed")
# ── Session cache ────────────────────────────────────────────────────────────
_SESSION = None
_SESSION_HISTORY_PATH: Optional[Path] = None
def reset_session() -> None:
"""Drop the cached session so the next read_line() rebuilds from scratch."""
global _SESSION, _SESSION_HISTORY_PATH
_SESSION = None
_SESSION_HISTORY_PATH = None
def _build_session(history_path: Optional[Path]):
if not HAS_PROMPT_TOOLKIT:
raise RuntimeError("prompt_toolkit is not installed")
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.filters import Condition
from prompt_toolkit.application.current import get_app
completer = merge_completers([ # type: ignore[arg-type]
SlashCompleter(),
FileMentionCompleter(),
])
history = FileHistory(str(history_path)) if history_path else InMemoryHistory()
style = Style.from_dict({
"completion-menu.completion": "bg:#222222 #cccccc",
"completion-menu.completion.current": "bg:#005f87 #ffffff bold",
"completion-menu.meta.completion": "bg:#222222 #808080",
"completion-menu.meta.completion.current": "bg:#005f87 #eeeeee",
"auto-suggestion": "#606060 italic",
"bottom-toolbar": "",
"bottom-toolbar.text": "",
})
# Only bind Tab to accept suggestion — right/ctrl-f/ctrl-e are already
# handled by PromptSession's built-in load_auto_suggest_bindings().
# Adding our own right/ctrl-f bindings without filters caused double-fire.
@Condition
def _suggestion_available():
try:
app = get_app()
buf = app.current_buffer
return (
buf.suggestion is not None
and len(buf.suggestion.text) > 0
and buf.document.is_cursor_at_the_end
)
except Exception:
return False
kb = KeyBindings()
@kb.add("tab", filter=_suggestion_available)
def _tab_accept(event):
"""Tab accepts ghost suggestion when one is available."""
buf = event.app.current_buffer
if buf.suggestion:
buf.insert_text(buf.suggestion.text)
# ── Paste accumulation (kimi-cli style) ────────────────────────────────
if _paste_ph is not None:
@kb.add(Keys.BracketedPaste, eager=True)
def _on_bracketed_paste(event):
"""Fold large pastes into a placeholder instead of flooding the buffer."""
text = event.data
token = _paste_ph.maybe_placeholderize(text)
event.current_buffer.insert_text(token)
# Fallback for terminals without bracketed-paste support (Windows conhost, etc.)
@kb.add("c-v")
def _ctrl_v_paste(event):
"""Ctrl+V reads clipboard via pyperclip and inserts as placeholder."""
try:
import pyperclip
text = pyperclip.paste()
except Exception:
return
if text:
token = _paste_ph.maybe_placeholderize(text)
event.current_buffer.insert_text(token)
def _bottom_toolbar():
provider = _toolbar_provider
text = ""
if provider is not None:
try:
text = provider()
except Exception:
pass
# Inject status (e.g. energy bar) after the main toolbar text
status = _toolbar_status
if status:
if text:
text += " | " + status
else:
text = status
return ANSI(text) if text else ""
# ── VIM mode ──────────────────────────────────────────────────────────
# Power-user editing on the input line (hjkl, dd, cw, /search…). Opt-in via
# config `vim_mode: true` (toggle live with /vim). Defaults to emacs so we
# never surprise a user who's never touched vi.
_editing_mode = EditingMode.EMACS
try:
from config import load_config as _load_cfg
if _load_cfg().get("vim_mode", False):
_editing_mode = EditingMode.VI
except Exception:
pass
return PromptSession(
history=history,
completer=completer,
auto_suggest=AutoSuggestFromHistory(),
complete_while_typing=True,
enable_history_search=False,
mouse_support=False,
style=style,
key_bindings=kb,
bottom_toolbar=_bottom_toolbar,
editing_mode=_editing_mode,
)
def read_line(prompt_ansi: str, history_path: Optional[Path] = None, show_recent: bool = True) -> str:
"""Read one line of input via prompt_toolkit; caches the session across calls.
The history file passed here MUST NOT be the readline history file — the
two line-editors use incompatible formats. See Dulus REPL for the
dedicated PT_HISTORY_FILE.
show_recent=False skips the recent-messages strip AND the DEC cursor
save/restore + erase-to-end-of-screen dance. Callers that render their own
menu above the prompt (e.g. AskUserQuestion's drain) MUST pass False —
otherwise the \\0338\\033[J erase wipes the menu and any output printed
after it (the "no se ven tus mensajes" bug).
"""
global _SESSION, _SESSION_HISTORY_PATH, _notification_callback, _active_app
# Drain any pending background notifications before showing prompt
notifications = drain_notifications()
for note in notifications:
if _notification_callback:
_notification_callback(note)
else:
safe_print_notification(note)
if _SESSION is not None and _SESSION_HISTORY_PATH != history_path:
_SESSION = None
if _SESSION is None:
_SESSION = _build_session(history_path)
_SESSION_HISTORY_PATH = history_path
# ── Recent-message strip (sliding window above the prompt) ────────────
# Recent-strip: pre-print last N msgs, erase them + prompt after Enter.
# Use VT100 DEC save/restore (\0337/\0338) — separate register from
# ANSI \033[s/\033[u which prompt_toolkit uses internally and would
# clobber our saved position.
import sys as _sys
recent = (_RECENT_USER_MSGS[-_RECENT_MAX:] if _RECENT_USER_MSGS else []) if show_recent else []
if show_recent:
_sys.stdout.write("\0337") # DEC save cursor (ESC 7)
for msg in recent:
_sys.stdout.write(f"\033[2m» {msg}\033[0m\n")
_sys.stdout.flush()
with patch_stdout(raw=True):
try:
_active_app = _SESSION.app
result = _SESSION.prompt(ANSI(prompt_ansi))
finally:
_active_app = None
if show_recent:
_sys.stdout.write("\0338\033[J") # DEC restore cursor (ESC 8) → erase to end
_sys.stdout.flush()
return result
# ── Split Layout Mode (Kimi/Claude style) ────────────────────────────────────
# Fixed bottom input bar with scrollable output area above
_split_app: Any = None
_split_buffer: Any = None
_output_buffer: list[str] = []
_original_stdout = None
# When True, the user's typed message is NOT echoed into the main output area
# on Enter; instead it goes into the in-bar recent strip below.
_HIDE_SENDER: bool = True
# Last N user messages shown inside the sticky bar (above the input line).
_RECENT_USER_MSGS: list[str] = []
_RECENT_MAX = 5
def set_hide_sender(enabled: bool) -> None:
"""Toggle whether the typed message gets echoed above the sticky bar."""
global _HIDE_SENDER
_HIDE_SENDER = bool(enabled)
def _count_deduped_recent() -> int:
"""Count non-consecutive-duplicate entries in _RECENT_USER_MSGS (same key as render)."""
def _k(s: str) -> str:
return s.replace("\n", " ").strip().casefold()
n = 0
last = None
for m in _RECENT_USER_MSGS:
k = _k(m)
if k and k != last:
n += 1
last = k
return n
def add_recent_msg(text: str) -> None:
"""Push a user message into the recent-history strip (sliding window)."""
global _RECENT_USER_MSGS
stripped = text.strip()
if not stripped:
return
_RECENT_USER_MSGS.append(stripped)
# Keep only the last N — oldest slides off
del _RECENT_USER_MSGS[:-_RECENT_MAX]
class _OutputRedirector:
"""Redirects stdout to the split layout output buffer.
Thread-safe: multiple threads (main REPL, Telegram bg runner, sentinel)
may write concurrently. A lock prevents buffer corruption.
CRITICAL: Strips cursor-movement ANSI sequences (\033[A, \033[2K, etc.)
before storing. These sequences come from Rich Live, spinners, and other
terminal apps, but they are meaningless in a static split-layout buffer
and cause "ghost lines" that reappear on every redraw.
Color/style sequences (\033[31m, \033[1m) are preserved.
"""
def __init__(self, original):
self._original = original
self._buffer = ""
self._lock = threading.Lock()
# True when the last operation left an "open" line (no newline).
# Used by flush() to decide whether to concat or create a new line.
self._last_line_open = False
@staticmethod
def _strip_cursor_ansi(text: str) -> str:
"""Remove cursor-control ANSI sequences; keep color/style ones."""
import re as _re
# Matches CSI sequences for cursor move, erase, scroll, save/restore.
# Preserves 'm' suffix (SGR color/style) and other harmless codes.
return _re.sub(
r'\x1b\['
r'(?:\d*[ABCDEGHJKSTfnsu]|\d+;\d+[Hf]|\?[\d;]*[hl])',
'',
text,
)
def write(self, text: str) -> None:
if not text:
return
# When a background turn is running (_SUPPRESS_CONSOLE=True), discard
# all writes so we don't call append_output() → _split_app.invalidate()
# which would cause the split layout to flash/redraw mid-background-turn.
try:
import sys as _sys
_dulus_mod = _sys.modules.get('dulus') or _sys.modules.get('__main__')
if _dulus_mod and getattr(_dulus_mod, "_SUPPRESS_CONSOLE", False):
return
except Exception:
pass
# Sanitize: kill cursor-control ANSI sequences before they poison
# the split-layout buffer with ghost lines.
text = self._strip_cursor_ansi(text)
if not text:
return
with self._lock:
# Accumulate text to avoid character-by-character fragmentation
self._buffer += text
# Only process if we have complete lines OR buffer is getting large
if "\n" in self._buffer or len(self._buffer) > 200:
lines = self._buffer.split("\n")
# Process all complete lines
for line in lines[:-1]:
# Strip carriage returns (\r → ^M) from each line before display
clean = line.replace("\r", "")
if clean.strip():
append_output(clean)
self._last_line_open = False
# Keep incomplete last line in buffer (strip \r too)
self._buffer = lines[-1].replace("\r", "")
def flush(self) -> None:
# Flush any remaining buffered content.
# When the buffer has no newline, we treat it as a continuation of the
# same logical line — this prevents word-by-word fragmentation from
# streaming prints (e.g. thinking chunks with flush=True).
with self._lock:
if self._buffer:
clean = self._strip_cursor_ansi(self._buffer).replace("\r", "")
if clean.strip():
global _output_buffer
if _output_buffer and self._last_line_open and not clean.startswith("\n"):
# Continuation of the previous open line
_output_buffer[-1] += clean
else:
append_output(clean)
self._last_line_open = True
self._buffer = ""
# Rate-limit invalidations here too — each streaming chunk calls
# flush(), and without throttling the split layout redraws 20-30×/s,
# causing the input bar to flicker and "lose" the user's typed text.
global _invalidate_pending, _split_app, _last_invalidate_time
if _split_app:
now = time.monotonic()
if _invalidate_pending and now - _last_invalidate_time >= 0.05:
_last_invalidate_time = now
_invalidate_pending = False
_split_app.invalidate()
def reset(self) -> None:
"""Clear internal buffer and line-open state.
Call at the start of each turn to prevent residual buffered text
from concatenating with the new turn's output.
"""
with self._lock:
self._buffer = ""
self._last_line_open = False
def isatty(self) -> bool:
return False # Pretend we're not a tty to prevent echo
def read_line_split(prompt: str = "> ", history_path: Optional[Path] = None) -> str:
"""Read input with split layout - fixed bottom bar, scrollable output above.
Similar to Kimi Code and Claude Code interfaces.
"""
global _split_app, _split_buffer, _output_buffer, _original_stdout, _notification_callback, _active_app
# Drain any pending background notifications before showing prompt
# Drain notifications but don't display yet - we'll add them after creating the app
_pending_notes = drain_notifications()
if not HAS_PROMPT_TOOLKIT:
# No prompt_toolkit - print notifications directly
for note in _pending_notes:
if _notification_callback:
_notification_callback(note)
else:
print(note)
raise RuntimeError("prompt_toolkit is not installed")
import sys
# Save and redirect stdout
_original_stdout = sys.stdout
sys.stdout = _OutputRedirector(_original_stdout)
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout import HSplit, Layout, Window, ConditionalContainer
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.processors import BeforeInput, AppendAutoSuggestion
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
from prompt_toolkit.key_binding.bindings.emacs import load_emacs_bindings
from prompt_toolkit.filters import has_completions, Condition
# Output area (upper pane) - shows accumulated output with ANSI support
def get_output_text():
"""Get formatted output text with ANSI codes parsed."""
text = "\n".join(_output_buffer[-1000:])
return ANSI(text) if text else ""
output_control = FormattedTextControl(
text=get_output_text,
focusable=False,
show_cursor=False,
)
output_window = Window(
content=output_control,
wrap_lines=True,
allow_scroll_beyond_bottom=True,
)
# Input buffer with completer
history = FileHistory(str(history_path)) if history_path else InMemoryHistory()
completer = merge_completers([ # type: ignore[arg-type]
SlashCompleter(),
FileMentionCompleter(),
])
_split_buffer = Buffer(
history=history,
completer=completer,
auto_suggest=AutoSuggestFromHistory(),
complete_while_typing=True,
enable_history_search=False,
multiline=False,
)
# Input control with prompt
# Handle ANSI codes in prompt (e.g., from shell PS1)
# Filter out screen-clearing codes (J, K, etc.) but keep colors
import re
clean_prompt = prompt
if isinstance(prompt, str):
# Remove clear-screen codes: ESC[J, ESC[2J, ESC[K, ESC[0K, ESC[1K, ESC[2K
clean_prompt = re.sub(r'\x1b\[[0-9]*[JK]', '', prompt)
# Strip newlines (\n → ^J in split layout single-line input window)
clean_prompt = clean_prompt.replace('\n', ' ').strip()
# Parse remaining ANSI codes (colors)
if '\x1b[' in clean_prompt:
formatted_prompt = ANSI(clean_prompt)
else:
formatted_prompt = clean_prompt
else:
formatted_prompt = prompt
input_control = BufferControl(
buffer=_split_buffer,
# AppendAutoSuggestion renders the dim ghost text from history that
# PromptSession shows for free — bare BufferControl doesn't add it.
input_processors=[
BeforeInput(formatted_prompt, style="class:prompt"),
AppendAutoSuggestion(),
],
)
input_window = Window(
content=input_control,
height=1,
wrap_lines=False,
always_hide_cursor=False,
)
# Recent-messages strip (inside the sticky bar, above the input line).
# Shows up to _RECENT_MAX most-recent user submissions, oldest at top.
def _get_recent_text():
if not _RECENT_USER_MSGS:
return ""
# Collapse consecutive duplicates (compare stripped+normalised to
# ignore trailing whitespace/newline differences).
def _key(s: str) -> str:
return s.replace("\n", " ").strip().casefold()
deduped: list[str] = []
last_key = None
for m in _RECENT_USER_MSGS:
k = _key(m)
if k and k != last_key:
deduped.append(m)
last_key = k
lines = []
for m in deduped[-_RECENT_MAX:]:
line = m.replace("\n", " ").strip()
if len(line) > 200:
line = line[:197] + "..."
lines.append(f"{C['bold']}{C['cyan']}» {C['reset']}{C['gray']}{line}{C['reset']}")
return ANSI("\n".join(lines))
recent_control = FormattedTextControl(
text=_get_recent_text,
focusable=False,
show_cursor=False,
)
recent_window = ConditionalContainer(
content=Window(
content=recent_control,
height=lambda: max(1, min(_count_deduped_recent(), _RECENT_MAX)) if _RECENT_USER_MSGS else 0,
wrap_lines=False,
),
filter=Condition(lambda: bool(_HIDE_SENDER and _RECENT_USER_MSGS)),
)
# Completions menu (floating)
completions_menu = ConditionalContainer(
content=CompletionsMenu(
max_height=8,
scroll_offset=1,
extra_filter=has_completions,
),
filter=has_completions,
)
# Key bindings
kb = KeyBindings()
# Double-tap ↓ quick menu is DISABLED: a stray second ↓ while scrolling
# history kept popping the menu mid-typing. The menu itself still exists
# via its explicit command; only the double-tap trigger is off.
# To re-enable: import dulus_quick_menu and call register_key_bindings(kb).
# ── Paste accumulation (kimi-cli style) ────────────────────────────────
if _paste_ph is not None:
@kb.add(Keys.BracketedPaste, eager=True)
def _on_bracketed_paste_split(event):
"""Fold large pastes into a placeholder instead of flooding the buffer."""
text = event.data
token = _paste_ph.maybe_placeholderize(text)
event.current_buffer.insert_text(token)
# Fallback for terminals without bracketed-paste support (Windows conhost, etc.)
@kb.add("c-v")
def _ctrl_v_paste_split(event):
"""Ctrl+V reads clipboard via pyperclip and inserts as placeholder."""
try:
import pyperclip
text = pyperclip.paste()
except Exception:
return
if text:
token = _paste_ph.maybe_placeholderize(text)
event.current_buffer.insert_text(token)
@kb.add("enter")
def submit(event):
"""Submit input.
- hide_sender ON (default): push to in-bar recent strip (max 5).
- hide_sender OFF: echo `» <msg>` into the main output area.
Also persists to FileHistory so ↑/↓ recall works across sessions
(PromptSession does this for free; raw Application doesn't)."""
text = _split_buffer.text
if text.strip():
# Persist for ↑/↓ (bash-style command history).
# Dedupe consecutive duplicates (bash HISTCONTROL=ignoredups).
try:
_last_hist = None
try:
_strs = list(_split_buffer.history.get_strings())
_last_hist = _strs[-1] if _strs else None
except Exception:
pass
if _last_hist != text:
_split_buffer.append_to_history()
except Exception:
pass
if _HIDE_SENDER:
_norm = text.replace("\n", " ").strip().casefold()
_last_norm = (
_RECENT_USER_MSGS[-1].replace("\n", " ").strip().casefold()
if _RECENT_USER_MSGS else None
)
if _norm and _norm != _last_norm:
_RECENT_USER_MSGS.append(text)
if len(_RECENT_USER_MSGS) > _RECENT_MAX:
del _RECENT_USER_MSGS[:-_RECENT_MAX]
else:
append_output(f"{C['bold']}{C['cyan']}» {C['reset']}{text}")
# Keep only the last _RECENT_MAX `» ` echoes in the output buffer
# so we never crawl to Narnia.
marker = "» "
echo_idx = [i for i, ln in enumerate(_output_buffer) if marker in ln and ln.lstrip().startswith(f"{C['bold']}{C['cyan']}»")]
if len(echo_idx) > _RECENT_MAX:
drop = set(echo_idx[:-_RECENT_MAX])
_output_buffer[:] = [ln for i, ln in enumerate(_output_buffer) if i not in drop]
event.app.exit(result=text)
@kb.add("right")
def _accept_suggestion(event):
"""→ accepts the ghost suggestion when cursor is at end of line.
Otherwise moves cursor right as normal."""
buf = event.app.current_buffer
if (
buf.suggestion
and buf.suggestion.text
and buf.document.is_cursor_at_the_end
):
buf.insert_text(buf.suggestion.text)
else:
buf.cursor_position += 1
@kb.add("c-c")
@kb.add("c-d")
def cancel(event):
"""Cancel/exit."""
event.app.exit(result=None)
@kb.add("c-l")
def clear(event):
"""Clear output buffer."""
_output_buffer.clear()
output_control.text = ""
# NOTE: Up/Down (history), Right/End (accept ghost suggestion), Ctrl+A/E,
# word-jump etc. all come from load_emacs_bindings() merged below — DON'T
# re-bind them here or they'll override the well-tested defaults.
# Build layout: output on top, separator, recent-strip + input at bottom
import re as _re_toolbar
import shutil as _shutil_toolbar
import sys as _sys_toolbar
_ANSI_RE = _re_toolbar.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
def _get_toolbar_text():
# Get base text from provider
base_text = ""
provider = _toolbar_provider
if provider:
try:
base_text = str(provider())
except Exception:
pass
# Combine with background status (e.g. wake energy bar)
global _toolbar_status
status = _toolbar_status or ""
# Strip newlines defensively — anything multi-line in the toolbar
# would push the layout's other regions and look broken.
base_text = base_text.replace("\n", " ").replace("\r", " ")
status = status.replace("\n", " ").replace("\r", " ")
combined = f" {base_text} {status}".strip() if status else base_text.strip()
# Truncate by visible-cell width so the toolbar never wraps onto a
# second row. On Windows Terminal, emojis (🧠 📁 📊 🔒 🎙️) and
# the energy-bar glyphs (▁▂▃▄▅▆▇█) render as 2 cells while
# prompt_toolkit's Window/cwidth math treats them as 1 — the gap
# was leaking the right tail of the status onto a second line
# ("Waking up..." on row 1, "Listening..." on row 2). Strip ANSI
# for the char count, then keep a generous safety margin on
# Windows because there's no portable way to ask the OS how wide
# an emoji actually rendered.
try:
cols = _shutil_toolbar.get_terminal_size((120, 24)).columns
except Exception:
cols = 120
margin = 12 if _sys_toolbar.platform == "win32" else 2
budget = max(20, cols - margin)
visible = _ANSI_RE.sub("", combined)
if len(visible) > budget:
# Trim from the END (preserves the model/cwd info on the left;
# the energy-bar tail is the dispensable part).
keep = budget - 1
out, taken = [], 0
i = 0
while i < len(combined) and taken < keep:
ch = combined[i]
if ch == "\x1b":
m = _ANSI_RE.match(combined, i)
if m:
out.append(m.group(0))
i = m.end()
continue
out.append(ch)
taken += 1
i += 1
out.append("…")
combined = "".join(out)
return ANSI(combined) if combined else ""
toolbar_window = ConditionalContainer(
content=Window(
content=FormattedTextControl(text=_get_toolbar_text),
height=1,
),
filter=Condition(lambda: _toolbar_provider is not None),
)
root_container = HSplit([
output_window, # Flexible height for output
Window(height=1, char="─", style="class:separator"),
recent_window, # Last N user messages (in-bar history strip)
input_window, # Fixed height for input
toolbar_window, # Status toolbar (model, tokens, git)
completions_menu, # Floating completions
])
style = Style.from_dict({
"completion-menu.completion": "bg:#222222 #cccccc",
"completion-menu.completion.current": "bg:#005f87 #ffffff bold",
"completion-menu.meta.completion": "bg:#222222 #808080",
"completion-menu.meta.completion.current": "bg:#005f87 #eeeeee",
"auto-suggestion": "#606060 italic",
"prompt": "#00aa00 bold",
"separator": "#444444",
})
layout = Layout(root_container, focused_element=input_window)
_split_app = Application(
layout=layout,
key_bindings=merge_key_bindings([load_emacs_bindings(), kb]),
style=style,
mouse_support=False,
full_screen=False,
# Erase the rendered frame on exit so the prompt-envelope ghost
# ([cwd] [pct] » <typed>) doesn't get left behind in scrollback
# — we already echoed a clean `» <msg>` line via append_output().
erase_when_done=True,
)
# Now display pending notifications in the split layout
if _pending_notes:
for note in _pending_notes:
if _notification_callback:
_notification_callback(note)
else: