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.
Files changed (63) hide show
  1. {deepparallel-0.4.1 → deepparallel-0.4.3}/PKG-INFO +7 -2
  2. {deepparallel-0.4.1 → deepparallel-0.4.3}/README.md +6 -1
  3. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/__init__.py +1 -1
  4. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/agent.py +2 -0
  5. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/branding.py +98 -0
  6. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/cli.py +40 -6
  7. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/licensing.py +1 -1
  8. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/renderer.py +85 -66
  9. deepparallel-0.4.3/deepparallel/serve.py +261 -0
  10. deepparallel-0.4.3/deepparallel/system_prompt.txt +7 -0
  11. deepparallel-0.4.3/deepparallel/tools/shell.py +69 -0
  12. deepparallel-0.4.3/deepparallel/userinput.py +61 -0
  13. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/PKG-INFO +7 -2
  14. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/SOURCES.txt +5 -1
  15. {deepparallel-0.4.1 → deepparallel-0.4.3}/pyproject.toml +1 -1
  16. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_renderer.py +82 -6
  17. deepparallel-0.4.3/tests/test_spinner_color.py +45 -0
  18. deepparallel-0.4.3/tests/test_tools_shell.py +48 -0
  19. deepparallel-0.4.3/tests/test_userinput.py +40 -0
  20. deepparallel-0.4.1/deepparallel/system_prompt.txt +0 -4
  21. deepparallel-0.4.1/deepparallel/tools/shell.py +0 -38
  22. deepparallel-0.4.1/tests/test_tools_shell.py +0 -24
  23. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/backend.py +0 -0
  24. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/config.py +0 -0
  25. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/fusion.py +0 -0
  26. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/registry.json +0 -0
  27. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/research/__init__.py +0 -0
  28. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/research/conduit.py +0 -0
  29. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/supply_chain.py +0 -0
  30. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/__init__.py +0 -0
  31. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/codeast.py +0 -0
  32. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/edit.py +0 -0
  33. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/files.py +0 -0
  34. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/registry.py +0 -0
  35. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/sandbox.py +0 -0
  36. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/search.py +0 -0
  37. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/vision.py +0 -0
  38. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel/tools/web.py +0 -0
  39. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/dependency_links.txt +0 -0
  40. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/entry_points.txt +0 -0
  41. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/requires.txt +0 -0
  42. {deepparallel-0.4.1 → deepparallel-0.4.3}/deepparallel.egg-info/top_level.txt +0 -0
  43. {deepparallel-0.4.1 → deepparallel-0.4.3}/setup.cfg +0 -0
  44. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_agent.py +0 -0
  45. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_backend.py +0 -0
  46. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_backend_chat.py +0 -0
  47. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_backend_stream.py +0 -0
  48. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_branding.py +0 -0
  49. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_cli.py +0 -0
  50. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_config.py +0 -0
  51. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_fusion.py +0 -0
  52. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_issuer_signer.py +0 -0
  53. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_licensing.py +0 -0
  54. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_research.py +0 -0
  55. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_supply_chain.py +0 -0
  56. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tool_registry.py +0 -0
  57. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_codeast.py +0 -0
  58. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_edit.py +0 -0
  59. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_files.py +0 -0
  60. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_sandbox.py +0 -0
  61. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_search.py +0 -0
  62. {deepparallel-0.4.1 → deepparallel-0.4.3}/tests/test_tools_vision.py +0 -0
  63. {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.1
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`).
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.4.3"
@@ -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 = console.input(branding.user_prefix() + "› ").strip()
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
- tag = f"[{mode}] " if mode != "off" else ""
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 = console.input(f"{tag}{branding.user_prefix()}› ").strip()
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=settings.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 = "I0YYNFwGGUZGD6h15leBEPBQ197Snik+njzxkVf/Owg="
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
- for line in branding.wordmark_lines():
141
- self._console.print(f"[{branding.DP_ACCENT}]{line}[/]", highlight=False)
142
- if animate:
143
- time.sleep(_REVEAL_SECONDS)
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 live in a panel
159
- that grows as tokens arrive (headings, code, lists format in place); the
160
- final frame is the same panel `answer()` would print, so there is no
161
- double render. On a pipe / non-tty, fall back to raw inline streaming."""
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._stream_live_markdown(chunks)
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 _stream_live_markdown(self, chunks: Iterable[str]) -> str:
189
- from rich.live import Live
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
- parts: list[str] = []
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
- def _answer_panel(self, text: str):
219
- return branding.build_transcript_markdown(self._console, _balance_fences(text))
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 "",