dulus 0.2.0__py3-none-any.whl

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