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
ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ui package – nothing special needed here
|
ui/input.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
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
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from prompt_toolkit import PromptSession
|
|
18
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
19
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
20
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
21
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
22
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
23
|
+
from prompt_toolkit.styles import Style
|
|
24
|
+
HAS_PROMPT_TOOLKIT = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
HAS_PROMPT_TOOLKIT = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Injected providers ───────────────────────────────────────────────────────
|
|
30
|
+
# Callers (Dulus REPL) must call setup() before read_line().
|
|
31
|
+
_commands_provider: Optional[Callable[[], dict]] = None
|
|
32
|
+
_meta_provider: Optional[Callable[[], dict]] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def setup(
|
|
36
|
+
commands_provider: Callable[[], dict],
|
|
37
|
+
meta_provider: Callable[[], dict],
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Register providers for the live command registry and metadata.
|
|
40
|
+
|
|
41
|
+
`commands_provider` returns the dispatcher's COMMANDS dict.
|
|
42
|
+
`meta_provider` returns the _CMD_META dict (descriptions + subcommands).
|
|
43
|
+
"""
|
|
44
|
+
global _commands_provider, _meta_provider
|
|
45
|
+
_commands_provider = commands_provider
|
|
46
|
+
_meta_provider = meta_provider
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Completer ────────────────────────────────────────────────────────────────
|
|
50
|
+
if HAS_PROMPT_TOOLKIT:
|
|
51
|
+
|
|
52
|
+
class SlashCompleter(Completer):
|
|
53
|
+
"""Two-level completer for slash commands.
|
|
54
|
+
|
|
55
|
+
Level 1: /partial (no space) → command names.
|
|
56
|
+
Level 2: /cmd partial → subcommands listed in the meta dict.
|
|
57
|
+
|
|
58
|
+
Providers default to the module-level ones registered via setup(),
|
|
59
|
+
but can be injected via the constructor for testing.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
commands_provider: Optional[Callable[[], dict]] = None,
|
|
65
|
+
meta_provider: Optional[Callable[[], dict]] = None,
|
|
66
|
+
):
|
|
67
|
+
self._commands_override = commands_provider
|
|
68
|
+
self._meta_override = meta_provider
|
|
69
|
+
self._cache_key: Optional[tuple] = None
|
|
70
|
+
self._cache_names: list[str] = []
|
|
71
|
+
|
|
72
|
+
def _get_commands(self) -> dict:
|
|
73
|
+
provider = self._commands_override or _commands_provider
|
|
74
|
+
return (provider() if provider else {}) or {}
|
|
75
|
+
|
|
76
|
+
def _get_meta(self) -> dict:
|
|
77
|
+
provider = self._meta_override or _meta_provider
|
|
78
|
+
return (provider() if provider else {}) or {}
|
|
79
|
+
|
|
80
|
+
def _live_command_names(self) -> list[str]:
|
|
81
|
+
keys = sorted(set(self._get_commands().keys()) | set(self._get_meta().keys()))
|
|
82
|
+
sig = tuple(keys)
|
|
83
|
+
if self._cache_key == sig:
|
|
84
|
+
return self._cache_names
|
|
85
|
+
self._cache_key = sig
|
|
86
|
+
self._cache_names = keys
|
|
87
|
+
return keys
|
|
88
|
+
|
|
89
|
+
def get_completions(self, document, complete_event): # type: ignore[override]
|
|
90
|
+
text = document.text_before_cursor
|
|
91
|
+
if not text.startswith("/"):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
meta = self._get_meta()
|
|
95
|
+
|
|
96
|
+
if " " not in text:
|
|
97
|
+
word = text[1:]
|
|
98
|
+
for name in self._live_command_names():
|
|
99
|
+
if not name.startswith(word):
|
|
100
|
+
continue
|
|
101
|
+
desc, subs = meta.get(name, ("", []))
|
|
102
|
+
hint = ""
|
|
103
|
+
if subs:
|
|
104
|
+
head = ", ".join(subs[:3])
|
|
105
|
+
more = "…" if len(subs) > 3 else ""
|
|
106
|
+
hint = f" [{head}{more}]"
|
|
107
|
+
yield Completion(
|
|
108
|
+
"/" + name,
|
|
109
|
+
start_position=-len(text),
|
|
110
|
+
display=ANSI(f"\x1b[36m/{name}\x1b[0m"),
|
|
111
|
+
display_meta=(desc + hint) if desc else hint.strip(),
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
head, _, tail = text.partition(" ")
|
|
116
|
+
cmd = head[1:]
|
|
117
|
+
meta_entry = meta.get(cmd)
|
|
118
|
+
if not meta_entry:
|
|
119
|
+
return
|
|
120
|
+
subs = meta_entry[1]
|
|
121
|
+
if not subs:
|
|
122
|
+
return
|
|
123
|
+
partial = tail.rsplit(" ", 1)[-1]
|
|
124
|
+
for sub in subs:
|
|
125
|
+
if sub.startswith(partial):
|
|
126
|
+
yield Completion(
|
|
127
|
+
sub,
|
|
128
|
+
start_position=-len(partial),
|
|
129
|
+
display_meta=f"{cmd} subcommand",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
else: # pragma: no cover — unreachable when prompt_toolkit is installed
|
|
133
|
+
class SlashCompleter:
|
|
134
|
+
def __init__(self, *_args, **_kwargs):
|
|
135
|
+
raise RuntimeError("prompt_toolkit is not installed")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Session cache ────────────────────────────────────────────────────────────
|
|
139
|
+
_SESSION = None
|
|
140
|
+
_SESSION_HISTORY_PATH: Optional[Path] = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def reset_session() -> None:
|
|
144
|
+
"""Drop the cached session so the next read_line() rebuilds from scratch."""
|
|
145
|
+
global _SESSION, _SESSION_HISTORY_PATH
|
|
146
|
+
_SESSION = None
|
|
147
|
+
_SESSION_HISTORY_PATH = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_session(history_path: Optional[Path]):
|
|
151
|
+
if not HAS_PROMPT_TOOLKIT:
|
|
152
|
+
raise RuntimeError("prompt_toolkit is not installed")
|
|
153
|
+
completer = SlashCompleter()
|
|
154
|
+
history = FileHistory(str(history_path)) if history_path else InMemoryHistory()
|
|
155
|
+
style = Style.from_dict({
|
|
156
|
+
"completion-menu.completion": "bg:#222222 #cccccc",
|
|
157
|
+
"completion-menu.completion.current": "bg:#005f87 #ffffff bold",
|
|
158
|
+
"completion-menu.meta.completion": "bg:#222222 #808080",
|
|
159
|
+
"completion-menu.meta.completion.current": "bg:#005f87 #eeeeee",
|
|
160
|
+
"auto-suggestion": "#606060 italic",
|
|
161
|
+
})
|
|
162
|
+
return PromptSession(
|
|
163
|
+
history=history,
|
|
164
|
+
completer=completer,
|
|
165
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
166
|
+
complete_while_typing=True,
|
|
167
|
+
enable_history_search=False,
|
|
168
|
+
mouse_support=False,
|
|
169
|
+
style=style,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def read_line(prompt_ansi: str, history_path: Optional[Path] = None) -> str:
|
|
174
|
+
"""Read one line of input via prompt_toolkit; caches the session across calls.
|
|
175
|
+
|
|
176
|
+
The history file passed here MUST NOT be the readline history file — the
|
|
177
|
+
two line-editors use incompatible formats. See Dulus REPL for the
|
|
178
|
+
dedicated PT_HISTORY_FILE.
|
|
179
|
+
"""
|
|
180
|
+
global _SESSION, _SESSION_HISTORY_PATH, _notification_callback
|
|
181
|
+
|
|
182
|
+
# Drain any pending background notifications before showing prompt
|
|
183
|
+
notifications = drain_notifications()
|
|
184
|
+
for note in notifications:
|
|
185
|
+
if _notification_callback:
|
|
186
|
+
_notification_callback(note)
|
|
187
|
+
else:
|
|
188
|
+
safe_print_notification(note)
|
|
189
|
+
|
|
190
|
+
if _SESSION is not None and _SESSION_HISTORY_PATH != history_path:
|
|
191
|
+
_SESSION = None
|
|
192
|
+
if _SESSION is None:
|
|
193
|
+
_SESSION = _build_session(history_path)
|
|
194
|
+
_SESSION_HISTORY_PATH = history_path
|
|
195
|
+
with patch_stdout(raw=True):
|
|
196
|
+
return _SESSION.prompt(ANSI(prompt_ansi))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── Split Layout Mode (Kimi/Claude style) ────────────────────────────────────
|
|
200
|
+
# Fixed bottom input bar with scrollable output area above
|
|
201
|
+
|
|
202
|
+
_split_app: Optional[Any] = None
|
|
203
|
+
_split_buffer: Optional[Any] = None
|
|
204
|
+
_output_buffer: list[str] = []
|
|
205
|
+
_original_stdout = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _OutputRedirector:
|
|
209
|
+
"""Redirects stdout to the split layout output buffer."""
|
|
210
|
+
def __init__(self, original):
|
|
211
|
+
self._original = original
|
|
212
|
+
self._buffer = ""
|
|
213
|
+
|
|
214
|
+
def write(self, text: str) -> None:
|
|
215
|
+
if not text:
|
|
216
|
+
return
|
|
217
|
+
self._buffer += text
|
|
218
|
+
if "\n" in text:
|
|
219
|
+
lines = self._buffer.split("\n")
|
|
220
|
+
for line in lines[:-1]:
|
|
221
|
+
if line.strip():
|
|
222
|
+
append_output(line)
|
|
223
|
+
self._buffer = lines[-1]
|
|
224
|
+
# Also write to original for compatibility
|
|
225
|
+
self._original.write(text)
|
|
226
|
+
|
|
227
|
+
def flush(self) -> None:
|
|
228
|
+
if self._buffer:
|
|
229
|
+
if self._buffer.strip():
|
|
230
|
+
append_output(self._buffer)
|
|
231
|
+
self._buffer = ""
|
|
232
|
+
self._original.flush()
|
|
233
|
+
|
|
234
|
+
def isatty(self) -> bool:
|
|
235
|
+
return self._original.isatty()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def read_line_split(prompt: str = "> ", history_path: Optional[Path] = None) -> str:
|
|
239
|
+
"""Read input with split layout - fixed bottom bar, scrollable output above.
|
|
240
|
+
|
|
241
|
+
Similar to Kimi Code and Claude Code interfaces.
|
|
242
|
+
"""
|
|
243
|
+
global _split_app, _split_buffer, _output_buffer, _original_stdout, _notification_callback
|
|
244
|
+
|
|
245
|
+
# Drain any pending background notifications before showing prompt
|
|
246
|
+
notifications = drain_notifications()
|
|
247
|
+
for note in notifications:
|
|
248
|
+
if _notification_callback:
|
|
249
|
+
_notification_callback(note)
|
|
250
|
+
else:
|
|
251
|
+
safe_print_notification(note)
|
|
252
|
+
|
|
253
|
+
if not HAS_PROMPT_TOOLKIT:
|
|
254
|
+
raise RuntimeError("prompt_toolkit is not installed")
|
|
255
|
+
|
|
256
|
+
import sys
|
|
257
|
+
# Save and redirect stdout
|
|
258
|
+
_original_stdout = sys.stdout
|
|
259
|
+
sys.stdout = _OutputRedirector(_original_stdout)
|
|
260
|
+
|
|
261
|
+
from prompt_toolkit import Application
|
|
262
|
+
from prompt_toolkit.buffer import Buffer
|
|
263
|
+
from prompt_toolkit.layout import HSplit, Layout, Window, ConditionalContainer
|
|
264
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
265
|
+
from prompt_toolkit.layout.processors import BeforeInput
|
|
266
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
267
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
268
|
+
from prompt_toolkit.filters import has_completions
|
|
269
|
+
|
|
270
|
+
# Output area (upper pane) - shows accumulated output with ANSI support
|
|
271
|
+
def get_output_text():
|
|
272
|
+
"""Get formatted output text with ANSI codes parsed."""
|
|
273
|
+
text = "\n".join(_output_buffer[-1000:])
|
|
274
|
+
return ANSI(text) if text else ""
|
|
275
|
+
|
|
276
|
+
output_control = FormattedTextControl(
|
|
277
|
+
text=get_output_text,
|
|
278
|
+
focusable=False,
|
|
279
|
+
show_cursor=False,
|
|
280
|
+
)
|
|
281
|
+
output_window = Window(
|
|
282
|
+
content=output_control,
|
|
283
|
+
wrap_lines=True,
|
|
284
|
+
allow_scroll_beyond_bottom=True,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Input buffer with completer
|
|
288
|
+
history = FileHistory(str(history_path)) if history_path else InMemoryHistory()
|
|
289
|
+
completer = SlashCompleter()
|
|
290
|
+
|
|
291
|
+
_split_buffer = Buffer(
|
|
292
|
+
history=history,
|
|
293
|
+
completer=completer,
|
|
294
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
295
|
+
complete_while_typing=True,
|
|
296
|
+
enable_history_search=False,
|
|
297
|
+
multiline=False,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Input control with prompt
|
|
301
|
+
input_control = BufferControl(
|
|
302
|
+
buffer=_split_buffer,
|
|
303
|
+
input_processors=[BeforeInput(prompt, style="class:prompt")],
|
|
304
|
+
)
|
|
305
|
+
input_window = Window(
|
|
306
|
+
content=input_control,
|
|
307
|
+
height=1,
|
|
308
|
+
wrap_lines=False,
|
|
309
|
+
always_hide_cursor=False,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Completions menu (floating)
|
|
313
|
+
completions_menu = ConditionalContainer(
|
|
314
|
+
content=CompletionsMenu(
|
|
315
|
+
max_height=8,
|
|
316
|
+
scroll_offset=1,
|
|
317
|
+
extra_filter=has_completions,
|
|
318
|
+
),
|
|
319
|
+
filter=has_completions,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Key bindings
|
|
323
|
+
kb = KeyBindings()
|
|
324
|
+
|
|
325
|
+
@kb.add("enter")
|
|
326
|
+
def submit(event):
|
|
327
|
+
"""Submit input."""
|
|
328
|
+
event.app.exit(result=_split_buffer.text)
|
|
329
|
+
|
|
330
|
+
@kb.add("c-c")
|
|
331
|
+
@kb.add("c-d")
|
|
332
|
+
def cancel(event):
|
|
333
|
+
"""Cancel/exit."""
|
|
334
|
+
event.app.exit(result=None)
|
|
335
|
+
|
|
336
|
+
@kb.add("c-l")
|
|
337
|
+
def clear(event):
|
|
338
|
+
"""Clear output buffer."""
|
|
339
|
+
_output_buffer.clear()
|
|
340
|
+
output_control.text = ""
|
|
341
|
+
|
|
342
|
+
# Build layout: output on top, separator, input at bottom
|
|
343
|
+
root_container = HSplit([
|
|
344
|
+
output_window, # Flexible height for output
|
|
345
|
+
Window(height=1, char="─", style="class:separator"),
|
|
346
|
+
input_window, # Fixed height for input
|
|
347
|
+
completions_menu, # Floating completions
|
|
348
|
+
])
|
|
349
|
+
|
|
350
|
+
style = Style.from_dict({
|
|
351
|
+
"completion-menu.completion": "bg:#222222 #cccccc",
|
|
352
|
+
"completion-menu.completion.current": "bg:#005f87 #ffffff bold",
|
|
353
|
+
"completion-menu.meta.completion": "bg:#222222 #808080",
|
|
354
|
+
"completion-menu.meta.completion.current": "bg:#005f87 #eeeeee",
|
|
355
|
+
"auto-suggestion": "#606060 italic",
|
|
356
|
+
"prompt": "#00aa00 bold",
|
|
357
|
+
"separator": "#444444",
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
layout = Layout(root_container, focused_element=input_window)
|
|
361
|
+
|
|
362
|
+
_split_app = Application(
|
|
363
|
+
layout=layout,
|
|
364
|
+
key_bindings=kb,
|
|
365
|
+
style=style,
|
|
366
|
+
mouse_support=False,
|
|
367
|
+
full_screen=False,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
result = _split_app.run()
|
|
371
|
+
|
|
372
|
+
# Restore stdout
|
|
373
|
+
sys.stdout = _original_stdout
|
|
374
|
+
_original_stdout = None
|
|
375
|
+
|
|
376
|
+
# Reset buffer for next use
|
|
377
|
+
if _split_buffer:
|
|
378
|
+
_split_buffer.reset()
|
|
379
|
+
|
|
380
|
+
return result if result else ""
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def append_output(text: str) -> None:
|
|
384
|
+
"""Append text to the output buffer (for split layout mode).
|
|
385
|
+
|
|
386
|
+
Use this to display messages without interrupting the input bar.
|
|
387
|
+
"""
|
|
388
|
+
global _output_buffer, _split_app
|
|
389
|
+
_output_buffer.append(text)
|
|
390
|
+
# Keep last 1000 lines
|
|
391
|
+
if len(_output_buffer) > 1000:
|
|
392
|
+
_output_buffer = _output_buffer[-1000:]
|
|
393
|
+
# Refresh display if app is running
|
|
394
|
+
if _split_app:
|
|
395
|
+
_split_app.invalidate()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def clear_split_output() -> None:
|
|
399
|
+
"""Clear the split layout output buffer."""
|
|
400
|
+
global _output_buffer
|
|
401
|
+
_output_buffer.clear()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ── Background Notification Queue ────────────────────────────────────────────
|
|
405
|
+
# Thread-safe queue for notifications that need to be displayed without
|
|
406
|
+
# corrupting the prompt_toolkit input rendering.
|
|
407
|
+
|
|
408
|
+
import queue
|
|
409
|
+
_notification_queue: queue.Queue = queue.Queue()
|
|
410
|
+
_notification_callback: Optional[Callable[[str], None]] = None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def set_notification_callback(callback: Callable[[str], None]) -> None:
|
|
414
|
+
"""Register a callback to handle background notifications.
|
|
415
|
+
|
|
416
|
+
The callback will be called with the notification text when it's safe
|
|
417
|
+
to display (during the next input cycle or when input is not active).
|
|
418
|
+
"""
|
|
419
|
+
global _notification_callback
|
|
420
|
+
_notification_callback = callback
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def queue_notification(text: str) -> None:
|
|
424
|
+
"""Queue a notification to be displayed safely.
|
|
425
|
+
|
|
426
|
+
This should be used by background threads (timers, jobs, etc.) to
|
|
427
|
+
display messages without corrupting the prompt_toolkit input bar.
|
|
428
|
+
"""
|
|
429
|
+
_notification_queue.put(text)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def drain_notifications() -> list[str]:
|
|
433
|
+
"""Drain all pending notifications from the queue.
|
|
434
|
+
|
|
435
|
+
Returns a list of notification texts. Should be called when it's
|
|
436
|
+
safe to display output (e.g., before showing a new prompt).
|
|
437
|
+
"""
|
|
438
|
+
notifications = []
|
|
439
|
+
while not _notification_queue.empty():
|
|
440
|
+
try:
|
|
441
|
+
notifications.append(_notification_queue.get_nowait())
|
|
442
|
+
except queue.Empty:
|
|
443
|
+
break
|
|
444
|
+
return notifications
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def safe_print_notification(text: str) -> None:
|
|
448
|
+
"""Print a notification in a prompt_toolkit-safe way.
|
|
449
|
+
|
|
450
|
+
If split layout is active, uses append_output.
|
|
451
|
+
Otherwise prints directly (which may cause display issues in sticky mode).
|
|
452
|
+
"""
|
|
453
|
+
global _split_app, _original_stdout
|
|
454
|
+
|
|
455
|
+
if _split_app:
|
|
456
|
+
# Split layout mode - use the safe append_output
|
|
457
|
+
append_output(text)
|
|
458
|
+
elif _original_stdout:
|
|
459
|
+
# We're in some form of redirected stdout
|
|
460
|
+
_original_stdout.write(text + "\n")
|
|
461
|
+
_original_stdout.flush()
|
|
462
|
+
else:
|
|
463
|
+
# Fallback to regular print (may have issues with sticky input)
|
|
464
|
+
print(text)
|