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.
- {bub-0.4.0 → bub-0.4.1}/PKG-INFO +1 -1
- {bub-0.4.0 → bub-0.4.1}/src/bub/_version.py +2 -2
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/codex_provider.py +8 -2
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/model_runner.py +9 -12
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/tape.py +24 -6
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/tools.py +10 -4
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/cli/__init__.py +177 -109
- bub-0.4.1/src/bub/channels/cli/ansi_bridge.py +23 -0
- bub-0.4.1/src/bub/channels/cli/terminal_output.py +140 -0
- bub-0.4.1/src/bub/channels/cli/writers.py +42 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/tools.py +12 -7
- {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_codex.py +8 -1
- {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_model_runner.py +43 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_tape.py +38 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_tools.py +22 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_channels.py +405 -44
- {bub-0.4.0 → bub-0.4.1}/.gitignore +0 -0
- {bub-0.4.0 → bub-0.4.1}/LICENSE +0 -0
- {bub-0.4.0 → bub-0.4.1}/README.md +0 -0
- {bub-0.4.0 → bub-0.4.1}/pyproject.toml +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/__init__.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/__main__.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/__init__.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/agent.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/auth.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/cli.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/context.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/hook_impl.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/settings.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/shell_manager.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/steering.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/builtin/store.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/__init__.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/admission.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/base.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/cli/renderer.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/contracts.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/handler.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/manager.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/message.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/channels/telegram.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/configure.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/envelope.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/errors.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/framework.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/__init__.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/interception.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/runtime.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/hooks/specs.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/inquirer.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/model_selection.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/skills.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/streaming.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/tape.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/turn.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/types.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/bub/utils.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/README.md +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/gh/SKILL.md +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/SKILL.md +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/scripts/telegram_rich.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/conftest.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_agent_hooks.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_agent.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_cli.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_builtin_hook_impl.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_cli_help.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_cli_renderer.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_configure.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_envelope.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_file_tape_store_entry_ids.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_fork_store_merge_back.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_framework.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_hook_runtime.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_image_message.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_settings.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_skills.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_subagent_tool.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_tape_search_output.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_tools.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_types.py +0 -0
- {bub-0.4.0 → bub-0.4.1}/tests/test_utils.py +0 -0
{bub-0.4.0 → bub-0.4.1}/PKG-INFO
RENAMED
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 4,
|
|
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,
|
|
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
|
-
|
|
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
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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__(
|
|
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.
|
|
48
|
-
self.
|
|
49
|
-
self.
|
|
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.
|
|
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
|
|
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.
|
|
92
|
-
await self.
|
|
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.
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
161
|
-
|
|
163
|
+
def commit() -> None:
|
|
164
|
+
self._console.print(finished)
|
|
165
|
+
self._writer.clear()
|
|
162
166
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) ->
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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()
|