tau-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from tau.tui.ansi import visible_width, wrap, RESET, BOLD, DIM
|
|
6
|
+
from tau.tui.component import Component
|
|
7
|
+
from tau.tui.diff import _is_diff
|
|
8
|
+
from tau.tui.input import InputEvent, KeyEvent
|
|
9
|
+
from tau.tui.markdown import render_markdown
|
|
10
|
+
from tau.tui.theme import MessageTheme
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tau.tool.types import Tool
|
|
14
|
+
|
|
15
|
+
_TOOL_INDENT = " "
|
|
16
|
+
_RESULT_INDENT = " "
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def apply_render_shell(lines: list[str], theme: Any, color_fn: Any = None) -> list[str]:
|
|
20
|
+
"""Apply the standard └ framing to a pre-rendered list of lines.
|
|
21
|
+
|
|
22
|
+
First line gets ' └ <line>', subsequent lines get ' <line>'.
|
|
23
|
+
Optional color_fn is applied to the first line (e.g. theme.error for red).
|
|
24
|
+
Shared by tool results and notify so any style change propagates everywhere.
|
|
25
|
+
"""
|
|
26
|
+
if not lines:
|
|
27
|
+
return []
|
|
28
|
+
first = color_fn(lines[0]) if color_fn else lines[0]
|
|
29
|
+
out = [f"{_RESULT_INDENT}{theme.dim('└')} {first}"]
|
|
30
|
+
out.extend(f"{_RESULT_INDENT} {l}" for l in lines[1:])
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── MessageBlock ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
class MessageBlock:
|
|
37
|
+
"""
|
|
38
|
+
Cached rendering of a single message (any type).
|
|
39
|
+
|
|
40
|
+
Pass a MessageTheme to control all colours. Call invalidate() whenever
|
|
41
|
+
the underlying message changes (e.g. a streaming token arrives).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
message: object,
|
|
47
|
+
streaming: bool = False,
|
|
48
|
+
theme: MessageTheme | None = None,
|
|
49
|
+
user_prefix: str = "❯ ",
|
|
50
|
+
tool_lookup: Callable[[str], "Tool | None"] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._message = message
|
|
53
|
+
self._streaming = streaming
|
|
54
|
+
self._expanded = False
|
|
55
|
+
self._theme = theme or MessageTheme()
|
|
56
|
+
self._user_prefix = user_prefix
|
|
57
|
+
self._tool_lookup = tool_lookup
|
|
58
|
+
self._cached: list[str] | None = None
|
|
59
|
+
self._cached_width = 0
|
|
60
|
+
# Keyed by (content_idx, image_idx) — persisted so Kitty image IDs stay stable
|
|
61
|
+
self._image_components: dict[tuple[int, int], Any] = {}
|
|
62
|
+
self._tool_results_cache: list[str] | None = None
|
|
63
|
+
self._tool_results_message: object | None = None
|
|
64
|
+
self._tool_results_width = 0
|
|
65
|
+
|
|
66
|
+
# -------------------------------------------------------------------------
|
|
67
|
+
# Public API
|
|
68
|
+
# -------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def invalidate(self) -> None:
|
|
71
|
+
self._cached = None
|
|
72
|
+
self._tool_results_cache = None
|
|
73
|
+
self._tool_results_message = None
|
|
74
|
+
|
|
75
|
+
def toggle_expanded(self) -> None:
|
|
76
|
+
self._expanded = not self._expanded
|
|
77
|
+
self.invalidate()
|
|
78
|
+
|
|
79
|
+
def set_streaming(self, value: bool) -> None:
|
|
80
|
+
if self._streaming != value:
|
|
81
|
+
self._streaming = value
|
|
82
|
+
self.invalidate()
|
|
83
|
+
|
|
84
|
+
def set_theme(self, theme: MessageTheme) -> None:
|
|
85
|
+
self._theme = theme
|
|
86
|
+
self.invalidate()
|
|
87
|
+
|
|
88
|
+
def set_user_prefix(self, prefix: str) -> None:
|
|
89
|
+
if self._user_prefix != prefix:
|
|
90
|
+
self._user_prefix = prefix
|
|
91
|
+
self.invalidate()
|
|
92
|
+
|
|
93
|
+
def set_tool_lookup(self, fn: Callable[[str], "Tool | None"] | None) -> None:
|
|
94
|
+
self._tool_lookup = fn
|
|
95
|
+
self.invalidate()
|
|
96
|
+
|
|
97
|
+
def _render_image(self, key: tuple[int, int], b64: str, mime: str, width: int) -> list[str]:
|
|
98
|
+
if not self._theme.show_images:
|
|
99
|
+
from tau.tui.components.image import Image
|
|
100
|
+
return [Image(b64, mime)._fallback_text()]
|
|
101
|
+
if key not in self._image_components:
|
|
102
|
+
from tau.tui.components.image import Image
|
|
103
|
+
self._image_components[key] = Image(b64, mime)
|
|
104
|
+
return self._image_components[key].render(width)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def message(self) -> object:
|
|
108
|
+
return self._message
|
|
109
|
+
|
|
110
|
+
# -------------------------------------------------------------------------
|
|
111
|
+
# Rendering
|
|
112
|
+
# -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def render(self, width: int) -> list[str]:
|
|
115
|
+
if self._cached is not None and self._cached_width == width:
|
|
116
|
+
return self._cached
|
|
117
|
+
self._cached = self._build(width)
|
|
118
|
+
self._cached_width = width
|
|
119
|
+
return self._cached
|
|
120
|
+
|
|
121
|
+
def render_with_tool_results(self, tool_message: object, width: int) -> list[str]:
|
|
122
|
+
from tau.message.types import AssistantMessage, ToolMessage, ToolResultContent
|
|
123
|
+
|
|
124
|
+
if not isinstance(self._message, AssistantMessage) or not isinstance(tool_message, ToolMessage):
|
|
125
|
+
return self.render(width)
|
|
126
|
+
if (
|
|
127
|
+
self._tool_results_cache is not None
|
|
128
|
+
and self._tool_results_message is tool_message
|
|
129
|
+
and self._tool_results_width == width
|
|
130
|
+
):
|
|
131
|
+
return self._tool_results_cache
|
|
132
|
+
|
|
133
|
+
results = {
|
|
134
|
+
item.id: item
|
|
135
|
+
for item in tool_message.contents
|
|
136
|
+
if isinstance(item, ToolResultContent)
|
|
137
|
+
}
|
|
138
|
+
lines = self._render_assistant(self._message, width, results)
|
|
139
|
+
|
|
140
|
+
matched_ids = {
|
|
141
|
+
item.id
|
|
142
|
+
for item in self._message.tool_calls()
|
|
143
|
+
if item.id in results
|
|
144
|
+
}
|
|
145
|
+
for item in tool_message.contents:
|
|
146
|
+
if isinstance(item, ToolResultContent) and item.id not in matched_ids:
|
|
147
|
+
lines.extend(self._render_tool_result(item, width, item.tool_name))
|
|
148
|
+
|
|
149
|
+
lines.append("")
|
|
150
|
+
self._tool_results_cache = lines
|
|
151
|
+
self._tool_results_message = tool_message
|
|
152
|
+
self._tool_results_width = width
|
|
153
|
+
return lines
|
|
154
|
+
|
|
155
|
+
def _build(self, width: int) -> list[str]:
|
|
156
|
+
from tau.message.types import (
|
|
157
|
+
AssistantMessage, UserMessage, ToolMessage,
|
|
158
|
+
CustomMessage, TerminalExecutionMessage,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
msg = self._message
|
|
162
|
+
lines: list[str] = []
|
|
163
|
+
|
|
164
|
+
if isinstance(msg, UserMessage):
|
|
165
|
+
lines.extend(self._render_user(msg, width))
|
|
166
|
+
elif isinstance(msg, AssistantMessage):
|
|
167
|
+
lines.extend(self._render_assistant(msg, width))
|
|
168
|
+
elif isinstance(msg, ToolMessage):
|
|
169
|
+
lines.extend(self._render_tool_message(msg, width))
|
|
170
|
+
elif isinstance(msg, TerminalExecutionMessage):
|
|
171
|
+
lines.extend(self._render_terminal(msg, width))
|
|
172
|
+
elif isinstance(msg, CustomMessage):
|
|
173
|
+
lines.extend(self._render_custom(msg, width))
|
|
174
|
+
else:
|
|
175
|
+
from tau.message.types import TemplateInvocationMessage, SkillInvocationMessage
|
|
176
|
+
if isinstance(msg, TemplateInvocationMessage):
|
|
177
|
+
lines.extend(self._render_template_invocation(msg, width))
|
|
178
|
+
elif isinstance(msg, SkillInvocationMessage):
|
|
179
|
+
lines.extend(self._render_skill_invocation(msg, width))
|
|
180
|
+
else:
|
|
181
|
+
lines.append(self._theme.dim(str(msg)))
|
|
182
|
+
|
|
183
|
+
from tau.message.types import (
|
|
184
|
+
TemplateInvocationMessage, SkillInvocationMessage, UserMessage, TextContent,
|
|
185
|
+
)
|
|
186
|
+
is_command = (
|
|
187
|
+
isinstance(msg, UserMessage)
|
|
188
|
+
and any(
|
|
189
|
+
isinstance(c, TextContent) and c.content.lstrip().startswith("/")
|
|
190
|
+
for c in msg.contents
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
if not isinstance(msg, (CustomMessage, TemplateInvocationMessage, SkillInvocationMessage)) and not is_command:
|
|
194
|
+
lines.append("") # blank separator after each message
|
|
195
|
+
return lines
|
|
196
|
+
|
|
197
|
+
# -------------------------------------------------------------------------
|
|
198
|
+
# Per-type renderers
|
|
199
|
+
# -------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def _render_user(self, msg: Any, width: int) -> list[str]:
|
|
202
|
+
from tau.message.types import UserMessage, TextContent, ImageContent
|
|
203
|
+
if not isinstance(msg, UserMessage):
|
|
204
|
+
return []
|
|
205
|
+
t = self._theme
|
|
206
|
+
prefix = self._user_prefix
|
|
207
|
+
inner_width = max(1, width - visible_width(prefix))
|
|
208
|
+
lines: list[str] = []
|
|
209
|
+
for c_idx, item in enumerate(msg.contents):
|
|
210
|
+
if isinstance(item, TextContent) and item.content:
|
|
211
|
+
for line in wrap(item.content.rstrip(), inner_width):
|
|
212
|
+
lead = t.you_label(prefix) if not lines else " "
|
|
213
|
+
lines.append(lead + line)
|
|
214
|
+
elif isinstance(item, ImageContent):
|
|
215
|
+
for i_idx, (b64, mime) in enumerate(item.to_base64()):
|
|
216
|
+
lines.extend(self._render_image((c_idx, i_idx), b64, mime, inner_width))
|
|
217
|
+
return lines
|
|
218
|
+
|
|
219
|
+
def _render_assistant(
|
|
220
|
+
self,
|
|
221
|
+
msg: Any,
|
|
222
|
+
width: int,
|
|
223
|
+
tool_results: dict[str, Any] | None = None,
|
|
224
|
+
) -> list[str]:
|
|
225
|
+
from tau.message.types import (
|
|
226
|
+
AssistantMessage, TextContent, ThinkingContent, ToolCallContent,
|
|
227
|
+
)
|
|
228
|
+
from tau.inference.types import StopReason
|
|
229
|
+
if not isinstance(msg, AssistantMessage):
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
t = self._theme
|
|
233
|
+
inner_width = max(1, width - 2)
|
|
234
|
+
tool_indent_w = max(1, width - len(_TOOL_INDENT) - 2)
|
|
235
|
+
lines: list[str] = []
|
|
236
|
+
|
|
237
|
+
has_content = any(
|
|
238
|
+
(isinstance(c, TextContent) and c.content)
|
|
239
|
+
or isinstance(c, (ThinkingContent, ToolCallContent))
|
|
240
|
+
for c in msg.contents
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if not has_content and msg.stop_reason == StopReason.Error and msg.error:
|
|
244
|
+
lines.append(t.error_label("error"))
|
|
245
|
+
for line in wrap(msg.error, inner_width):
|
|
246
|
+
lines.append(" " + line)
|
|
247
|
+
return lines
|
|
248
|
+
|
|
249
|
+
# No "assistant" label — the content speaks for itself.
|
|
250
|
+
from tau.message.types import ImageContent as _ImageContent
|
|
251
|
+
for idx, item in enumerate(msg.contents):
|
|
252
|
+
if isinstance(item, ThinkingContent):
|
|
253
|
+
if t.show_thinking:
|
|
254
|
+
if item.content:
|
|
255
|
+
for line in wrap(item.content.rstrip(), inner_width):
|
|
256
|
+
lines.append(" " + t.thinking(line))
|
|
257
|
+
else:
|
|
258
|
+
lines.append(" " + t.thinking(t.thinking_label))
|
|
259
|
+
next_item = msg.contents[idx + 1] if idx + 1 < len(msg.contents) else None
|
|
260
|
+
if not isinstance(next_item, ThinkingContent):
|
|
261
|
+
lines.append("")
|
|
262
|
+
|
|
263
|
+
elif isinstance(item, TextContent) and item.content:
|
|
264
|
+
for line in render_markdown(item.content.rstrip(), inner_width, t.markdown):
|
|
265
|
+
lines.append(" " + line)
|
|
266
|
+
|
|
267
|
+
elif isinstance(item, _ImageContent):
|
|
268
|
+
for i_idx, (b64, mime) in enumerate(item.to_base64()):
|
|
269
|
+
lines.extend(self._render_image((idx, i_idx), b64, mime, inner_width))
|
|
270
|
+
|
|
271
|
+
elif isinstance(item, ToolCallContent):
|
|
272
|
+
if t.show_tool_calls:
|
|
273
|
+
tool = self._tool_lookup(item.name) if self._tool_lookup else None
|
|
274
|
+
if tool is not None and tool.render_call is not None:
|
|
275
|
+
custom = tool.render_call(item.args, self._streaming)
|
|
276
|
+
if custom:
|
|
277
|
+
lines.extend(custom)
|
|
278
|
+
else:
|
|
279
|
+
from tau.tool.render import display_name, call_line
|
|
280
|
+
if item.args:
|
|
281
|
+
first_val = next(iter(item.args.values()), "")
|
|
282
|
+
lines.extend(call_line(item.name, str(first_val) if first_val else ""))
|
|
283
|
+
else:
|
|
284
|
+
lines.append(f"{_TOOL_INDENT}{BOLD}{display_name(item.name)}{RESET}")
|
|
285
|
+
if tool_results is not None and item.id in tool_results:
|
|
286
|
+
lines.extend(self._render_tool_result(tool_results[item.id], width, item.name))
|
|
287
|
+
# Separate consecutive tool-call blocks with a blank line so
|
|
288
|
+
# they don't render flush against each other (mirrors how
|
|
289
|
+
# ThinkingContent spaces itself above).
|
|
290
|
+
next_item = msg.contents[idx + 1] if idx + 1 < len(msg.contents) else None
|
|
291
|
+
if isinstance(next_item, ToolCallContent):
|
|
292
|
+
lines.append("")
|
|
293
|
+
|
|
294
|
+
if self._streaming:
|
|
295
|
+
cursor = t.stream_cursor("▋")
|
|
296
|
+
if lines:
|
|
297
|
+
lines[-1] = lines[-1] + cursor
|
|
298
|
+
else:
|
|
299
|
+
lines.append(" " + cursor)
|
|
300
|
+
elif msg.stop_reason == StopReason.Abort:
|
|
301
|
+
lines.append(" " + t.dim("┌ User Interrupted"))
|
|
302
|
+
|
|
303
|
+
return lines
|
|
304
|
+
|
|
305
|
+
def _render_terminal(self, msg: Any, width: int) -> list[str]:
|
|
306
|
+
from tau.message.types import TerminalExecutionMessage
|
|
307
|
+
from tau.tui.ansi import BRIGHT_RED
|
|
308
|
+
if not isinstance(msg, TerminalExecutionMessage):
|
|
309
|
+
return []
|
|
310
|
+
t = self._theme
|
|
311
|
+
label = t.dim("$ " + msg.command)
|
|
312
|
+
if msg.cancelled:
|
|
313
|
+
label += " " + BRIGHT_RED + "(cancelled)" + RESET
|
|
314
|
+
elif msg.exit_code is not None and msg.exit_code != 0:
|
|
315
|
+
label += " " + BRIGHT_RED + f"(exit {msg.exit_code})" + RESET
|
|
316
|
+
lines = [label]
|
|
317
|
+
if msg.output:
|
|
318
|
+
for line in msg.output.rstrip().split("\n"):
|
|
319
|
+
lines.append(" " + t.dim(line))
|
|
320
|
+
if self._streaming:
|
|
321
|
+
lines.append(" " + t.stream_cursor("▋"))
|
|
322
|
+
return lines
|
|
323
|
+
|
|
324
|
+
def _render_custom(self, msg: Any, width: int) -> list[str]:
|
|
325
|
+
from tau.message.types import CustomMessage, TextContent, LinesContent
|
|
326
|
+
if not isinstance(msg, CustomMessage):
|
|
327
|
+
return []
|
|
328
|
+
from tau.tui.message_renderers import message_renderer_registry
|
|
329
|
+
custom = message_renderer_registry.render(msg, self._theme, width)
|
|
330
|
+
if custom is not None:
|
|
331
|
+
return custom
|
|
332
|
+
t = self._theme
|
|
333
|
+
for item in msg.contents:
|
|
334
|
+
if isinstance(item, LinesContent):
|
|
335
|
+
color_fn = t.tool_result_err if item.notify_type == "error" else None
|
|
336
|
+
return apply_render_shell(item.lines, t, color_fn)
|
|
337
|
+
if isinstance(item, TextContent) and item.content:
|
|
338
|
+
lines = wrap(item.content.rstrip(), max(1, width - visible_width(_RESULT_INDENT) - 4))
|
|
339
|
+
return apply_render_shell([l for l in lines], t)
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
def _render_template_invocation(self, msg: Any, width: int) -> list[str]:
|
|
343
|
+
from tau.message.types import TemplateInvocationMessage
|
|
344
|
+
from tau.tui.ansi import BOLD, RESET, DIM
|
|
345
|
+
if not isinstance(msg, TemplateInvocationMessage):
|
|
346
|
+
return []
|
|
347
|
+
t = self._theme
|
|
348
|
+
label = BOLD + t.tool_arrow("[template]") + RESET
|
|
349
|
+
if msg.expanded:
|
|
350
|
+
lines = [f" {label}"]
|
|
351
|
+
header = f" {BOLD}{msg.name}{RESET}"
|
|
352
|
+
if msg.args:
|
|
353
|
+
header += t.dim(f" {msg.args}")
|
|
354
|
+
lines.append(header)
|
|
355
|
+
lines.append("")
|
|
356
|
+
for line in msg.expanded_content.splitlines():
|
|
357
|
+
lines.append(f" {t.dim(line) if line.strip() == '' else line}")
|
|
358
|
+
lines.append("")
|
|
359
|
+
else:
|
|
360
|
+
hint = t.dim(" (ctrl+o to expand)")
|
|
361
|
+
name_args = msg.name + (f" {msg.args}" if msg.args else "")
|
|
362
|
+
lines = [f" {label} {name_args}{hint}"]
|
|
363
|
+
return lines
|
|
364
|
+
|
|
365
|
+
def _render_skill_invocation(self, msg: Any, width: int) -> list[str]:
|
|
366
|
+
from tau.message.types import SkillInvocationMessage
|
|
367
|
+
from tau.tui.ansi import BOLD, RESET
|
|
368
|
+
if not isinstance(msg, SkillInvocationMessage):
|
|
369
|
+
return []
|
|
370
|
+
t = self._theme
|
|
371
|
+
label = BOLD + t.tool_arrow("[skill]") + RESET
|
|
372
|
+
if msg.expanded:
|
|
373
|
+
lines = [f" {label}"]
|
|
374
|
+
header = f" {BOLD}{msg.name}{RESET}"
|
|
375
|
+
if msg.args:
|
|
376
|
+
header += t.dim(f" {msg.args}")
|
|
377
|
+
lines.append(header)
|
|
378
|
+
lines.append("")
|
|
379
|
+
for line in msg.content.splitlines():
|
|
380
|
+
lines.append(f" {line}")
|
|
381
|
+
lines.append("")
|
|
382
|
+
else:
|
|
383
|
+
hint = t.dim(" (ctrl+o to expand)")
|
|
384
|
+
name_args = msg.name + (f" {msg.args}" if msg.args else "")
|
|
385
|
+
lines = [f" {label} {name_args}{hint}"]
|
|
386
|
+
return lines
|
|
387
|
+
|
|
388
|
+
def _render_tool_message(self, msg: Any, width: int) -> list[str]:
|
|
389
|
+
from tau.message.types import ToolMessage, ToolResultContent
|
|
390
|
+
if not isinstance(msg, ToolMessage):
|
|
391
|
+
return []
|
|
392
|
+
lines: list[str] = []
|
|
393
|
+
|
|
394
|
+
for item in msg.contents:
|
|
395
|
+
if isinstance(item, ToolResultContent):
|
|
396
|
+
lines.extend(self._render_tool_result(item, width, item.tool_name))
|
|
397
|
+
|
|
398
|
+
return lines
|
|
399
|
+
|
|
400
|
+
def _render_tool_result(self, item: Any, width: int, tool_name: str = "") -> list[str]:
|
|
401
|
+
from tau.message.types import ToolResultContent
|
|
402
|
+
if not isinstance(item, ToolResultContent):
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
tool = self._tool_lookup(tool_name) if (self._tool_lookup and tool_name) else None
|
|
406
|
+
if tool is not None and tool.render_result is not None:
|
|
407
|
+
from tau.tool.types import ToolRenderOptions
|
|
408
|
+
opts = ToolRenderOptions(
|
|
409
|
+
is_error=item.is_error,
|
|
410
|
+
expanded=self._expanded,
|
|
411
|
+
is_partial=self._streaming,
|
|
412
|
+
metadata=item.metadata,
|
|
413
|
+
)
|
|
414
|
+
custom = tool.render_result(item.content, opts)
|
|
415
|
+
if custom:
|
|
416
|
+
# A custom renderer must return one terminal line per element.
|
|
417
|
+
# Defensively flatten any embedded newlines so the differential
|
|
418
|
+
# renderer's per-line height accounting stays correct — a single
|
|
419
|
+
# element spanning multiple rows otherwise corrupts the diff.
|
|
420
|
+
if any("\n" in str(c) for c in custom):
|
|
421
|
+
custom = [seg for c in custom for seg in str(c).split("\n")]
|
|
422
|
+
if tool.render_shell == "default":
|
|
423
|
+
t = self._theme
|
|
424
|
+
color_fn = t.tool_result_err if item.is_error else t.tool_result_ok
|
|
425
|
+
framed = list(custom)
|
|
426
|
+
framed[0] = color_fn(framed[0])
|
|
427
|
+
lines = apply_render_shell(framed, t)
|
|
428
|
+
else:
|
|
429
|
+
lines = list(custom)
|
|
430
|
+
lines.extend(_render_extra_blocks(item.metadata, self._expanded, self._theme))
|
|
431
|
+
return lines
|
|
432
|
+
|
|
433
|
+
t = self._theme
|
|
434
|
+
color_fn = t.tool_result_err if item.is_error else t.tool_result_ok
|
|
435
|
+
content = item.content.strip() if item.content else ""
|
|
436
|
+
all_lines = content.split("\n") if content else []
|
|
437
|
+
if not all_lines:
|
|
438
|
+
rendered = [color_fn("(no output)")]
|
|
439
|
+
elif not item.is_error and _is_diff(content):
|
|
440
|
+
from tau.tui.diff import render_diff
|
|
441
|
+
diff_lines = render_diff(
|
|
442
|
+
content,
|
|
443
|
+
added=t.diff_added,
|
|
444
|
+
removed=t.diff_removed,
|
|
445
|
+
context=t.diff_context,
|
|
446
|
+
hunk=t.diff_hunk,
|
|
447
|
+
inverse=t.diff_inverse,
|
|
448
|
+
)
|
|
449
|
+
if self._expanded or len(diff_lines) <= 3:
|
|
450
|
+
rendered = diff_lines or [color_fn("(empty diff)")]
|
|
451
|
+
else:
|
|
452
|
+
rendered = diff_lines[:3] + [t.dim(f"({len(diff_lines)} lines — ctrl+o to expand)")]
|
|
453
|
+
elif self._expanded or len(all_lines) == 1:
|
|
454
|
+
rendered = [color_fn(all_lines[0])] + [t.dim(l) for l in all_lines[1:]]
|
|
455
|
+
else:
|
|
456
|
+
rendered = [color_fn(all_lines[0]), t.dim(f"({len(all_lines)} lines)")]
|
|
457
|
+
lines = apply_render_shell(rendered, t)
|
|
458
|
+
lines.extend(_render_extra_blocks(item.metadata, self._expanded, self._theme))
|
|
459
|
+
return lines
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _render_extra_blocks(metadata: dict, expanded: bool, theme: Any) -> list[str]:
|
|
463
|
+
"""Render generic extension blocks appended below any tool result."""
|
|
464
|
+
blocks = (metadata or {}).get("_extra_blocks")
|
|
465
|
+
if not blocks:
|
|
466
|
+
return []
|
|
467
|
+
lines: list[str] = []
|
|
468
|
+
for block in blocks:
|
|
469
|
+
block_lines: list[str] = block.get("expanded" if expanded else "collapsed") or []
|
|
470
|
+
if not block_lines:
|
|
471
|
+
continue
|
|
472
|
+
lines.append(f"{_RESULT_INDENT}{theme.dim('└')} {block_lines[0]}")
|
|
473
|
+
lines.extend(f"{_RESULT_INDENT} {l}" for l in block_lines[1:])
|
|
474
|
+
return lines
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ── MessageList ───────────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
class MessageList(Component):
|
|
480
|
+
"""
|
|
481
|
+
Scrollable list of MessageBlock objects rendered inside a fixed-height
|
|
482
|
+
viewport. Pass a MessageTheme to MessageList to apply it to all new blocks.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(
|
|
486
|
+
self,
|
|
487
|
+
height: int = 20,
|
|
488
|
+
theme: MessageTheme | None = None,
|
|
489
|
+
user_prefix: str = "❯ ",
|
|
490
|
+
) -> None:
|
|
491
|
+
self._blocks: list[MessageBlock] = []
|
|
492
|
+
self._height = height
|
|
493
|
+
self._scroll = 0
|
|
494
|
+
self._auto_scroll = True
|
|
495
|
+
self._focused = False
|
|
496
|
+
self._theme = theme or MessageTheme()
|
|
497
|
+
self._user_prefix = user_prefix
|
|
498
|
+
self._tool_lookup: Callable[[str], "Tool | None"] | None = None
|
|
499
|
+
|
|
500
|
+
# -------------------------------------------------------------------------
|
|
501
|
+
# Public API
|
|
502
|
+
# -------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
def set_height(self, height: int) -> None:
|
|
505
|
+
self._height = max(1, height)
|
|
506
|
+
|
|
507
|
+
def set_theme(self, theme: MessageTheme) -> None:
|
|
508
|
+
self._theme = theme
|
|
509
|
+
for block in self._blocks:
|
|
510
|
+
block.set_theme(theme)
|
|
511
|
+
|
|
512
|
+
def set_user_prefix(self, prefix: str) -> None:
|
|
513
|
+
self._user_prefix = prefix
|
|
514
|
+
for block in self._blocks:
|
|
515
|
+
block.set_user_prefix(prefix)
|
|
516
|
+
|
|
517
|
+
def set_tool_lookup(self, fn: Callable[[str], "Tool | None"] | None) -> None:
|
|
518
|
+
self._tool_lookup = fn
|
|
519
|
+
for block in self._blocks:
|
|
520
|
+
block.set_tool_lookup(fn)
|
|
521
|
+
|
|
522
|
+
def toggle_tool_results_expanded(self) -> None:
|
|
523
|
+
"""Ctrl+E — toggle expanded/collapsed view for all tool result blocks."""
|
|
524
|
+
from tau.message.types import AssistantMessage, ToolMessage
|
|
525
|
+
targets = [
|
|
526
|
+
b for b in self._blocks
|
|
527
|
+
if isinstance(b.message, (AssistantMessage, ToolMessage))
|
|
528
|
+
]
|
|
529
|
+
if not targets:
|
|
530
|
+
return
|
|
531
|
+
new_state = not targets[-1]._expanded # type: ignore[union-attr]
|
|
532
|
+
for b in targets:
|
|
533
|
+
b._expanded = new_state
|
|
534
|
+
b.invalidate()
|
|
535
|
+
|
|
536
|
+
def toggle_invocations_expanded(self) -> None:
|
|
537
|
+
"""Ctrl+O — toggle expand/collapse for all template and skill invocation blocks."""
|
|
538
|
+
from tau.message.types import TemplateInvocationMessage, SkillInvocationMessage
|
|
539
|
+
targets = [
|
|
540
|
+
b for b in self._blocks
|
|
541
|
+
if isinstance(b.message, (TemplateInvocationMessage, SkillInvocationMessage))
|
|
542
|
+
]
|
|
543
|
+
if not targets:
|
|
544
|
+
return
|
|
545
|
+
new_state = not targets[-1].message.expanded # type: ignore[union-attr]
|
|
546
|
+
for b in targets:
|
|
547
|
+
b.message.expanded = new_state # type: ignore[union-attr]
|
|
548
|
+
b.invalidate()
|
|
549
|
+
|
|
550
|
+
def add_block(self, block: MessageBlock) -> None:
|
|
551
|
+
self._blocks.append(block)
|
|
552
|
+
if self._auto_scroll:
|
|
553
|
+
self._scroll = 0
|
|
554
|
+
|
|
555
|
+
def remove_last(self) -> bool:
|
|
556
|
+
"""Remove the last block (used to undo a user message on pre-stream abort)."""
|
|
557
|
+
if self._blocks:
|
|
558
|
+
self._blocks.pop()
|
|
559
|
+
return True
|
|
560
|
+
return False
|
|
561
|
+
|
|
562
|
+
def remove_pending_user_turn(self) -> bool:
|
|
563
|
+
"""Pop trailing blocks up to and including the most recent user message.
|
|
564
|
+
|
|
565
|
+
Used to undo a pre-stream abort. ``message_start`` may have already added
|
|
566
|
+
an empty assistant placeholder block (the model began a message but no
|
|
567
|
+
token arrived yet), so removing only the last block would drop that
|
|
568
|
+
placeholder and leave the user message visible. This removes both.
|
|
569
|
+
Returns True if a user message was removed.
|
|
570
|
+
"""
|
|
571
|
+
from tau.message.types import UserMessage
|
|
572
|
+
while self._blocks:
|
|
573
|
+
block = self._blocks.pop()
|
|
574
|
+
if isinstance(block.message, UserMessage):
|
|
575
|
+
return True
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
def clear(self) -> None:
|
|
579
|
+
self._blocks.clear()
|
|
580
|
+
self._scroll = 0
|
|
581
|
+
self._auto_scroll = True
|
|
582
|
+
|
|
583
|
+
def add_message(self, message: object, streaming: bool = False) -> MessageBlock:
|
|
584
|
+
block = MessageBlock(
|
|
585
|
+
message,
|
|
586
|
+
streaming=streaming,
|
|
587
|
+
theme=self._theme,
|
|
588
|
+
user_prefix=self._user_prefix,
|
|
589
|
+
tool_lookup=self._tool_lookup,
|
|
590
|
+
)
|
|
591
|
+
self.add_block(block)
|
|
592
|
+
return block
|
|
593
|
+
|
|
594
|
+
def set_focused(self, focused: bool) -> None:
|
|
595
|
+
self._focused = focused
|
|
596
|
+
|
|
597
|
+
def scroll_up(self, n: int = 1) -> None:
|
|
598
|
+
self._scroll += n
|
|
599
|
+
self._auto_scroll = False
|
|
600
|
+
|
|
601
|
+
def scroll_down(self, n: int = 1) -> None:
|
|
602
|
+
self._scroll = max(0, self._scroll - n)
|
|
603
|
+
if self._scroll == 0:
|
|
604
|
+
self._auto_scroll = True
|
|
605
|
+
|
|
606
|
+
def scroll_to_bottom(self) -> None:
|
|
607
|
+
self._scroll = 0
|
|
608
|
+
self._auto_scroll = True
|
|
609
|
+
|
|
610
|
+
def scroll_to_top(self) -> None:
|
|
611
|
+
self._auto_scroll = False
|
|
612
|
+
self._scroll = 999_999
|
|
613
|
+
|
|
614
|
+
@property
|
|
615
|
+
def at_bottom(self) -> bool:
|
|
616
|
+
return self._scroll == 0
|
|
617
|
+
|
|
618
|
+
# -------------------------------------------------------------------------
|
|
619
|
+
# Component
|
|
620
|
+
# -------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
def all_lines(self, width: int) -> list[str]:
|
|
623
|
+
return self._render_blocks(width)
|
|
624
|
+
|
|
625
|
+
def render(self, width: int) -> list[str]:
|
|
626
|
+
# In scrollback mode the terminal's own buffer handles scrolling.
|
|
627
|
+
# Return all rendered lines without any clipping or top-padding so that
|
|
628
|
+
# (a) no blank lines appear above content when the list is short, and
|
|
629
|
+
# (b) old messages naturally flow into the terminal's scrollback buffer
|
|
630
|
+
# as new content pushes them off the visible viewport.
|
|
631
|
+
return self._render_blocks(width)
|
|
632
|
+
|
|
633
|
+
def _render_blocks(self, width: int) -> list[str]:
|
|
634
|
+
from tau.message.types import AssistantMessage, ToolCallContent, ToolMessage
|
|
635
|
+
|
|
636
|
+
lines: list[str] = []
|
|
637
|
+
index = 0
|
|
638
|
+
while index < len(self._blocks):
|
|
639
|
+
block = self._blocks[index]
|
|
640
|
+
next_message = (
|
|
641
|
+
self._blocks[index + 1].message
|
|
642
|
+
if index + 1 < len(self._blocks)
|
|
643
|
+
else None
|
|
644
|
+
)
|
|
645
|
+
message = block.message
|
|
646
|
+
followed_by_tool_result = (
|
|
647
|
+
isinstance(message, AssistantMessage)
|
|
648
|
+
and any(isinstance(item, ToolCallContent) for item in message.contents)
|
|
649
|
+
and isinstance(next_message, ToolMessage)
|
|
650
|
+
)
|
|
651
|
+
if followed_by_tool_result:
|
|
652
|
+
lines.extend(block.render_with_tool_results(next_message, width))
|
|
653
|
+
index += 2
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
lines.extend(block.render(width))
|
|
657
|
+
index += 1
|
|
658
|
+
return lines
|
|
659
|
+
|
|
660
|
+
def handle_input(self, event: InputEvent) -> bool:
|
|
661
|
+
if not self._focused or not isinstance(event, KeyEvent):
|
|
662
|
+
return False
|
|
663
|
+
match str(event):
|
|
664
|
+
case "page_up" | "b": self.scroll_up(self._height)
|
|
665
|
+
case "page_down" | "space": self.scroll_down(self._height)
|
|
666
|
+
case "up" | "k": self.scroll_up(1)
|
|
667
|
+
case "down" | "j": self.scroll_down(1)
|
|
668
|
+
case "end" | "shift+g": self.scroll_to_bottom()
|
|
669
|
+
case "home" | "g": self.scroll_to_top()
|
|
670
|
+
case _: return False
|
|
671
|
+
return True
|
|
672
|
+
|
|
673
|
+
def invalidate(self) -> None:
|
|
674
|
+
for block in self._blocks:
|
|
675
|
+
block.invalidate()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ── Arg formatter ─────────────────────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
def _format_args(args: dict, max_width: int) -> str:
|
|
681
|
+
if not args:
|
|
682
|
+
return ""
|
|
683
|
+
parts = []
|
|
684
|
+
for k, v in args.items():
|
|
685
|
+
v_str = str(v)
|
|
686
|
+
if len(v_str) > 40:
|
|
687
|
+
v_str = v_str[:37] + "…"
|
|
688
|
+
parts.append(f"{k}={v_str}")
|
|
689
|
+
result = " ".join(parts)
|
|
690
|
+
if len(result) > max_width:
|
|
691
|
+
result = result[: max_width - 1] + "…"
|
|
692
|
+
return result
|