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
tau/tui/app.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Callable, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from tau.tui.input import KeyEvent, InputEvent
|
|
7
|
+
from tau.tui.components.layout import Layout
|
|
8
|
+
from tau.tui.keybindings import KeyMap, configure_keybindings
|
|
9
|
+
from tau.tui.theme import LayoutTheme
|
|
10
|
+
from tau.tui.tui import TUI
|
|
11
|
+
from tau.tui.commands.context import CommandContext
|
|
12
|
+
from tau.tui.agent_hooks import AgentHookHandler
|
|
13
|
+
from tau.tui.input_handler import InputHandler
|
|
14
|
+
from tau.extensions import ExtensionContext
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from tau.runtime.service import Runtime
|
|
18
|
+
from tau.runtime.types import RuntimeConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class App:
|
|
22
|
+
"""
|
|
23
|
+
Wires the TUI layout to the agent runtime.
|
|
24
|
+
|
|
25
|
+
Delegates to focused collaborators:
|
|
26
|
+
- AgentHookHandler — subscribes to agent events, drives spinner/messages
|
|
27
|
+
- InputHandler — submit, paste, clipboard, steer, history
|
|
28
|
+
- tau.tui.commands.* — slash command logic, each receiving a CommandContext
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
config = RuntimeConfig(cwd=Path.cwd(), model_id="claude-sonnet-4-6")
|
|
33
|
+
app = await App.create(config)
|
|
34
|
+
await app.run()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, runtime: Runtime, tui: TUI, layout: Layout) -> None:
|
|
38
|
+
self._runtime = runtime
|
|
39
|
+
self._tui = tui
|
|
40
|
+
self._layout = layout
|
|
41
|
+
self._input = InputHandler(runtime, layout, tui)
|
|
42
|
+
self._hooks = AgentHookHandler(
|
|
43
|
+
runtime, layout, tui,
|
|
44
|
+
on_palette_refresh=self.refresh_palette,
|
|
45
|
+
on_turn_content=self._input.mark_turn_content,
|
|
46
|
+
)
|
|
47
|
+
self._unsubs: list[Callable[[], None]] = []
|
|
48
|
+
self._last_ctrl_c: float = 0.0
|
|
49
|
+
self._last_escape: float = 0.0
|
|
50
|
+
|
|
51
|
+
# -------------------------------------------------------------------------
|
|
52
|
+
# Factory
|
|
53
|
+
# -------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
async def create(
|
|
57
|
+
cls,
|
|
58
|
+
runtime: Runtime,
|
|
59
|
+
theme: LayoutTheme | str | None = None,
|
|
60
|
+
keybindings: KeyMap | None = None,
|
|
61
|
+
) -> App:
|
|
62
|
+
"""Build the TUI around an already-constructed Runtime."""
|
|
63
|
+
from tau.themes.registry import theme_registry
|
|
64
|
+
|
|
65
|
+
cwd = runtime.session_manager.cwd if runtime.session_manager is not None else None
|
|
66
|
+
theme_registry.load_external(cwd=cwd)
|
|
67
|
+
|
|
68
|
+
from tau.prompts.registry import prompt_registry
|
|
69
|
+
prompt_registry.load_external(cwd=cwd)
|
|
70
|
+
|
|
71
|
+
resolved_theme: LayoutTheme | None
|
|
72
|
+
theme_name = "default"
|
|
73
|
+
if isinstance(theme, str):
|
|
74
|
+
theme_name = theme
|
|
75
|
+
resolved_theme = theme_registry.get(theme_name)
|
|
76
|
+
elif theme is None:
|
|
77
|
+
sm = runtime.settings_manager
|
|
78
|
+
theme_name = (sm.get_theme() if sm is not None else None) or "default"
|
|
79
|
+
try:
|
|
80
|
+
resolved_theme = theme_registry.get(theme_name)
|
|
81
|
+
except ValueError:
|
|
82
|
+
theme_name = "default"
|
|
83
|
+
resolved_theme = theme_registry.get(theme_name)
|
|
84
|
+
else:
|
|
85
|
+
resolved_theme = theme
|
|
86
|
+
|
|
87
|
+
sm = runtime.settings_manager
|
|
88
|
+
picker_max_visible = 8
|
|
89
|
+
autocomplete_max_visible = 5
|
|
90
|
+
if sm is not None:
|
|
91
|
+
resolved_theme.message.show_thinking = sm.get_show_thinking()
|
|
92
|
+
resolved_theme.message.show_tool_calls = sm.get_show_tool_calls()
|
|
93
|
+
resolved_theme.message.show_images = sm.get_show_images()
|
|
94
|
+
picker_max_visible = sm.get_picker_max_visible()
|
|
95
|
+
autocomplete_max_visible = sm.get_autocomplete_max_visible()
|
|
96
|
+
|
|
97
|
+
if keybindings:
|
|
98
|
+
configure_keybindings(keybindings)
|
|
99
|
+
|
|
100
|
+
show_hardware_cursor = False
|
|
101
|
+
editor_padding_x = 0
|
|
102
|
+
if sm is not None:
|
|
103
|
+
show_hardware_cursor = sm.get_show_hardware_cursor()
|
|
104
|
+
editor_padding_x = sm.get_editor_padding_x()
|
|
105
|
+
|
|
106
|
+
tui = TUI(show_hardware_cursor=show_hardware_cursor)
|
|
107
|
+
layout = Layout(
|
|
108
|
+
tui,
|
|
109
|
+
theme=resolved_theme,
|
|
110
|
+
picker_max_visible=picker_max_visible,
|
|
111
|
+
autocomplete_max_visible=autocomplete_max_visible,
|
|
112
|
+
editor_padding_x=editor_padding_x,
|
|
113
|
+
)
|
|
114
|
+
tui.set_focus(layout)
|
|
115
|
+
app = cls(runtime, tui, layout)
|
|
116
|
+
|
|
117
|
+
# ESC clears the editor only while idle; mid-stream it must fall through
|
|
118
|
+
# to the global key handler so it can abort the run.
|
|
119
|
+
layout.set_busy_check(
|
|
120
|
+
lambda: (a := runtime.agent) is not None and not a.is_idle()
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
runtime.set_layout(layout)
|
|
124
|
+
|
|
125
|
+
tool_registry = getattr(getattr(runtime, "_context", None), "tool_registry", None)
|
|
126
|
+
if tool_registry is not None:
|
|
127
|
+
layout.messages.set_tool_lookup(tool_registry.get)
|
|
128
|
+
|
|
129
|
+
ext = runtime.extension_runtime
|
|
130
|
+
if ext is not None:
|
|
131
|
+
from tau.tui.message_renderers import message_renderer_registry
|
|
132
|
+
for ctype, fn in ext.get_message_renderers().items():
|
|
133
|
+
message_renderer_registry.register(ctype, fn)
|
|
134
|
+
for provider in ext.get_autocomplete_providers():
|
|
135
|
+
layout.register_autocomplete_provider(provider)
|
|
136
|
+
return app
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
async def from_config(
|
|
140
|
+
cls,
|
|
141
|
+
config: RuntimeConfig,
|
|
142
|
+
theme: LayoutTheme | str | None = None,
|
|
143
|
+
keybindings: KeyMap | None = None,
|
|
144
|
+
) -> App:
|
|
145
|
+
"""Convenience: build Runtime from config then attach the TUI."""
|
|
146
|
+
from tau.runtime.service import Runtime
|
|
147
|
+
runtime = await Runtime.create(config)
|
|
148
|
+
return await cls.create(runtime, theme=theme, keybindings=keybindings)
|
|
149
|
+
|
|
150
|
+
# -------------------------------------------------------------------------
|
|
151
|
+
# Command context
|
|
152
|
+
# -------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _ctx(self) -> CommandContext:
|
|
155
|
+
return CommandContext(
|
|
156
|
+
runtime=self._runtime,
|
|
157
|
+
layout=self._layout,
|
|
158
|
+
tui=self._tui,
|
|
159
|
+
on_palette_refresh=self.refresh_palette,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _get_current_model_key(self) -> str:
|
|
163
|
+
agent = self._runtime.agent
|
|
164
|
+
if agent is None:
|
|
165
|
+
return ""
|
|
166
|
+
llm = getattr(getattr(agent, "_engine", None), "llm", None)
|
|
167
|
+
model = getattr(llm, "model", None) if llm is not None else None
|
|
168
|
+
if model is None:
|
|
169
|
+
return ""
|
|
170
|
+
return f"{model.provider}/{model.id}"
|
|
171
|
+
|
|
172
|
+
def _on_model_palette_commit(self, model_id: str, provider: str) -> None:
|
|
173
|
+
import asyncio
|
|
174
|
+
from tau.tui.commands import model as cmd_model
|
|
175
|
+
asyncio.ensure_future(cmd_model._apply_model(self._ctx(), model_id, provider))
|
|
176
|
+
|
|
177
|
+
# -------------------------------------------------------------------------
|
|
178
|
+
# UI command registration
|
|
179
|
+
# -------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _register_ui_commands(self) -> None:
|
|
182
|
+
from tau.commands.types import CommandInfo
|
|
183
|
+
from tau.tui.commands import model as cmd_model
|
|
184
|
+
from tau.tui.commands import appearance as cmd_appearance
|
|
185
|
+
from tau.tui.commands import session as cmd_session
|
|
186
|
+
from tau.tui.commands import auth as cmd_auth
|
|
187
|
+
from tau.tui.commands import misc as cmd_misc
|
|
188
|
+
|
|
189
|
+
reg = [
|
|
190
|
+
CommandInfo(name="model", description="Switch the active model (interactive picker).",
|
|
191
|
+
call=lambda _r, _a: cmd_model.open_model_selector(self._ctx()),
|
|
192
|
+
),
|
|
193
|
+
CommandInfo(name="effort", description="Set the thinking effort level for the current model.",
|
|
194
|
+
call=lambda _r, _a: cmd_model.open_effort_selector(self._ctx())),
|
|
195
|
+
CommandInfo(name="theme", description="Change the UI theme (interactive picker).",
|
|
196
|
+
call=lambda _r, _a: cmd_appearance.open_theme_selector(self._ctx())),
|
|
197
|
+
CommandInfo(name="settings", description="Show current settings.",
|
|
198
|
+
call=lambda _r, _a: cmd_appearance.open_settings_panel(self._ctx())),
|
|
199
|
+
CommandInfo(name="resume", description="Browse and resume a past session interactively.",
|
|
200
|
+
call=lambda _r, _a: cmd_session.open_resume_selector(self._ctx())),
|
|
201
|
+
CommandInfo(name="tree", description="Navigate the session tree and switch to a different branch.",
|
|
202
|
+
call=lambda _r, _a: cmd_session.open_tree_selector(self._ctx())),
|
|
203
|
+
CommandInfo(name="clone", description="Duplicate the current session at the current position.",
|
|
204
|
+
call=lambda _r, _a: cmd_session.cmd_clone(self._ctx())),
|
|
205
|
+
CommandInfo(name="session", description="Show session info and stats.",
|
|
206
|
+
call=lambda _r, _a: cmd_session.cmd_session(self._ctx())),
|
|
207
|
+
CommandInfo(name="login", description="Save an API key for a provider.",
|
|
208
|
+
call=lambda _r, _a: cmd_auth.open_login_selector(self._ctx())),
|
|
209
|
+
CommandInfo(name="logout", description="Remove stored credentials for a provider.",
|
|
210
|
+
call=lambda _r, _a: cmd_auth.open_logout_selector(self._ctx())),
|
|
211
|
+
CommandInfo(name="copy", description="Copy the last assistant message to the clipboard.",
|
|
212
|
+
call=lambda _r, _a: cmd_misc.cmd_copy(self._ctx())),
|
|
213
|
+
CommandInfo(name="help", description="List all commands and keyboard shortcuts.",
|
|
214
|
+
call=lambda _r, _a: cmd_misc.show_help(self._ctx()),
|
|
215
|
+
aliases=["?"]),
|
|
216
|
+
CommandInfo(name="quit", description="Exit tau.",
|
|
217
|
+
call=lambda _r, _a: self._tui.stop(),
|
|
218
|
+
aliases=["q", "exit"]),
|
|
219
|
+
]
|
|
220
|
+
for info in reg:
|
|
221
|
+
self._runtime.commands.register(info)
|
|
222
|
+
|
|
223
|
+
# -------------------------------------------------------------------------
|
|
224
|
+
# Lifecycle
|
|
225
|
+
# -------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _redirect_logging_off_terminal(self) -> None:
|
|
228
|
+
"""Keep all logging off the terminal while the TUI owns the screen.
|
|
229
|
+
|
|
230
|
+
The renderer tracks the screen with a differential model; any bytes
|
|
231
|
+
written to the terminal by something other than the renderer desync it
|
|
232
|
+
and leave stale lines (e.g. a stranded spinner). Without an explicit
|
|
233
|
+
handler, Python's ``logging.lastResort`` writes WARNING+ records to
|
|
234
|
+
stderr — and the LSP client logs the language server's stderr at WARNING
|
|
235
|
+
on every read. Route everything to a log file instead and neutralise the
|
|
236
|
+
stderr fallback so nothing reaches the TTY.
|
|
237
|
+
"""
|
|
238
|
+
import logging
|
|
239
|
+
import sys
|
|
240
|
+
from tau.settings.paths import get_logs_dir
|
|
241
|
+
from tau.session.utils import create_session_id
|
|
242
|
+
|
|
243
|
+
root = logging.getLogger()
|
|
244
|
+
# Drop any handler that writes to the live terminal (e.g. --debug's
|
|
245
|
+
# basicConfig stderr handler) — it would corrupt the renderer.
|
|
246
|
+
for h in list(root.handlers):
|
|
247
|
+
if isinstance(h, logging.StreamHandler) and getattr(h, "stream", None) in (sys.stdout, sys.stderr):
|
|
248
|
+
root.removeHandler(h)
|
|
249
|
+
# Unconfigured loggers must never fall back to the stderr last-resort.
|
|
250
|
+
logging.lastResort = logging.NullHandler()
|
|
251
|
+
# One log file per run, named by the active session id so logs don't grow
|
|
252
|
+
# unbounded in a single file. Fall back to a fresh id if no session yet.
|
|
253
|
+
sm = self._runtime.session_manager
|
|
254
|
+
log_id = (sm.session_id if sm is not None else None) or create_session_id()
|
|
255
|
+
try:
|
|
256
|
+
logs_dir = get_logs_dir()
|
|
257
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
fh = logging.FileHandler(logs_dir / f"{log_id}.log")
|
|
259
|
+
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
260
|
+
root.addHandler(fh)
|
|
261
|
+
if root.level == logging.NOTSET or root.level > logging.WARNING:
|
|
262
|
+
root.setLevel(logging.WARNING)
|
|
263
|
+
except OSError:
|
|
264
|
+
# Couldn't open the log file — at least keep logs off the terminal.
|
|
265
|
+
root.addHandler(logging.NullHandler())
|
|
266
|
+
|
|
267
|
+
async def run(self) -> None:
|
|
268
|
+
"""Set up hooks, replay session, then run the TUI loop."""
|
|
269
|
+
self._redirect_logging_off_terminal()
|
|
270
|
+
self._hooks.subscribe()
|
|
271
|
+
|
|
272
|
+
sm = self._runtime.settings_manager
|
|
273
|
+
if sm is None or not sm.get_quiet_startup():
|
|
274
|
+
self._replay_session()
|
|
275
|
+
|
|
276
|
+
self._hooks._refresh_model_badge()
|
|
277
|
+
self._input.load_history()
|
|
278
|
+
|
|
279
|
+
self._register_ui_commands()
|
|
280
|
+
self._layout.set_commands(self._build_palette_entries())
|
|
281
|
+
self._layout.set_model_callbacks(
|
|
282
|
+
commit_cb=self._on_model_palette_commit,
|
|
283
|
+
current_key_cb=self._get_current_model_key,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
sm = self._runtime.session_manager
|
|
287
|
+
if sm is not None:
|
|
288
|
+
self._layout.set_cwd(sm.cwd)
|
|
289
|
+
|
|
290
|
+
self._input.bind()
|
|
291
|
+
self._tui.on_input(self._on_global_key)
|
|
292
|
+
self._register_extension_shortcuts()
|
|
293
|
+
|
|
294
|
+
# Fire tui_ready so extensions can run initial UI setup now that the
|
|
295
|
+
# layout exists (session_start fires earlier, before the layout is set).
|
|
296
|
+
from tau.hooks.tui import TuiReadyEvent, TuiStartEvent, TuiExitEvent
|
|
297
|
+
await self._runtime.hooks.emit(TuiReadyEvent())
|
|
298
|
+
|
|
299
|
+
# If the project needs a trust decision, replace the root with TrustScreen
|
|
300
|
+
# before the loop starts so the layout never renders until the user acts.
|
|
301
|
+
trust_needed = self._setup_trust_screen_if_needed()
|
|
302
|
+
|
|
303
|
+
asyncio.ensure_future(self._announce_update())
|
|
304
|
+
|
|
305
|
+
await self._runtime.hooks.emit(TuiStartEvent())
|
|
306
|
+
try:
|
|
307
|
+
await self._tui.run()
|
|
308
|
+
finally:
|
|
309
|
+
await self._runtime.hooks.emit(TuiExitEvent())
|
|
310
|
+
self._cleanup()
|
|
311
|
+
|
|
312
|
+
# -------------------------------------------------------------------------
|
|
313
|
+
# Project trust prompt
|
|
314
|
+
# -------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def _setup_trust_screen_if_needed(self) -> bool:
|
|
317
|
+
"""If the project needs a trust decision, swap the TUI root to TrustScreen.
|
|
318
|
+
|
|
319
|
+
Returns True if the trust screen was installed (caller can ignore the value).
|
|
320
|
+
The trust screen schedules its own async resolution and swaps back to the
|
|
321
|
+
normal layout once the user acts.
|
|
322
|
+
"""
|
|
323
|
+
sm = self._runtime.settings_manager
|
|
324
|
+
if sm is None or sm.is_project_trusted():
|
|
325
|
+
return False
|
|
326
|
+
session_mgr = self._runtime.session_manager
|
|
327
|
+
if session_mgr is None:
|
|
328
|
+
return False
|
|
329
|
+
cwd = session_mgr.cwd
|
|
330
|
+
|
|
331
|
+
from tau.trust.manager import has_project_trust_inputs, get_trust_options, trust_store, TrustOption
|
|
332
|
+
if not has_project_trust_inputs(cwd):
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
options = get_trust_options(cwd, session_only=True)
|
|
336
|
+
|
|
337
|
+
def _on_commit(chosen: TrustOption | None) -> None:
|
|
338
|
+
if chosen is None or not chosen.trusted:
|
|
339
|
+
# User declined trust (or cancelled) — exit instead of
|
|
340
|
+
# falling through to the normal agent layout.
|
|
341
|
+
self._tui.stop()
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# Restore the normal layout now that the project is trusted
|
|
345
|
+
self._tui.clear()
|
|
346
|
+
self._layout.attach(self._tui)
|
|
347
|
+
self._tui.set_focus(self._layout)
|
|
348
|
+
self._tui.request_render()
|
|
349
|
+
|
|
350
|
+
trust_store.apply_option(chosen)
|
|
351
|
+
sm.set_project_trusted(True)
|
|
352
|
+
|
|
353
|
+
# Reload extensions so project config takes effect
|
|
354
|
+
import asyncio as _asyncio
|
|
355
|
+
async def _reload() -> None:
|
|
356
|
+
await self._runtime.reload_extensions()
|
|
357
|
+
_asyncio.ensure_future(_reload())
|
|
358
|
+
|
|
359
|
+
from tau.tui.components.trust_screen import TrustScreen
|
|
360
|
+
screen = TrustScreen(str(cwd), options, _on_commit)
|
|
361
|
+
self._layout.detach(self._tui)
|
|
362
|
+
self._tui.add_child(screen)
|
|
363
|
+
self._tui.set_focus(screen)
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
# -------------------------------------------------------------------------
|
|
367
|
+
# Global key handler
|
|
368
|
+
# -------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def _on_global_key(self, event: InputEvent) -> None:
|
|
371
|
+
if not isinstance(event, KeyEvent):
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if event.matches("escape"):
|
|
375
|
+
import time
|
|
376
|
+
agent = self._runtime.agent
|
|
377
|
+
if agent is not None and not agent.is_idle():
|
|
378
|
+
self._input.escape_abort()
|
|
379
|
+
self._last_escape = 0.0
|
|
380
|
+
elif not self._layout.input.text:
|
|
381
|
+
# Double-escape on empty editor: perform the configured action
|
|
382
|
+
now = time.monotonic()
|
|
383
|
+
if now - self._last_escape < 0.5:
|
|
384
|
+
self._last_escape = 0.0
|
|
385
|
+
self._do_double_escape()
|
|
386
|
+
else:
|
|
387
|
+
self._last_escape = now
|
|
388
|
+
else:
|
|
389
|
+
self._last_escape = 0.0
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
if event.matches("ctrl+c"):
|
|
393
|
+
import time
|
|
394
|
+
agent = self._runtime.agent
|
|
395
|
+
if agent is not None and not agent.is_idle():
|
|
396
|
+
agent.abort()
|
|
397
|
+
else:
|
|
398
|
+
now = time.monotonic()
|
|
399
|
+
if now - self._last_ctrl_c < 0.5:
|
|
400
|
+
self._tui.stop()
|
|
401
|
+
else:
|
|
402
|
+
self._last_ctrl_c = now
|
|
403
|
+
self._layout.input.clear()
|
|
404
|
+
self._tui.request_render()
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
if event.matches("ctrl+o"):
|
|
408
|
+
self._layout.messages.toggle_tool_results_expanded()
|
|
409
|
+
self._tui.request_render()
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
if event.matches("ctrl+e"):
|
|
413
|
+
self._layout.messages.toggle_invocations_expanded()
|
|
414
|
+
self._tui.request_render()
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
if event.matches("ctrl+d"):
|
|
418
|
+
self._tui.stop()
|
|
419
|
+
|
|
420
|
+
def _do_double_escape(self) -> None:
|
|
421
|
+
"""Execute the action configured for double-Escape on an empty editor."""
|
|
422
|
+
import asyncio
|
|
423
|
+
sm = self._runtime.settings_manager
|
|
424
|
+
action = sm.get_double_escape_action() if sm is not None else "fork"
|
|
425
|
+
match action:
|
|
426
|
+
case "none":
|
|
427
|
+
return
|
|
428
|
+
case "tree":
|
|
429
|
+
from tau.tui.commands import session as cmd_session
|
|
430
|
+
cmd_session.open_tree_selector(self._ctx())
|
|
431
|
+
case "fork" | _:
|
|
432
|
+
from tau.tui.commands import session as cmd_session
|
|
433
|
+
cmd_session.cmd_clone(self._ctx())
|
|
434
|
+
|
|
435
|
+
# -------------------------------------------------------------------------
|
|
436
|
+
# Extension shortcuts
|
|
437
|
+
# -------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def _register_extension_shortcuts(self) -> None:
|
|
440
|
+
runtime = self._runtime
|
|
441
|
+
for shortcut in runtime.extension_shortcuts:
|
|
442
|
+
key = shortcut.key
|
|
443
|
+
handler = shortcut.handler
|
|
444
|
+
|
|
445
|
+
def _make_handler(k, h):
|
|
446
|
+
def on_input(event: object) -> None:
|
|
447
|
+
if not isinstance(event, KeyEvent) or not event.matches(k):
|
|
448
|
+
return
|
|
449
|
+
ctx = ExtensionContext.from_runtime(runtime)
|
|
450
|
+
result = h(ctx)
|
|
451
|
+
if asyncio.iscoroutine(result):
|
|
452
|
+
asyncio.ensure_future(result) # type: ignore[arg-type]
|
|
453
|
+
return on_input
|
|
454
|
+
|
|
455
|
+
self._unsubs.append(self._tui.on_input(_make_handler(key, handler)))
|
|
456
|
+
|
|
457
|
+
# -------------------------------------------------------------------------
|
|
458
|
+
# Startup helpers
|
|
459
|
+
# -------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
def _build_palette_entries(self):
|
|
462
|
+
from tau.commands.types import CommandInfo
|
|
463
|
+
from tau.prompts.registry import prompt_registry
|
|
464
|
+
|
|
465
|
+
# Commands whose feature is currently switched off are hidden from the
|
|
466
|
+
# palette (and treated as unavailable) for this session.
|
|
467
|
+
sm = self._runtime.settings_manager
|
|
468
|
+
hidden: set[str] = set()
|
|
469
|
+
if sm is not None and not sm.is_compaction_enabled():
|
|
470
|
+
hidden.add("compact")
|
|
471
|
+
|
|
472
|
+
overrides = self._palette_dynamic_descriptions()
|
|
473
|
+
entries = []
|
|
474
|
+
for cmd in self._runtime.commands.list():
|
|
475
|
+
if cmd.name in hidden:
|
|
476
|
+
continue
|
|
477
|
+
if cmd.name in overrides:
|
|
478
|
+
from dataclasses import replace
|
|
479
|
+
entries.append(replace(cmd, description=overrides[cmd.name]))
|
|
480
|
+
else:
|
|
481
|
+
entries.append(cmd)
|
|
482
|
+
for tmpl in prompt_registry.list():
|
|
483
|
+
hint = f" {tmpl.argument_hint}" if tmpl.argument_hint else ""
|
|
484
|
+
entries.append(CommandInfo(
|
|
485
|
+
name=tmpl.name,
|
|
486
|
+
description=tmpl.description + hint,
|
|
487
|
+
call=lambda _r, _a: None,
|
|
488
|
+
argument_hint=tmpl.argument_hint,
|
|
489
|
+
))
|
|
490
|
+
return entries
|
|
491
|
+
|
|
492
|
+
def _palette_dynamic_descriptions(self) -> dict[str, str]:
|
|
493
|
+
from tau.tui.commands import model as cmd_model
|
|
494
|
+
from tau.tui.commands import auth as cmd_auth
|
|
495
|
+
overrides = cmd_model.get_palette_overrides(self._runtime.agent)
|
|
496
|
+
overrides.update(cmd_auth.get_palette_overrides())
|
|
497
|
+
return overrides
|
|
498
|
+
|
|
499
|
+
def refresh_palette(self) -> None:
|
|
500
|
+
self._layout.set_commands(self._build_palette_entries())
|
|
501
|
+
|
|
502
|
+
def _replay_session(self) -> None:
|
|
503
|
+
sm = self._runtime.session_manager
|
|
504
|
+
if sm is None:
|
|
505
|
+
return
|
|
506
|
+
ctx = sm.build_session_context()
|
|
507
|
+
for msg in ctx.messages:
|
|
508
|
+
self._layout.add_message(msg)
|
|
509
|
+
|
|
510
|
+
# -------------------------------------------------------------------------
|
|
511
|
+
# Cleanup
|
|
512
|
+
# -------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
async def _announce_update(self) -> None:
|
|
515
|
+
task = self._runtime.version_check_task
|
|
516
|
+
if task is None:
|
|
517
|
+
return
|
|
518
|
+
latest = await task
|
|
519
|
+
if latest is None:
|
|
520
|
+
return
|
|
521
|
+
from tau.tui.ansi import BOLD, BRIGHT_YELLOW, DIM, RESET
|
|
522
|
+
from tau.tui.component import Column, StaticComponent
|
|
523
|
+
from tau.tui.components.dynamic_border import DynamicBorder
|
|
524
|
+
color = lambda s: BRIGHT_YELLOW + s + RESET # noqa: E731
|
|
525
|
+
banner = Column([
|
|
526
|
+
DynamicBorder(color),
|
|
527
|
+
StaticComponent([
|
|
528
|
+
f" {BRIGHT_YELLOW}⚡{RESET} Update available: {BOLD}v{latest}{RESET}"
|
|
529
|
+
f"{DIM} · run: tau update{RESET}",
|
|
530
|
+
]),
|
|
531
|
+
DynamicBorder(color),
|
|
532
|
+
])
|
|
533
|
+
self._layout.set_widget("version_update", banner, placement="above_editor")
|
|
534
|
+
|
|
535
|
+
def _cleanup(self) -> None:
|
|
536
|
+
self._input.save_history()
|
|
537
|
+
self._hooks.unsubscribe()
|
|
538
|
+
for unsub in self._unsubs:
|
|
539
|
+
unsub()
|
|
540
|
+
self._unsubs.clear()
|
tau/tui/autocomplete.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AutocompleteItem:
|
|
9
|
+
"""A single completion suggestion returned by a provider."""
|
|
10
|
+
label: str
|
|
11
|
+
description: str = ""
|
|
12
|
+
# Text inserted into the editor. When None the label itself is inserted.
|
|
13
|
+
insert_text: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AutocompleteContext:
|
|
18
|
+
"""Snapshot passed to a provider's get_items() call."""
|
|
19
|
+
text: str # full editor text at the moment of the call
|
|
20
|
+
cursor_pos: int # character index of the cursor in text
|
|
21
|
+
trigger: str # the trigger character that activated this provider (e.g. "#")
|
|
22
|
+
query: str # text typed after the trigger up to the cursor (no spaces)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AutocompleteRegistration:
|
|
27
|
+
"""A provider registered by an extension via tau.add_autocomplete_provider()."""
|
|
28
|
+
trigger: str
|
|
29
|
+
get_items: Callable[
|
|
30
|
+
[AutocompleteContext],
|
|
31
|
+
list[AutocompleteItem] | Awaitable[list[AutocompleteItem]],
|
|
32
|
+
]
|
|
33
|
+
description: str = ""
|
tau/tui/capabilities.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
ImageProtocol = Literal["kitty", "iterm2"] | None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class TerminalCapabilities:
|
|
12
|
+
images: ImageProtocol
|
|
13
|
+
truecolor: bool
|
|
14
|
+
hyperlinks: bool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CellDimensions:
|
|
19
|
+
width_px: int
|
|
20
|
+
height_px: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_cached: TerminalCapabilities | None = None
|
|
24
|
+
_cell_dims = CellDimensions(width_px=9, height_px=18)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_cell_dimensions() -> CellDimensions:
|
|
28
|
+
return _cell_dims
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_cell_dimensions(dims: CellDimensions) -> None:
|
|
32
|
+
global _cell_dims
|
|
33
|
+
_cell_dims = dims
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _probe_cell_dimensions() -> CellDimensions:
|
|
37
|
+
"""Read pixel and cell sizes from the terminal via TIOCGWINSZ."""
|
|
38
|
+
try:
|
|
39
|
+
import fcntl
|
|
40
|
+
import struct
|
|
41
|
+
import termios
|
|
42
|
+
|
|
43
|
+
buf = struct.pack("HHHH", 0, 0, 0, 0)
|
|
44
|
+
res = fcntl.ioctl(1, termios.TIOCGWINSZ, buf)
|
|
45
|
+
rows, cols, width_px, height_px = struct.unpack("HHHH", res)
|
|
46
|
+
if rows > 0 and cols > 0 and width_px > 0 and height_px > 0:
|
|
47
|
+
return CellDimensions(width_px=width_px // cols, height_px=height_px // rows)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return CellDimensions(width_px=9, height_px=18)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tmux_forwards_hyperlinks() -> bool:
|
|
54
|
+
try:
|
|
55
|
+
import subprocess
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
["tmux", "display-message", "-p", "#{client_termfeatures}"],
|
|
58
|
+
capture_output=True, text=True, timeout=0.25,
|
|
59
|
+
)
|
|
60
|
+
return "hyperlinks" in result.stdout.split(",")
|
|
61
|
+
except Exception:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect_capabilities() -> TerminalCapabilities:
|
|
66
|
+
term = os.environ.get("TERM", "").lower()
|
|
67
|
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
|
68
|
+
terminal_emulator = os.environ.get("TERMINAL_EMULATOR", "").lower()
|
|
69
|
+
color_term = os.environ.get("COLORTERM", "").lower()
|
|
70
|
+
truecolor = color_term in ("truecolor", "24bit")
|
|
71
|
+
|
|
72
|
+
if os.environ.get("TMUX") or term.startswith("tmux"):
|
|
73
|
+
return TerminalCapabilities(images=None, truecolor=truecolor, hyperlinks=_tmux_forwards_hyperlinks())
|
|
74
|
+
|
|
75
|
+
if term.startswith("screen"):
|
|
76
|
+
return TerminalCapabilities(images=None, truecolor=truecolor, hyperlinks=False)
|
|
77
|
+
|
|
78
|
+
if os.environ.get("KITTY_WINDOW_ID") or term_program == "kitty":
|
|
79
|
+
return TerminalCapabilities(images="kitty", truecolor=True, hyperlinks=True)
|
|
80
|
+
|
|
81
|
+
if term_program == "ghostty" or "ghostty" in term or os.environ.get("GHOSTTY_RESOURCES_DIR"):
|
|
82
|
+
return TerminalCapabilities(images="kitty", truecolor=True, hyperlinks=True)
|
|
83
|
+
|
|
84
|
+
if os.environ.get("WEZTERM_PANE") or term_program == "wezterm":
|
|
85
|
+
return TerminalCapabilities(images="kitty", truecolor=True, hyperlinks=True)
|
|
86
|
+
|
|
87
|
+
if os.environ.get("ITERM_SESSION_ID") or term_program == "iterm.app":
|
|
88
|
+
return TerminalCapabilities(images="iterm2", truecolor=True, hyperlinks=True)
|
|
89
|
+
|
|
90
|
+
if os.environ.get("WT_SESSION"):
|
|
91
|
+
return TerminalCapabilities(images=None, truecolor=True, hyperlinks=True)
|
|
92
|
+
|
|
93
|
+
if term_program == "vscode":
|
|
94
|
+
return TerminalCapabilities(images=None, truecolor=True, hyperlinks=True)
|
|
95
|
+
|
|
96
|
+
if term_program == "alacritty":
|
|
97
|
+
return TerminalCapabilities(images=None, truecolor=True, hyperlinks=False)
|
|
98
|
+
|
|
99
|
+
if terminal_emulator == "jetbrains-jediterm":
|
|
100
|
+
return TerminalCapabilities(images=None, truecolor=True, hyperlinks=False)
|
|
101
|
+
|
|
102
|
+
return TerminalCapabilities(images=None, truecolor=truecolor, hyperlinks=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_capabilities() -> TerminalCapabilities:
|
|
106
|
+
global _cached
|
|
107
|
+
if _cached is None:
|
|
108
|
+
_cached = detect_capabilities()
|
|
109
|
+
set_cell_dimensions(_probe_cell_dimensions())
|
|
110
|
+
return _cached
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def reset_capabilities_cache() -> None:
|
|
114
|
+
global _cached
|
|
115
|
+
_cached = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_image_line(line: str) -> bool:
|
|
119
|
+
return "\x1b_G" in line or "\x1b]1337;" in line
|