bub 0.4.0__tar.gz → 0.4.1__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 (88) hide show
  1. {bub-0.4.0 → bub-0.4.1}/PKG-INFO +1 -1
  2. {bub-0.4.0 → bub-0.4.1}/src/bub/_version.py +2 -2
  3. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/codex_provider.py +8 -2
  4. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/model_runner.py +9 -12
  5. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/tape.py +24 -6
  6. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/tools.py +10 -4
  7. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/cli/__init__.py +177 -109
  8. bub-0.4.1/src/bub/channels/cli/ansi_bridge.py +23 -0
  9. bub-0.4.1/src/bub/channels/cli/terminal_output.py +140 -0
  10. bub-0.4.1/src/bub/channels/cli/writers.py +42 -0
  11. {bub-0.4.0 → bub-0.4.1}/src/bub/tools.py +12 -7
  12. {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_codex.py +8 -1
  13. {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_model_runner.py +43 -0
  14. {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_tape.py +38 -0
  15. {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_tools.py +22 -0
  16. {bub-0.4.0 → bub-0.4.1}/tests/test_channels.py +405 -44
  17. {bub-0.4.0 → bub-0.4.1}/.gitignore +0 -0
  18. {bub-0.4.0 → bub-0.4.1}/LICENSE +0 -0
  19. {bub-0.4.0 → bub-0.4.1}/README.md +0 -0
  20. {bub-0.4.0 → bub-0.4.1}/pyproject.toml +0 -0
  21. {bub-0.4.0 → bub-0.4.1}/src/bub/__init__.py +0 -0
  22. {bub-0.4.0 → bub-0.4.1}/src/bub/__main__.py +0 -0
  23. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/__init__.py +0 -0
  24. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/agent.py +0 -0
  25. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/auth.py +0 -0
  26. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/cli.py +0 -0
  27. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/context.py +0 -0
  28. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/hook_impl.py +0 -0
  29. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/settings.py +0 -0
  30. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/shell_manager.py +0 -0
  31. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/steering.py +0 -0
  32. {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/store.py +0 -0
  33. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/__init__.py +0 -0
  34. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/admission.py +0 -0
  35. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/base.py +0 -0
  36. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/cli/renderer.py +0 -0
  37. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/contracts.py +0 -0
  38. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/handler.py +0 -0
  39. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/manager.py +0 -0
  40. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/message.py +0 -0
  41. {bub-0.4.0 → bub-0.4.1}/src/bub/channels/telegram.py +0 -0
  42. {bub-0.4.0 → bub-0.4.1}/src/bub/configure.py +0 -0
  43. {bub-0.4.0 → bub-0.4.1}/src/bub/envelope.py +0 -0
  44. {bub-0.4.0 → bub-0.4.1}/src/bub/errors.py +0 -0
  45. {bub-0.4.0 → bub-0.4.1}/src/bub/framework.py +0 -0
  46. {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/__init__.py +0 -0
  47. {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/interception.py +0 -0
  48. {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/runtime.py +0 -0
  49. {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/specs.py +0 -0
  50. {bub-0.4.0 → bub-0.4.1}/src/bub/inquirer.py +0 -0
  51. {bub-0.4.0 → bub-0.4.1}/src/bub/model_selection.py +0 -0
  52. {bub-0.4.0 → bub-0.4.1}/src/bub/skills.py +0 -0
  53. {bub-0.4.0 → bub-0.4.1}/src/bub/streaming.py +0 -0
  54. {bub-0.4.0 → bub-0.4.1}/src/bub/tape.py +0 -0
  55. {bub-0.4.0 → bub-0.4.1}/src/bub/turn.py +0 -0
  56. {bub-0.4.0 → bub-0.4.1}/src/bub/types.py +0 -0
  57. {bub-0.4.0 → bub-0.4.1}/src/bub/utils.py +0 -0
  58. {bub-0.4.0 → bub-0.4.1}/src/skills/README.md +0 -0
  59. {bub-0.4.0 → bub-0.4.1}/src/skills/gh/SKILL.md +0 -0
  60. {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/SKILL.md +0 -0
  61. {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/license.txt +0 -0
  62. {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  63. {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  64. {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/SKILL.md +0 -0
  65. {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  66. {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/scripts/telegram_rich.py +0 -0
  67. {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/scripts/telegram_send.py +0 -0
  68. {bub-0.4.0 → bub-0.4.1}/tests/conftest.py +0 -0
  69. {bub-0.4.0 → bub-0.4.1}/tests/test_agent_hooks.py +0 -0
  70. {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_agent.py +0 -0
  71. {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_cli.py +0 -0
  72. {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_hook_impl.py +0 -0
  73. {bub-0.4.0 → bub-0.4.1}/tests/test_cli_help.py +0 -0
  74. {bub-0.4.0 → bub-0.4.1}/tests/test_cli_renderer.py +0 -0
  75. {bub-0.4.0 → bub-0.4.1}/tests/test_configure.py +0 -0
  76. {bub-0.4.0 → bub-0.4.1}/tests/test_envelope.py +0 -0
  77. {bub-0.4.0 → bub-0.4.1}/tests/test_file_tape_store_entry_ids.py +0 -0
  78. {bub-0.4.0 → bub-0.4.1}/tests/test_fork_store_merge_back.py +0 -0
  79. {bub-0.4.0 → bub-0.4.1}/tests/test_framework.py +0 -0
  80. {bub-0.4.0 → bub-0.4.1}/tests/test_hook_runtime.py +0 -0
  81. {bub-0.4.0 → bub-0.4.1}/tests/test_image_message.py +0 -0
  82. {bub-0.4.0 → bub-0.4.1}/tests/test_settings.py +0 -0
  83. {bub-0.4.0 → bub-0.4.1}/tests/test_skills.py +0 -0
  84. {bub-0.4.0 → bub-0.4.1}/tests/test_subagent_tool.py +0 -0
  85. {bub-0.4.0 → bub-0.4.1}/tests/test_tape_search_output.py +0 -0
  86. {bub-0.4.0 → bub-0.4.1}/tests/test_tools.py +0 -0
  87. {bub-0.4.0 → bub-0.4.1}/tests/test_types.py +0 -0
  88. {bub-0.4.0 → bub-0.4.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: A common shape for agents that live alongside people.
5
5
  Project-URL: Homepage, https://bub.build
6
6
  Project-URL: Repository, https://github.com/bubbuild/bub
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.4.1'
22
+ __version_tuple__ = version_tuple = (0, 4, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -365,22 +365,28 @@ class CodexCompletionChunkMapper:
365
365
  })
366
366
 
367
367
 
368
- def _completion_usage_from_response_usage(usage: Any) -> dict[str, int]:
368
+ def _completion_usage_from_response_usage(usage: Any) -> dict[str, Any]:
369
369
  payload = usage.model_dump(exclude_none=True) if hasattr(usage, "model_dump") else usage
370
370
  if not isinstance(payload, dict):
371
371
  payload = {
372
372
  "input_tokens": getattr(usage, "input_tokens", 0),
373
373
  "output_tokens": getattr(usage, "output_tokens", 0),
374
374
  "total_tokens": getattr(usage, "total_tokens", 0),
375
+ "input_tokens_details": getattr(usage, "input_tokens_details", None),
375
376
  }
376
377
  prompt_tokens = int(payload.get("input_tokens") or payload.get("prompt_tokens") or 0)
377
378
  completion_tokens = int(payload.get("output_tokens") or payload.get("completion_tokens") or 0)
378
379
  total_tokens = int(payload.get("total_tokens") or prompt_tokens + completion_tokens)
379
- return {
380
+ completion_usage: dict[str, Any] = {
380
381
  "prompt_tokens": prompt_tokens,
381
382
  "completion_tokens": completion_tokens,
382
383
  "total_tokens": total_tokens,
383
384
  }
385
+ input_details = _mapping_from_value(payload.get("input_tokens_details"))
386
+ cached_tokens = input_details.get("cached_tokens")
387
+ if isinstance(cached_tokens, int) and not isinstance(cached_tokens, bool):
388
+ completion_usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
389
+ return completion_usage
384
390
 
385
391
 
386
392
  def _string_attr(obj: Any, name: str) -> str:
@@ -11,6 +11,7 @@ from typing import Any, Literal, cast
11
11
 
12
12
  from any_llm import AnyLLM
13
13
  from any_llm.constants import LLMProvider
14
+ from any_llm.providers.anthropic.base import BaseAnthropicProvider
14
15
  from any_llm.providers.openai.base import BaseOpenAIProvider
15
16
  from any_llm.types.completion import (
16
17
  ChatCompletion,
@@ -46,17 +47,13 @@ TOOL_ARGUMENTS_ADAPTER = TypeAdapter(dict[str, Any])
46
47
  CompletionResult = ChatCompletion | ParsedChatCompletion[Any] | AsyncIterator[ChatCompletionChunk]
47
48
 
48
49
 
49
- def _stream_usage_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any] | None:
50
- """Make streaming completions report token usage.
51
-
52
- OpenAI-style streaming responses omit the `usage` block unless the request
53
- sets `stream_options.include_usage`; without it every streamed run records
54
- zero tokens (and zero cost). Only OpenAI-compatible providers accept the
55
- field, so gate on the provider base class — anthropic/gemini reject it.
56
- """
57
- if stream and isinstance(llm, BaseOpenAIProvider):
58
- return {"include_usage": True}
59
- return None
50
+ def _extra_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any]:
51
+ """Return provider-specific extra completion options."""
52
+ if isinstance(llm, BaseAnthropicProvider):
53
+ return {"cache_control": {"type": "ephemeral"}}
54
+ elif stream and isinstance(llm, BaseOpenAIProvider):
55
+ return {"stream_options": {"include_usage": True}}
56
+ return {}
60
57
 
61
58
 
62
59
  class ModelRunner:
@@ -101,7 +98,7 @@ class ModelRunner:
101
98
  tools=tool_payloads,
102
99
  max_tokens=max_tokens if max_tokens is not None else self.settings.max_tokens,
103
100
  stream=streaming,
104
- stream_options=_stream_usage_options(llm, stream=streaming),
101
+ **_extra_options(llm, stream=streaming),
105
102
  )
106
103
  except Exception as exc:
107
104
  if completion_error is None:
@@ -4,7 +4,7 @@ import contextlib
4
4
  import hashlib
5
5
  import inspect
6
6
  import json
7
- from collections.abc import AsyncGenerator
7
+ from collections.abc import AsyncGenerator, Mapping
8
8
  from dataclasses import asdict, dataclass, field, replace
9
9
  from datetime import UTC, datetime
10
10
  from pathlib import Path
@@ -33,6 +33,7 @@ class TapeInfo:
33
33
  last_anchor: str | None
34
34
  entries_since_last_anchor: int
35
35
  last_token_usage: int | None
36
+ last_token_cache_hit_rate: float | None
36
37
 
37
38
 
38
39
  @dataclass(frozen=True)
@@ -77,13 +78,29 @@ class Tape:
77
78
  last_anchor = None
78
79
  entries_since_last_anchor = len(entries)
79
80
  last_token_usage: int | None = None
81
+ last_token_cache_hit_rate: float | None = None
80
82
  for entry in reversed(entries):
81
83
  if entry.kind == "event" and entry.payload.get("name") == "run":
82
- with contextlib.suppress(AttributeError):
83
- token_usage = entry.payload.get("data", {}).get("usage", {}).get("total_tokens")
84
- if token_usage and isinstance(token_usage, int):
85
- last_token_usage = token_usage
86
- break
84
+ data = entry.payload.get("data")
85
+ usage = data.get("usage") if isinstance(data, Mapping) else None
86
+ if not isinstance(usage, Mapping):
87
+ continue
88
+ token_usage = usage.get("total_tokens")
89
+ if not isinstance(token_usage, int) or isinstance(token_usage, bool):
90
+ continue
91
+ last_token_usage = token_usage
92
+ prompt_tokens = usage.get("prompt_tokens")
93
+ prompt_details = usage.get("prompt_tokens_details")
94
+ cached_tokens = prompt_details.get("cached_tokens") if isinstance(prompt_details, Mapping) else None
95
+ if (
96
+ isinstance(prompt_tokens, int)
97
+ and not isinstance(prompt_tokens, bool)
98
+ and prompt_tokens > 0
99
+ and isinstance(cached_tokens, int)
100
+ and not isinstance(cached_tokens, bool)
101
+ ):
102
+ last_token_cache_hit_rate = cached_tokens / prompt_tokens
103
+ break
87
104
  return TapeInfo(
88
105
  name=self.name,
89
106
  entries=len(entries),
@@ -91,6 +108,7 @@ class Tape:
91
108
  last_anchor=str(last_anchor) if last_anchor else None,
92
109
  entries_since_last_anchor=entries_since_last_anchor,
93
110
  last_token_usage=last_token_usage,
111
+ last_token_cache_hit_rate=last_token_cache_hit_rate,
94
112
  )
95
113
 
96
114
  async def ensure_bootstrap_anchor(self) -> None:
@@ -253,16 +253,20 @@ def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolConte
253
253
 
254
254
 
255
255
  @tool(context=True, name="skill")
256
- def skill_describe(name: str, *, context: ToolContext) -> str:
257
- """Load the skill content by name. Return the location and skill content."""
256
+ def skill_describe(name: str | None = None, *, context: ToolContext) -> str:
257
+ """Load the skill content by name. Return the location and skill content.
258
+ If name is not provided, list all available skills in the current workspace.
259
+ """
258
260
  from bub.utils import workspace_from_state
259
261
 
260
262
  allowed_skills = context.state.get("allowed_skills")
261
- if allowed_skills is not None and name.casefold() not in allowed_skills:
263
+ if allowed_skills is not None and name and name.casefold() not in allowed_skills:
262
264
  return f"(skill '{name}' is not allowed in this context)"
263
265
 
264
266
  workspace = workspace_from_state(context.state)
265
267
  skill_index = {skill.name: skill for skill in discover_skills(workspace)}
268
+ if name is None:
269
+ return "Available skills:\n" + "\n".join(f"- {skill.name}" for skill in skill_index.values())
266
270
  if name.casefold() not in skill_index:
267
271
  return "(no such skill)"
268
272
  skill = skill_index[name.casefold()]
@@ -273,13 +277,15 @@ def skill_describe(name: str, *, context: ToolContext) -> str:
273
277
  async def tape_info(context: ToolContext) -> str:
274
278
  """Get information about the current tape, such as number of entries and anchors."""
275
279
  info = await context.tape.info()
280
+ cache_hit_rate = f"{info.last_token_cache_hit_rate:.2%}" if info.last_token_cache_hit_rate is not None else "None"
276
281
  return (
277
282
  f"name: {info.name}\n"
278
283
  f"entries: {info.entries}\n"
279
284
  f"anchors: {info.anchors}\n"
280
285
  f"last_anchor: {info.last_anchor}\n"
281
286
  f"entries_since_last_anchor: {info.entries_since_last_anchor}\n"
282
- f"last_token_usage: {info.last_token_usage}"
287
+ f"last_token_usage: {info.last_token_usage}\n"
288
+ f"last_token_cache_hit_rate: {cache_hit_rate}"
283
289
  )
284
290
 
285
291
 
@@ -9,13 +9,15 @@ from typing import Any
9
9
 
10
10
  from loguru import logger
11
11
  from prompt_toolkit import PromptSession
12
- from prompt_toolkit.application import run_in_terminal
13
12
  from prompt_toolkit.completion import WordCompleter
14
- from prompt_toolkit.formatted_text import FormattedText
13
+ from prompt_toolkit.data_structures import Point
14
+ from prompt_toolkit.filters import Condition
15
+ from prompt_toolkit.formatted_text import ANSI, AnyFormattedText, FormattedText
15
16
  from prompt_toolkit.history import FileHistory
16
17
  from prompt_toolkit.key_binding import KeyBindings
18
+ from prompt_toolkit.layout import ConditionalContainer, FormattedTextControl, HSplit, Window
19
+ from prompt_toolkit.layout.dimension import Dimension
17
20
  from prompt_toolkit.patch_stdout import patch_stdout
18
- from prompt_toolkit.utils import get_cwidth
19
21
  from rich import get_console
20
22
  from rich.spinner import SPINNERS
21
23
  from rich.text import Text
@@ -26,7 +28,13 @@ from bub.builtin.agent import Agent
26
28
  from bub.builtin.tape import TapeInfo
27
29
  from bub.channels.admission import AdmitDecision, TurnSnapshot
28
30
  from bub.channels.base import Interface
31
+ from bub.channels.cli.ansi_bridge import render_to_ansi
29
32
  from bub.channels.cli.renderer import CliRenderer
33
+ from bub.channels.cli.terminal_output import (
34
+ TerminalPresenter,
35
+ create_synchronized_output,
36
+ )
37
+ from bub.channels.cli.writers import MarkdownWriter
30
38
  from bub.channels.contracts import MessageHandler
31
39
  from bub.channels.message import ChannelMessage
32
40
  from bub.envelope import Envelope, field_of
@@ -38,15 +46,25 @@ _PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type:
38
46
 
39
47
 
40
48
  class _StreamPrinter:
41
- def __init__(self, *, console, print_head: Callable[[], None], expand_thinking: bool) -> None:
49
+ def __init__(
50
+ self,
51
+ *,
52
+ console,
53
+ print_head: Callable[[], None],
54
+ expand_thinking: bool,
55
+ presenter: TerminalPresenter,
56
+ writer: MarkdownWriter | None = None,
57
+ invalidate: Callable[[], None] | None = None,
58
+ ) -> None:
42
59
  self._console = console
43
60
  self._print_head = print_head
44
61
  self._expand_thinking = expand_thinking
62
+ self._presenter = presenter
45
63
  self._reasoning_chars = 0
46
64
  self._reasoning_streaming = False
47
- self._current_text_line = ""
48
- self._rendered_text_line: str | None = None
49
- self._live_text_rows = 0
65
+ self._writer = writer or MarkdownWriter()
66
+ self._invalidate = invalidate or (lambda: None)
67
+ self._ansi_cache: tuple[int, str] | None = None
50
68
  self.head_printed = False
51
69
 
52
70
  async def render(self, event: StreamEvent) -> bool:
@@ -59,7 +77,7 @@ class _StreamPrinter:
59
77
  elif event.kind == "tool_call":
60
78
  await self._print_stream_boundary()
61
79
  elif event.kind == "final":
62
- await self._print_end()
80
+ await self.finish()
63
81
  return True
64
82
 
65
83
  async def _record_reasoning(self, reasoning: str) -> None:
@@ -67,6 +85,7 @@ class _StreamPrinter:
67
85
  if self._reasoning_chars == 0:
68
86
  await self._ensure_head()
69
87
  self._reasoning_chars += len(reasoning)
88
+ self._invalidate()
70
89
  return
71
90
 
72
91
  await self._ensure_head()
@@ -84,27 +103,23 @@ class _StreamPrinter:
84
103
  await self._write_text(content)
85
104
  return True
86
105
 
87
- async def _print_end(self) -> None:
106
+ async def finish(self) -> None:
107
+ await self._close_reasoning_stream()
88
108
  if self._reasoning_chars:
89
109
  await self._ensure_head()
90
110
  await self._flush_reasoning()
91
- if self._current_text_line:
92
- await self._commit_text_line()
93
- elif self.head_printed and not self._live_text_rows:
94
- await self._print("")
111
+ if self._writer.has_content():
112
+ await self._flush_text()
95
113
 
96
114
  async def _print_stream_boundary(self) -> None:
97
- await self._close_reasoning_stream()
98
- await self._flush_reasoning()
99
- if self._current_text_line or self._live_text_rows:
100
- await self._commit_text_line()
115
+ await self.finish()
101
116
  if self.head_printed:
102
117
  await self._print("")
103
118
 
104
119
  async def _ensure_head(self) -> None:
105
120
  if self.head_printed:
106
121
  return
107
- await run_in_terminal(self._print_head, render_cli_done=False)
122
+ await self._presenter.write(self._print_head)
108
123
  self.head_printed = True
109
124
 
110
125
  async def _close_reasoning_stream(self) -> None:
@@ -121,80 +136,59 @@ class _StreamPrinter:
121
136
  self._reasoning_chars = 0
122
137
 
123
138
  async def _write_text(self, text: str) -> None:
124
- parts = text.split("\n")
125
- for index, part in enumerate(parts):
126
- self._current_text_line += part
127
- if index < len(parts) - 1:
128
- await self._commit_text_line()
129
-
130
- if self._current_text_line:
131
- await self._render_live_text_line()
132
-
133
- async def _commit_text_line(self) -> None:
134
- if self._live_text_rows and self._rendered_text_line == self._current_text_line:
135
- self._current_text_line = ""
136
- self._rendered_text_line = None
137
- self._live_text_rows = 0
139
+ self._writer.append(text)
140
+ self._ansi_cache = None
141
+ self._invalidate()
142
+
143
+ def render_live_ansi(self, *, width: int) -> str:
144
+ if not self._writer.has_content():
145
+ return ""
146
+ if self._ansi_cache is None or self._ansi_cache[0] != width:
147
+ rendered = render_to_ansi(self._writer.render_live(), width=width).rstrip("\n")
148
+ self._ansi_cache = (width, rendered)
149
+ return self._ansi_cache[1]
150
+
151
+ def live_cursor_position(self, *, width: int) -> Point:
152
+ rendered = self.render_live_ansi(width=width)
153
+ return Point(x=0, y=max(0, len(rendered.splitlines()) - 1))
154
+
155
+ def has_live_content(self) -> bool:
156
+ return self._writer.has_content()
157
+
158
+ async def _flush_text(self) -> None:
159
+ finished = self._writer.render_final()
160
+ if finished is None:
138
161
  return
139
- self._live_text_rows = await self._render_text_line(self._current_text_line)
140
- self._current_text_line = ""
141
- self._rendered_text_line = None
142
- self._live_text_rows = 0
143
-
144
- async def commit_live_text(self) -> None:
145
- if self._current_text_line or self._live_text_rows:
146
- await self._commit_text_line()
147
-
148
- async def _render_live_text_line(self) -> None:
149
- self._live_text_rows = await self._render_text_line(self._current_text_line)
150
- self._rendered_text_line = self._current_text_line
151
-
152
- async def _render_text_line(self, text: str) -> int:
153
- previous_rows = self._live_text_rows
154
- rows = self._display_rows(text)
155
-
156
- def render() -> None:
157
- self._rewind_live_text(previous_rows)
158
- self._console.print(f"{text}\n", end="", highlight=False)
159
162
 
160
- await run_in_terminal(render, render_cli_done=False)
161
- return rows
163
+ def commit() -> None:
164
+ self._console.print(finished)
165
+ self._writer.clear()
162
166
 
163
- def _display_rows(self, text: str) -> int:
164
- columns = max(1, int(getattr(self._console, "width", 80) or 80))
165
- return max(1, (get_cwidth(text) + columns - 1) // columns)
166
-
167
- def _rewind_live_text(self, rows: int) -> None:
168
- if rows <= 0:
169
- return
170
- output = getattr(self._console, "file", None)
171
- if output is None:
172
- return
173
- output.write(f"\x1b[{rows}A\r")
174
- for row in range(rows):
175
- output.write("\x1b[2K")
176
- if row < rows - 1:
177
- output.write("\x1b[1B\r")
178
- if rows > 1:
179
- output.write(f"\x1b[{rows - 1}A\r")
180
- output.flush()
167
+ await self._presenter.write(commit)
168
+ self._ansi_cache = None
169
+ self._invalidate()
181
170
 
182
171
  async def _print(self, *args: Any, **kwargs: Any) -> None:
183
- await run_in_terminal(lambda: self._console.print(*args, **kwargs), render_cli_done=False)
172
+ await self._presenter.write(lambda: self._console.print(*args, **kwargs))
184
173
 
185
174
 
186
175
  class _CliToolCallReporter:
187
- def __init__(self, renderer: CliRenderer) -> None:
176
+ def __init__(self, renderer: CliRenderer, presenter: TerminalPresenter) -> None:
188
177
  self._renderer = renderer
178
+ self._presenter = presenter
189
179
 
190
- def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
191
- self._renderer.tool_call_start(name=name, args=args, kwargs=kwargs)
180
+ async def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
181
+ await self._presenter.write(lambda: self._renderer.tool_call_start(name=name, args=args, kwargs=kwargs))
192
182
 
193
- def success(self, name: str, result: object, elapsed_ms: float) -> None:
194
- self._renderer.tool_call_success(name=name, result=result, elapsed_ms=elapsed_ms)
183
+ async def success(self, name: str, result: object, elapsed_ms: float) -> None:
184
+ await self._presenter.write(
185
+ lambda: self._renderer.tool_call_success(name=name, result=result, elapsed_ms=elapsed_ms)
186
+ )
195
187
 
196
- def error(self, name: str, error: BaseException, elapsed_ms: float) -> None:
197
- self._renderer.tool_call_error(name=name, error=error, elapsed_ms=elapsed_ms)
188
+ async def error(self, name: str, error: BaseException, elapsed_ms: float) -> None:
189
+ await self._presenter.write(
190
+ lambda: self._renderer.tool_call_error(name=name, error=error, elapsed_ms=elapsed_ms)
191
+ )
198
192
 
199
193
 
200
194
  class CliChannel(Interface):
@@ -214,9 +208,11 @@ class CliChannel(Interface):
214
208
  self._mode = "agent" # or "shell"
215
209
  self._expand_thinking = False
216
210
  self._llm_loop_running = False
211
+ self._generation_tick: asyncio.TimerHandle | None = None
217
212
  self._main_task: asyncio.Task | None = None
218
213
  self._stream_printer: _StreamPrinter | None = None
219
214
  self._renderer = CliRenderer(get_console())
215
+ self._presenter = TerminalPresenter()
220
216
  self._last_tape_info: TapeInfo | None = None
221
217
  self._workspace = self._agent.framework.workspace
222
218
  self._prompt = self._build_prompt(self._workspace)
@@ -242,6 +238,7 @@ class CliChannel(Interface):
242
238
  self._main_task = asyncio.create_task(self._main_loop())
243
239
 
244
240
  async def stop(self) -> None:
241
+ self._stop_generation_animation()
245
242
  if self._main_task is not None:
246
243
  self._main_task.cancel()
247
244
  with contextlib.suppress(asyncio.CancelledError):
@@ -250,23 +247,20 @@ class CliChannel(Interface):
250
247
  async def send(self, message: ChannelMessage) -> None:
251
248
  if message.kind != "error":
252
249
  return
253
- self._renderer.error(message.content)
250
+ await self._presenter.write(lambda: self._renderer.error(message.content))
254
251
 
255
252
  async def _main_loop(self) -> None:
256
- self._renderer.welcome(model=self._agent.settings.model, workspace=str(self._workspace))
253
+ await self._presenter.write(
254
+ lambda: self._renderer.welcome(model=self._agent.settings.model, workspace=str(self._workspace))
255
+ )
257
256
  await self._refresh_tape_info()
258
257
 
259
258
  while not self._stop_event.is_set():
260
259
  try:
261
260
  with patch_stdout(raw=True):
262
- raw = (
263
- await self._prompt.prompt_async(
264
- self._prompt_message,
265
- refresh_interval=_PROMPT_REFRESH_INTERVAL,
266
- )
267
- ).strip()
261
+ raw = (await self._prompt.prompt_async(self._prompt_message)).strip()
268
262
  except KeyboardInterrupt:
269
- self._renderer.info("Interrupted. Use ',quit' to exit.")
263
+ await self._presenter.write(lambda: self._renderer.info("Interrupted. Use ',quit' to exit."))
270
264
  continue
271
265
  except EOFError:
272
266
  break
@@ -277,7 +271,7 @@ class CliChannel(Interface):
277
271
  break
278
272
  if raw == ",thinking":
279
273
  await self._echo_input(raw)
280
- self._toggle_thinking()
274
+ await self._toggle_thinking()
281
275
  continue
282
276
 
283
277
  request = self._normalize_input(raw)
@@ -297,7 +291,8 @@ class CliChannel(Interface):
297
291
  self._set_llm_loop_running(False)
298
292
  raise
299
293
 
300
- self._renderer.info("Bye.")
294
+ self._stop_generation_animation()
295
+ await self._presenter.write(lambda: self._renderer.info("Bye."))
301
296
  self._stop_event.set()
302
297
 
303
298
  @contextlib.asynccontextmanager
@@ -316,16 +311,32 @@ class CliChannel(Interface):
316
311
  return raw
317
312
  return f",{raw}"
318
313
 
319
- def _prompt_message(self) -> FormattedText:
320
- prompt = self._prompt_label()
321
- if not self._llm_loop_running:
322
- return FormattedText([("bold", prompt)])
314
+ def _prompt_message(self) -> AnyFormattedText:
315
+ return FormattedText([("bold", self._prompt_label())])
316
+
317
+ def _live_output_message(self) -> AnyFormattedText:
318
+ stream_printer: _StreamPrinter | None = getattr(self, "_stream_printer", None)
319
+ if stream_printer is None:
320
+ return FormattedText([])
321
+ return ANSI(stream_printer.render_live_ansi(width=get_console().width))
322
+
323
+ def _live_output_cursor(self) -> Point:
324
+ stream_printer: _StreamPrinter | None = getattr(self, "_stream_printer", None)
325
+ if stream_printer is None:
326
+ return Point(x=0, y=0)
327
+ return stream_printer.live_cursor_position(width=get_console().width)
328
+
329
+ def _has_live_output(self) -> bool:
330
+ stream_printer: _StreamPrinter | None = getattr(self, "_stream_printer", None)
331
+ return stream_printer is not None and stream_printer.has_live_content()
332
+
333
+ def _is_generating(self) -> bool:
334
+ return getattr(self, "_stream_printer", None) is not None or self._llm_loop_running
335
+
336
+ def _generation_status(self) -> FormattedText:
323
337
  index = int(monotonic() / _PROMPT_REFRESH_INTERVAL) % len(_GENERATION_SPINNER)
324
338
  spinner = _GENERATION_SPINNER[index]
325
- return FormattedText([
326
- ("blue", f"\n{spinner} Generating\n"),
327
- ("bold", prompt),
328
- ])
339
+ return FormattedText([("blue", f"{spinner} Generating")])
329
340
 
330
341
  def _prompt_label(self) -> str:
331
342
  cwd = Path.cwd().name
@@ -333,10 +344,7 @@ class CliChannel(Interface):
333
344
  return f"{cwd} {symbol} "
334
345
 
335
346
  async def _echo_input(self, raw: str, steering: bool = False) -> None:
336
- stream_printer = getattr(self, "_stream_printer", None)
337
- if stream_printer is not None:
338
- await stream_printer.commit_live_text()
339
- self._renderer.input_echo(self._prompt_label(), raw, steering=steering)
347
+ await self._presenter.write(lambda: self._renderer.input_echo(self._prompt_label(), raw, steering=steering))
340
348
 
341
349
  async def stream_events(
342
350
  self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]
@@ -346,16 +354,23 @@ class CliChannel(Interface):
346
354
  console=console,
347
355
  print_head=lambda: self._renderer.print_head(message.kind),
348
356
  expand_thinking=self._expand_thinking,
357
+ presenter=self._presenter,
358
+ invalidate=self._invalidate_prompt,
349
359
  )
350
360
  self._stream_printer = printer
361
+ self._invalidate_prompt()
351
362
  try:
352
- with tool_call_reporter(_CliToolCallReporter(self._renderer)):
363
+ with tool_call_reporter(_CliToolCallReporter(self._renderer, self._presenter)):
353
364
  async for event in stream:
354
365
  if await printer.render(event):
355
366
  yield event
356
367
  finally:
357
- if self._stream_printer is printer:
358
- self._stream_printer = None
368
+ try:
369
+ await printer.finish()
370
+ finally:
371
+ if self._stream_printer is printer:
372
+ self._stream_printer = None
373
+ self._invalidate_prompt()
359
374
 
360
375
  def _build_prompt(self, workspace: Path) -> PromptSession[str]:
361
376
  kb = KeyBindings()
@@ -374,14 +389,42 @@ class CliChannel(Interface):
374
389
  history = FileHistory(str(history_file))
375
390
  tool_names = sorted([*(f",{name}" for name in REGISTRY), ",thinking"], key=_tool_sort_key)
376
391
  completer = WordCompleter(tool_names, ignore_case=True, sentence=True)
377
- return PromptSession(
392
+ prompt: PromptSession[str] = PromptSession(
378
393
  completer=completer,
379
394
  complete_while_typing=True,
380
395
  key_bindings=kb,
381
396
  history=history,
382
397
  bottom_toolbar=self._render_bottom_toolbar,
383
398
  erase_when_done=True,
399
+ output=create_synchronized_output(),
400
+ )
401
+ self._attach_live_layout(prompt)
402
+ prompt.app.min_redraw_interval = _PROMPT_REFRESH_INTERVAL
403
+ return prompt
404
+
405
+ def _attach_live_layout(self, prompt: PromptSession[str]) -> None:
406
+ root = prompt.layout.container
407
+ if not isinstance(root, HSplit):
408
+ raise TypeError("PromptSession root layout must be an HSplit")
409
+ live_output = Window(
410
+ FormattedTextControl(
411
+ self._live_output_message,
412
+ show_cursor=False,
413
+ get_cursor_position=self._live_output_cursor,
414
+ ),
415
+ height=Dimension(min=0, weight=1),
416
+ wrap_lines=False,
417
+ always_hide_cursor=True,
418
+ )
419
+ generation_status = Window(
420
+ FormattedTextControl(self._generation_status),
421
+ height=1,
422
+ dont_extend_height=True,
384
423
  )
424
+ root.children[0:0] = [
425
+ ConditionalContainer(live_output, Condition(self._has_live_output)),
426
+ ConditionalContainer(generation_status, Condition(self._is_generating)),
427
+ ]
385
428
 
386
429
  def _render_bottom_toolbar(self) -> FormattedText:
387
430
  info = self._last_tape_info
@@ -396,10 +439,10 @@ class CliChannel(Interface):
396
439
  )
397
440
  return FormattedText([("", f"{left} {right}")])
398
441
 
399
- def _toggle_thinking(self) -> None:
442
+ async def _toggle_thinking(self) -> None:
400
443
  self._expand_thinking = not self._expand_thinking
401
444
  state = "expanded" if self._expand_thinking else "collapsed"
402
- self._renderer.info(f"Thinking output is now {state}.")
445
+ await self._presenter.write(lambda: self._renderer.info(f"Thinking output is now {state}."))
403
446
 
404
447
  def _invalidate_prompt(self) -> None:
405
448
  with contextlib.suppress(Exception):
@@ -409,8 +452,33 @@ class CliChannel(Interface):
409
452
  if self._llm_loop_running == running:
410
453
  return
411
454
  self._llm_loop_running = running
455
+ if running:
456
+ self._schedule_generation_tick()
457
+ else:
458
+ self._stop_generation_animation()
412
459
  self._invalidate_prompt()
413
460
 
461
+ def _schedule_generation_tick(self) -> None:
462
+ if getattr(self, "_generation_tick", None) is not None:
463
+ return
464
+ self._generation_tick = asyncio.get_running_loop().call_later(
465
+ _PROMPT_REFRESH_INTERVAL,
466
+ self._tick_generation_status,
467
+ )
468
+
469
+ def _tick_generation_status(self) -> None:
470
+ self._generation_tick = None
471
+ if not self._llm_loop_running:
472
+ return
473
+ self._invalidate_prompt()
474
+ self._schedule_generation_tick()
475
+
476
+ def _stop_generation_animation(self) -> None:
477
+ tick: asyncio.TimerHandle | None = getattr(self, "_generation_tick", None)
478
+ if tick is not None:
479
+ tick.cancel()
480
+ self._generation_tick = None
481
+
414
482
  @staticmethod
415
483
  def _history_file(home: Path, workspace: Path) -> Path:
416
484
  workspace_hash = md5(str(workspace).encode("utf-8"), usedforsecurity=False).hexdigest()