deepparallel 0.4.1__tar.gz → 0.4.3__tar.gz
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.
- {deepparallel-0.4.1 → deepparallel-0.4.3}/PKG-INFO +7 -2
- {deepparallel-0.4.1 → deepparallel-0.4.3}/README.md +6 -1
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/__init__.py +1 -1
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/agent.py +2 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/branding.py +98 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/cli.py +40 -6
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/licensing.py +1 -1
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/renderer.py +85 -66
- deepparallel-0.4.3/deepparallel/serve.py +261 -0
- deepparallel-0.4.3/deepparallel/system_prompt.txt +7 -0
- deepparallel-0.4.3/deepparallel/tools/shell.py +69 -0
- deepparallel-0.4.3/deepparallel/userinput.py +61 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/PKG-INFO +7 -2
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/SOURCES.txt +5 -1
- {deepparallel-0.4.1 → deepparallel-0.4.3}/pyproject.toml +1 -1
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_renderer.py +82 -6
- deepparallel-0.4.3/tests/test_spinner_color.py +45 -0
- deepparallel-0.4.3/tests/test_tools_shell.py +48 -0
- deepparallel-0.4.3/tests/test_userinput.py +40 -0
- deepparallel-0.4.1/deepparallel/system_prompt.txt +0 -4
- deepparallel-0.4.1/deepparallel/tools/shell.py +0 -38
- deepparallel-0.4.1/tests/test_tools_shell.py +0 -24
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/backend.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/config.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/fusion.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/registry.json +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/research/__init__.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/research/conduit.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/supply_chain.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/__init__.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/codeast.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/edit.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/files.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/registry.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/sandbox.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/search.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/vision.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/web.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/dependency_links.txt +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/entry_points.txt +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/requires.txt +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/top_level.txt +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/setup.cfg +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_agent.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_backend.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_backend_chat.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_backend_stream.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_branding.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_cli.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_config.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_fusion.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_issuer_signer.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_licensing.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_research.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_supply_chain.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tool_registry.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_codeast.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_edit.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_files.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_sandbox.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_search.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_vision.py +0 -0
- {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepparallel
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
|
|
5
5
|
Author-email: Michael Crowe <michael@crowelogic.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -115,7 +115,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
|
|
|
115
115
|
- Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
|
|
116
116
|
`ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
|
|
117
117
|
- Mutating / executing (require confirmation): `write_file`, `edit_file`,
|
|
118
|
-
`ast_replace_symbol`, `run_shell`, `run_code
|
|
118
|
+
`ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
|
|
119
|
+
or URL in the OS default app - e.g. an HTML report in the browser).
|
|
120
|
+
|
|
121
|
+
In interactive chat, type `/auto` to toggle auto-approve (tools run without
|
|
122
|
+
asking; edits outside the project and unknown dependencies still confirm), or
|
|
123
|
+
start with `deepparallel --yes`.
|
|
119
124
|
|
|
120
125
|
`web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
|
|
121
126
|
box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
|
|
@@ -91,7 +91,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
|
|
|
91
91
|
- Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
|
|
92
92
|
`ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
|
|
93
93
|
- Mutating / executing (require confirmation): `write_file`, `edit_file`,
|
|
94
|
-
`ast_replace_symbol`, `run_shell`, `run_code
|
|
94
|
+
`ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
|
|
95
|
+
or URL in the OS default app - e.g. an HTML report in the browser).
|
|
96
|
+
|
|
97
|
+
In interactive chat, type `/auto` to toggle auto-approve (tools run without
|
|
98
|
+
asking; edits outside the project and unknown dependencies still confirm), or
|
|
99
|
+
start with `deepparallel --yes`.
|
|
95
100
|
|
|
96
101
|
`web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
|
|
97
102
|
box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
|
|
@@ -71,6 +71,8 @@ def _summarize_result(name: str, result: str) -> str:
|
|
|
71
71
|
return "edited"
|
|
72
72
|
if name == "ast_replace_symbol":
|
|
73
73
|
return "replaced"
|
|
74
|
+
if name == "open_path":
|
|
75
|
+
return f"opened {obj.get('opened', '')}" if "opened" in obj else "ok"
|
|
74
76
|
if name == "run_shell":
|
|
75
77
|
lines = (obj.get("stdout") or "").count("\n")
|
|
76
78
|
return f"rc {obj.get('return_code')} · {_plural(lines, 'line', 'lines')}"
|
|
@@ -9,6 +9,8 @@ status colors follow the Crowe Logic CLI family patterns.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import math
|
|
13
|
+
|
|
12
14
|
from rich import box
|
|
13
15
|
from rich.console import Console, Group
|
|
14
16
|
from rich.markdown import Markdown
|
|
@@ -66,6 +68,91 @@ def thinking(text: str) -> None:
|
|
|
66
68
|
console.print(f"[{DIM}]{text}[/]", end="", soft_wrap=True, highlight=False)
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
_PULSE_BLOCKS = "▁▂▃▄▅▆▇█"
|
|
72
|
+
|
|
73
|
+
# Crest color drifts slowly through these stops while the pulse travels. On-brand:
|
|
74
|
+
# DeepParallel cyan -> a cooler teal-blue -> Crowe green, then loops. The drift is
|
|
75
|
+
# what makes the spinner "change colors" as it works, distinct from the per-lane
|
|
76
|
+
# brightness that creates the travelling crest.
|
|
77
|
+
_CREST_PALETTE = ("#3fd0d0", "#5fb8c4", "#8fa4bf", "#79bf94", "#6fbf73")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _lerp_hex(a: str, b: str, t: float) -> str:
|
|
81
|
+
"""Linear blend between two #rrggbb colors; t in 0..1. Pure, for the crest drift."""
|
|
82
|
+
t = 0.0 if t < 0 else 1.0 if t > 1 else t
|
|
83
|
+
ar, ag, ab = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
|
|
84
|
+
br, bg, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
|
|
85
|
+
r = round(ar + (br - ar) * t)
|
|
86
|
+
g = round(ag + (bg - ag) * t)
|
|
87
|
+
bl = round(ab + (bb - ab) * t)
|
|
88
|
+
return f"#{r:02x}{g:02x}{bl:02x}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _crest_color(phase: float) -> str:
|
|
92
|
+
"""The crest hue at a continuous phase, cycling smoothly around _CREST_PALETTE."""
|
|
93
|
+
n = len(_CREST_PALETTE)
|
|
94
|
+
pos = (phase % n + n) % n # wrap into 0..n
|
|
95
|
+
i = int(pos)
|
|
96
|
+
return _lerp_hex(_CREST_PALETTE[i], _CREST_PALETTE[(i + 1) % n], pos - i)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ThinkingSpinner:
|
|
100
|
+
"""The 'working' animation in DeepParallel's idiom: several reasoning lanes
|
|
101
|
+
pulsing in parallel with a bright crest travelling across them, anchored by
|
|
102
|
+
the ◆ mark - parallel chains computing toward one answer, in the cyan
|
|
103
|
+
identity. Distinct from a borrowed braille dot.
|
|
104
|
+
|
|
105
|
+
Stateful: each render advances one frame. A rich `Live` widget animates it
|
|
106
|
+
simply by re-rendering at its refresh rate (the same mechanism the built-in
|
|
107
|
+
Spinner relies on), so no timer thread of our own is needed.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
label: str = "thinking",
|
|
113
|
+
*,
|
|
114
|
+
lanes: int = 5,
|
|
115
|
+
speed: float = 0.45,
|
|
116
|
+
spread: float = 0.7,
|
|
117
|
+
hue_speed: float = 0.06,
|
|
118
|
+
):
|
|
119
|
+
self._label = label
|
|
120
|
+
self._lanes = lanes
|
|
121
|
+
self._speed = speed # radians advanced per frame (animation tempo)
|
|
122
|
+
self._spread = spread # phase offset between lanes (crest travel)
|
|
123
|
+
self._hue_speed = hue_speed # palette stops advanced per frame (color drift)
|
|
124
|
+
self._tick = 0
|
|
125
|
+
|
|
126
|
+
def frame(self, tick: int) -> Text:
|
|
127
|
+
"""Build the frame at a given tick (pure; used for rendering + tests)."""
|
|
128
|
+
text = Text()
|
|
129
|
+
crest = _crest_color(tick * self._hue_speed) # this frame's drifting hue
|
|
130
|
+
text.append(f"{MARK} ", style=f"bold {crest}")
|
|
131
|
+
for i in range(self._lanes):
|
|
132
|
+
level = (math.sin(tick * self._speed - i * self._spread) + 1) / 2 # 0..1
|
|
133
|
+
# Crest cells take the live hue; mid cells a dimmed blend; troughs go dim.
|
|
134
|
+
if level > 0.72:
|
|
135
|
+
style = f"bold {crest}"
|
|
136
|
+
elif level > 0.4:
|
|
137
|
+
style = _lerp_hex("#3a4a4a", crest, 0.6)
|
|
138
|
+
else:
|
|
139
|
+
style = DIM
|
|
140
|
+
text.append(_PULSE_BLOCKS[round(level * (len(_PULSE_BLOCKS) - 1))], style=style)
|
|
141
|
+
text.append(f" {self._label}…", style=DIM)
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
def __rich__(self) -> Text:
|
|
145
|
+
frame = self.frame(self._tick)
|
|
146
|
+
self._tick += 1
|
|
147
|
+
return frame
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def thinking_spinner(label: str = "thinking") -> ThinkingSpinner:
|
|
151
|
+
"""A fresh DeepParallel thinking animation (one per turn so it starts at
|
|
152
|
+
frame 0). Drive it inside a transient `rich.live.Live`."""
|
|
153
|
+
return ThinkingSpinner(label)
|
|
154
|
+
|
|
155
|
+
|
|
69
156
|
def info(msg: str) -> None:
|
|
70
157
|
console.print(f"[{DIM}]{msg}[/]")
|
|
71
158
|
|
|
@@ -179,6 +266,17 @@ def wordmark_lines() -> list[str]:
|
|
|
179
266
|
return _WORDMARK.split("\n")
|
|
180
267
|
|
|
181
268
|
|
|
269
|
+
def wordmark_width() -> int:
|
|
270
|
+
"""Column width of the block wordmark; below this it wraps and shatters."""
|
|
271
|
+
return max((len(line) for line in wordmark_lines()), default=0)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def compact_wordmark() -> str:
|
|
275
|
+
"""A one-line banner for panes too narrow for the block wordmark. Rich
|
|
276
|
+
markup; always fits, so a split/narrow pane never shows shattered letters."""
|
|
277
|
+
return f"[bold {DP_ACCENT}]{MARK} DeepParallel[/]"
|
|
278
|
+
|
|
279
|
+
|
|
182
280
|
def status_text(
|
|
183
281
|
*, version: str, tool_count: int, fusion_modes: tuple[str, ...], backend_label: str
|
|
184
282
|
):
|
|
@@ -35,6 +35,7 @@ from deepparallel.agent import (
|
|
|
35
35
|
)
|
|
36
36
|
from deepparallel.backend import Backend, backend_for_deployment, resolve_backend
|
|
37
37
|
from deepparallel.branding import console
|
|
38
|
+
from deepparallel.userinput import read_user_input
|
|
38
39
|
from deepparallel.config import (
|
|
39
40
|
Settings,
|
|
40
41
|
_bool_env,
|
|
@@ -233,7 +234,7 @@ def _stream_repl(backend: Backend, settings: Settings) -> None:
|
|
|
233
234
|
history: list[tuple[str, str]] = []
|
|
234
235
|
while True:
|
|
235
236
|
try:
|
|
236
|
-
user_msg =
|
|
237
|
+
user_msg = read_user_input()
|
|
237
238
|
except (EOFError, KeyboardInterrupt):
|
|
238
239
|
console.print()
|
|
239
240
|
break
|
|
@@ -289,10 +290,12 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
|
|
|
289
290
|
messages: list[dict] = [{"role": "system", "content": system}]
|
|
290
291
|
mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
|
|
291
292
|
deep_next = False
|
|
293
|
+
auto = settings.auto_approve
|
|
292
294
|
while True:
|
|
293
|
-
|
|
295
|
+
bits = ([mode] if mode != "off" else []) + (["auto"] if auto else [])
|
|
296
|
+
tag = f"[{' · '.join(bits)}] " if bits else ""
|
|
294
297
|
try:
|
|
295
|
-
user_msg =
|
|
298
|
+
user_msg = read_user_input(tag)
|
|
296
299
|
except (EOFError, KeyboardInterrupt):
|
|
297
300
|
console.print()
|
|
298
301
|
break
|
|
@@ -302,9 +305,19 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
|
|
|
302
305
|
break
|
|
303
306
|
if user_msg == "/help":
|
|
304
307
|
branding.info(
|
|
305
|
-
"/quit · /reset · /info · /tools · /fast //fuse //escalate //deep · prompt"
|
|
308
|
+
"/quit · /reset · /info · /tools · /auto · /fast //fuse //escalate //deep · prompt"
|
|
306
309
|
)
|
|
307
310
|
continue
|
|
311
|
+
if user_msg in {"/auto", "/yes"}:
|
|
312
|
+
auto = not auto
|
|
313
|
+
if auto:
|
|
314
|
+
branding.info(
|
|
315
|
+
"auto-approve ON - tools run without asking. Edits outside this "
|
|
316
|
+
"project and unknown dependencies still confirm. /auto to turn off."
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
branding.info("auto-approve OFF - mutating tools will ask first.")
|
|
320
|
+
continue
|
|
308
321
|
if user_msg == "/reset":
|
|
309
322
|
messages = [{"role": "system", "content": system}]
|
|
310
323
|
branding.info("conversation cleared.")
|
|
@@ -334,7 +347,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
|
|
|
334
347
|
settings,
|
|
335
348
|
renderer,
|
|
336
349
|
interactive=True,
|
|
337
|
-
auto_approve=
|
|
350
|
+
auto_approve=auto,
|
|
338
351
|
stream=True,
|
|
339
352
|
guardian=guardian,
|
|
340
353
|
)
|
|
@@ -376,14 +389,19 @@ def _chat_loop(settings: Settings) -> None:
|
|
|
376
389
|
)
|
|
377
390
|
@click.version_option(__version__, prog_name="deepparallel")
|
|
378
391
|
@click.option("--temperature", "-t", default=None, type=float, help="Sampling temperature.")
|
|
392
|
+
@click.option(
|
|
393
|
+
"--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool actions (toggle with /auto)."
|
|
394
|
+
)
|
|
379
395
|
@click.pass_context
|
|
380
|
-
def main(ctx: click.Context, temperature: float | None) -> None:
|
|
396
|
+
def main(ctx: click.Context, temperature: float | None, assume_yes: bool) -> None:
|
|
381
397
|
"""DeepParallel - a focused agentic CLI for the DeepParallel model."""
|
|
382
398
|
load_dotenv()
|
|
383
399
|
ctx.ensure_object(dict)
|
|
384
400
|
settings = resolve_settings()
|
|
385
401
|
if temperature is not None:
|
|
386
402
|
settings = replace(settings, temperature=temperature)
|
|
403
|
+
if assume_yes:
|
|
404
|
+
settings = replace(settings, auto_approve=True)
|
|
387
405
|
ctx.obj["settings"] = settings
|
|
388
406
|
if ctx.invoked_subcommand is None:
|
|
389
407
|
_chat_loop(settings)
|
|
@@ -504,6 +522,22 @@ def review(ctx: click.Context, as_diff: bool, path: str | None) -> None:
|
|
|
504
522
|
sys.exit(verdict_exit_code(verdict))
|
|
505
523
|
|
|
506
524
|
|
|
525
|
+
@main.command()
|
|
526
|
+
@click.option("--host", default="127.0.0.1", help="Bind host.")
|
|
527
|
+
@click.option("--port", default=8013, type=int, help="Bind port.")
|
|
528
|
+
def serve(host: str, port: int) -> None:
|
|
529
|
+
"""Run the OpenAI-compatible gateway for Crowe Terminal / Crowe Code, etc.
|
|
530
|
+
|
|
531
|
+
Exposes /v1/chat/completions + /v1/models so any OpenAI-format client can use
|
|
532
|
+
the Crowe Logic model stack through one endpoint, with the Crowe Logic persona
|
|
533
|
+
injected and raw-model identity leaks scrubbed. Select a model by deployment
|
|
534
|
+
name (see /v1/models); fusion models plug in here later.
|
|
535
|
+
"""
|
|
536
|
+
from deepparallel.serve import run_server
|
|
537
|
+
|
|
538
|
+
run_server(host, port)
|
|
539
|
+
|
|
540
|
+
|
|
507
541
|
@main.command()
|
|
508
542
|
@click.argument("path", required=True)
|
|
509
543
|
@click.pass_context
|
|
@@ -26,7 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
|
|
27
27
|
# Issuer public key (Ed25519, raw, base64). The matching private key is the
|
|
28
28
|
# issuance secret and is never shipped.
|
|
29
|
-
_EMBEDDED_PUBKEY = "
|
|
29
|
+
_EMBEDDED_PUBKEY = "BdxSRIB5F2K2bXf7c0UqsR6jC/cSRMSxojpzRpTCUQg="
|
|
30
30
|
|
|
31
31
|
# Paid features -> minimum tier required.
|
|
32
32
|
_FEATURE_TIER: dict[str, "Tier"] = {}
|
|
@@ -13,10 +13,9 @@ incrementally (no redrawing Live regions) so output never ghosts.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import sys
|
|
16
|
-
import threading
|
|
17
16
|
import time
|
|
18
17
|
from abc import ABC, abstractmethod
|
|
19
|
-
from typing import Iterable
|
|
18
|
+
from typing import Iterable, Iterator
|
|
20
19
|
|
|
21
20
|
from rich.console import Console
|
|
22
21
|
|
|
@@ -132,15 +131,18 @@ class RichRenderer(Renderer):
|
|
|
132
131
|
self._console = console or branding.console
|
|
133
132
|
self._input_fn = input_fn or self._console.input
|
|
134
133
|
self._cur: str | None = None
|
|
135
|
-
self._timer_stop: threading.Event | None = None
|
|
136
|
-
self._timer_thread: threading.Thread | None = None
|
|
137
134
|
|
|
138
135
|
def welcome(self, backend_label, *, version="", tool_count=0, fusion_modes=()) -> None:
|
|
139
136
|
animate = self._console.is_terminal and _REVEAL_SECONDS > 0
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
if self._console.width < branding.wordmark_width():
|
|
138
|
+
# Narrow / split pane: the block wordmark would wrap mid-letter and
|
|
139
|
+
# shatter. Use a compact one-line mark that always fits.
|
|
140
|
+
self._console.print(branding.compact_wordmark(), highlight=False)
|
|
141
|
+
else:
|
|
142
|
+
for line in branding.wordmark_lines():
|
|
143
|
+
self._console.print(f"[{branding.DP_ACCENT}]{line}[/]", highlight=False)
|
|
144
|
+
if animate:
|
|
145
|
+
time.sleep(_REVEAL_SECONDS)
|
|
144
146
|
self._console.print()
|
|
145
147
|
self._console.print(
|
|
146
148
|
branding.status_text(
|
|
@@ -155,14 +157,39 @@ class RichRenderer(Renderer):
|
|
|
155
157
|
self._console.print(branding.build_transcript_markdown(self._console, text))
|
|
156
158
|
|
|
157
159
|
def answer_stream(self, chunks: Iterable[str]) -> str:
|
|
158
|
-
"""Stream the answer. On a real terminal, render Markdown
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
"""Stream the answer. On a real terminal, render Markdown block by block,
|
|
161
|
+
append-only: each complete block (a paragraph/heading/list ended by a
|
|
162
|
+
blank line, or a closed code fence) is rendered once as it settles. This
|
|
163
|
+
never moves the cursor up, so it cannot ghost or stack the way a growing
|
|
164
|
+
Live region does - it works the same on any terminal and at any answer
|
|
165
|
+
length. On a pipe / non-tty, fall back to raw inline streaming."""
|
|
166
|
+
chunks = self._spin_until_first(chunks)
|
|
162
167
|
if self._console.is_terminal:
|
|
163
|
-
return self.
|
|
168
|
+
return self._stream_markdown_blocks(chunks)
|
|
164
169
|
return self._stream_inline(chunks)
|
|
165
170
|
|
|
171
|
+
def _spin_until_first(self, chunks: Iterable[str]) -> Iterator[str]:
|
|
172
|
+
"""Show a transient 'thinking' spinner during the wait for the first
|
|
173
|
+
token, then yield the stream through unchanged. The spinner is stopped
|
|
174
|
+
and cleared (transient=True) before any answer text prints, so unlike a
|
|
175
|
+
growing Live region it can never overlap or stack with the answer."""
|
|
176
|
+
it = iter(chunks)
|
|
177
|
+
if not self._console.is_terminal:
|
|
178
|
+
yield from it
|
|
179
|
+
return
|
|
180
|
+
from rich.live import Live
|
|
181
|
+
|
|
182
|
+
spinner = branding.thinking_spinner()
|
|
183
|
+
first = None
|
|
184
|
+
with Live(spinner, console=self._console, transient=True, refresh_per_second=12):
|
|
185
|
+
for c in it:
|
|
186
|
+
first = c
|
|
187
|
+
break
|
|
188
|
+
if first is None:
|
|
189
|
+
return # empty stream (e.g. a tool-only turn): nothing to reveal
|
|
190
|
+
yield first
|
|
191
|
+
yield from it
|
|
192
|
+
|
|
166
193
|
def _stream_inline(self, chunks: Iterable[str]) -> str:
|
|
167
194
|
# Raw token streaming for pipes / non-tty: no Live, never ghosts. The
|
|
168
195
|
# marker is printed only on the first VISIBLE character, so empty /
|
|
@@ -185,71 +212,63 @@ class RichRenderer(Renderer):
|
|
|
185
212
|
self._console.print()
|
|
186
213
|
return "".join(parts)
|
|
187
214
|
|
|
188
|
-
def
|
|
189
|
-
from rich.
|
|
215
|
+
def _stream_markdown_blocks(self, chunks: Iterable[str]) -> str:
|
|
216
|
+
from rich.markdown import Markdown
|
|
217
|
+
from rich.padding import Padding
|
|
190
218
|
|
|
191
|
-
|
|
219
|
+
full: list[str] = []
|
|
220
|
+
buf = "" # text not yet split into complete lines
|
|
221
|
+
block: list[str] = [] # lines of the block currently accumulating
|
|
222
|
+
in_fence = False
|
|
192
223
|
started = False
|
|
193
|
-
last_draw = 0.0
|
|
194
|
-
live = Live(
|
|
195
|
-
console=self._console,
|
|
196
|
-
auto_refresh=False, # we drive refreshes; deterministic, no bg thread
|
|
197
|
-
vertical_overflow="visible", # let answers taller than the screen scroll
|
|
198
|
-
)
|
|
199
|
-
try:
|
|
200
|
-
for c in chunks:
|
|
201
|
-
parts.append(c)
|
|
202
|
-
if not started:
|
|
203
|
-
if not "".join(parts).strip():
|
|
204
|
-
continue # hold the panel back until real content arrives
|
|
205
|
-
started = True
|
|
206
|
-
live.start()
|
|
207
|
-
now = time.monotonic()
|
|
208
|
-
if now - last_draw >= 0.06: # throttle to ~16 fps
|
|
209
|
-
live.update(self._answer_panel("".join(parts)), refresh=True)
|
|
210
|
-
last_draw = now
|
|
211
|
-
if started: # final frame: the complete, settled answer
|
|
212
|
-
live.update(self._answer_panel("".join(parts)), refresh=True)
|
|
213
|
-
finally:
|
|
214
|
-
if started:
|
|
215
|
-
live.stop()
|
|
216
|
-
return "".join(parts)
|
|
217
224
|
|
|
218
|
-
|
|
219
|
-
|
|
225
|
+
def emit() -> None:
|
|
226
|
+
nonlocal block, started
|
|
227
|
+
text = "\n".join(block).strip("\n")
|
|
228
|
+
block = []
|
|
229
|
+
if not text.strip():
|
|
230
|
+
return
|
|
231
|
+
if not started:
|
|
232
|
+
started = True
|
|
233
|
+
self._console.print(f"[{branding.DP_ACCENT}]{branding.MARK}[/]", highlight=False)
|
|
234
|
+
self._console.print(
|
|
235
|
+
Padding(Markdown(_balance_fences(text)), (0, 0, 0, branding.GUTTER))
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
for c in chunks:
|
|
239
|
+
full.append(c)
|
|
240
|
+
buf += c
|
|
241
|
+
while "\n" in buf: # only act on complete lines; keep the partial
|
|
242
|
+
line, buf = buf.split("\n", 1)
|
|
243
|
+
if line.strip().startswith("```"):
|
|
244
|
+
block.append(line)
|
|
245
|
+
if in_fence: # this line closes the fence -> block is complete
|
|
246
|
+
in_fence = False
|
|
247
|
+
emit()
|
|
248
|
+
else:
|
|
249
|
+
in_fence = True
|
|
250
|
+
elif in_fence:
|
|
251
|
+
block.append(line)
|
|
252
|
+
elif line.strip() == "": # blank line ends a prose block
|
|
253
|
+
emit()
|
|
254
|
+
else:
|
|
255
|
+
block.append(line)
|
|
256
|
+
if buf.strip(): # trailing partial line at end of stream
|
|
257
|
+
block.append(buf)
|
|
258
|
+
emit() # flush whatever block remains (fence balanced if still open)
|
|
259
|
+
return "".join(full)
|
|
220
260
|
|
|
221
261
|
def reasoning(self, text: str) -> None:
|
|
222
262
|
self._console.print(branding.build_reasoning_panel(self._console, text))
|
|
223
263
|
|
|
224
264
|
def tool_start(self, name: str, args_preview: str) -> None:
|
|
265
|
+
# Append-only: render the running card and stop. A per-tick elapsed timer
|
|
266
|
+
# needs carriage-return overwrites, which some terminals don't honor and
|
|
267
|
+
# then flood with hundreds of lines; the final card carries the duration.
|
|
225
268
|
self._cur = name
|
|
226
269
|
branding.render_tool_card(self._console, name, args_preview, status="running")
|
|
227
|
-
if self._console.is_terminal:
|
|
228
|
-
self._timer_stop = threading.Event()
|
|
229
|
-
start = time.monotonic()
|
|
230
|
-
fh = self._console.file
|
|
231
|
-
|
|
232
|
-
def tick() -> None:
|
|
233
|
-
while not self._timer_stop.wait(0.5): # type: ignore[union-attr]
|
|
234
|
-
fh.write(f"\r {name}... {time.monotonic() - start:.0f}s ")
|
|
235
|
-
fh.flush()
|
|
236
|
-
|
|
237
|
-
self._timer_thread = threading.Thread(target=tick, daemon=True)
|
|
238
|
-
self._timer_thread.start()
|
|
239
|
-
|
|
240
|
-
def _stop_timer(self) -> None:
|
|
241
|
-
if self._timer_stop is None:
|
|
242
|
-
return
|
|
243
|
-
self._timer_stop.set()
|
|
244
|
-
if self._timer_thread is not None:
|
|
245
|
-
self._timer_thread.join(timeout=1.0)
|
|
246
|
-
self._timer_stop = None
|
|
247
|
-
self._timer_thread = None
|
|
248
|
-
self._console.file.write("\r" + " " * 48 + "\r") # clear the timer line
|
|
249
|
-
self._console.file.flush()
|
|
250
270
|
|
|
251
271
|
def tool_result(self, ok: bool, summary: str, duration_s: float) -> None:
|
|
252
|
-
self._stop_timer()
|
|
253
272
|
branding.render_tool_card(
|
|
254
273
|
self._console,
|
|
255
274
|
self._cur or "",
|