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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- 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)
|